Repository: wannanbigpig/gin-layout
Branch: master
Commit: 48da724a1693
Files: 397
Total size: 1.4 MB
Directory structure:
gitextract_fx8etgvi/
├── .air.toml
├── .github/
│ ├── pull_request_template.md
│ └── workflows/
│ ├── codeql.yml
│ └── go.yml
├── .gitignore
├── .golangci.yml
├── LICENSE
├── MEMORY.md
├── README.en.md
├── README.md
├── build.sh
├── cmd/
│ ├── .gitignore
│ ├── bootstrapx/
│ │ ├── bootstrap.go
│ │ └── bootstrap_test.go
│ ├── command/
│ │ └── command.go
│ ├── completion.go
│ ├── cron/
│ │ ├── cron.go
│ │ ├── schedule.go
│ │ ├── schedule_test.go
│ │ ├── task_record_test.go
│ │ ├── tasks.go
│ │ └── tasks_test.go
│ ├── root.go
│ ├── service/
│ │ └── service.go
│ ├── version/
│ │ └── version.go
│ └── worker/
│ └── worker.go
├── config/
│ ├── .gitignore
│ ├── autoload/
│ │ ├── app.go
│ │ ├── jwt.go
│ │ ├── logger.go
│ │ ├── mysql.go
│ │ ├── queue.go
│ │ └── redis.go
│ ├── config.go
│ ├── config.yaml.example
│ ├── config_clone.go
│ ├── config_load.go
│ ├── config_load_test.go
│ ├── config_test.go
│ ├── provider.go
│ ├── runtime.go
│ ├── runtime_test.go
│ └── testing_helper.go
├── data/
│ ├── data.go
│ ├── data_test.go
│ ├── migrations/
│ │ ├── 20260425000001_init_table.down.sql
│ │ ├── 20260425000001_init_table.up.sql
│ │ ├── 20260425000002_init_data.down.sql
│ │ ├── 20260425000002_init_data.up.sql
│ │ ├── 20260515010000_upload_file_objects.down.sql
│ │ └── 20260515010000_upload_file_objects.up.sql
│ ├── mysql.go
│ ├── redis.go
│ ├── runtime_health.go
│ └── runtime_health_test.go
├── docs/
│ ├── COMMANDS_AND_TASKS.en.md
│ ├── COMMANDS_AND_TASKS.md
│ ├── DONATE.en.md
│ ├── DONATE.md
│ ├── MIGRATE_COMMANDS.en.md
│ ├── MIGRATE_COMMANDS.md
│ ├── SECURITY_PERMISSION_FIXES_2026-05.md
│ ├── SYSTEM_CONFIG_AND_DICT_GUIDELINES.en.md
│ └── SYSTEM_CONFIG_AND_DICT_GUIDELINES.md
├── go.mod
├── go.sum
├── internal/
│ ├── access/
│ │ └── casbin/
│ │ ├── adapter.go
│ │ ├── casbin.go
│ │ ├── enforcer_init.go
│ │ └── policy_ops.go
│ ├── console/
│ │ ├── confirm.go
│ │ ├── demo/
│ │ │ └── demo.go
│ │ ├── init/
│ │ │ └── init.go
│ │ ├── migrate/
│ │ │ └── migrate.go
│ │ ├── system_init/
│ │ │ └── system_init.go
│ │ └── task/
│ │ ├── task.go
│ │ └── task_test.go
│ ├── controller/
│ │ ├── admin_v1/
│ │ │ ├── auth.go
│ │ │ ├── auth_admin_user.go
│ │ │ ├── auth_api.go
│ │ │ ├── auth_dept.go
│ │ │ ├── auth_file_resource.go
│ │ │ ├── auth_login_log.go
│ │ │ ├── auth_menu.go
│ │ │ ├── auth_request_log.go
│ │ │ ├── auth_role.go
│ │ │ ├── auth_session.go
│ │ │ ├── auth_storage_config.go
│ │ │ ├── auth_sys_config.go
│ │ │ ├── auth_sys_dict.go
│ │ │ ├── auth_task_center.go
│ │ │ ├── auth_test.go
│ │ │ ├── dashboard.go
│ │ │ └── sys_common.go
│ │ ├── sys_base.go
│ │ ├── sys_base_test.go
│ │ └── sys_demo.go
│ ├── cron/
│ │ ├── queue_fallback.go
│ │ ├── queue_fallback_test.go
│ │ ├── registry.go
│ │ └── registry_test.go
│ ├── filestorage/
│ │ ├── aliyun_oss.go
│ │ ├── local.go
│ │ ├── local_test.go
│ │ ├── s3.go
│ │ └── types.go
│ ├── global/
│ │ ├── api_auth_mode.go
│ │ ├── api_auth_mode_test.go
│ │ ├── auth.go
│ │ ├── common.go
│ │ ├── context_keys.go
│ │ └── system_defaults.go
│ ├── jobs/
│ │ ├── audit_log.go
│ │ ├── audit_log_test.go
│ │ └── registry.go
│ ├── middleware/
│ │ ├── admin_auth.go
│ │ ├── admin_auth_test.go
│ │ ├── audit_context.go
│ │ ├── audit_queue.go
│ │ ├── audit_queue_test.go
│ │ ├── cors.go
│ │ ├── cors_test.go
│ │ ├── database_ready.go
│ │ ├── database_ready_test.go
│ │ ├── logger.go
│ │ ├── logger_bench_test.go
│ │ ├── logger_recorder.go
│ │ ├── logger_storage.go
│ │ ├── logger_test.go
│ │ ├── parse_token.go
│ │ ├── recovery.go
│ │ ├── request_cost.go
│ │ ├── request_locale.go
│ │ └── request_locale_test.go
│ ├── model/
│ │ ├── admin_login_logs.go
│ │ ├── admin_user_dept_map.go
│ │ ├── admin_user_role_map.go
│ │ ├── admin_users.go
│ │ ├── admin_users_test.go
│ │ ├── api.go
│ │ ├── base.go
│ │ ├── base_crud.go
│ │ ├── base_list.go
│ │ ├── base_list_test.go
│ │ ├── base_owner.go
│ │ ├── base_test.go
│ │ ├── base_tree.go
│ │ ├── dept.go
│ │ ├── dept_role_map.go
│ │ ├── file_upload.go
│ │ ├── file_upload_test.go
│ │ ├── login_security_state.go
│ │ ├── menu.go
│ │ ├── menu_api_map.go
│ │ ├── menu_i18n.go
│ │ ├── modelDict/
│ │ │ └── base.go
│ │ ├── request_logs.go
│ │ ├── role.go
│ │ ├── role_menu_map.go
│ │ ├── sys_config.go
│ │ ├── sys_dict.go
│ │ ├── sys_i18n.go
│ │ └── task_center.go
│ ├── pkg/
│ │ ├── auditdiff/
│ │ │ ├── diff.go
│ │ │ └── diff_test.go
│ │ ├── errors/
│ │ │ ├── code.go
│ │ │ ├── code_test.go
│ │ │ ├── en-us.go
│ │ │ ├── error.go
│ │ │ └── zh-cn.go
│ │ ├── func_make/
│ │ │ ├── func_make.go
│ │ │ └── func_make_test.go
│ │ ├── i18n/
│ │ │ ├── locale.go
│ │ │ └── locale_test.go
│ │ ├── logger/
│ │ │ ├── logger.go
│ │ │ └── logger_test.go
│ │ ├── query_builder/
│ │ │ ├── query_builder.go
│ │ │ └── query_builder_test.go
│ │ ├── request/
│ │ │ ├── request.go
│ │ │ └── request_test.go
│ │ ├── response/
│ │ │ ├── response.go
│ │ │ └── response_test.go
│ │ ├── testkit/
│ │ │ ├── secret.go
│ │ │ └── secret_test.go
│ │ └── utils/
│ │ ├── desensitize.go
│ │ ├── format_time.go
│ │ ├── sensitive/
│ │ │ ├── fields.go
│ │ │ ├── http_mask.go
│ │ │ ├── mask.go
│ │ │ └── string_mask.go
│ │ ├── token/
│ │ │ ├── jwt.go
│ │ │ └── jwt_test.go
│ │ ├── utils.go
│ │ └── utils_test.go
│ ├── queue/
│ │ ├── asynqx/
│ │ │ ├── asynq.go
│ │ │ ├── asynq_test.go
│ │ │ ├── inspector_test.go
│ │ │ └── task_record_test.go
│ │ ├── queue.go
│ │ └── queue_test.go
│ ├── resources/
│ │ ├── admin_user.go
│ │ ├── api.go
│ │ ├── base.go
│ │ ├── base_test.go
│ │ ├── common.go
│ │ ├── dept.go
│ │ ├── file_resource.go
│ │ ├── file_resource_test.go
│ │ ├── login_log.go
│ │ ├── login_log_test.go
│ │ ├── menu.go
│ │ ├── request_log.go
│ │ ├── role.go
│ │ ├── session.go
│ │ ├── sys_config.go
│ │ ├── sys_dict.go
│ │ └── task_center.go
│ ├── routers/
│ │ ├── admin_router.go
│ │ ├── defs.go
│ │ ├── deps.go
│ │ ├── meta.go
│ │ ├── register.go
│ │ ├── router.go
│ │ ├── router_deps_test.go
│ │ ├── router_test.go
│ │ └── validate.go
│ ├── runtime/
│ │ └── config_reload.go
│ ├── service/
│ │ ├── access/
│ │ │ ├── api_cache.go
│ │ │ ├── api_cache_test.go
│ │ │ ├── common.go
│ │ │ ├── coordinator.go
│ │ │ ├── graph_loader.go
│ │ │ ├── menu_api_defaults.go
│ │ │ ├── menu_api_defaults_test.go
│ │ │ ├── scope_resolver.go
│ │ │ ├── system_defaults.go
│ │ │ ├── system_defaults_test.go
│ │ │ ├── transaction.go
│ │ │ ├── user_permission_sync.go
│ │ │ ├── user_permission_sync_bench_test.go
│ │ │ └── user_permission_sync_test.go
│ │ ├── admin/
│ │ │ ├── admin_user.go
│ │ │ ├── admin_user_bind.go
│ │ │ ├── admin_user_create_test.go
│ │ │ ├── admin_user_mutation.go
│ │ │ ├── admin_user_test.go
│ │ │ ├── audit_diff.go
│ │ │ └── audit_diff_test.go
│ │ ├── api_permission/
│ │ │ ├── api.go
│ │ │ ├── api_test.go
│ │ │ ├── audit_diff.go
│ │ │ └── audit_diff_test.go
│ │ ├── audit/
│ │ │ ├── list_helpers.go
│ │ │ ├── login_log.go
│ │ │ ├── login_log_test.go
│ │ │ ├── request_log.go
│ │ │ ├── request_log_manage.go
│ │ │ ├── request_log_manage_test.go
│ │ │ └── request_log_write.go
│ │ ├── auth/
│ │ │ ├── login.go
│ │ │ ├── login_bench_test.go
│ │ │ ├── login_blacklist.go
│ │ │ ├── login_helpers_test.go
│ │ │ ├── login_log_helpers.go
│ │ │ ├── login_refresh.go
│ │ │ ├── login_revoke.go
│ │ │ ├── login_security.go
│ │ │ ├── login_security_test.go
│ │ │ ├── login_token_ops.go
│ │ │ ├── login_types.go
│ │ │ ├── login_types_test.go
│ │ │ ├── principal.go
│ │ │ ├── session.go
│ │ │ └── session_test.go
│ │ ├── common.go
│ │ ├── common_test.go
│ │ ├── common_upload_helpers.go
│ │ ├── common_upload_helpers_test.go
│ │ ├── dashboard/
│ │ │ └── overview.go
│ │ ├── dept/
│ │ │ ├── audit_diff.go
│ │ │ ├── audit_diff_test.go
│ │ │ ├── dept.go
│ │ │ ├── dept_mutation.go
│ │ │ └── dept_test.go
│ │ ├── file_object.go
│ │ ├── file_reference.go
│ │ ├── file_reference_test.go
│ │ ├── file_resource.go
│ │ ├── file_resource_folder_upload.go
│ │ ├── file_resource_test.go
│ │ ├── i18n_text.go
│ │ ├── menu/
│ │ │ ├── audit_diff.go
│ │ │ ├── audit_diff_test.go
│ │ │ ├── menu.go
│ │ │ ├── menu_edit.go
│ │ │ ├── menu_query.go
│ │ │ └── menu_test.go
│ │ ├── role/
│ │ │ ├── audit_diff.go
│ │ │ ├── audit_diff_test.go
│ │ │ ├── role.go
│ │ │ ├── role_mutation.go
│ │ │ └── role_test.go
│ │ ├── storage_config.go
│ │ ├── sys_base.go
│ │ ├── sys_config/
│ │ │ ├── audit_diff.go
│ │ │ ├── audit_request_body.go
│ │ │ ├── cache.go
│ │ │ ├── cache_sync.go
│ │ │ ├── cache_sync_test.go
│ │ │ ├── runtime_audit.go
│ │ │ ├── runtime_audit_test.go
│ │ │ ├── sys_config.go
│ │ │ ├── sys_config_mask_test.go
│ │ │ ├── typed_value.go
│ │ │ └── typed_value_test.go
│ │ ├── sys_dict/
│ │ │ ├── audit_diff.go
│ │ │ └── sys_dict.go
│ │ ├── system/
│ │ │ ├── init.go
│ │ │ ├── migration_runner.go
│ │ │ ├── reset.go
│ │ │ ├── reset_path_test.go
│ │ │ └── reset_test.go
│ │ └── taskcenter/
│ │ ├── action.go
│ │ ├── action_test.go
│ │ ├── audit_diff.go
│ │ ├── audit_diff_test.go
│ │ ├── list_helpers.go
│ │ ├── query.go
│ │ ├── recorder.go
│ │ └── recorder_test.go
│ └── validator/
│ ├── binding.go
│ ├── binding_i18n_test.go
│ ├── form/
│ │ ├── admin_user.go
│ │ ├── admin_user_test.go
│ │ ├── auth.go
│ │ ├── common.go
│ │ ├── dept.go
│ │ ├── file_resource.go
│ │ ├── file_resource_test.go
│ │ ├── id_array_validation_test.go
│ │ ├── login_log.go
│ │ ├── menu.go
│ │ ├── menu_test.go
│ │ ├── permission.go
│ │ ├── permission_test.go
│ │ ├── request_log.go
│ │ ├── request_log_test.go
│ │ ├── role.go
│ │ ├── role_test.go
│ │ ├── session.go
│ │ ├── session_test.go
│ │ ├── storage_config.go
│ │ ├── storage_config_test.go
│ │ ├── sys_config.go
│ │ ├── sys_config_test.go
│ │ ├── sys_dict.go
│ │ ├── sys_dict_test.go
│ │ ├── task_center.go
│ │ └── task_center_test.go
│ ├── rules.go
│ ├── runtime.go
│ ├── translation.go
│ └── validator_test.go
├── main.go
├── pkg/
│ ├── convert/
│ │ └── convert.go
│ └── utils/
│ ├── captcha/
│ │ └── captcha.go
│ ├── crypto/
│ │ ├── README.md
│ │ ├── crypto.go
│ │ ├── crypto_aes.go
│ │ └── types.go
│ ├── helpers.go
│ ├── helpers_test.go
│ ├── http.go
│ ├── http_test.go
│ ├── upload.go
│ ├── upload_test.go
│ ├── utils.go
│ └── utils_test.go
├── policy.csv
├── rbac_model.conf
└── tests/
├── README.md
├── admin_test/
│ ├── README.md
│ ├── admin_test.go
│ ├── admin_user_test.go
│ ├── auth_routes_test.go
│ ├── common_routes_test.go
│ ├── department_test.go
│ ├── log_routes_test.go
│ ├── menu_test.go
│ ├── permission_routes_test.go
│ ├── public_routes_test.go
│ ├── role_test.go
│ ├── system_routes_test.go
│ ├── task_routes_test.go
│ └── test_helpers_test.go
├── ping_test.go
└── test.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .air.toml
================================================
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
# 使用相对路径,或者不指定配置文件让程序自动查找
bin = "./tmp/main service"
cmd = "go build -o ./tmp/main"
# 设置环境变量
env = ["GO_ENV=development"]
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata", "logs", "tests"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "./logs/build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
================================================
FILE: .github/pull_request_template.md
================================================
## 变更说明
- 主要目标:
- 影响模块:
- 风险等级:低 / 中 / 高
## 自查清单
- [ ] 已确认本次改动解决的是主要问题,而不是无关重构
- [ ] 已调研相关代码并复用现有实现(未重复造轮子)
- [ ] 已评估影响范围与兼容性(必要时给出回滚方案)
- [ ] 已补充或更新测试(至少覆盖一个新增/修改分支)
## 可读性专项检查(必填)
- [ ] 未引入“仅一层转发且无语义增量”的新方法
- [ ] 如果新增拆分,已说明拆分理由(事务边界 / 复用 / 测试隔离)
- [ ] 方法职责保持单一,命名表达清晰
- [ ] 跨文件跳转成本可接受(阅读主流程不需要反复来回定位)
## 验证记录
- 本地执行命令:
- `go test ./...`
- 关键结果:
## 关联信息
- 关联 Issue / 任务:
- 额外说明:
================================================
FILE: .github/workflows/codeql.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master, x_l_admin ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master, x_l_admin ]
schedule:
- cron: '27 0 * * 6'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# 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
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
================================================
FILE: .github/workflows/go.yml
================================================
name: Go
on:
push:
branches: [ master, x_l_admin ]
pull_request:
branches: [ master, x_l_admin ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ '1.26.x' ]
steps:
- uses: actions/checkout@v3
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- name: Display Go version
run: go version
- name: Build
run: go build -v ./...
- name: Test
run: go test $(go list ./... | grep -v /tests/)
================================================
FILE: .gitignore
================================================
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# IDE and editor files
.idea
.vscode
*.DS_Store
# Runtime directories
storage
logs/
tmp
build/
# Local config and environment files
*.yaml
*.ini
.env
.env.local
.env.*.local
# Claude Code local settings (keep memory but ignore local configs)
.claude
# Application binaries
gin-layout
gin-layout/
go-layout
/migrate
# Documentation (project-specific, not for version control)
PROJECT_ARCHITECTURE.md
CODE_OPTIMIZATION_REPORT.md
# Go cache
.gocache
.go-test-cache
# Memory (if you want to keep it local only)
# memory/
.aitasks
================================================
FILE: .golangci.yml
================================================
# golangci-lint 配置文件
# 参考: https://golangci-lint.run/usage/configuration/
run:
# 超时时间
timeout: 5m
# 并发数
concurrency: 4
# 包含的测试文件
tests: true
# 跳过目录
skip-dirs:
- vendor
- build
- dist
- logs
- storage
- tmp
# 跳过文件
skip-files:
- ".*\\.pb\\.go$"
- ".*\\.gen\\.go$"
# 输出配置
output:
# 输出格式: colored-line-number, line-number, json, tab, checkstyle, code-climate, html, junit-xml, github-actions
format: colored-line-number
# 打印问题数量
print-issued-lines: true
# 打印 linter 名称
print-linter-name: true
# 不打印欢迎信息
uniq-by-line: false
# 打印源代码行
print-welcome: false
# 启用的 linter
linters:
enable:
# 代码质量
- errcheck # 检查错误处理
- gosimple # 简化代码建议
- govet # go vet 检查
- ineffassign # 检查未使用的赋值
- staticcheck # 静态分析
- unused # 检查未使用的代码
# 代码风格
- gofmt # 代码格式检查
- goimports # import 语句检查
- misspell # 拼写检查
- revive # Go 代码检查工具(替代 golint)
- stylecheck # 风格检查
# 性能
- prealloc # 预分配切片检查
# 复杂度
- gocyclo # 圈复杂度检查
- gocognit # 认知复杂度检查
# 其他
- exportloopref # 循环变量引用检查
- gocritic # 代码审查建议
- gosec # 安全检查
- nakedret # 检查裸返回
- noctx # 检查 context 传递
- rowserrcheck # 检查 rows.Err()
- sqlclosecheck # 检查 SQL 连接关闭
linters-settings:
# errcheck 配置
errcheck:
check-type-assertions: true
check-blank: true
# gocyclo 配置 - 圈复杂度阈值
gocyclo:
min-complexity: 15
# gocognit 配置 - 认知复杂度阈值
gocognit:
min-complexity: 15
# revive 配置
revive:
rules:
- name: exported
severity: warning
- name: var-naming
severity: warning
- name: package-comments
severity: warning
- name: range
severity: warning
- name: increment-decrement
severity: warning
- name: error-return
severity: warning
- name: error-strings
severity: warning
- name: error-naming
severity: warning
- name: receiver-naming
severity: warning
- name: unexported-return
severity: warning
- name: time-equal
severity: warning
- name: banned-characters
severity: warning
- name: context-keys-type
severity: warning
- name: context-as-argument
severity: warning
- name: if-return
severity: warning
- name: increment-decrement
severity: warning
- name: var-declaration
severity: warning
- name: range-val-in-closure
severity: warning
- name: range-val-address
severity: warning
- name: waitgroup-by-value
severity: warning
- name: atomic
severity: warning
- name: empty-lines
severity: warning
- name: line-length-limit
severity: warning
arguments:
- 120
# gocritic 配置
gocritic:
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
disabled-checks:
- dupImport # 允许重复导入(某些情况下需要)
- ifElseChain # 允许 if-else 链
- octalLiteral # 允许八进制字面量
# gosec 配置
gosec:
# 严重程度: low, medium, high
severity: medium
# 置信度: low, medium, high
confidence: medium
# 排除的规则
excludes:
- G104 # 审计错误未检查(某些情况下可以接受)
- G401 # 弱随机数生成(某些场景可以接受)
- G501 # 导入黑名单(某些依赖是必需的)
issues:
# 排除的问题
exclude-rules:
# 排除测试文件中的某些检查
- path: _test\.go
linters:
- errcheck
- gosec
- gocritic
- gocyclo
- gocognit
# 最大问题数(0 表示不限制)
max-issues-per-linter: 0
max-same-issues: 0
# 排除的问题模式
exclude:
# 排除 "Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked"
- 'Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked'
# 排除 "exported function .* should have comment"
- 'exported function .* should have comment'
# 排除 "comment on exported .* should be of the form"
- 'comment on exported .* should be of the form'
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 wannanbigpig
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: MEMORY.md
================================================
# MEMORY
这份文档写给接手 `gin-layout` 的 AI Agent。它不是项目营销介绍,而是为了让下一位 AI 尽快知道:
- 项目当前真实状态是什么
- 主要入口、核心链路和高风险区域在哪
- 关键约束和禁止事项是什么
- 写代码时如何保持当前项目风格
## 0. 快速结论
先记住这些,不确定时再往下查细节:
- 代码是真相,文档只是辅助;文档和代码冲突时,以代码为准并顺手修正文档。
- 新功能优先沿用现有 `service / model / resources / validator` 分层。
- 保持现有工程范式,不引入重框架化、函数式大重写、新 DI 容器或 repository 全量替换。
- Casbin 使用 `github.com/casbin/casbin/v3`。
- 鉴权链路采用 claims-first:请求上下文保存 `AuthPrincipal`,不是完整数据库 `AdminUser`。
- 错误文案按请求语言国际化返回;需要自定义错误文案时优先使用错误码或 message key,而不是在 controller 里硬编码中文。
- 请求审计是“审计快照 + 队列优先”:队列开启时异步落库,队列关闭时才同步落库审计日志。
- CORS 使用常规 `["*"]` 语义。
- 菜单列表/树只返回当前语言 `title`;菜单详情返回 `title_i18n`,不返回 `title`。
- 继续改代码时保持小步修改、局部重组、命名稳定。
- 改完代码必须自己验证;新增或修改 HTTP 行为默认要补 `tests/` 接口级测试。
## 1. 禁止事项
当前代码库禁止这样做:
- 引入 `casbin/v2` 或混用不同主版本的 Casbin adapter。
- 在认证中间件里每个请求都回表查询完整 `AdminUser`;账号状态变更应通过服务层撤销 token 或更新会话状态生效。
- 把 `AuthPrincipal.AdminUser()` 当成数据库实时状态。
- 在队列开启时同步落库请求审计日志。
- 把请求日志设计成“每个请求同步写文件 + 异步审计”的双写链路。
- 把 CORS 空数组当成全放开;显式全放开使用 `["*"]`。
- 让菜单列表或用户菜单树返回 `title_i18n`。
- 让菜单详情返回 `title`。
- 让菜单写接口接收 `title`;写接口使用 `title_i18n`。
- 把业务逻辑、事务或批量数据修复塞进 controller。
- 把所有逻辑塞回单个超大 service 文件。
- 绕过 `PermissionSyncCoordinator` 分散写入 Casbin 权限。
- 只改代码不跑测试,或把验证责任留给用户或下一个 AI。
## 2. 当前定位
项目名:`gin-layout`
这是一个偏后台管理场景的 Go 后端骨架,内置:
- JWT 登录、校验、刷新、登出
- Casbin RBAC 接口权限控制
- 管理员、角色、部门、菜单、API 权限体系
- 请求日志、登录日志、审计日志
- 文件上传与本地文件访问
- CLI 命令、定时任务、异步队列 worker
运行形态包含三类进程:
1. `service`
- 提供 HTTP API
- 构建请求审计日志快照
- 队列开启时把审计快照投递到异步队列
- 队列关闭时在当前请求链路同步落库审计日志
2. `worker`
- 消费异步任务
- 主要消费请求审计日志异步落库
3. `cron`
- 负责周期任务
## 3. 当前事实
这些是当前实现与开发规范。
### 3.1 Casbin v3
权限引擎是:
- `github.com/casbin/casbin/v3`
- `github.com/casbin/gorm-adapter/v3`
相关入口:
- [internal/access/casbin/casbin.go](/Users/liuml/data/go/src/go-layout/internal/access/casbin/casbin.go:1)
- [internal/access/casbin/adapter.go](/Users/liuml/data/go/src/go-layout/internal/access/casbin/adapter.go:1)
### 3.2 Claims-First 鉴权
请求上下文保存 claims 快照,不保存完整数据库 `AdminUser` 模型:
- [internal/service/auth/principal.go](/Users/liuml/data/go/src/go-layout/internal/service/auth/principal.go:1)
- [internal/middleware/parse_token.go](/Users/liuml/data/go/src/go-layout/internal/middleware/parse_token.go:1)
- [internal/middleware/admin_auth.go](/Users/liuml/data/go/src/go-layout/internal/middleware/admin_auth.go:1)
这意味着:
- 中间件不应为每个请求回表查用户。
- 需要用户实时数据库状态的地方,由具体业务接口显式查库。
- `AuthPrincipal.AdminUser()` 是轻量投影,不代表数据库最新状态。
### 3.3 请求日志
请求日志链路以审计日志为核心:
- 请求链路:缓存请求体、录制响应体,构建审计快照。
- 队列开启:把审计快照投给队列,由 worker 异步落库。
- 队列关闭:在当前请求链路同步落库审计日志。
全局 zap 文件日志用于系统日志、错误日志和 panic 日志;每个请求日志按审计快照链路处理。
关键文件:
- [internal/middleware/logger.go](/Users/liuml/data/go/src/go-layout/internal/middleware/logger.go:1)
- [internal/middleware/audit_queue.go](/Users/liuml/data/go/src/go-layout/internal/middleware/audit_queue.go:1)
- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go:1)
- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go:1)
### 3.4 CORS
支持常规 `*` 语义:
- `cors_origins: ["*"]`
- `cors_methods: ["*"]`
- `cors_headers: ["*"]`
- `cors_expose_headers: ["*"]`
实现位置:
- [internal/middleware/cors.go](/Users/liuml/data/go/src/go-layout/internal/middleware/cors.go:1)
- [config/config.yaml.example](/Users/liuml/data/go/src/go-layout/config/config.yaml.example:17)
### 3.5 错误国际化
错误文案由请求语言驱动:
- `RequestLocaleHandler` 从 `Accept-Language` 解析语言并写入请求上下文。
- 响应层根据上下文语言选择错误文案语言。
- 通用业务错误默认走错误码文案表。
- 需要细粒度错误文案时,优先使用 `message key`,由响应层统一国际化。
- 参数校验错误由 validator translator 输出多语言文案,字段名优先使用 `label/json/form` 标签。
关键文件:
- [internal/middleware/request_locale.go](/Users/liuml/data/go/src/go-layout/internal/middleware/request_locale.go:1)
- [internal/pkg/i18n/locale.go](/Users/liuml/data/go/src/go-layout/internal/pkg/i18n/locale.go:1)
- [internal/pkg/errors/error.go](/Users/liuml/data/go/src/go-layout/internal/pkg/errors/error.go:1)
- [internal/pkg/errors/zh-cn.go](/Users/liuml/data/go/src/go-layout/internal/pkg/errors/zh-cn.go:1)
- [internal/pkg/errors/en-us.go](/Users/liuml/data/go/src/go-layout/internal/pkg/errors/en-us.go:1)
- [internal/pkg/response/response.go](/Users/liuml/data/go/src/go-layout/internal/pkg/response/response.go:1)
- [internal/validator/runtime.go](/Users/liuml/data/go/src/go-layout/internal/validator/runtime.go:1)
- [internal/validator/translation.go](/Users/liuml/data/go/src/go-layout/internal/validator/translation.go:1)
- [internal/validator/binding.go](/Users/liuml/data/go/src/go-layout/internal/validator/binding.go:1)
### 3.6 菜单 I18n
菜单标题由请求语言驱动:
- 列表/树:只返回当前语言 `title`,不返回 `title_i18n`。
- 详情:返回 `title_i18n` 供编辑回填,不返回 `title`。
- 写接口:使用 `title_i18n`,支持 `zh-CN` 与 `en-US`,至少一种非空。
关键文件:
- [internal/controller/admin_v1/auth_menu.go](/Users/liuml/data/go/src/go-layout/internal/controller/admin_v1/auth_menu.go:1)
- [internal/service/menu/menu_query.go](/Users/liuml/data/go/src/go-layout/internal/service/menu/menu_query.go:1)
- [internal/service/menu/menu_edit.go](/Users/liuml/data/go/src/go-layout/internal/service/menu/menu_edit.go:1)
- [internal/resources/menu.go](/Users/liuml/data/go/src/go-layout/internal/resources/menu.go:1)
- [tests/admin_test/menu_test.go](/Users/liuml/data/go/src/go-layout/tests/admin_test/menu_test.go:1)
### 3.7 文件组织
典型职责包:
- `internal/service/admin/`
- `internal/service/role/`
- `internal/service/dept/`
- `internal/service/access/`
- `internal/validator/`
- `internal/model/`
继续改时,优先往对应职责文件里放;可以同包拆文件,保持文件职责清晰。
## 4. 任务阅读索引
新会话建议先看:
1. [README.md](/Users/liuml/data/go/src/go-layout/README.md:1)
2. [docs/COMMANDS_AND_TASKS.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.md:1)
3. [cmd/root.go](/Users/liuml/data/go/src/go-layout/cmd/root.go:1)
4. [cmd/service/service.go](/Users/liuml/data/go/src/go-layout/cmd/service/service.go:1)
5. [internal/routers](/Users/liuml/data/go/src/go-layout/internal/routers)
按任务追加阅读:
- 新增或修改后台接口:
- [internal/routers](/Users/liuml/data/go/src/go-layout/internal/routers)
- [internal/controller/admin_v1](/Users/liuml/data/go/src/go-layout/internal/controller/admin_v1)
- [internal/validator/form](/Users/liuml/data/go/src/go-layout/internal/validator/form)
- [internal/service](/Users/liuml/data/go/src/go-layout/internal/service)
- [internal/resources](/Users/liuml/data/go/src/go-layout/internal/resources)
- [tests/admin_test](/Users/liuml/data/go/src/go-layout/tests/admin_test)
- 权限 / Casbin / 菜单可见性:
- [internal/access/casbin](/Users/liuml/data/go/src/go-layout/internal/access/casbin)
- [internal/service/access](/Users/liuml/data/go/src/go-layout/internal/service/access)
- [rbac_model.conf](/Users/liuml/data/go/src/go-layout/rbac_model.conf:1)
- 鉴权 / token / 当前用户:
- [internal/middleware/parse_token.go](/Users/liuml/data/go/src/go-layout/internal/middleware/parse_token.go:1)
- [internal/middleware/admin_auth.go](/Users/liuml/data/go/src/go-layout/internal/middleware/admin_auth.go:1)
- [internal/service/auth](/Users/liuml/data/go/src/go-layout/internal/service/auth)
- 请求日志 / 审计 / 队列:
- [internal/middleware/logger.go](/Users/liuml/data/go/src/go-layout/internal/middleware/logger.go:1)
- [internal/middleware/audit_queue.go](/Users/liuml/data/go/src/go-layout/internal/middleware/audit_queue.go:1)
- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go:1)
- [internal/service/audit](/Users/liuml/data/go/src/go-layout/internal/service/audit)
- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go:1)
- 菜单标题国际化:
- [internal/service/menu](/Users/liuml/data/go/src/go-layout/internal/service/menu)
- [internal/model/menu_i18n.go](/Users/liuml/data/go/src/go-layout/internal/model/menu_i18n.go:1)
- [internal/resources/menu.go](/Users/liuml/data/go/src/go-layout/internal/resources/menu.go:1)
- [tests/admin_test/menu_test.go](/Users/liuml/data/go/src/go-layout/tests/admin_test/menu_test.go:1)
- 模型基础能力 / 列表分页 / 删除:
- [internal/model/base.go](/Users/liuml/data/go/src/go-layout/internal/model/base.go:1)
- [internal/model/base_crud.go](/Users/liuml/data/go/src/go-layout/internal/model/base_crud.go:1)
- [internal/model/base_list.go](/Users/liuml/data/go/src/go-layout/internal/model/base_list.go:1)
- [internal/model/base_tree.go](/Users/liuml/data/go/src/go-layout/internal/model/base_tree.go:1)
## 5. 目录边界
目录需要按职责边界理解,而不只是物理位置。
### 5.1 `cmd/`
负责程序入口和启动编排,不负责业务逻辑。
关键命令:
- `service`
- `worker`
- `cron`
- `command`
### 5.2 `internal/controller/`
HTTP 控制器层。
职责:
- 收参
- 调 validator/form
- 调 service
- 返回 response
不应该在 controller 里写复杂业务判断、事务或批量数据修复逻辑。
### 5.3 `internal/service/`
业务层核心。
职责:
- 业务规则
- 事务编排
- 模型组合查询
- 触发权限同步
- 触发 token 撤销
这个项目的大部分改动都应该优先落在 service 层。
### 5.4 `internal/model/`
GORM 模型层和基础数据访问层。
职责:
- 表结构
- 通用 CRUD
- 列表分页
- 树形节点辅助
复杂业务判断放在 service 层,model 保持数据模型与通用数据访问职责。
### 5.5 `internal/resources/`
响应整形层。
职责:
- 把 model / service 结果转换成前端要的结构。
响应整形采用显式 transformer,不直接把 model 原样 JSON 输出。
### 5.6 `internal/validator/` 与 `internal/validator/form/`
参数对象和校验规则层。
职责:
- 定义请求参数 struct。
- 定义 tag 校验规则。
- 统一错误翻译和绑定错误处理。
### 5.7 `internal/service/access/`
权限同步协调层。
职责拆分为:
- `api_cache`
- `scope_resolver`
- `graph_loader`
- `coordinator`
- `menu_api_defaults`
- `system_defaults`
- `user_permission_sync`
外层统一从 `PermissionSyncCoordinator` 进入,业务层通过协调器触发 Casbin 权限同步。
## 6. 开发风格
### 6.1 命名
- service 名称以业务对象命名,如 `AdminUserService`、`RoleService`。
- constructor 用 `NewXxxService()`。
- 业务入口方法名直接用动词:`List`、`Create`、`Update`、`Delete`、`BindRole`。
- 内部辅助方法用小写,尽量语义直接:`buildListCondition`、`updateDeptRole`。
避免引入抽象但缺少项目语境的命名,例如:
- `Processor`
- `Manager`
- `Facade`
- `RepositoryFactory`
只有代码里存在明确同类模式时,才沿用同类命名。
### 6.2 结构
优先做“同包拆文件”,新增 package 前先确认职责边界确实独立。
结构整理方式:
- 保留 `package` 不变。
- 保留原 service 名称不变。
- 把一个大文件拆成多个职责文件。
### 6.3 错误处理
业务错误优先使用:
- `internal/pkg/errors`
典型方式:
- `e.NewBusinessError(...)`
业务错误码优先复用,保持现有错误码风格。
### 6.4 事务与权限刷新
事务与权限刷新规范:
- service 层显式事务。
- 事务工具统一复用。
- 权限刷新或缓存刷新在事务提交后处理。
“事务 + 后置刷新”保持在 service 层编排。
### 6.5 响应
接口响应走统一响应封装,不直接返回裸对象。
所以:
- controller 保持现有 response 风格。
- 列表接口尽量返回 collection。
- detail 接口尽量走 transformer。
### 6.6 注释
注释使用少量中文,说明职责和意图;除非必要复杂需求才写长注释。
可以写:
- `// BindRole 绑定角色。`
- `// ReloadPolicyCache 在事务提交后刷新共享 Casbin Enforcer 的内存策略。`
注释保持高信息密度,只解释职责、意图和复杂逻辑。
## 7. 写代码硬规则
### 7.1 先复用,再新增
做任何功能前,先查:
- service 是否存在类似逻辑。
- model 是否存在通用方法。
- validator 是否存在相同校验模式。
- resources 是否存在类似 transformer。
优先复用现有能力。
### 7.2 小步修改
这个项目是工程型仓库,不是实验仓库。
可以:
- 局部重构。
- 同包拆文件。
- 补测试。
保持现有范式,尤其避免突然引入:
- repository 模式全量替换。
- 泛型 service 框架大改。
- 新 DI 容器。
- 新 HTTP 框架。
### 7.3 保持接口签名稳定
如果只是做结构优化或内部修复:
- controller 签名不改。
- service 对外方法名尽量不改。
- HTTP API 不改。
- CLI 命令名不改。
### 7.4 新增行为要配测试
尤其是下面几类:
- 鉴权
- 权限同步
- CORS
- 日志截断
- token 撤销
- 默认值生成
- 事务提交后刷新
如果是新增接口,不仅要补直接相关的测试,还要默认在 `tests/` 目录下补接口级测试用例。
原因:
- 项目有明确的 `tests/admin_test` 集成测试入口。
- 只补 service 或 middleware 级测试,不足以证明接口链路真实可用。
- 新接口至少要验证路由、鉴权、请求参数、响应结构中的关键路径。
默认要求:
- 新增 HTTP 接口:补 `tests/` 目录下的接口测试。
- 修改现有 HTTP 接口行为:补或更新 `tests/` 目录下对应测试。
- 只改内部实现且对外行为不变:可以只补包内测试,但要能说明为什么不需要接口测试。
### 7.5 改完先验证
改完代码后,先自己跑测试,再决定是否结束本轮工作。
最小要求:
- 小改动:跑直接受影响包测试。
- 中等改动:跑相关子系统测试。
- 公共层或高风险改动:优先跑 `go test ./...`。
以下结束方式不符合项目要求:
- 只改代码,不跑测试。
- 只说“理论上没问题”。
- 只让用户自己去验证。
如果因为环境限制无法完成某项测试,结果里必须明确:
- 哪些测试跑了。
- 哪些测试没跑。
- 没跑的原因是什么。
推荐基线:
```bash
go test ./internal/...
go test ./...
go test ./internal/middleware ./internal/service/access ./internal/service/auth
```
## 8. 新增后台接口路径
建议按这个顺序:
1. 在 `internal/validator/form/` 定义参数 struct。
2. 在 `internal/model/` 复用或补充数据访问方法。
3. 在 `internal/service/` 写业务逻辑。
4. 在 `internal/resources/` 补返回结构。
5. 在 `internal/controller/admin_v1/` 加控制器方法。
6. 在 `internal/routers/` 的声明式路由树里注册。
7. 如涉及权限元数据,同步执行 `command api-route`。
8. 如涉及角色/菜单/API 关系变化,确认权限重建链路是否需要触发。
9. 在 `tests/` 目录下补接口级测试。
## 9. 高风险区域
### 9.1 `internal/service/access`
会影响:
- 菜单可见性
- API 权限
- Casbin 最终策略
### 9.2 `internal/service/auth`
风险点:
- 鉴权链路采用 claims-first。
- token 黑名单 / 撤销逻辑集中在认证服务内。
- 改不好会影响所有登录态请求。
### 9.3 `internal/middleware/logger*`
风险点:
- 请求体截断。
- 响应体捕获。
- 队列异步投递。
- 队列关闭时的同步审计落库。
- 性能与日志正确性平衡。
### 9.4 `internal/model/base_*`
风险点:
- 很多 model 共用。
- 一旦改坏,会影响列表、分页、删除、防误删逻辑。
## 10. 接手检查命令
先看工作树状态:
```bash
git status --short
```
再看测试基线:
```bash
go test ./...
```
如果只改中间件 / 权限 / 鉴权,优先跑对应子集:
```bash
go test ./internal/middleware ./internal/service/access ./internal/service/auth
```
## 11. 文档关系
根目录文档建议这样理解:
- [README.md](/Users/liuml/data/go/src/go-layout/README.md:1)
- 对外项目介绍、基础使用说明。
- [docs/COMMANDS_AND_TASKS.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.md:1)
- 命令说明、定时任务与操作约束。
- 本文档
- 给下一位 AI 的“当前状态 + 接手约束 + 写码风格”说明。
如果文档和代码冲突:
- 以代码为准。
- 再顺手修正文档。
## 12. 一句话接手策略
先读入口和当前链路,再读与你任务相关的 service;优先复用现有结构,保持命名和接口稳定,用小步修改把需求做完,维持项目既有风格。
================================================
FILE: README.en.md
================================================
#
gin-layout
A Gin-based admin backend scaffold
Built with JWT auth, RBAC, request/login logging, file upload, readiness probes, validation, request-locale i18n, declarative routing, and CLI initialization commands.
## Why This Exists
Most 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:
- Auth, permissions, logging, and file handling are split across too many places
- Route declarations, menus, and API permissions drift apart over time
- The same admin infrastructure is rewritten repeatedly across projects
- Config, command, migration, and deployment workflows lack a clear baseline
`gin-layout` is built to turn these repeated backend concerns into a reusable, production-oriented foundation for admin systems.
## Highlights
| Capability | Description |
| --- | --- |
| Auth | JWT login, token verification, auto refresh, and blacklist support |
| RBAC | Admin, role, department, menu, and API permission management |
| Route Metadata | Declarative route tree generates both Gin routes and API metadata |
| Logs | Built-in login logs, request logs, and unified response structure |
| File Access | File upload and public / private file access |
| I18n | Error messages and menu titles are resolved by `Accept-Language` (`zh-CN` / `en-US`) |
| Health Probes | Built-in `/ping` and `/health/readiness` for liveness and dependency-readiness checks |
| Tooling | CLI commands for initialization, route sync, permission rebuild, and migrations |
| Hot Reload | Partial config hot reload with fallback to previous live instances on failure |
## Related Resources
- Frontend project: [go-admin-ui](https://github.com/wannanbigpig/go-admin-ui)
- Online docs: [Apifox](https://wannanbigpig.apifox.cn/)
- Demo: [Live Demo](https://x-l-admin.wannanbigpig.com/)
- Commands and jobs guide: [docs/COMMANDS_AND_TASKS.en.md](./docs/COMMANDS_AND_TASKS.en.md)
- Migration command guide: [docs/MIGRATE_COMMANDS.en.md](./docs/MIGRATE_COMMANDS.en.md)
## Quick Start
### 1. Requirements
- `Go >= 1.23`
- `MySQL >= 5.7`
- `Redis >= 5.0` (optional)
### 2. Install
```bash
git clone https://github.com/wannanbigpig/gin-layout.git
cd gin-layout
go mod download
```
### 3. Run Migrations
Recommended project commands:
```bash
go run main.go command migrate # defaults to migrate up
go run main.go command migrate check
```
After 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.
For migration file creation, timestamp naming rules, and full `down/goto/force/version` usage, see [docs/MIGRATE_COMMANDS.en.md](./docs/MIGRATE_COMMANDS.en.md).
### 4. Configure
For source runs, `GO_ENV=development` is recommended. Without `-c`:
- development mode uses `config.yaml` in the current working directory
- if missing, it copies from `config/config.yaml.example` in the current working directory
- non-development mode resolves `config.yaml` next to the executable and copies from sibling `config.yaml.example` when needed
You can also copy and edit the config file manually.
Minimal example:
```yaml
app:
app_env: local
debug: true
language: zh_CN
trusted_proxies:
- 127.0.0.1
watch_config: true
# allow_degraded_startup: false
jwt:
ttl: 7200
refresh_ttl: 3600
secret_key: change-me-to-a-random-secret
mysql:
enable: true
host: 127.0.0.1
port: 3306
database: go_layout
username: root
password: your_password
redis:
enable: true
host: 127.0.0.1
port: 6379
password: ""
database: 0
queue:
enable: true
use_default_redis: true
namespace: go_layout
concurrency: 8
strict_priority: false
queues:
critical: 4
default: 2
audit: 2
low: 1
audit_max_retry: 3
audit_timeout_seconds: 10
```
Notes:
- `jwt.secret_key` is required and cannot be empty
- If you only run the API and do not need async jobs, set `queue.enable` to `false`
- If `queue.enable=true` but you do not want to reuse `redis.*`, set `queue.use_default_redis` to `false` and fill in `queue.redis.*`
### 5. Start Service
```bash
GO_ENV=development go run main.go service
```
To explicitly set the listen host or port:
```bash
GO_ENV=development go run main.go service -H 127.0.0.1 -P 9001
```
If `queue.enable=true`, start the worker in a separate process as well:
```bash
GO_ENV=development go run main.go worker
```
### 6. Verify
```bash
curl http://127.0.0.1:9001/ping
curl http://127.0.0.1:9001/health/readiness
```
- `/ping` returns `pong` when the HTTP process is alive.
- `/health/readiness` returns `ready=true` when required dependencies are ready for the current runtime mode.
## Core Ideas
### Prefer Declarative Routing
Admin 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.
### Database Relations Are The Source Of Truth
The 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.
### Hot Reload Is Tiered
The 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.
### Request-Locale Driven Texts
The request pipeline parses `Accept-Language` (currently `zh-CN` / `en-US`) and applies normalized fallback behavior:
- Error messages are resolved by request locale, with fallback to default locale (`zh-CN`).
- Menu list / user menu tree returns localized `title` only.
- Menu detail returns `title_i18n` for edit backfill, not `title`.
- Menu create/update writes `title_i18n`; at least one of `zh-CN` / `en-US` must be non-empty.
## Commands
Help:
```bash
go run main.go -h
go run main.go command -h
go run main.go service --help
```
Common commands:
| Command | Description |
| --- | --- |
| `go run main.go version` | Print the current version |
| `go run main.go service` | Start the API service |
| `go run main.go service -H 0.0.0.0 -P 9001` | Explicitly set the listen host and port |
| `go run main.go worker` | Start the Asynq async worker |
| `go run main.go cron` | Start scheduled jobs |
| `go run main.go command demo` | Run the demo command |
| `go run main.go command api-route` | Scan the declarative route tree and rebuild the `api` route table |
| `go run main.go command rebuild-user-permissions` | Rebuild final user API permissions from database relationships |
| `go run main.go command init-system` | Roll back and rerun migrations, rebuild API routes, and rebuild user permissions |
| `go run main.go -c ./config.yaml command task scan-async` | Scan async task registration against the task-definition mirror |
| `go run main.go -c ./config.yaml command task scan-cron` | Scan cron task definitions against the task-definition mirror |
| `go run main.go -c ./config.yaml command migrate check` | Validate migration naming and up/down pairing |
| `go run main.go -c ./config.yaml command migrate up` | Apply all pending migrations |
| `go run main.go -c ./config.yaml command migrate down 1` | Roll back one migration version |
If the config file is not in the default location:
```bash
go run main.go -c /path/to/config.yaml service
go run main.go -c /path/to/config.yaml command init-system
```
See [docs/MIGRATE_COMMANDS.en.md](./docs/MIGRATE_COMMANDS.en.md) for full migration command details.
## Configuration
### Config Resolution
Config lookup order:
1. Explicit `-c` / `--config`
2. `config.yaml` in the current working directory for development mode
3. if missing, copy from `config/config.yaml.example` in the current working directory
4. `config.yaml` next to the executable for non-development mode
5. if missing, copy from sibling `config.yaml.example`
### Key Settings
| Key | Description |
| --- | --- |
| `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) |
| `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 |
| `app.base_url` | URL prefix used to generate public file access URLs |
| `app.trusted_proxies` | Trusted proxy addresses or CIDRs that affect `ClientIP()` and log IPs |
| `jwt.secret_key` | Required; do not use weak placeholder values in production |
| `jwt.ttl` / `jwt.refresh_ttl` | Token expiration and auto-refresh threshold |
| `mysql` | Database enable flag and connection settings |
| `redis` | Cache, blacklist, and distributed lock settings |
| `queue.use_default_redis` | `true` reuses `redis.*`; `false` uses the independent `queue.redis.*` connection |
| `queue` | Asynq enable flag, queue concurrency, priorities, and audit-log retry settings |
| `logger` | Log output, rotation, and retention strategy |
If requests pass through Nginx, Ingress, or a load balancer, keep `app.trusted_proxies` aligned or client IP logging may be inaccurate.
### Worker And Cron
- `service` serves the HTTP API.
- `worker` consumes Asynq jobs. The current first phase only moves request audit-log persistence to Asynq.
- When `queue.enable=false`, you do not need to start `worker`; request audit logs are persisted synchronously in the current request flow.
- `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.
- `reset-system-data` is only registered when `app.enable_reset_system_cron=true` is explicitly configured, and startup logs a high-risk warning.
- Do not register the same recurring business task in both `cron` and the async worker flow, or it will run twice.
Note: `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.
### Hot Reload
Enable it with:
```yaml
app:
watch_config: true
```
Hot-reload supported:
- `logger.*`
- `mysql.*`
- `redis.*`
- `app.base_url`
- `app.cors_*`
- `jwt.ttl`
- `jwt.refresh_ttl`
Detected but requires restart:
- `app.trusted_proxies`
- `app.language`
- `app.allow_degraded_startup`
- `jwt.secret_key`
- service listen address and port
- route structure
Notes:
- `watch_config=true` only enables file watching; it does not mean every setting is safely swappable
- MySQL, Redis, and Casbin instances are rebuilt from the new config and the old instance is kept on failure
- JWT secret hot reload is not currently supported; changes are logged and only take effect after restart
## Development
### Add A New Endpoint
1. Write the controller in `internal/controller/`
2. Write the business logic in `internal/service/`
3. Define request params in `internal/validator/form/`
4. Declare the route in `AdminRouteTree()`
5. Run `go run main.go command api-route` if the API route table needs to be refreshed
### Validation Conventions
- For enum-like fields (for example `status`, `is_auth`, `method`), prefer explicit `oneof` constraints.
- 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.
- When adding/changing validation rules, add both positive and negative tests to prevent regressions.
### Test
```bash
go test ./...
go test ./tests/admin_test
```
Tests 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.
## Deployment
### Build
```bash
go build -o go-layout main.go
./go-layout service
```
If `-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.
### Supervisor
```ini
[program:go-layout]
command=/path/to/go-layout -c /path/to/config.yaml service
directory=/path/to/go-layout
autostart=true
autorestart=true
startsecs=5
user=www-data
redirect_stderr=true
stdout_logfile=/path/to/go-layout/supervisord.log
```
### Nginx
```nginx
server {
listen 80;
server_name api.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:9001;
}
}
```
If you are behind a reverse proxy, add the proxy address or CIDR to `app.trusted_proxies`.
## Project Layout
```text
gin-layout/
├── cmd/ # CLI entrypoints
├── config/ # Config structs and example config
├── data/ # MySQL / Redis and migrations
├── docs/ # Supplementary docs and resources
├── internal/
│ ├── access/ # Access and permission infrastructure
│ ├── controller/ # Controllers
│ ├── middleware/ # Middlewares
│ ├── model/ # Data models
│ ├── resources/ # Resource transformers
│ ├── routers/ # Declarative routing
│ ├── service/ # Business services
│ └── validator/ # Request validation
├── pkg/ # Shared utilities
├── storage/ # File storage
├── tests/ # Route and integration tests
└── README.md
```
## 💝 Support This Project
Thanks for using `gin-layout`.
If this project helps you, you can support its ongoing development and maintenance.
## License
This project is released under the MIT License. See [LICENSE](LICENSE).
## Contributing
Issues and pull requests are welcome.
## Disclaimer
This 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.
================================================
FILE: README.md
================================================
# gin-layout
基于 Gin 的后台管理系统脚手架
内置 JWT 认证、RBAC 权限、请求/登录日志、文件上传、readiness 探针、参数校验、请求语言国际化、声明式路由和 CLI 初始化命令。
## 项目定位
很多后台项目一开始都只是想“先把登录、权限、菜单、上传和日志跑起来”,但真正进入开发后,通常会很快碰到这些重复问题:
- 认证、权限、日志和文件能力分散,初始化成本高
- 路由、菜单、API 权限关系容易逐步失控
- 不同项目里同一套后台基础设施被反复重写
- 配置、命令、迁移和部署流程缺少统一约定
`gin-layout` 的目标很明确:把后台管理场景里高频、重复、工程化要求高的基础能力沉淀成一套可以直接落地的后端骨架。
## 核心特性
| 能力 | 说明 |
| --- | --- |
| Auth | 内置 JWT 登录、Token 校验、自动刷新、黑名单 |
| RBAC | 管理员、角色、部门、菜单、API 权限管理 |
| Route Metadata | 声明式路由树统一生成 Gin 路由和 API 元数据 |
| Logs | 内置登录日志、请求日志、统一响应结构 |
| File Access | 文件上传与公开 / 私有文件访问 |
| I18n | 基于 `Accept-Language` 返回错误文案与菜单标题(支持 `zh-CN` / `en-US`) |
| Health Probes | 提供 `/ping` 与 `/health/readiness`,便于存活检测与依赖就绪检查 |
| Tooling | 提供 CLI 初始化、路由同步、权限重建、迁移配套能力 |
| Hot Reload | 支持部分配置热更新,失败时保留旧实例继续运行 |
## 相关资源
- 前端项目:[go-admin-ui](https://github.com/wannanbigpig/go-admin-ui)
- 在线文档:[Apifox](https://wannanbigpig.apifox.cn/)
- 演示地址:[在线演示](https://x-l-admin.wannanbigpig.com/)
- 命令与任务文档:[docs/COMMANDS_AND_TASKS.md](./docs/COMMANDS_AND_TASKS.md)
- 迁移命令详解:[docs/MIGRATE_COMMANDS.md](./docs/MIGRATE_COMMANDS.md)
## 快速开始
### 1. 环境要求
- `Go >= 1.23`
- `MySQL >= 5.7`
- `Redis >= 5.0`(可选)
### 2. 安装项目
```bash
git clone https://github.com/wannanbigpig/gin-layout.git
cd gin-layout
go mod download
```
### 3. 执行迁移
推荐使用项目命令:
```bash
go run main.go command migrate # 默认等价于 migrate up
go run main.go command migrate check
```
迁移执行完成后会写入一套默认基础数据,其中包含超级管理员账号 `super_admin / 123456`。仅建议用于本地初始化,首次登录后请立即修改密码。
迁移文件创建、时间戳命名规范、`down/goto/force/version` 等详细说明见:[docs/MIGRATE_COMMANDS.md](./docs/MIGRATE_COMMANDS.md)。
### 4. 配置项目
源码运行时建议带上 `GO_ENV=development`。未显式传入 `-c` 时:
- 开发模式会把当前工作目录下的 `config/config.yaml.example` 自动复制为项目根目录 `config.yaml`
- 非开发模式会在可执行文件同级查找 `config.yaml`,若不存在则尝试从同目录 `config.yaml.example` 复制
也可以手动复制配置文件后再修改。
最小配置示例:
```yaml
app:
app_env: local
debug: true
language: zh_CN
trusted_proxies:
- 127.0.0.1
watch_config: true
# allow_degraded_startup: false
jwt:
ttl: 7200
refresh_ttl: 3600
secret_key: change-me-to-a-random-secret
mysql:
enable: true
host: 127.0.0.1
port: 3306
database: go_layout
username: root
password: your_password
redis:
enable: true
host: 127.0.0.1
port: 6379
password: ""
database: 0
queue:
enable: true
use_default_redis: true
namespace: go_layout
concurrency: 8
strict_priority: false
queues:
critical: 4
default: 2
audit: 2
low: 1
audit_max_retry: 3
audit_timeout_seconds: 10
```
注意:
- `jwt.secret_key` 为必填项,不能为空
- 如果只启动 API、不启用异步任务,可以把 `queue.enable` 设为 `false`
- 如果 `queue.enable=true` 但不想复用 `redis.*`,请把 `queue.use_default_redis` 设为 `false`,并补齐 `queue.redis.*`
### 5. 启动服务
```bash
GO_ENV=development go run main.go service
```
需要显式指定监听地址或端口时:
```bash
GO_ENV=development go run main.go service -H 127.0.0.1 -P 9001
```
如果启用了 `queue.enable=true`,还需要单独启动 worker:
```bash
GO_ENV=development go run main.go worker
```
### 6. 验证服务
```bash
curl http://127.0.0.1:9001/ping
curl http://127.0.0.1:9001/health/readiness
```
- `/ping` 返回 `pong`,说明 HTTP 进程已正常启动
- `/health/readiness` 返回 `ready=true`,说明当前配置下需要的依赖已经就绪
## 设计思路
### 声明式路由优先
后台路由维护在一棵声明式路由树中,目前入口位于 `internal/routers/admin_router.go` 的 `AdminRouteTree()`。Gin 路由注册和 API 元数据初始化都从这棵树生成,避免“代码路由”和“权限路由”长期分叉。
### 数据库关系是权限真相
当前权限模型采用“数据库关系为真相,Casbin 负责最终接口判定”的方式。角色、部门、菜单和 API 的业务关系以数据库为准,`rebuild-user-permissions` 命令会按这些关系重建用户最终 API 权限。
### 配置热更新是分级的
项目支持配置热更新,但不是所有配置都会在运行中立即生效。支持热更新的资源会尝试重建;如果重建失败,会继续保留旧实例运行,避免把服务直接打挂。
### 请求语言驱动文案
项目会在请求链路读取 `Accept-Language`(当前支持 `zh-CN` / `en-US`),并做归一化与降级处理:
- 错误码文案:按请求语言返回;无法识别时降级到默认语言(`zh-CN`)。
- 菜单列表/用户菜单树:仅返回 `title`,由后端按请求语言解析后返回。
- 菜单详情:返回 `title_i18n`(用于前端编辑回填),不返回 `title`。
- 菜单新增/编辑:写接口使用 `title_i18n`,目前支持 `zh-CN` 与 `en-US`,两者至少一种非空。
## 常用命令
查看帮助:
```bash
go run main.go -h
go run main.go command -h
go run main.go service --help
```
常用命令:
| 命令 | 说明 |
| --- | --- |
| `go run main.go version` | 输出当前版本号 |
| `go run main.go service` | 启动 API 服务 |
| `go run main.go service -H 0.0.0.0 -P 9001` | 显式指定监听地址与端口 |
| `go run main.go worker` | 启动 Asynq 异步任务消费进程 |
| `go run main.go cron` | 启动定时任务 |
| `go run main.go command demo` | 运行示例命令 |
| `go run main.go command api-route -y` | 扫描声明式路由树并重建 `api` 路由表 |
| `go run main.go command rebuild-user-permissions -y` | 按数据库关系重建用户最终 API 权限 |
| `go run main.go command init-system -y` | 回滚并重新执行迁移、重建 API 路由、重建用户权限 |
| `go run main.go -c ./config.yaml command task scan-async` | 扫描异步任务注册与任务定义镜像 |
| `go run main.go -c ./config.yaml command task scan-cron` | 扫描定时任务定义与任务定义镜像 |
| `go run main.go -c ./config.yaml command migrate check` | 校验迁移文件命名与 up/down 配对 |
| `go run main.go -c ./config.yaml command migrate up` | 执行全部未应用迁移 |
| `go run main.go -c ./config.yaml command migrate down 1` | 回滚 1 个迁移版本 |
如果配置文件不在默认位置,可以显式指定:
```bash
go run main.go -c /path/to/config.yaml service
go run main.go -c /path/to/config.yaml command init-system
```
迁移命令完整参数见:[docs/MIGRATE_COMMANDS.md](./docs/MIGRATE_COMMANDS.md)。
补充说明:
- `api-route`、`rebuild-user-permissions`、`init-system` 默认会二次确认;自动化场景建议显式加 `-y`
- `init-system` 会清空并重建系统数据,只适合本地初始化或明确允许重置的环境
## 配置说明
### 配置查找顺序
配置文件查找顺序:
1. 显式传入 `-c` / `--config`
2. `GO_ENV=development` 时,使用当前工作目录的 `config.yaml`
3. 若第 2 步缺失,则尝试从当前工作目录的 `config/config.yaml.example` 复制生成
4. 非开发模式下,使用可执行文件所在目录的 `config.yaml`
5. 若第 4 步缺失,则尝试从可执行文件同级的 `config.yaml.example` 复制生成
### 主要配置项
| 配置项 | 说明 |
| --- | --- |
| `app.base_path` | 日志、上传文件等本地路径的基础目录;未配置时默认按 `GO_ENV` 选择(development=当前工作目录,其他=可执行文件目录) |
| `app.allow_degraded_startup` | 仅 `service` 命令生效;依赖初始化失败时允许 HTTP 服务先启动,并通过 readiness / 路由守卫暴露未就绪状态 |
| `app.base_url` | 文件访问 URL 前缀,用于生成公开文件地址 |
| `app.trusted_proxies` | 受信任代理地址或网段,影响 `ClientIP()` 与日志 IP |
| `jwt.secret_key` | 必填;生产环境不能使用弱占位值 |
| `jwt.ttl` / `jwt.refresh_ttl` | Token 过期时间与自动刷新阈值 |
| `mysql` | 数据库开关与连接信息 |
| `redis` | 缓存、黑名单和分布式锁配置 |
| `queue.use_default_redis` | `true` 复用 `redis.*`;`false` 时改用 `queue.redis.*` 独立连接 |
| `queue` | Asynq 异步任务开关、队列命名空间、并发度、优先级和审计日志重试策略 |
| `logger` | 日志输出、切割和保留策略 |
如果通过 Nginx、Ingress 或负载均衡转发请求,需要同步配置 `app.trusted_proxies`,否则客户端 IP 可能记录不准确。
### Worker 与 Cron
- `service` 负责提供 HTTP API。
- `worker` 负责消费 Asynq 异步任务。当前首版只接入请求审计日志异步落库。
- `queue.enable=false` 时,不需要启动 `worker`,请求审计日志会在当前请求链路同步落库。
- `cron` 负责定时任务调度;演示定时任务由系统配置 `task.cron_demo_enabled` 控制,初始化默认关闭。
- `reset-system-data` 需要显式配置 `app.enable_reset_system_cron=true` 才会注册,并在启动时输出高风险告警。
- 不要把同一个周期任务同时注册到 `cron` 和 `worker` 体系里,否则会重复执行。
注意:`reset-system-data` 当前调用的是 `system.ReinitializeSystemData()`,会回滚迁移并重建系统数据。生产环境建议保持 `app.enable_reset_system_cron=false`,只有在明确需要重建系统数据时才临时开启。
### 热更新
启用方式:
```yaml
app:
watch_config: true
```
支持热更新:
- `logger.*`
- `mysql.*`
- `redis.*`
- `app.base_url`
- `app.cors_*`
- `jwt.ttl`
- `jwt.refresh_ttl`
仅检测并提示“需要重启”:
- `app.trusted_proxies`
- `app.language`
- `app.allow_degraded_startup`
- `jwt.secret_key`
- 服务监听地址与端口
- 路由结构
说明:
- `watch_config=true` 只表示启用监听,不代表所有配置都能无损切换
- MySQL、Redis、Casbin 会按新配置重建实例,失败时保留旧实例
- JWT 密钥当前不支持热更新,修改后会记录告警并继续使用旧密钥,直到进程重启
## 开发指南
### 新增接口流程
1. 在 `internal/controller/` 编写控制器
2. 在 `internal/service/` 编写业务逻辑
3. 在 `internal/validator/form/` 定义请求参数
4. 在 `AdminRouteTree()` 中声明路由
5. 需要更新 API 路由表时执行 `go run main.go command api-route`
### 参数校验约定
- 枚举字段(如 `status`、`is_auth`、`method`)建议使用 `oneof` 显式约束。
- ID 数组字段(如 `role_ids`、`menu_list`、`api_list`)建议统一使用 `dive,gt=0`,避免无效 ID(如 `0`)进入业务层。
- 新增/修改校验规则时,建议同时补充正反例单测,避免后续回归。
### 测试
```bash
go test ./...
go test ./tests/admin_test
```
测试会优先使用项目根目录 `config.yaml`。如果当前环境的 MySQL 或 Redis 不可用,会自动回退到示例配置运行可脱离外部依赖的测试。
## 部署说明
### 构建
```bash
go build -o go-layout main.go
./go-layout service
```
如果没有显式传 `-c`,请在开发环境使用 `GO_ENV=development`,并确保当前工作目录存在 `config/config.yaml.example`(或已生成 `config.yaml`);部署二进制时请保证可执行文件同级存在配置文件。
### Supervisor
```ini
[program:go-layout]
command=/path/to/go-layout -c /path/to/config.yaml service
directory=/path/to/go-layout
autostart=true
autorestart=true
startsecs=5
user=www-data
redirect_stderr=true
stdout_logfile=/path/to/go-layout/supervisord.log
```
### Nginx
```nginx
server {
listen 80;
server_name api.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:9001;
}
}
```
如果前面有反向代理,请把代理地址或网段加入 `app.trusted_proxies`。
## 目录结构
```text
gin-layout/
├── cmd/ # 命令行入口
├── config/ # 配置结构与示例配置
├── data/ # MySQL / Redis 与迁移
├── docs/ # 补充文档与资源
├── internal/
│ ├── access/ # 权限基础设施
│ ├── controller/ # 控制器
│ ├── middleware/ # 中间件
│ ├── model/ # 数据模型
│ ├── resources/ # 资源转换
│ ├── routers/ # 声明式路由
│ ├── service/ # 业务逻辑
│ └── validator/ # 参数验证
├── pkg/ # 通用工具
├── storage/ # 文件存储
├── tests/ # 路由与集成测试
└── README.md
```
## 💝 赞助项目
感谢你使用 `gin-layout`。
如果这个项目对你有帮助,欢迎支持项目的持续开发与维护。
## 许可证
本项目采用 MIT 许可证,详见 [LICENSE](LICENSE)。
## 贡献
欢迎提交 Issue 和 Pull Request。
## 免责声明
本项目按 **“现状”提供**,不附带任何明示或默示担保。项目可能存在缺陷、安全漏洞或与特定业务场景不匹配的实现;上线前请自行完成代码审查、安全加固、配置审查、权限验收和数据备份。因使用、依赖、部署、改造或运维本项目导致的问题,由使用者自行承担。
================================================
FILE: build.sh
================================================
#!/bin/bash
set -e # 遇到错误立即退出
# ==================== 配置区域 ====================
PROJECT_NAME="go-layout"
BUILD_DIR="build"
DIST_DIR="${BUILD_DIR}/dist"
KEEP_VERSIONS=3
# 默认平台(单平台构建时使用)
DEFAULT_OS="linux"
DEFAULT_ARCH="amd64"
# 多平台构建列表
PLATFORMS=(
"linux/amd64"
"linux/arm64"
"darwin/amd64"
"darwin/arm64"
"windows/amd64"
)
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 生成版本号
VERSION=$(date +"%Y%m%d_%H%M%S")
# ==================== 工具函数 ====================
usage() {
cat << EOF
用法:$0 [选项]
选项:
-o, --os 目标操作系统 (linux, darwin, windows)
-a, --arch 目标架构 (amd64, arm64, 386)
-p, --platform 指定平台 (格式:os/arch,如 linux/amd64)
-a, --all 构建所有支持的平台
-n, --no-compress 不使用 UPX 压缩
-k, --keep 保留的历史版本数量 (默认:3,0=全部清理)
-h, --help 显示帮助信息
示例:
$0 # 使用默认平台 linux/amd64
$0 -o linux -a arm64 # 构建 linux/arm64
$0 -p darwin/amd64 # 构建 darwin/amd64
$0 --all # 构建所有平台
$0 --all -n # 构建所有平台,不压缩
$0 --all -k 5 # 构建所有平台,保留 5 个历史版本
$0 --clean-only -k 0 # 仅清理,删除所有历史版本
EOF
exit 0
}
# 清理 macOS 资源分叉文件
clean_macos_artifacts() {
local target_dir="$1"
if command -v dot_clean &> /dev/null; then
dot_clean "${target_dir}" 2>/dev/null || true
fi
find "${target_dir}" -type f \( -name "._*" -o -name ".DS_Store" \) -delete 2>/dev/null || true
}
# 获取文件大小(字节)
get_file_size() {
stat -f%z "$1" 2>/dev/null || stat -c%s "$1" 2>/dev/null
}
# 获取人类可读的文件大小
get_human_size() {
local bytes=$1
if [ "$bytes" -lt 1024 ]; then
echo "${bytes}B"
elif [ "$bytes" -lt 1048576 ]; then
awk "BEGIN {printf \"%.1fK\", $bytes/1024}"
else
awk "BEGIN {printf \"%.1fM\", $bytes/1048576}"
fi
}
# ==================== 构建流程 ====================
print_header() {
local os="$1"
local arch="$2"
echo -e "${BLUE}=========================================="
echo "构建:${os}/${arch}"
echo "版本:${VERSION}"
echo "==========================================${NC}"
}
# 编译 Go 二进制文件
build_binary() {
local os="$1"
local arch="$2"
local dist_dir="$3"
local binary_name="$4"
echo "正在编译..."
local binary_file="${dist_dir}/${binary_name}"
# Windows 平台添加.exe 后缀
if [ "$os" = "windows" ]; then
binary_file="${dist_dir}/${binary_name}.exe"
fi
if ! CGO_ENABLED=0 GOOS="${os}" GOARCH="${arch}" go build \
-ldflags="-w -s" -trimpath -o "${binary_file}" .; then
echo -e "${RED}编译失败:${os}/${arch}${NC}"
return 1
fi
local size=$(get_file_size "${binary_file}")
echo "编译完成:$(get_human_size ${size})"
return 0
}
# 使用 UPX 压缩二进制文件
compress_with_upx() {
local os="$1"
local dist_dir="$2"
local binary_name="$3"
local use_compress="$4"
if [ "$use_compress" = "false" ]; then
echo "跳过 UPX 压缩"
return 0
fi
if ! command -v upx &> /dev/null; then
echo "提示:安装 UPX 可减小二进制大小 (brew install upx)"
return 0
fi
local binary_file="${dist_dir}/${binary_name}"
[ "$os" = "windows" ] && binary_file="${dist_dir}/${binary_name}.exe"
echo "正在使用 UPX 压缩..."
local original_size=$(get_file_size "${binary_file}")
if upx --best --lzma "${binary_file}" > /dev/null 2>&1; then
local compressed_size=$(get_file_size "${binary_file}")
local ratio=$(awk "BEGIN {printf \"%.1f\", (1 - ${compressed_size}/${original_size}) * 100}")
echo -e "${GREEN}压缩完成:$(get_human_size ${compressed_size}) (压缩率:${ratio}%)${NC}"
else
echo -e "${YELLOW}UPX 压缩失败,使用原始二进制${NC}"
fi
}
# 复制必要的资源文件
copy_resources() {
local dist_dir="$1"
echo "复制资源文件..."
# 配置文件示例
[ -f "config/config.yaml.example" ] && cp "config/config.yaml.example" "${dist_dir}/"
[ -f "AI_DEPLOYMENT.md" ] && cp "AI_DEPLOYMENT.md" "${dist_dir}/"
# 数据库迁移文件
if [ -d "data/migrations" ]; then
mkdir -p "${dist_dir}/data/migrations"
rsync -av --exclude='._*' --exclude='.DS_Store' \
data/migrations/ "${dist_dir}/data/migrations/" 2>/dev/null \
|| cp -r data/migrations/* "${dist_dir}/data/migrations/" 2>/dev/null || true
fi
# 权限相关文件
[ -f "policy.csv" ] && cp "policy.csv" "${dist_dir}/"
[ -f "rbac_model.conf" ] && cp "rbac_model.conf" "${dist_dir}/"
}
# 创建压缩包
create_tarball() {
local os="$1"
local arch="$2"
local dist_dir="$3"
local tarball_name="${PROJECT_NAME}_${os}_${arch}_${VERSION}.tar.gz"
# 输出到 stderr(不污染返回值)
echo "创建压缩包..." >&2
cd "${BUILD_DIR}"
# Windows 平台使用 zip 格式
if [ "$os" = "windows" ]; then
tarball_name="${PROJECT_NAME}_${os}_${arch}_${VERSION}.zip"
if command -v zip &> /dev/null; then
zip -rq "${tarball_name}" dist
else
echo -e "${YELLOW}警告:zip 命令不可用,使用 tar.gz 格式${NC}" >&2
tar -czf "${tarball_name%.zip}.tar.gz" -C dist .
tarball_name="${tarball_name%.zip}.tar.gz"
fi
else
tar -czf "${tarball_name}" -C dist .
fi
cd ..
# 只输出文件名(用于返回值)
echo "TARBALL:${tarball_name}"
}
# 验证压缩包
verify_tarball() {
local tarball="$1"
echo "验证压缩包..."
# Windows zip 文件验证
if [[ "$tarball" == *.zip ]]; then
if command -v unzip &> /dev/null; then
unzip -l "${tarball}" | grep -q "\._" && {
echo -e "${RED}错误:压缩包中包含 macOS 资源分叉文件!${NC}"
return 1
}
fi
else
if tar -tzf "${tarball}" | grep -q "\._"; then
echo -e "${RED}错误:压缩包中包含 macOS 资源分叉文件!${NC}"
return 1
fi
fi
local file_count
if [[ "$tarball" == *.zip ]]; then
file_count=$(unzip -l "${tarball}" | tail -1 | awk '{print $2}')
else
file_count=$(tar -tzf "${tarball}" | wc -l | tr -d ' ')
fi
echo "验证通过:共 ${file_count} 个文件"
return 0
}
# 构建单个平台
build_platform() {
local os="$1"
local arch="$2"
local use_compress="$3"
local platform_dist="${DIST_DIR}/${os}_${arch}"
mkdir -p "${platform_dist}"
print_header "${os}" "${arch}"
if ! build_binary "${os}" "${arch}" "${platform_dist}" "${PROJECT_NAME}"; then
return 1
fi
compress_with_upx "${os}" "${platform_dist}" "${PROJECT_NAME}" "${use_compress}"
copy_resources "${platform_dist}"
echo "清理 macOS 资源分叉文件..."
clean_macos_artifacts "${platform_dist}"
local tarball
tarball=$(create_tarball "${os}" "${arch}" "${platform_dist}")
tarball="${tarball#TARBALL:}"
# 压缩包已在 BUILD_DIR 目录下,无需移动
# 清理平台临时目录
rm -rf "${platform_dist}"
if ! verify_tarball "${BUILD_DIR}/${tarball}"; then
return 1
fi
echo -e "${GREEN}构建完成:${tarball}${NC}"
echo ""
return 0
}
# 清理旧版本
cleanup_old_versions() {
local keep_count="${1:-$KEEP_VERSIONS}"
if [ "$keep_count" -eq 0 ]; then
echo "清理所有历史版本..."
rm -f "${BUILD_DIR}"/${PROJECT_NAME}_*_*.tar.gz "${BUILD_DIR}"/${PROJECT_NAME}_*_*.zip 2>/dev/null || true
echo "已删除所有历史版本"
return
fi
echo "清理旧版本(保留最新${keep_count}个)..."
for platform in "${PLATFORMS[@]}"; do
local os="${platform%/*}"
local arch="${platform#*/}"
local pattern="${PROJECT_NAME}_${os}_${arch}_*"
local all_versions=($(ls -t "${BUILD_DIR}"/${pattern}.tar.gz "${BUILD_DIR}"/${pattern}.zip 2>/dev/null || true))
local count=${#all_versions[@]}
if [ "${count}" -le "${keep_count}" ]; then
continue
fi
local delete_count=$((count - keep_count))
for ((i=keep_count; i/dev/null || echo "无构建文件"
return
fi
echo "=========================================="
echo "Go 项目构建脚本"
echo "版本:${VERSION}"
echo "=========================================="
# 准备构建目录
echo "清理旧的构建文件..."
rm -rf "${DIST_DIR}"
mkdir -p "${DIST_DIR}"
if [ "$BUILD_ALL" = true ]; then
# 多平台构建
echo -e "${BLUE}开始多平台构建...${NC}"
for platform in "${PLATFORMS[@]}"; do
local os="${platform%/*}"
local arch="${platform#*/}"
if ! build_platform "${os}" "${arch}" "${USE_COMPRESS}"; then
echo -e "${YELLOW}跳过失败的平台:${platform}${NC}"
fi
done
cleanup_old_versions "${KEEP_COUNT}"
echo "=========================================="
echo -e "${GREEN}所有平台构建完成!${NC}"
echo "输出目录:${BUILD_DIR}/"
ls -lh "${BUILD_DIR}"/*.tar.gz "${BUILD_DIR}"/*.zip 2>/dev/null || true
echo "=========================================="
elif [ -n "$PLATFORM" ]; then
# 指定平台构建
if [[ ! "$PLATFORM" =~ ^[a-z0-9]+/[a-z0-9]+$ ]]; then
echo -e "${RED}错误:平台格式不正确,应为 os/arch 格式${NC}"
exit 1
fi
local os="${PLATFORM%/*}"
local arch="${PLATFORM#*/}"
build_platform "${os}" "${arch}" "${USE_COMPRESS}"
echo "=========================================="
echo -e "${GREEN}构建完成!${NC}"
ls -lh "${BUILD_DIR}"/*.tar.gz "${BUILD_DIR}"/*.zip 2>/dev/null || true
echo "=========================================="
else
# 单平台构建(使用默认或命令行指定)
local os="${TARGET_OS:-$DEFAULT_OS}"
local arch="${TARGET_ARCH:-$DEFAULT_ARCH}"
build_platform "${os}" "${arch}" "${USE_COMPRESS}"
echo "=========================================="
echo -e "${GREEN}构建完成!${NC}"
ls -lh "${BUILD_DIR}"/*.tar.gz "${BUILD_DIR}"/*.zip 2>/dev/null || true
echo "=========================================="
fi
}
main "$@"
================================================
FILE: cmd/.gitignore
================================================
!.gitignore
go-layout
================================================
FILE: cmd/bootstrapx/bootstrap.go
================================================
package bootstrapx
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
taskcron "github.com/wannanbigpig/gin-layout/internal/cron"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue"
"github.com/wannanbigpig/gin-layout/internal/service/sys_config"
"github.com/wannanbigpig/gin-layout/internal/service/system"
"github.com/wannanbigpig/gin-layout/internal/validator"
"go.uber.org/zap"
)
const errorLoadingLocation = "Error loading location: %v"
// Requirements 描述命令运行前需要初始化的依赖。
type Requirements struct {
Data bool
Validator bool
Queue bool
AllowDegradedStartup bool
}
var (
initializeDataFunc = InitializeData
initializeValidatorFunc = InitializeValidator
initializeQueueFunc = InitializeQueue
)
// InitializeConfig 初始化配置。
func InitializeConfig(configPath string) error {
return config.InitConfig(configPath)
}
// InitializeTimezone 根据配置设置进程时区。
func InitializeTimezone() {
cfg := config.GetConfig()
if cfg.Timezone == nil {
return
}
location, err := time.LoadLocation(*cfg.Timezone)
if err != nil {
if log.Logger != nil {
log.Logger.Error(fmt.Sprintf(errorLoadingLocation, err), zap.Error(err))
}
fmt.Printf(errorLoadingLocation+"\n", err)
return
}
time.Local = location
}
// InitializeLogger 初始化全局日志组件。
func InitializeLogger() error {
return log.InitLogger()
}
// InitializeData 初始化数据源依赖。
func InitializeData() error {
if err := data.InitData(); err != nil {
return err
}
taskcron.RegisterHandler(taskcron.HandlerCronResetSystemData, func(ctx context.Context, payload map[string]any) error {
_ = ctx
_ = payload
return system.ReinitializeSystemData()
})
if err := sys_config.NewSysConfigService().WarmupRuntimeConfigIfAvailable(); err != nil {
return err
}
return taskcron.SyncBuiltinDefinitionsIfAvailable(config.GetConfig())
}
// InitializeValidator 初始化参数校验器。
func InitializeValidator() error {
return validator.InitValidatorTrans("zh")
}
// WrapCommand 为命令注入统一的初始化逻辑,并保留原有 PreRunE/RunE。
func WrapCommand(cmd *cobra.Command, req Requirements) *cobra.Command {
if cmd == nil {
return cmd
}
originalPreRunE := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if req.Data {
if err := initializeDataFunc(); err != nil {
if shouldAllowDegradedStartup(req) {
logDependencyInitWarning(c, "data", err)
} else {
return err
}
}
}
if req.Validator {
if err := initializeValidatorFunc(); err != nil {
return err
}
}
if req.Queue {
if err := initializeQueueFunc(); err != nil {
if shouldAllowDegradedStartup(req) {
logDependencyInitWarning(c, "queue", err)
} else {
return err
}
}
}
if originalPreRunE != nil {
return originalPreRunE(c, args)
}
return nil
}
return cmd
}
// InitializeQueue 初始化队列发布者。
func InitializeQueue() error {
cfg := config.GetConfig()
if !cfg.Queue.Enable {
return nil
}
if err := queue.InitPublisher(cfg); err != nil {
return err
}
return queue.InitInspector(cfg)
}
func shouldAllowDegradedStartup(req Requirements) bool {
if !req.AllowDegradedStartup {
return false
}
cfg := config.GetConfig()
return cfg != nil && cfg.AllowDegradedStartup
}
func logDependencyInitWarning(cmd *cobra.Command, dependency string, err error) {
if err == nil {
return
}
commandPath := ""
if cmd != nil {
commandPath = cmd.CommandPath()
}
if log.Logger != nil {
log.Logger.Warn("Dependency initialization failed, continue with degraded startup",
zap.String("command", commandPath),
zap.String("dependency", dependency),
zap.Error(err))
return
}
fmt.Printf("warning: dependency initialization failed, continue with degraded startup; command=%s dependency=%s err=%v\n", commandPath, dependency, err)
}
================================================
FILE: cmd/bootstrapx/bootstrap_test.go
================================================
package bootstrapx
import (
"errors"
"testing"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
func TestWrapCommandReturnsDataErrorWhenDegradedStartupDisabled(t *testing.T) {
restoreInit := stubBootstrapInitializers(
func() error { return errTestDataInit },
func() error { return nil },
func() error { return nil },
)
defer restoreInit()
restoreConfig := setAllowDegradedStartup(t, false)
defer restoreConfig()
cmd := WrapCommand(&cobra.Command{Use: "service"}, Requirements{
Data: true,
AllowDegradedStartup: true,
})
err := cmd.PreRunE(cmd, nil)
if !errors.Is(err, errTestDataInit) {
t.Fatalf("expected data init error, got %v", err)
}
}
func TestWrapCommandAllowsDataAndQueueErrorsWhenEnabled(t *testing.T) {
dataCalled := false
validatorCalled := false
queueCalled := false
originalCalled := false
restoreInit := stubBootstrapInitializers(
func() error {
dataCalled = true
return errTestDataInit
},
func() error {
validatorCalled = true
return nil
},
func() error {
queueCalled = true
return errTestQueueInit
},
)
defer restoreInit()
restoreConfig := setAllowDegradedStartup(t, true)
defer restoreConfig()
cmd := WrapCommand(&cobra.Command{
Use: "service",
PreRunE: func(cmd *cobra.Command, args []string) error {
originalCalled = true
return nil
},
}, Requirements{
Data: true,
Validator: true,
Queue: true,
AllowDegradedStartup: true,
})
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("expected degraded startup to continue, got %v", err)
}
if !dataCalled || !validatorCalled || !queueCalled {
t.Fatalf("expected all initializers to run, got data=%v validator=%v queue=%v", dataCalled, validatorCalled, queueCalled)
}
if !originalCalled {
t.Fatal("expected original PreRunE to be called")
}
}
func TestWrapCommandKeepsStrictModeForCommandsWithoutOptIn(t *testing.T) {
restoreInit := stubBootstrapInitializers(
func() error { return errTestDataInit },
func() error { return nil },
func() error { return nil },
)
defer restoreInit()
restoreConfig := setAllowDegradedStartup(t, true)
defer restoreConfig()
cmd := WrapCommand(&cobra.Command{Use: "cron"}, Requirements{
Data: true,
})
err := cmd.PreRunE(cmd, nil)
if !errors.Is(err, errTestDataInit) {
t.Fatalf("expected strict command to return data init error, got %v", err)
}
}
func TestWrapCommandKeepsValidatorStrictEvenWhenDegradedStartupEnabled(t *testing.T) {
restoreInit := stubBootstrapInitializers(
func() error { return errTestDataInit },
func() error { return errTestValidatorInit },
func() error { return nil },
)
defer restoreInit()
restoreConfig := setAllowDegradedStartup(t, true)
defer restoreConfig()
cmd := WrapCommand(&cobra.Command{Use: "service"}, Requirements{
Data: true,
Validator: true,
AllowDegradedStartup: true,
})
err := cmd.PreRunE(cmd, nil)
if !errors.Is(err, errTestValidatorInit) {
t.Fatalf("expected validator init error, got %v", err)
}
}
var (
errTestDataInit = errors.New("data init failed")
errTestQueueInit = errors.New("queue init failed")
errTestValidatorInit = errors.New("validator init failed")
)
func stubBootstrapInitializers(dataFn, validatorFn, queueFn func() error) func() {
previousData := initializeDataFunc
previousValidator := initializeValidatorFunc
previousQueue := initializeQueueFunc
initializeDataFunc = dataFn
initializeValidatorFunc = validatorFn
initializeQueueFunc = queueFn
return func() {
initializeDataFunc = previousData
initializeValidatorFunc = previousValidator
initializeQueueFunc = previousQueue
}
}
func setAllowDegradedStartup(t *testing.T, enabled bool) func() {
t.Helper()
originalLogger := log.Logger
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.AllowDegradedStartup = enabled
})
log.Logger = zap.NewNop()
return func() {
restoreConfig()
log.Logger = originalLogger
}
}
================================================
FILE: cmd/command/command.go
================================================
package command
import (
"github.com/spf13/cobra"
"github.com/wannanbigpig/gin-layout/cmd/bootstrapx"
"github.com/wannanbigpig/gin-layout/internal/console/demo"
initconsole "github.com/wannanbigpig/gin-layout/internal/console/init"
migrateconsole "github.com/wannanbigpig/gin-layout/internal/console/migrate"
"github.com/wannanbigpig/gin-layout/internal/console/system_init"
taskconsole "github.com/wannanbigpig/gin-layout/internal/console/task"
)
var (
Cmd = &cobra.Command{
Use: "command",
Short: "The control head runs the command",
Example: "go-layout command demo",
}
)
func init() {
registerSubCommands()
}
// registerSubCommands 注册子命令
func registerSubCommands() {
// 一次性运行脚本
Cmd.AddCommand(demo.Cmd)
Cmd.AddCommand(bootstrapx.WrapCommand(initconsole.ApiRouteCmd, bootstrapx.Requirements{Data: true})) // 初始化API路由表: go-layout command api-route
Cmd.AddCommand(bootstrapx.WrapCommand(initconsole.RebuildUserPermissionsCmd, bootstrapx.Requirements{Data: true})) // 重建用户最终 API 权限: go-layout command rebuild-user-permissions
Cmd.AddCommand(bootstrapx.WrapCommand(system_init.InitSystemCmd, bootstrapx.Requirements{Data: true})) // 初始化系统: go-layout command init-system
Cmd.AddCommand(migrateconsole.Cmd) // 迁移管理: go-layout command migrate up / down / create / check
Cmd.AddCommand(bootstrapx.WrapCommand(taskconsole.Cmd, bootstrapx.Requirements{Data: true})) // 任务扫描: go-layout command task scan-async
}
================================================
FILE: cmd/completion.go
================================================
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var completionNoDesc bool
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate shell completion scripts for go-layout.
Load examples:
bash: source <(go-layout completion bash)
zsh: source <(go-layout completion zsh)
fish: go-layout completion fish | source
powershell: go-layout completion powershell | Out-String | Invoke-Expression`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletionV2(os.Stdout, !completionNoDesc)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, !completionNoDesc)
case "powershell":
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell type %q", args[0])
}
},
}
func init() {
completionCmd.Flags().BoolVar(&completionNoDesc, "no-descriptions", false, "Disable completion descriptions where supported")
}
================================================
FILE: cmd/cron/cron.go
================================================
package cron
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/robfig/cron/v3"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/cmd/bootstrapx"
"github.com/wannanbigpig/gin-layout/data"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
var (
Cmd = bootstrapx.WrapCommand(&cobra.Command{
Use: "cron",
Short: "Starting a scheduled task",
Example: "go-layout cron",
RunE: func(cmd *cobra.Command, args []string) error {
return Start()
},
}, bootstrapx.Requirements{Data: true})
)
// Start 启动定时任务服务
func Start() error {
crontab, err := newScheduler()
if err != nil {
return err
}
if err := registerTasks(crontab); err != nil {
log.Logger.Error("定时任务启动失败", zap.Error(err))
return fmt.Errorf("定时任务启动失败: %w", err)
}
// 启动定时器
crontab.Start()
log.Logger.Info("Cron service started successfully")
// 优雅关闭
waitForShutdown()
stopCtx := crontab.Stop()
<-stopCtx.Done()
if err := data.Shutdown(); err != nil {
return fmt.Errorf("shutdown data resources failed: %w", err)
}
log.Logger.Info("Cron service stopped gracefully")
return nil
}
func newScheduler() (*cron.Cron, error) {
logger := &cronLogger{}
scheduler := cron.New(
cron.WithSeconds(),
cron.WithChain(cron.Recover(logger)),
)
if scheduler == nil {
return nil, fmt.Errorf("创建定时任务调度器失败")
}
return scheduler, nil
}
// waitForShutdown 等待关闭信号,实现优雅关闭
func waitForShutdown() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go handleSignals(cancel)
<-ctx.Done()
}
// handleSignals 处理系统信号(SIGINT、SIGTERM)
func handleSignals(cancel context.CancelFunc) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigChan
log.Logger.Warn("Received shutdown signal", zap.String("signal", sig.String()))
cancel()
}
// cronLogger 定时任务日志记录器
type cronLogger struct{}
// Info 记录信息日志
func (cl *cronLogger) Info(msg string, keysAndValues ...interface{}) {
if len(keysAndValues) > 0 {
log.Logger.Info(fmt.Sprintf(msg, keysAndValues...))
} else {
log.Logger.Info(msg)
}
}
// Error 记录错误日志
func (cl *cronLogger) Error(err error, msg string, keysAndValues ...interface{}) {
errorMsg := err.Error()
if len(keysAndValues) > 0 {
errorMsg += " " + fmt.Sprintf(msg, keysAndValues...)
} else if msg != "" {
errorMsg += " " + msg
}
log.Logger.Error(errorMsg, zap.Error(err))
}
================================================
FILE: cmd/cron/schedule.go
================================================
package cron
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/robfig/cron/v3"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/service/taskcenter"
)
// Scheduler 提供链式的任务注册方式。
type Scheduler struct {
logger *cronLogger
tasks []*scheduledTask
}
type scheduledTask struct {
name string
spec string
specErr error
run func() error
skipIfRun bool
}
// TaskBuilder 用于链式声明任务调度规则。
type TaskBuilder struct {
task *scheduledTask
}
var cronSpecParser = cron.NewParser(
cron.Second |
cron.Minute |
cron.Hour |
cron.Dom |
cron.Month |
cron.Dow |
cron.Descriptor,
)
func registerTasks(crontab *cron.Cron) error {
logger := &cronLogger{}
schedule := NewSchedule(logger)
defineSchedule(schedule)
return schedule.Register(crontab)
}
// NewSchedule 创建任务声明器。
func NewSchedule(logger *cronLogger) *Scheduler {
return &Scheduler{
logger: logger,
tasks: make([]*scheduledTask, 0, 4),
}
}
// Call 注册一个函数任务,默认启用防重入。
func (s *Scheduler) Call(name string, fn func()) *TaskBuilder {
task := &scheduledTask{
name: name,
run: func() error { fn(); return nil },
skipIfRun: true,
}
s.tasks = append(s.tasks, task)
return &TaskBuilder{task: task}
}
// CallE 注册一个返回 error 的函数任务。
func (s *Scheduler) CallE(name string, fn func() error) *TaskBuilder {
task := &scheduledTask{
name: name,
run: fn,
skipIfRun: true,
}
s.tasks = append(s.tasks, task)
return &TaskBuilder{task: task}
}
// Cron 直接使用 cron 表达式。
func (b *TaskBuilder) Cron(spec string) *TaskBuilder {
b.task.spec = spec
return b
}
// EveryFiveSeconds 每 5 秒执行一次,适合本地测试任务。
func (b *TaskBuilder) EveryFiveSeconds() *TaskBuilder {
return b.Cron("0/5 * * * * *")
}
// DailyAt 每天固定时间执行,支持 HH:MM 或 HH:MM:SS。
func (b *TaskBuilder) DailyAt(value string) *TaskBuilder {
spec, err := dailyAtSpec(value)
if err != nil {
b.task.specErr = err
return b
}
b.task.spec = spec
return b
}
// WithoutOverlapping 表示任务执行期间跳过重入。
func (b *TaskBuilder) WithoutOverlapping() *TaskBuilder {
b.task.skipIfRun = true
return b
}
// AllowOverlap 允许任务重入。
func (b *TaskBuilder) AllowOverlap() *TaskBuilder {
b.task.skipIfRun = false
return b
}
// Register 把声明过的任务统一注册到 cron 实例中。
func (s *Scheduler) Register(crontab *cron.Cron) error {
for _, task := range s.tasks {
if err := s.registerTask(crontab, task); err != nil {
return err
}
}
return nil
}
func (s *Scheduler) registerTask(crontab *cron.Cron, task *scheduledTask) error {
if task.specErr != nil {
return fmt.Errorf("定时任务 %s 调度表达式无效: %w", task.name, task.specErr)
}
if task.spec == "" {
return fmt.Errorf("定时任务 %s 缺少调度表达式", task.name)
}
chain := cron.NewChain(cron.Recover(s.logger))
if task.skipIfRun {
chain = cron.NewChain(
cron.SkipIfStillRunning(s.logger),
cron.Recover(s.logger),
)
}
if _, err := crontab.AddJob(task.spec, chain.Then(s.recordedJob(task))); err != nil {
return fmt.Errorf("添加定时任务失败 [%s] (schedule: %s): %w", task.name, task.spec, err)
}
log.Logger.Info("定时任务添加成功",
zap.String("name", task.name),
zap.String("schedule", task.spec),
zap.Bool("skip_if_still_running", task.skipIfRun),
)
return nil
}
func (s *Scheduler) recordedJob(task *scheduledTask) cron.Job {
return cron.FuncJob(func() {
if task == nil || task.run == nil {
return
}
ctx := context.Background()
taskCode := "cron:" + task.name
run, recordErr := taskcenter.NewRunRecorder().Start(ctx, taskcenter.RunStart{
TaskCode: taskCode,
Kind: model.TaskKindCron,
Source: model.TaskSourceCron,
SourceID: task.name,
CronSpec: task.spec,
})
if recordErr != nil {
log.Logger.Warn("记录定时任务开始失败",
zap.String("name", task.name),
zap.Error(recordErr))
}
err := task.run()
if err != nil {
log.Logger.Error("定时任务执行失败",
zap.String("name", task.name),
zap.Error(err))
}
if run == nil {
return
}
finishInput := taskcenter.RunFinish{Error: err}
nextRunAt, nextRunErr := calculateNextRunAt(task.spec, time.Now())
if nextRunErr != nil {
log.Logger.Warn("计算定时任务下次执行时间失败",
zap.String("name", task.name),
zap.String("schedule", task.spec),
zap.Error(nextRunErr))
}
finishInput.NextRunAt = nextRunAt
if recordErr := taskcenter.NewRunRecorder().Finish(ctx, run, finishInput); recordErr != nil {
log.Logger.Warn("记录定时任务结束失败",
zap.String("name", task.name),
zap.Error(recordErr))
}
})
}
func calculateNextRunAt(spec string, base time.Time) (*time.Time, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, nil
}
schedule, err := cronSpecParser.Parse(spec)
if err != nil {
return nil, err
}
nextRunAt := schedule.Next(base)
if nextRunAt.IsZero() {
return nil, nil
}
return &nextRunAt, nil
}
func dailyAtSpec(value string) (string, error) {
parts := strings.Split(value, ":")
switch len(parts) {
case 2:
hour, err := parseTimePart("hour", parts[0], 0, 23)
if err != nil {
return "", err
}
minute, err := parseTimePart("minute", parts[1], 0, 59)
if err != nil {
return "", err
}
return fmt.Sprintf("0 %d %d * * *", minute, hour), nil
case 3:
hour, err := parseTimePart("hour", parts[0], 0, 23)
if err != nil {
return "", err
}
minute, err := parseTimePart("minute", parts[1], 0, 59)
if err != nil {
return "", err
}
second, err := parseTimePart("second", parts[2], 0, 59)
if err != nil {
return "", err
}
return fmt.Sprintf("%d %d %d * * *", second, minute, hour), nil
default:
return "", fmt.Errorf("invalid daily time format: %s", value)
}
}
func parseTimePart(name, raw string, min, max int) (int, error) {
raw = strings.TrimSpace(raw)
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("invalid %s value %q", name, raw)
}
if value < min || value > max {
return 0, fmt.Errorf("%s value out of range [%d,%d]: %d", name, min, max, value)
}
return value, nil
}
================================================
FILE: cmd/cron/schedule_test.go
================================================
package cron
import (
"strings"
"testing"
"github.com/robfig/cron/v3"
)
func TestDailyAtSpecWithHHMM(t *testing.T) {
spec, err := dailyAtSpec("02:30")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if spec != "0 30 2 * * *" {
t.Fatalf("unexpected cron spec: %s", spec)
}
}
func TestDailyAtSpecWithHHMMSS(t *testing.T) {
spec, err := dailyAtSpec("03:04:05")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if spec != "5 4 3 * * *" {
t.Fatalf("unexpected cron spec: %s", spec)
}
}
func TestDailyAtSpecRejectsInvalidInput(t *testing.T) {
_, err := dailyAtSpec("invalid")
if err == nil {
t.Fatal("expected invalid input to return error")
}
}
func TestDailyAtSpecRejectsOutOfRange(t *testing.T) {
_, err := dailyAtSpec("25:00")
if err == nil {
t.Fatal("expected out of range input to return error")
}
}
func TestSchedulerRegisterReturnsDailyAtError(t *testing.T) {
schedule := NewSchedule(&cronLogger{})
schedule.Call("bad-task", func() {}).DailyAt("bad-time")
c := cron.New(cron.WithSeconds())
err := schedule.Register(c)
if err == nil {
t.Fatal("expected register to return error for invalid daily time")
}
if !strings.Contains(err.Error(), "调度表达式无效") {
t.Fatalf("unexpected error: %v", err)
}
}
================================================
FILE: cmd/cron/task_record_test.go
================================================
package cron
import (
"context"
"errors"
"testing"
"time"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/service/taskcenter"
)
func TestRecordedJobRecordsCallEFailure(t *testing.T) {
fake := &fakeCronRunRecorder{}
restore := taskcenter.SetRecorderForTesting(fake)
defer restore()
expectedErr := errors.New("cron failed")
schedule := NewSchedule(&cronLogger{})
builder := schedule.CallE("demo", func() error {
return expectedErr
}).EveryFiveSeconds()
startAt := time.Now()
schedule.recordedJob(builder.task).Run()
if len(fake.starts) != 1 {
t.Fatalf("expected 1 start call, got %d", len(fake.starts))
}
start := fake.starts[0]
if start.TaskCode != "cron:demo" || start.Kind != model.TaskKindCron || start.Source != model.TaskSourceCron {
t.Fatalf("unexpected start input: %#v", start)
}
if start.CronSpec != "0/5 * * * * *" {
t.Fatalf("unexpected cron spec: %s", start.CronSpec)
}
if len(fake.finishes) != 1 {
t.Fatalf("expected 1 finish call, got %d", len(fake.finishes))
}
if !errors.Is(fake.finishes[0].Error, expectedErr) {
t.Fatalf("unexpected finish error: %v", fake.finishes[0].Error)
}
if fake.finishes[0].NextRunAt == nil {
t.Fatal("expected finish next run at to be set")
}
if !fake.finishes[0].NextRunAt.After(startAt) {
t.Fatalf("expected next run at after start time, got %s", fake.finishes[0].NextRunAt.Format(time.DateTime))
}
}
func TestCalculateNextRunAtInvalidSpec(t *testing.T) {
nextRunAt, err := calculateNextRunAt("bad spec", time.Now())
if err == nil {
t.Fatal("expected parse error for invalid cron spec")
}
if nextRunAt != nil {
t.Fatalf("expected nil next run at on parse error, got %v", nextRunAt)
}
}
type fakeCronRunRecorder struct {
starts []taskcenter.RunStart
finishes []taskcenter.RunFinish
}
func (f *fakeCronRunRecorder) Enqueue(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {
_ = ctx
f.starts = append(f.starts, input)
return &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source}, nil
}
func (f *fakeCronRunRecorder) Start(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {
_ = ctx
f.starts = append(f.starts, input)
return &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source}, nil
}
func (f *fakeCronRunRecorder) Finish(ctx context.Context, run *model.TaskRun, input taskcenter.RunFinish) error {
_ = ctx
_ = run
f.finishes = append(f.finishes, input)
return nil
}
================================================
FILE: cmd/cron/tasks.go
================================================
package cron
import (
"context"
"strings"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
taskcron "github.com/wannanbigpig/gin-layout/internal/cron"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
func defineSchedule(schedule *Scheduler) {
cfg := config.GetConfig()
for _, definition := range taskcron.BuiltinTaskDefinitions(cfg) {
if definition.Kind != model.TaskKindCron || definition.Status != model.TaskStatusEnabled {
continue
}
if strings.TrimSpace(definition.Code) == "" || strings.TrimSpace(definition.CronSpec) == "" {
continue
}
taskName := taskNameFromCode(definition.Code)
handler := definition.Handler
if definition.IsHighRisk == model.TaskHighRisk {
log.Logger.Warn("高风险定时任务已启用",
zap.String("name", taskName),
zap.String("schedule", definition.CronSpec),
)
}
schedule.CallE(taskName, func() error {
return taskcron.ExecuteHandler(context.Background(), handler, nil)
}).
Cron(definition.CronSpec).
WithoutOverlapping()
}
}
func taskNameFromCode(code string) string {
code = strings.TrimSpace(code)
if strings.HasPrefix(code, "cron:") {
trimmed := strings.TrimPrefix(code, "cron:")
if trimmed != "" {
return trimmed
}
}
return code
}
================================================
FILE: cmd/cron/tasks_test.go
================================================
package cron
import (
"testing"
"github.com/wannanbigpig/gin-layout/config"
)
func TestDefineScheduleSkipsResetTaskByDefault(t *testing.T) {
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.EnableResetSystemCron = false
})
defer restoreConfig()
schedule := NewSchedule(&cronLogger{})
defineSchedule(schedule)
if hasScheduledTask(schedule, "reset-system-data") {
t.Fatal("expected reset-system-data task to be skipped by default")
}
if hasScheduledTask(schedule, "demo") {
t.Fatal("expected demo task to be skipped by default")
}
}
func TestDefineScheduleRegistersResetTaskWhenEnabled(t *testing.T) {
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.EnableResetSystemCron = true
})
defer restoreConfig()
schedule := NewSchedule(&cronLogger{})
defineSchedule(schedule)
if !hasScheduledTask(schedule, "reset-system-data") {
t.Fatal("expected reset-system-data task to be registered when enabled")
}
}
func hasScheduledTask(schedule *Scheduler, name string) bool {
for _, task := range schedule.tasks {
if task.name == name {
return true
}
}
return false
}
================================================
FILE: cmd/root.go
================================================
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/cmd/bootstrapx"
"github.com/wannanbigpig/gin-layout/cmd/command"
"github.com/wannanbigpig/gin-layout/cmd/cron"
"github.com/wannanbigpig/gin-layout/cmd/service"
"github.com/wannanbigpig/gin-layout/cmd/version"
"github.com/wannanbigpig/gin-layout/cmd/worker"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/runtime"
)
const (
welcomeMessage = "Welcome to go-layout. Use -h to see more commands"
)
var (
rootCmd = &cobra.Command{
Use: "go-layout",
Short: "go-layout",
SilenceUsage: true,
SilenceErrors: true,
Long: `Gin framework is used as the core of this project to build a scaffold,
based on the project can be quickly completed business development, out of the box 📦`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if shouldSkipBootstrap(cmd) {
return nil
}
if err := bootstrapx.InitializeConfig(configPath); err != nil {
return err
}
bootstrapx.InitializeTimezone()
if err := bootstrapx.InitializeLogger(); err != nil {
return err
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("%s\n", welcomeMessage)
},
}
configPath string
)
func init() {
runtime.RegisterConfigReloadHandlers()
registerFlags()
registerCommands()
}
// registerFlags 注册命令行标志
func registerFlags() {
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "The absolute path of the configuration file")
}
func shouldSkipBootstrap(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
if cmd.Name() == "help" {
return true
}
commandPath := cmd.CommandPath()
switch commandPath {
case "go-layout", "go-layout version", "go-layout help":
return true
default:
return strings.HasPrefix(commandPath, "go-layout completion") ||
strings.HasPrefix(commandPath, "go-layout __complete")
}
}
// registerCommands 注册子命令
func registerCommands() {
rootCmd.AddCommand(version.Cmd) // 查看版本: go-layout version
rootCmd.AddCommand(completionCmd)
rootCmd.AddCommand(service.Cmd) // 启动服务: go-layout service
rootCmd.AddCommand(command.Cmd) // 运行命令: go-layout command demo / go-layout command init api-route
rootCmd.AddCommand(cron.Cmd) // 启动计划任务: go-layout cron
rootCmd.AddCommand(worker.Cmd) // 启动异步任务 worker: go-layout worker
}
// Execute 执行命令
func Execute() {
if err := rootCmd.Execute(); err != nil {
if log.Logger != nil {
log.Logger.Error("Command execution failed", zap.Error(err))
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
================================================
FILE: cmd/service/service.go
================================================
package service
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/cmd/bootstrapx"
"github.com/wannanbigpig/gin-layout/data"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
_ "github.com/wannanbigpig/gin-layout/internal/queue/asynqx"
"github.com/wannanbigpig/gin-layout/internal/routers"
)
const (
defaultHost = "0.0.0.0"
defaultPort = 9001
gracefulShutdownTimout = 10 * time.Second
)
var (
Cmd = bootstrapx.WrapCommand(&cobra.Command{
Use: "service",
Short: "Start API service",
Example: "go-layout service -c config.yml",
RunE: func(cmd *cobra.Command, args []string) error {
return run()
},
}, bootstrapx.Requirements{Data: true, Validator: true, Queue: true, AllowDegradedStartup: true})
host string
port int
)
func init() {
registerFlags()
}
// registerFlags 注册命令行标志
func registerFlags() {
Cmd.Flags().StringVarP(&host, "host", "H", defaultHost, "监听服务器地址")
Cmd.Flags().IntVarP(&port, "port", "P", defaultPort, "监听服务器端口")
}
// run 运行服务器
func run() error {
engine, err := routers.SetRouters()
if err != nil {
return fmt.Errorf("build router failed: %w", err)
}
address := fmt.Sprintf("%s:%d", host, port)
server := &http.Server{
Addr: address,
Handler: engine,
}
errChan := make(chan error, 1)
go func() {
log.Logger.Info("API service starting", zap.String("address", address))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
close(errChan)
}()
return waitForShutdown(server, errChan)
}
func waitForShutdown(server *http.Server, errChan <-chan error) error {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
select {
case err, ok := <-errChan:
if ok && err != nil {
return err
}
return nil
case sig := <-sigChan:
log.Logger.Warn("Received API shutdown signal", zap.String("signal", sig.String()))
}
ctx, cancel := context.WithTimeout(context.Background(), gracefulShutdownTimout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return fmt.Errorf("shutdown http server failed: %w", err)
}
if err := data.Shutdown(); err != nil {
return fmt.Errorf("shutdown data resources failed: %w", err)
}
log.Logger.Info("API service stopped gracefully")
return nil
}
================================================
FILE: cmd/version/version.go
================================================
package version
import (
"fmt"
"github.com/spf13/cobra"
"github.com/wannanbigpig/gin-layout/internal/global"
)
var (
// Cmd 版本信息命令
Cmd = &cobra.Command{
Use: "version",
Short: "Get version info",
Example: "go-layout version",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(global.Version)
return nil
},
}
)
================================================
FILE: cmd/worker/worker.go
================================================
package worker
import (
"fmt"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/cmd/bootstrapx"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
taskcron "github.com/wannanbigpig/gin-layout/internal/cron"
"github.com/wannanbigpig/gin-layout/internal/jobs"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue/asynqx"
)
var Cmd = bootstrapx.WrapCommand(&cobra.Command{
Use: "worker",
Short: "Start async worker",
Example: "go-layout worker -c config.yml",
RunE: func(cmd *cobra.Command, args []string) error {
return run()
},
}, bootstrapx.Requirements{Data: true})
func run() error {
cfg := config.GetConfig()
if cfg == nil {
return fmt.Errorf("queue config is not initialized")
}
if !cfg.Queue.Enable {
return fmt.Errorf("queue.enable is false")
}
if cfg.Queue.UseDefaultRedis {
if !cfg.Redis.Enable {
return fmt.Errorf("queue uses default redis, but redis.enable is false")
}
if err := data.GetRedisInitError(); err != nil {
return fmt.Errorf("redis initialization failed: %w", err)
}
if data.RedisClient() == nil {
return fmt.Errorf("redis client is unavailable")
}
}
registry := jobs.NewRegistry()
cronFallbackHandlers := 0
if cfg.Queue.ConsumeCronFallback {
cronFallbackHandlers = taskcron.RegisterQueueFallbackHandlers(registry, cfg)
}
server, mux, err := asynqx.NewServer(cfg, registry)
if err != nil {
return err
}
log.Logger.Info("Async worker starting",
zap.Int("concurrency", cfg.Queue.Concurrency),
zap.Bool("strict_priority", cfg.Queue.StrictPriority),
zap.Bool("consume_cron_fallback", cfg.Queue.ConsumeCronFallback),
zap.Int("cron_fallback_handlers", cronFallbackHandlers),
zap.Any("queues", cfg.Queue.Queues))
if err := server.Run(mux); err != nil {
return err
}
if err := data.Shutdown(); err != nil {
return fmt.Errorf("shutdown data resources failed: %w", err)
}
log.Logger.Info("Async worker stopped gracefully")
return nil
}
================================================
FILE: config/.gitignore
================================================
*.yaml
*.ini
================================================
FILE: config/autoload/app.go
================================================
package autoload
import (
"github.com/wannanbigpig/gin-layout/pkg/utils"
)
// AppConfig 定义应用运行时基础配置。
type AppConfig struct {
// AppEnv 应用环境标识,如:local(本地)、dev(开发)、prod(生产)
AppEnv string `mapstructure:"app_env"`
// Debug 是否开启调试模式,true 时输出详细调试信息
Debug bool `mapstructure:"debug"`
// Language 国际化语言,如:zh_CN(中文)、en_US(英文)
Language string `mapstructure:"language"`
// WatchConfig 是否开启配置热更新,true 时配置变更自动重载
WatchConfig bool `mapstructure:"watch_config"`
// BasePath 应用基础路径,用于拼接文件存储路径。
// 未在配置文件显式设置时,会在配置加载阶段优先回填为当前工作目录。
BasePath string `mapstructure:"base_path"`
// BaseURL 文件访问的基础 URL(如:https://example.com),用于拼接文件访问地址
BaseURL string `mapstructure:"base_url"`
// Timezone 时区设置,nil 时使用系统默认时区
Timezone *string `mapstructure:"timezone"`
// TrustedProxies 受信任代理列表,仅这些代理转发的 X-Forwarded-For/X-Real-IP 会被信任
// 生产环境应配置为负载均衡或反向代理的 IP/网段
TrustedProxies []string `mapstructure:"trusted_proxies"`
// CorsOrigins CORS 允许的源列表,如:["http://localhost:3000", "https://example.com"]
// 使用 ["*"] 表示允许所有源(生产环境慎用)
CorsOrigins []string `mapstructure:"cors_origins"`
// CorsMethods 允许的 HTTP 方法,如:["GET", "POST", "PUT", "DELETE"]
// 使用 ["*"] 表示允许全部已支持方法,空数组使用默认值
CorsMethods []string `mapstructure:"cors_methods"`
// CorsHeaders 允许的请求头,如:["Content-Type", "Authorization"]
// 使用 ["*"] 表示允许全部请求头
CorsHeaders []string `mapstructure:"cors_headers"`
// CorsExposeHeaders 暴露的响应头,如:["Content-Length", "X-Request-Id"]
// 使用 ["*"] 表示暴露全部响应头
CorsExposeHeaders []string `mapstructure:"cors_expose_headers"`
// CorsMaxAge 预检请求(OPTIONS)缓存时间(秒),默认 43200(12 小时)
CorsMaxAge int `mapstructure:"cors_max_age"`
// CorsCredentials 是否允许携带凭证(cookies、Authorization 头等),默认 false
CorsCredentials bool `mapstructure:"cors_credentials"`
// AllowDegradedStartup 是否允许 service 在依赖初始化失败时降级启动。
// true 时仅 HTTP 服务会继续启动,由 readiness 与路由守卫体现未就绪状态。
AllowDegradedStartup bool `mapstructure:"allow_degraded_startup"`
// EnableResetSystemCron 是否启用高风险的系统重建定时任务。
// 默认 false,避免在非预期环境触发系统数据重建。
EnableResetSystemCron bool `mapstructure:"enable_reset_system_cron"`
}
var App = AppConfig{
AppEnv: "local", // 默认本地环境
Debug: true, // 默认开启调试模式
Language: "zh_CN", // 默认中文
WatchConfig: false, // 默认关闭配置热更新
BasePath: getDefaultPath(),
BaseURL: "", // 默认空,需要配置
Timezone: nil, // 默认使用系统时区
TrustedProxies: []string{"127.0.0.1"}, // 默认只信任本地
CorsOrigins: []string{}, // 默认空数组,不放行跨域来源;使用 ["*"] 表示允许所有源
CorsMethods: []string{}, // 默认空数组,使用默认方法列表;使用 ["*"] 表示允许全部已支持方法
CorsHeaders: []string{}, // 默认空数组,按请求头自动放行预检头;使用 ["*"] 表示允许全部请求头
CorsExposeHeaders: []string{}, // 默认空数组,默认暴露全部响应头;使用 ["*"] 明确表示暴露全部响应头
CorsMaxAge: 43200, // 默认 12 小时(43200 秒)
CorsCredentials: false, // 默认不允许携带凭证
AllowDegradedStartup: false, // 默认关闭降级启动,依赖初始化失败时直接退出
EnableResetSystemCron: false, // 默认关闭高风险系统重建定时任务
}
func getDefaultPath() (path string) {
// 初始化时优先按 GO_ENV 处理:
// - development: 当前工作目录
// - 其他环境: 可执行文件所在目录
// 配置加载阶段会按 app.base_path 是否显式配置进一步修正。
path, err := utils.GetDefaultPath()
if err != nil || path == "" {
path = "."
}
return
}
================================================
FILE: config/autoload/jwt.go
================================================
package autoload
import "time"
// JwtConfig 定义 JWT 相关配置。
type JwtConfig struct {
// TTL Token 有效期(秒),默认 7200 秒(2 小时)
TTL time.Duration `mapstructure:"ttl"`
// RefreshTTL Token 刷新阈值(秒)。
// 默认 0:不主动刷新 Token
// 大于 0 时:当 Token 剩余有效期小于该值时,自动刷新 Token 并在 Response Header 中返回新 Token
// 推荐设置为 TTL/2,例如 TTL=7200 时,RefreshTTL=3600
RefreshTTL time.Duration `mapstructure:"refresh_ttl"`
// SecretKey JWT 签名密钥,用于生成和验证 Token
// 启动时会校验非空;生产环境还会拒绝弱占位值和长度不足的密钥
// 建议使用随机密钥,例如:openssl rand -hex 32
SecretKey string `mapstructure:"secret_key"`
}
var Jwt = JwtConfig{
TTL: 7200, // Token 有效期 2 小时
RefreshTTL: 0, // 0 表示不主动刷新 Token
SecretKey: "", // 默认空,启动时必须由配置提供有效密钥
}
================================================
FILE: config/autoload/logger.go
================================================
package autoload
// DivisionTime 定义按时间切割日志时的参数。
type DivisionTime struct {
// MaxAge 日志文件保留的最大天数,过期会被删除
MaxAge int `mapstructure:"max_age"`
// RotationTime 多长时间切割一次日志文件,单位小时(24 表示每天切割)
RotationTime int `mapstructure:"rotation_time"`
}
// DivisionSize 定义按大小切割日志时的参数。
type DivisionSize struct {
// MaxSize 日志文件的最大大小(以 MB 为单位),超过该值会触发切割
MaxSize int `mapstructure:"max_size"`
// MaxBackups 保留的旧日志文件最大个数,超过会被删除
MaxBackups int `mapstructure:"max_backups"`
// MaxAge 旧日志文件保留的最大天数,过期会被删除
MaxAge int `mapstructure:"max_age"`
// Compress 是否压缩/归档旧日志文件(gzip 格式)
Compress bool `mapstructure:"compress"`
}
// LoggerConfig 定义日志输出与切割策略。
type LoggerConfig struct {
// Output 日志输出方式:file(输出到文件)、stderr(输出到标准错误)
Output string `mapstructure:"output"`
// DefaultDivision 默认切割方式:time(按时间)、size(按大小)
DefaultDivision string `mapstructure:"default_division"`
// Filename 日志文件名
Filename string `mapstructure:"file_name"`
// DivisionTime 按时间切割的参数配置
DivisionTime DivisionTime `mapstructure:"division_time"`
// DivisionSize 按大小切割的参数配置
DivisionSize DivisionSize `mapstructure:"division_size"`
}
var Logger = LoggerConfig{
Output: "file", // 默认输出到文件
DefaultDivision: "time", // 默认按时间切割
Filename: "gin-layout.sys.log",
DivisionTime: DivisionTime{
MaxAge: 15, // 日志保留 15 天
RotationTime: 24, // 每 24 小时切割一次
},
DivisionSize: DivisionSize{
MaxSize: 20, // 日志文件最大 20MB
MaxBackups: 15, // 最多保留 15 个备份
MaxAge: 15, // 日志保留 15 天
Compress: false, // 默认不压缩
},
}
================================================
FILE: config/autoload/mysql.go
================================================
package autoload
import "time"
// MysqlConfig 定义 MySQL 连接与连接池配置。
type MysqlConfig struct {
// Enable 是否启用 MySQL 连接
Enable bool `mapstructure:"enable"`
// Host 数据库服务器地址
Host string `mapstructure:"host"`
// Username 数据库用户名
Username string `mapstructure:"username"`
// Password 数据库密码
Password string `mapstructure:"password"`
// Port 数据库端口
Port uint16 `mapstructure:"port"`
// Database 数据库名称
Database string `mapstructure:"database"`
// Charset 字符集,推荐 utf8mb4
Charset string `mapstructure:"charset"`
// TablePrefix 表名前缀,用于区分不同应用的表
TablePrefix string `mapstructure:"table_prefix"`
// MaxIdleConns 最大空闲连接数
MaxIdleConns int `mapstructure:"max_idle_conns"`
// MaxOpenConns 最大打开连接数(并发连接数上限)
MaxOpenConns int `mapstructure:"max_open_conns"`
// MaxLifetime 连接最大存活时间,超时会被复用前重新创建
MaxLifetime time.Duration `mapstructure:"max_lifetime"`
// LogLevel GORM 日志级别:1=silent, 2=error, 3=warn, 4=info
LogLevel int `mapstructure:"log_level"`
// PrintSql 是否打印 SQL 到控制台,调试时使用
PrintSql bool `mapstructure:"print_sql"`
}
// Mysql 数据库默认配置。
var Mysql = MysqlConfig{
Enable: false, // 默认关闭,需要时开启
Host: "127.0.0.1",
Username: "root",
Password: "root1234",
Port: 3306,
Database: "test",
Charset: "utf8mb4",
TablePrefix: "",
MaxIdleConns: 10,
MaxOpenConns: 100,
MaxLifetime: time.Hour, // 连接存活 1 小时
LogLevel: 4, // info 级别
PrintSql: false, // 默认不打印 SQL
}
================================================
FILE: config/autoload/queue.go
================================================
package autoload
// QueueRedisConfig 队列使用的 Redis 连接配置。
type QueueRedisConfig struct {
// Host Redis 服务器地址
Host string `mapstructure:"host" yaml:"host"`
// Port Redis 服务器端口
Port string `mapstructure:"port" yaml:"port"`
// Password Redis 密码,空字符串表示无密码
Password string `mapstructure:"password" yaml:"password"`
// Database 数据库编号
Database int `mapstructure:"database" yaml:"database"`
}
// QueueConfig 异步任务队列配置。
type QueueConfig struct {
// Enable 是否启用异步队列。false 时同步执行任务(如审计日志直接写库)
Enable bool `mapstructure:"enable" yaml:"enable"`
// UseDefaultRedis 是否复用全局 redis 配置。
// true: 使用 redis.* 作为队列连接(默认)
// false: 使用 queue.redis.* 作为队列独立连接
UseDefaultRedis bool `mapstructure:"use_default_redis" yaml:"use_default_redis"`
// Redis 队列独立 Redis 配置,仅当 UseDefaultRedis=false 时生效
Redis QueueRedisConfig `mapstructure:"redis" yaml:"redis"`
// Namespace 队列命名空间前缀,用于隔离不同应用的队列
Namespace string `mapstructure:"namespace" yaml:"namespace"`
// Concurrency Worker Server 的最大并发协程数(全局上限)
// 建议值:开发环境 2-4,小流量生产 8-16,中等流量 16-32
// 注意:并发过高会增加数据库压力,审计日志类任务建议 8-16
Concurrency int `mapstructure:"concurrency" yaml:"concurrency"`
// StrictPriority 是否严格优先级模式。
// true: 必须处理完高优先级队列的所有任务后,才处理低优先级队列
// false: 按权重比例调度,高优先级队列的任务被调度的概率更大(推荐)
StrictPriority bool `mapstructure:"strict_priority" yaml:"strict_priority"`
// ConsumeCronFallback 是否允许 worker 消费历史误入 Asynq 的非高风险 cron 任务。
// 仅用于清理旧队列残留,不影响 cron 调度开关。
ConsumeCronFallback bool `mapstructure:"consume_cron_fallback" yaml:"consume_cron_fallback"`
// Queues 各队列的权重配置,key 为队列名,value 为权重值
// 新增队列必须在此配置,否则 Worker 不会消费该队列的任务!
// 权重决定任务被调度的概率,不是分配的协程数量!
// 所有队列共享 Concurrency 个协程,权重越高越容易被优先调度
// 调度概率 = 该队列权重 / 所有队列权重之和
// 示例(总权重=4+2+2+1=9):
// critical: 权重 4 → 4/9≈44% 概率被调度(支付回调、短信发送)
// default: 权重 2 → 2/9≈22% 概率被调度(普通异步任务)
// audit: 权重 2 → 2/9≈22% 概率被调度(请求日志、登录日志)
// low: 权重 1 → 1/9≈11% 概率被调度(批量通知、数据导出)
Queues map[string]int `mapstructure:"queues" yaml:"queues"`
// AuditMaxRetry 审计日志队列的最大重试次数
AuditMaxRetry int `mapstructure:"audit_max_retry" yaml:"audit_max_retry"`
// AuditTimeoutSeconds 审计日志任务的超时时间(秒)
AuditTimeoutSeconds int `mapstructure:"audit_timeout_seconds" yaml:"audit_timeout_seconds"`
}
// Queue 队列默认配置。
var Queue = QueueConfig{
Enable: false, // 默认关闭队列,同步执行
UseDefaultRedis: true, // 默认复用全局 redis 配置
Redis: QueueRedisConfig{
Host: "127.0.0.1",
Port: "6379",
Password: "",
Database: 0,
},
Namespace: "go_layout",
Concurrency: 8, // 整个 worker 最多同时处理 8 个任务(全局上限)
StrictPriority: false, // 按权重比例调度,非严格优先级
// 默认不让 worker 消费 cron 类型任务;需要清理历史残留时再显式开启。
ConsumeCronFallback: false,
Queues: map[string]int{
// 权重值表示调度概率,不是协程数量!
// 所有队列共享 8 个协程,权重越高越容易被优先调度
// 总权重 = 4+2+2+1 = 9
// critical 被选中的概率 ≈ 4/9 ≈ 44%
"critical": 4, // 权重 4,约 44% 概率被调度(如支付回调)
"default": 2, // 权重 2,约 22% 概率被调度(普通任务)
"audit": 2, // 权重 2,约 22% 概率被调度(审计日志)
"low": 1, // 权重 1,约 11% 概率被调度(批量通知)
},
AuditMaxRetry: 3, // 审计日志失败最多重试 3 次
AuditTimeoutSeconds: 10, // 审计日志任务超时 10 秒
}
================================================
FILE: config/autoload/redis.go
================================================
package autoload
import "time"
// RedisConfig 定义 Redis 连接配置。
type RedisConfig struct {
// Enable 是否启用 Redis 连接
Enable bool `mapstructure:"enable"`
// Host Redis 服务器地址
Host string `mapstructure:"host"`
// Port Redis 服务器端口
Port string `mapstructure:"port"`
// Password Redis 密码,空字符串表示无密码
Password string `mapstructure:"password"`
// Database 数据库编号,默认 0
Database int `mapstructure:"database"`
// PoolSize 连接池大小(最大连接数)
PoolSize int `mapstructure:"pool_size"`
// MinIdleConns 最小空闲连接数
MinIdleConns int `mapstructure:"min_idle_conns"`
// ConnMaxIdleTime 连接最大空闲时间,超时会被回收
ConnMaxIdle time.Duration `mapstructure:"conn_max_idle_time"`
// ConnMaxLifetime 连接最大存活时间,超时会被重新创建
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
// ReadTimeout 读取超时时间
ReadTimeout time.Duration `mapstructure:"read_timeout"`
// WriteTimeout 写入超时时间
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
// Redis 默认配置。
var Redis = RedisConfig{
Enable: false, // 默认关闭,需要时开启
Host: "127.0.0.1",
Password: "",
Port: "6379",
Database: 0,
PoolSize: 10,
MinIdleConns: 5,
ConnMaxIdle: 5 * time.Minute, // 空闲 5 分钟回收
ConnMaxLifetime: 30 * time.Minute, // 连接存活 30 分钟
ReadTimeout: 3 * time.Second, // 读取超时 3 秒
WriteTimeout: 3 * time.Second, // 写入超时 3 秒
}
================================================
FILE: config/config.go
================================================
package config
import (
"sort"
"sync"
"sync/atomic"
"github.com/spf13/viper"
"github.com/wannanbigpig/gin-layout/config/autoload"
)
// Conf 配置项主结构体
type Conf struct {
autoload.AppConfig `mapstructure:"app"`
Mysql autoload.MysqlConfig `mapstructure:"mysql"`
Redis autoload.RedisConfig `mapstructure:"redis"`
Logger autoload.LoggerConfig `mapstructure:"logger"`
Jwt autoload.JwtConfig `mapstructure:"jwt"`
Queue autoload.QueueConfig `mapstructure:"queue"`
}
var (
Config = &Conf{
AppConfig: cloneAppConfig(autoload.App),
Mysql: autoload.Mysql,
Redis: autoload.Redis,
Logger: autoload.Logger,
Jwt: autoload.Jwt,
Queue: cloneQueueConfig(autoload.Queue),
}
once sync.Once
initErr error
V *viper.Viper
configValue atomic.Value
reloadHandlersMu sync.RWMutex
reloadHandlers []ConfigReloadHandler
)
// ConfigReloadHandler 在配置热更新时被调用。
type ConfigReloadHandler struct {
Name string
Priority int
Handle func(oldConfig, newConfig *Conf, diff ConfigDiff) error
}
// ConfigDiff 描述配置变更摘要。
type ConfigDiff struct {
LoggerChanged bool
MysqlChanged bool
RedisChanged bool
JWTChanged bool
JWTSecretChanged bool
BaseURLChanged bool
CORSChanged bool
TrustedProxiesChanged bool
LightAppChanged bool
RestartRequiredFields []string
ChangedFields []string
}
// GetConfig 返回当前生效的配置快照。
func GetConfig() *Conf {
if cfg, ok := configValue.Load().(*Conf); ok && cfg != nil {
return cfg
}
return Config
}
// RegisterConfigReloadHandler 注册配置热更新回调。
func RegisterConfigReloadHandler(handler ConfigReloadHandler) {
if handler.Name == "" {
return
}
reloadHandlersMu.Lock()
defer reloadHandlersMu.Unlock()
for i := range reloadHandlers {
if reloadHandlers[i].Name == handler.Name {
reloadHandlers[i] = handler
sortConfigReloadHandlersLocked()
return
}
}
reloadHandlers = append(reloadHandlers, handler)
sortConfigReloadHandlersLocked()
}
func sortConfigReloadHandlersLocked() {
sort.SliceStable(reloadHandlers, func(i, j int) bool {
if reloadHandlers[i].Priority == reloadHandlers[j].Priority {
return reloadHandlers[i].Name < reloadHandlers[j].Name
}
return reloadHandlers[i].Priority < reloadHandlers[j].Priority
})
}
================================================
FILE: config/config.yaml.example
================================================
# 该文件为配置示例文件,请复制该文件改名为 config.yaml, 不要直接修改该文件,修改无意义
app:
app_env: local
debug: true
language: zh_CN
# allow_degraded_startup: false # service 启动时依赖初始化失败是否继续启动;仅建议在需要“先起服务再排障”场景开启
# enable_reset_system_cron: false # 高风险:是否启用 reset-system-data 定时任务;默认关闭,避免误重建系统数据
# watch_config: false
# base_path: ""
# base_url: "https://example.com" # 文件访问的基础URL,用于拼接本地文件访问地址
# trusted_proxies: # 受信任代理列表,仅这些代理转发的 X-Forwarded-For / X-Real-IP 会被信任
# - "127.0.0.1"
# # 生产环境可按实际代理网段配置,例如:
# # - "10.0.0.0/8"
# # - "172.16.0.0/12"
# # - "192.168.0.0/16"
# # CORS 跨域配置
# cors_origins: # CORS允许的源列表,使用 ["*"] 表示允许所有源
# - "http://localhost:3000"
# - "http://localhost:8080"
# - "https://example.com"
# # 支持通配符匹配,例如:
# - "https://*.wannanbigpig.com" # 匹配所有 wannanbigpig.com 的子域名(如 https://x-l-admin.wannanbigpig.com)
# # 或者明确指定:
# - "https://x-l-admin.wannanbigpig.com"
# cors_methods: # 允许的HTTP方法,使用 ["*"] 表示允许全部已支持方法,空数组使用默认值
# - "GET"
# - "POST"
# - "PUT"
# - "PATCH"
# - "DELETE"
# - "HEAD"
# - "OPTIONS"
# cors_headers: # 允许的请求头,使用 ["*"] 表示允许所有请求头
# - "Content-Type"
# - "Authorization"
# - "X-Requested-With"
# cors_expose_headers: # 暴露的响应头,使用 ["*"] 表示暴露所有响应头
# - "Content-Length"
# - "X-Request-Id"
# cors_max_age: 43200 # 预检请求缓存时间(秒),默认 43200(12小时)
# cors_credentials: false # 是否允许携带凭证(cookies等),默认 false
jwt:
ttl: 7200
refresh_ttl: 3600
secret_key: # 请替换为随机密钥;启动时会校验非空,生产环境还会拒绝弱占位值和短密钥
mysql:
enable: false
host: 127.0.0.1
port: 3306
database: test
username: root
password: root1234
charset: utf8mb4
table_prefix: ""
max_idle_conns: 10
max_open_conns: 100
max_lifetime: 3600s
redis:
enable: false
host: 127.0.0.1
port: 6379
password:
database: 0
queue:
enable: false
use_default_redis: true # true=复用 redis.*;false=使用 queue.redis.* 独立连接
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
namespace: go_layout
concurrency: 8
strict_priority: false
consume_cron_fallback: false # true=worker 兜底消费历史误入 Asynq 的非高风险 cron 任务
queues:
critical: 4
default: 2
audit: 2
low: 1
audit_max_retry: 3
audit_timeout_seconds: 10
logger:
# 日志输出默认为文件,stderr 可选
output: file
default_division: time
file_name: gin-layout.sys.log
division_time:
max_age: 15
rotation_time: 24
division_size:
max_size: 20
max_backups: 15
max_age: 15
compress: false
================================================
FILE: config/config_clone.go
================================================
package config
import (
"fmt"
"io"
"os"
"github.com/wannanbigpig/gin-layout/config/autoload"
)
func setActiveConfig(cfg *Conf) {
Config = cfg
configValue.Store(cfg)
}
func cloneDefaultConfig() *Conf {
return &Conf{
AppConfig: cloneAppConfig(autoload.App),
Mysql: autoload.Mysql,
Redis: autoload.Redis,
Logger: autoload.Logger,
Jwt: autoload.Jwt,
Queue: cloneQueueConfig(autoload.Queue),
}
}
func cloneAppConfig(src autoload.AppConfig) autoload.AppConfig {
cloned := src
cloned.TrustedProxies = cloneStringSlice(src.TrustedProxies)
cloned.CorsOrigins = cloneStringSlice(src.CorsOrigins)
cloned.CorsMethods = cloneStringSlice(src.CorsMethods)
cloned.CorsHeaders = cloneStringSlice(src.CorsHeaders)
cloned.CorsExposeHeaders = cloneStringSlice(src.CorsExposeHeaders)
if src.Timezone != nil {
tz := *src.Timezone
cloned.Timezone = &tz
}
return cloned
}
func cloneQueueConfig(src autoload.QueueConfig) autoload.QueueConfig {
cloned := src
if src.Queues != nil {
cloned.Queues = make(map[string]int, len(src.Queues))
for key, value := range src.Queues {
cloned.Queues[key] = value
}
}
return cloned
}
func cloneStringSlice(src []string) []string {
if src == nil {
return nil
}
return append([]string(nil), src...)
}
// copyConf 复制配置示例文件
func copyConf(exampleConfig, config string) error {
fileInfo, err := os.Stat(config)
if err == nil {
if !fileInfo.IsDir() {
return nil
}
return fmt.Errorf("配置文件目录存在同名的文件夹,无法创建配置文件")
}
if !os.IsNotExist(err) {
return fmt.Errorf("初始化失败: %w", err)
}
source, err := os.Open(exampleConfig)
if err != nil {
return fmt.Errorf("创建配置文件失败,配置示例文件不存在: %w", err)
}
defer func(source *os.File) {
_ = source.Close()
}(source)
dst, err := os.Create(config)
if err != nil {
return fmt.Errorf("生成配置文件失败: %w", err)
}
defer func(dst *os.File) {
_ = dst.Close()
}(dst)
if _, err := io.Copy(dst, source); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
return nil
}
================================================
FILE: config/config_load.go
================================================
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"github.com/wannanbigpig/gin-layout/pkg/utils"
)
// InitConfig 初始化配置系统并加载首个生效快照。
func InitConfig(configPath string) error {
once.Do(func() {
var loaded *Conf
loaded, initErr = load(configPath)
if initErr != nil {
return
}
initErr = validateJWTSecretKey(loaded)
if initErr != nil {
return
}
setActiveConfig(loaded)
})
return initErr
}
func checkJwtSecretKey() error {
return validateJWTSecretKey(GetConfig())
}
func validateJWTSecretKey(cfg *Conf) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
secret := strings.TrimSpace(cfg.Jwt.SecretKey)
if secret == "" {
return fmt.Errorf("jwt.secret_key is empty, please set a non-empty secret key")
}
isProd := strings.EqualFold(cfg.AppEnv, "prod") || strings.EqualFold(cfg.AppEnv, "production")
if !isProd {
return nil
}
weakSecrets := map[string]struct{}{
"": {},
"your-secret-key-here": {},
"default-secret-key": {},
"change-me": {},
"changeme": {},
"secret": {},
"123456": {},
}
if _, ok := weakSecrets[strings.ToLower(secret)]; ok {
return fmt.Errorf("jwt.secret_key uses a weak placeholder value in production")
}
if len(secret) < 16 {
return fmt.Errorf("jwt.secret_key is too short in production, require at least 16 characters")
}
return nil
}
func load(configPath string) (*Conf, error) {
filePath, err := resolveConfigPath(configPath)
if err != nil {
return nil, err
}
V = viper.New()
V.SetConfigFile(filePath)
if err := V.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
return nil, fmt.Errorf("未找到配置: %w", err)
}
return nil, fmt.Errorf("读取配置出错: %w", err)
}
loaded := cloneDefaultConfig()
if err := V.Unmarshal(loaded); err != nil {
return nil, fmt.Errorf("映射配置出错: %w", err)
}
resolveEnvVars(loaded)
ensureBasePathDefault(loaded, V.IsSet("app.base_path"))
ensureCorsDefaults(loaded)
registerConfigWatcherIfNeeded(loaded)
return loaded, nil
}
func resolveConfigPath(configPath string) (string, error) {
if configPath != "" {
return configPath, nil
}
exampleConfig, targetConfig, err := resolveDefaultConfigFiles()
if err != nil {
return "", err
}
if err := copyConf(exampleConfig, targetConfig); err != nil {
return "", err
}
return targetConfig, nil
}
func resolveDefaultConfigFiles() (string, string, error) {
if os.Getenv("GO_ENV") == "development" {
return resolveDevelopmentConfigFiles()
}
runDirectory, err := utils.GetCurrentPath()
if err != nil {
return "", "", fmt.Errorf("获取执行文件目录失败: %w", err)
}
return filepath.Join(runDirectory, "config.yaml.example"), filepath.Join(runDirectory, "config.yaml"), nil
}
func resolveDevelopmentConfigFiles() (string, string, error) {
workDir, err := os.Getwd()
if err != nil {
return "", "", fmt.Errorf("获取工作目录失败: %w", err)
}
exampleConfig := filepath.Join(workDir, "config", "config.yaml.example")
if !fileExists(exampleConfig) {
exampleConfig = filepath.Join(workDir, "config.yaml.example")
}
return exampleConfig, filepath.Join(workDir, "config.yaml"), nil
}
func fileExists(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}
func ensureBasePathDefault(cfg *Conf, basePathConfigured bool) {
if cfg == nil {
return
}
if basePathConfigured && strings.TrimSpace(cfg.BasePath) != "" {
return
}
if os.Getenv("GO_ENV") == "development" {
workDir, err := os.Getwd()
if err == nil && strings.TrimSpace(workDir) != "" {
cfg.BasePath = workDir
return
}
}
runDir, err := utils.GetCurrentPath()
if err == nil && strings.TrimSpace(runDir) != "" {
cfg.BasePath = runDir
return
}
workDir, err := os.Getwd()
if err == nil && strings.TrimSpace(workDir) != "" {
cfg.BasePath = workDir
return
}
cfg.BasePath = strings.TrimSpace(cfg.BasePath)
if cfg.BasePath == "" {
cfg.BasePath = "."
}
}
func registerConfigWatcherIfNeeded(cfg *Conf) {
if !cfg.WatchConfig {
return
}
V.WatchConfig()
V.OnConfigChange(func(in fsnotify.Event) {
initErr = reloadConfigFromWatcher()
})
}
func ensureCorsDefaults(cfg *Conf) {
if cfg.CorsOrigins == nil {
cfg.CorsOrigins = []string{}
}
if cfg.CorsMethods == nil {
cfg.CorsMethods = []string{}
}
if cfg.CorsHeaders == nil {
cfg.CorsHeaders = []string{}
}
if cfg.CorsExposeHeaders == nil {
cfg.CorsExposeHeaders = []string{}
}
if cfg.TrustedProxies == nil {
cfg.TrustedProxies = []string{"127.0.0.1"}
}
if cfg.CorsMaxAge == 0 {
cfg.CorsMaxAge = 43200
}
}
func reloadConfigFromWatcher() error {
if err := V.ReadInConfig(); err != nil {
return fmt.Errorf("重新读取配置出错: %w", err)
}
next := cloneDefaultConfig()
if err := V.Unmarshal(next); err != nil {
return fmt.Errorf("重新映射配置出错: %w", err)
}
resolveEnvVars(next)
ensureBasePathDefault(next, V.IsSet("app.base_path"))
ensureCorsDefaults(next)
if err := validateJWTSecretKey(next); err != nil {
return fmt.Errorf("JWT 配置校验失败: %w", err)
}
current := GetConfig()
diff := BuildConfigDiff(current, next)
applied := BuildAppliedConfig(current, next, diff)
reloadHandlersMu.RLock()
handlers := append([]ConfigReloadHandler(nil), reloadHandlers...)
reloadHandlersMu.RUnlock()
for _, handler := range handlers {
if handler.Handle == nil {
continue
}
if err := handler.Handle(current, applied, diff); err != nil {
return fmt.Errorf("配置热更新失败[%s]: %w", handler.Name, err)
}
}
setActiveConfig(applied)
return nil
}
func resolveEnvVars(cfg *Conf) {
cfg.Mysql.Username = resolveEnvVar(cfg.Mysql.Username)
cfg.Mysql.Password = resolveEnvVar(cfg.Mysql.Password)
cfg.Mysql.Host = resolveEnvVar(cfg.Mysql.Host)
cfg.Redis.Password = resolveEnvVar(cfg.Redis.Password)
cfg.Redis.Host = resolveEnvVar(cfg.Redis.Host)
cfg.Queue.Redis.Password = resolveEnvVar(cfg.Queue.Redis.Password)
cfg.Queue.Redis.Host = resolveEnvVar(cfg.Queue.Redis.Host)
cfg.Jwt.SecretKey = resolveEnvVar(cfg.Jwt.SecretKey)
}
func resolveEnvVar(val string) string {
if !strings.HasPrefix(val, "${") || !strings.HasSuffix(val, "}") {
return val
}
envKey := val[2 : len(val)-1]
if envVal := os.Getenv(envKey); envVal != "" {
return envVal
}
return val
}
================================================
FILE: config/config_load_test.go
================================================
package config
import (
"os"
"path/filepath"
"testing"
)
func assertSamePath(t *testing.T, expected string, actual string) {
t.Helper()
expectedResolved, err := filepath.EvalSymlinks(expected)
if err != nil {
expectedResolved = filepath.Clean(expected)
}
actualResolved, err := filepath.EvalSymlinks(actual)
if err != nil {
actualResolved = filepath.Clean(actual)
}
if expectedResolved != actualResolved {
t.Fatalf("expected %s, got %s", expectedResolved, actualResolved)
}
}
func TestResolveConfigPathPrefersWorkingDirectoryConfig(t *testing.T) {
t.Setenv("GO_ENV", "development")
workDir := t.TempDir()
configPath := filepath.Join(workDir, "config.yaml")
if err := os.WriteFile(configPath, []byte("app:\n port: 8080\n"), 0o644); err != nil {
t.Fatalf("write config file failed: %v", err)
}
originWD, err := os.Getwd()
if err != nil {
t.Fatalf("getwd failed: %v", err)
}
if err := os.Chdir(workDir); err != nil {
t.Fatalf("chdir failed: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(originWD)
})
resolved, err := resolveConfigPath("")
if err != nil {
t.Fatalf("resolveConfigPath failed: %v", err)
}
assertSamePath(t, configPath, resolved)
}
func TestResolveDefaultConfigFilesUsesWorkingDirectoryExample(t *testing.T) {
t.Setenv("GO_ENV", "development")
workDir := t.TempDir()
configDir := filepath.Join(workDir, "config")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("mkdir config dir failed: %v", err)
}
examplePath := filepath.Join(configDir, "config.yaml.example")
if err := os.WriteFile(examplePath, []byte("app:\n port: 8080\n"), 0o644); err != nil {
t.Fatalf("write example file failed: %v", err)
}
originWD, err := os.Getwd()
if err != nil {
t.Fatalf("getwd failed: %v", err)
}
if err := os.Chdir(workDir); err != nil {
t.Fatalf("chdir failed: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(originWD)
})
gotExample, gotTarget, err := resolveDefaultConfigFiles()
if err != nil {
t.Fatalf("resolveDefaultConfigFiles failed: %v", err)
}
assertSamePath(t, examplePath, gotExample)
expectedTarget := filepath.Join(filepath.Dir(filepath.Dir(gotExample)), "config.yaml")
if gotTarget != expectedTarget {
t.Fatalf("expected target %s, got %s", expectedTarget, gotTarget)
}
}
func TestEnsureBasePathDefaultUsesWorkingDirectoryWhenNotConfigured(t *testing.T) {
t.Setenv("GO_ENV", "development")
workDir := t.TempDir()
originWD, err := os.Getwd()
if err != nil {
t.Fatalf("getwd failed: %v", err)
}
if err := os.Chdir(workDir); err != nil {
t.Fatalf("chdir failed: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(originWD)
})
cfg := cloneDefaultConfig()
cfg.BasePath = "/tmp/legacy-base-path"
ensureBasePathDefault(cfg, false)
assertSamePath(t, workDir, cfg.BasePath)
}
func TestEnsureBasePathDefaultKeepsExplicitConfiguredValue(t *testing.T) {
cfg := cloneDefaultConfig()
cfg.BasePath = "/tmp/custom-base-path"
ensureBasePathDefault(cfg, true)
if cfg.BasePath != "/tmp/custom-base-path" {
t.Fatalf("expected explicit base_path to be kept, got %s", cfg.BasePath)
}
}
func TestValidateJWTSecretKeyRejectsNilConfig(t *testing.T) {
if err := validateJWTSecretKey(nil); err == nil {
t.Fatal("expected nil config to return error")
}
}
func TestCheckJwtSecretKeyRejectsEmptySecret(t *testing.T) {
original := GetConfig()
testCfg := cloneDefaultConfig()
testCfg.Jwt.SecretKey = ""
setActiveConfig(testCfg)
t.Cleanup(func() { setActiveConfig(original) })
if err := checkJwtSecretKey(); err == nil {
t.Fatal("expected empty jwt secret key to return error")
}
}
func TestCheckJwtSecretKeyRejectsWeakProdSecret(t *testing.T) {
original := GetConfig()
testCfg := cloneDefaultConfig()
testCfg.AppEnv = "prod"
testCfg.Jwt.SecretKey = "default-secret-key"
setActiveConfig(testCfg)
t.Cleanup(func() { setActiveConfig(original) })
if err := checkJwtSecretKey(); err == nil {
t.Fatal("expected weak prod jwt secret key to return error")
}
}
func TestCheckJwtSecretKeyAllowsLocalWeakSecret(t *testing.T) {
original := GetConfig()
testCfg := cloneDefaultConfig()
testCfg.AppEnv = "local"
testCfg.Jwt.SecretKey = "default-secret-key"
setActiveConfig(testCfg)
t.Cleanup(func() { setActiveConfig(original) })
if err := checkJwtSecretKey(); err != nil {
t.Fatalf("expected local weak secret key to pass, got %v", err)
}
}
================================================
FILE: config/config_test.go
================================================
package config
import "testing"
func resetConfigReloadHandlersForTest(t *testing.T) {
t.Helper()
reloadHandlersMu.Lock()
defer reloadHandlersMu.Unlock()
reloadHandlers = nil
}
func TestRegisterConfigReloadHandlerDeduplicatesByName(t *testing.T) {
resetConfigReloadHandlersForTest(t)
RegisterConfigReloadHandler(ConfigReloadHandler{Name: "data", Priority: 20})
RegisterConfigReloadHandler(ConfigReloadHandler{Name: "data", Priority: 10})
reloadHandlersMu.RLock()
defer reloadHandlersMu.RUnlock()
if len(reloadHandlers) != 1 {
t.Fatalf("expected 1 handler, got %d", len(reloadHandlers))
}
if reloadHandlers[0].Priority != 10 {
t.Fatalf("expected overwritten priority 10, got %d", reloadHandlers[0].Priority)
}
}
func TestRegisterConfigReloadHandlerKeepsStablePriorityOrder(t *testing.T) {
resetConfigReloadHandlersForTest(t)
RegisterConfigReloadHandler(ConfigReloadHandler{Name: "warnings", Priority: 100})
RegisterConfigReloadHandler(ConfigReloadHandler{Name: "logger", Priority: 10})
RegisterConfigReloadHandler(ConfigReloadHandler{Name: "data", Priority: 20})
reloadHandlersMu.RLock()
defer reloadHandlersMu.RUnlock()
if len(reloadHandlers) != 3 {
t.Fatalf("expected 3 handlers, got %d", len(reloadHandlers))
}
got := []string{reloadHandlers[0].Name, reloadHandlers[1].Name, reloadHandlers[2].Name}
want := []string{"logger", "data", "warnings"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected order: got %v want %v", got, want)
}
}
}
func TestRegisterConfigReloadHandlerIgnoresEmptyName(t *testing.T) {
resetConfigReloadHandlersForTest(t)
RegisterConfigReloadHandler(ConfigReloadHandler{Priority: 1})
reloadHandlersMu.RLock()
defer reloadHandlersMu.RUnlock()
if len(reloadHandlers) != 0 {
t.Fatalf("expected empty-name handler to be ignored, got %d handlers", len(reloadHandlers))
}
}
================================================
FILE: config/provider.go
================================================
package config
// GetConfigFrom 通过指定 provider 获取配置,并保证返回值非 nil。
func GetConfigFrom(provider func() *Conf) *Conf {
if provider == nil {
return &Conf{}
}
cfg := provider()
if cfg == nil {
return &Conf{}
}
return cfg
}
================================================
FILE: config/runtime.go
================================================
package config
import "reflect"
// BuildConfigDiff 生成配置差异摘要。
func BuildConfigDiff(oldConfig, newConfig *Conf) ConfigDiff {
diff := ConfigDiff{}
if oldConfig == nil || newConfig == nil {
return diff
}
diff.LoggerChanged = !reflect.DeepEqual(oldConfig.Logger, newConfig.Logger)
diff.MysqlChanged = !reflect.DeepEqual(oldConfig.Mysql, newConfig.Mysql)
diff.RedisChanged = !reflect.DeepEqual(oldConfig.Redis, newConfig.Redis)
diff.JWTChanged = oldConfig.Jwt.TTL != newConfig.Jwt.TTL || oldConfig.Jwt.RefreshTTL != newConfig.Jwt.RefreshTTL
diff.JWTSecretChanged = oldConfig.Jwt.SecretKey != newConfig.Jwt.SecretKey
diff.BaseURLChanged = oldConfig.BaseURL != newConfig.BaseURL
diff.CORSChanged = !reflect.DeepEqual(oldConfig.CorsOrigins, newConfig.CorsOrigins) ||
!reflect.DeepEqual(oldConfig.CorsMethods, newConfig.CorsMethods) ||
!reflect.DeepEqual(oldConfig.CorsHeaders, newConfig.CorsHeaders) ||
!reflect.DeepEqual(oldConfig.CorsExposeHeaders, newConfig.CorsExposeHeaders) ||
oldConfig.CorsMaxAge != newConfig.CorsMaxAge ||
oldConfig.CorsCredentials != newConfig.CorsCredentials
diff.TrustedProxiesChanged = !reflect.DeepEqual(oldConfig.TrustedProxies, newConfig.TrustedProxies)
diff.LightAppChanged = oldConfig.BasePath != newConfig.BasePath ||
oldConfig.AppEnv != newConfig.AppEnv ||
oldConfig.Debug != newConfig.Debug ||
oldConfig.WatchConfig != newConfig.WatchConfig ||
oldConfig.Language != newConfig.Language ||
oldConfig.AllowDegradedStartup != newConfig.AllowDegradedStartup
if diff.LoggerChanged {
diff.ChangedFields = append(diff.ChangedFields, "logger.*")
}
if diff.MysqlChanged {
diff.ChangedFields = append(diff.ChangedFields, "mysql.*")
}
if diff.RedisChanged {
diff.ChangedFields = append(diff.ChangedFields, "redis.*")
}
if diff.JWTChanged {
diff.ChangedFields = append(diff.ChangedFields, "jwt.ttl", "jwt.refresh_ttl")
}
if diff.JWTSecretChanged {
diff.ChangedFields = append(diff.ChangedFields, "jwt.secret_key")
diff.RestartRequiredFields = append(diff.RestartRequiredFields, "jwt.secret_key")
}
if diff.BaseURLChanged {
diff.ChangedFields = append(diff.ChangedFields, "app.base_url")
}
if diff.CORSChanged {
diff.ChangedFields = append(diff.ChangedFields, "app.cors_*")
}
if diff.TrustedProxiesChanged {
diff.ChangedFields = append(diff.ChangedFields, "app.trusted_proxies")
diff.RestartRequiredFields = append(diff.RestartRequiredFields, "app.trusted_proxies")
}
if oldConfig.Language != newConfig.Language {
diff.RestartRequiredFields = append(diff.RestartRequiredFields, "app.language")
}
if oldConfig.AllowDegradedStartup != newConfig.AllowDegradedStartup {
diff.ChangedFields = append(diff.ChangedFields, "app.allow_degraded_startup")
diff.RestartRequiredFields = append(diff.RestartRequiredFields, "app.allow_degraded_startup")
}
return diff
}
// BuildAppliedConfig 返回当前进程应采用的配置快照。
// 对不支持热更新的字段保持旧值,避免配置快照与实际运行状态不一致。
func BuildAppliedConfig(oldConfig, newConfig *Conf, diff ConfigDiff) *Conf {
if oldConfig == nil {
return newConfig
}
applied := *newConfig
applied.AppConfig = cloneAppConfig(newConfig.AppConfig)
applied.Queue = cloneQueueConfig(newConfig.Queue)
if diff.JWTSecretChanged {
applied.Jwt.SecretKey = oldConfig.Jwt.SecretKey
}
if diff.TrustedProxiesChanged {
applied.TrustedProxies = cloneStringSlice(oldConfig.TrustedProxies)
}
if oldConfig.Language != newConfig.Language {
applied.Language = oldConfig.Language
}
if oldConfig.AllowDegradedStartup != newConfig.AllowDegradedStartup {
applied.AllowDegradedStartup = oldConfig.AllowDegradedStartup
}
return &applied
}
================================================
FILE: config/runtime_test.go
================================================
package config
import (
"testing"
. "github.com/wannanbigpig/gin-layout/config/autoload"
)
func TestBuildConfigDiff(t *testing.T) {
oldCfg := &Conf{
AppConfig: App,
Mysql: Mysql,
Redis: Redis,
Logger: Logger,
Jwt: Jwt,
}
newCfg := &Conf{
AppConfig: App,
Mysql: Mysql,
Redis: Redis,
Logger: Logger,
Jwt: Jwt,
}
newCfg.Logger.Output = "stderr"
newCfg.Redis.Enable = true
newCfg.BaseURL = "https://example.com"
newCfg.CorsOrigins = []string{"https://ui.example.com"}
newCfg.TrustedProxies = []string{"10.0.0.0/8"}
newCfg.Jwt.TTL = 999
newCfg.Jwt.SecretKey = "new-secret"
newCfg.Language = "en"
diff := BuildConfigDiff(oldCfg, newCfg)
if !diff.LoggerChanged {
t.Fatalf("expected logger change")
}
if !diff.RedisChanged {
t.Fatalf("expected redis change")
}
if !diff.JWTChanged {
t.Fatalf("expected jwt ttl change")
}
if !diff.JWTSecretChanged {
t.Fatalf("expected jwt secret change")
}
if !diff.BaseURLChanged {
t.Fatalf("expected base_url change")
}
if !diff.CORSChanged {
t.Fatalf("expected cors change")
}
if !diff.TrustedProxiesChanged {
t.Fatalf("expected trusted proxies change")
}
if len(diff.RestartRequiredFields) == 0 {
t.Fatalf("expected restart-required fields")
}
}
func TestBuildAppliedConfigKeepsUnsupportedFields(t *testing.T) {
oldCfg := &Conf{
AppConfig: App,
Mysql: Mysql,
Redis: Redis,
Logger: Logger,
Jwt: JwtConfig{
TTL: 100,
RefreshTTL: 10,
SecretKey: "old-secret",
},
}
oldCfg.TrustedProxies = []string{"127.0.0.1"}
oldCfg.Language = "zh_CN"
newCfg := &Conf{
AppConfig: App,
Mysql: Mysql,
Redis: Redis,
Logger: Logger,
Jwt: JwtConfig{
TTL: 200,
RefreshTTL: 20,
SecretKey: "new-secret",
},
}
newCfg.TrustedProxies = []string{"10.0.0.0/8"}
newCfg.Language = "en"
newCfg.BaseURL = "https://cdn.example.com"
diff := BuildConfigDiff(oldCfg, newCfg)
applied := BuildAppliedConfig(oldCfg, newCfg, diff)
if applied.Jwt.SecretKey != "old-secret" {
t.Fatalf("expected jwt secret to remain old, got %q", applied.Jwt.SecretKey)
}
if applied.Language != "zh_CN" {
t.Fatalf("expected language to remain old, got %q", applied.Language)
}
if len(applied.TrustedProxies) != 1 || applied.TrustedProxies[0] != "127.0.0.1" {
t.Fatalf("expected trusted proxies to remain old, got %#v", applied.TrustedProxies)
}
if applied.BaseURL != "https://cdn.example.com" {
t.Fatalf("expected supported field base_url to update, got %q", applied.BaseURL)
}
if applied.Jwt.TTL != 200 {
t.Fatalf("expected supported jwt ttl to update, got %v", applied.Jwt.TTL)
}
}
================================================
FILE: config/testing_helper.go
================================================
package config
// CloneConf 返回配置的深拷贝,避免测试场景下共享可变引用。
func CloneConf(src *Conf) *Conf {
if src == nil {
return &Conf{}
}
cloned := *src
cloned.AppConfig = cloneAppConfig(src.AppConfig)
cloned.Queue = cloneQueueConfig(src.Queue)
return &cloned
}
// ReplaceConfigForTesting 替换当前配置并返回恢复函数。
func ReplaceConfigForTesting(cfg *Conf) func() {
previous := CloneConf(GetConfig())
if cfg == nil {
setActiveConfig(&Conf{})
} else {
setActiveConfig(CloneConf(cfg))
}
return func() {
setActiveConfig(previous)
}
}
// UpdateConfigForTesting 在当前配置副本上应用变更并返回恢复函数。
func UpdateConfigForTesting(mutator func(cfg *Conf)) func() {
next := CloneConf(GetConfig())
if mutator != nil {
mutator(next)
}
return ReplaceConfigForTesting(next)
}
================================================
FILE: data/data.go
================================================
package data
import (
"errors"
"fmt"
"sync"
c "github.com/wannanbigpig/gin-layout/config"
)
var once sync.Once
var initErr error
// InitData 按配置初始化 MySQL 和 Redis 数据源。
func InitData() error {
once.Do(func() {
cfg := c.GetConfig()
var errs []error
if cfg.Mysql.Enable {
if err := initMysql(); err != nil {
errs = append(errs, fmt.Errorf("mysql init error: %w", err))
}
}
if cfg.Redis.Enable {
if err := initRedis(); err != nil {
errs = append(errs, fmt.Errorf("redis init error: %w", err))
}
}
if len(errs) > 0 {
initErr = errors.Join(errs...)
}
})
return initErr
}
// Shutdown 关闭当前已初始化的数据源。
func Shutdown() error {
var firstErr error
if err := CloseRedis(); err != nil && firstErr == nil {
firstErr = err
}
if err := CloseMysql(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
================================================
FILE: data/data_test.go
================================================
package data
import "testing"
func TestShutdownWithoutInitializedResources(t *testing.T) {
if err := Shutdown(); err != nil {
t.Fatalf("shutdown should be safe without initialized resources: %v", err)
}
if err := Shutdown(); err != nil {
t.Fatalf("shutdown should be idempotent: %v", err)
}
}
================================================
FILE: data/migrations/20260425000001_init_table.down.sql
================================================
BEGIN;
-- 删除系统参数与字典相关表
DROP TABLE IF EXISTS `sys_dict_item_i18n`;
DROP TABLE IF EXISTS `sys_dict_type_i18n`;
DROP TABLE IF EXISTS `sys_config_i18n`;
DROP TABLE IF EXISTS `sys_dict_item`;
DROP TABLE IF EXISTS `sys_dict_type`;
DROP TABLE IF EXISTS `sys_config`;
-- 删除任务中心相关表
DROP TABLE IF EXISTS `cron_task_states`;
DROP TABLE IF EXISTS `task_run_events`;
DROP TABLE IF EXISTS `task_runs`;
DROP TABLE IF EXISTS `task_definitions`;
-- 删除管理员表
DROP TABLE IF EXISTS `admin_user`;
-- 删除路由表
DROP TABLE IF EXISTS `api`;
-- 删除路由分组表
DROP TABLE IF EXISTS `api_group`;
-- 删除菜单多语言标题表
DROP TABLE IF EXISTS `menu_i18n`;
-- 删除菜单表
DROP TABLE IF EXISTS `menu`;
-- 删除部门表
DROP TABLE IF EXISTS `department`;
-- 删除角色表
DROP TABLE IF EXISTS `role`;
-- 删除用户部门映射表
DROP TABLE IF EXISTS `admin_user_department_map`;
-- 删除部门角色映射表
DROP TABLE IF EXISTS `department_role_map`;
-- 删除用户菜单映射表
DROP TABLE IF EXISTS `admin_user_menu_map`;
-- 删除菜单权限映射表
DROP TABLE IF EXISTS `menu_api_map`;
-- 删除角色菜单映射表
DROP TABLE IF EXISTS `role_menu_map`;
-- 删除用户角色映射表
DROP TABLE IF EXISTS `admin_user_role_map`;
-- 删除请求日志表
DROP TABLE IF EXISTS `request_logs`;
-- 删除登录安全状态表
DROP TABLE IF EXISTS `login_security_state`;
-- 删除管理员登录日志表
DROP TABLE IF EXISTS `admin_login_logs`;
-- 删除casbin规则表
DROP TABLE IF EXISTS `casbin_rule`;
-- 删除文件上传表
DROP TABLE IF EXISTS `upload_file_references`;
DROP TABLE IF EXISTS `upload_file_folders`;
DROP TABLE IF EXISTS `upload_files`;
COMMIT;
================================================
FILE: data/migrations/20260425000001_init_table.up.sql
================================================
BEGIN;
-- 创建管理员表
CREATE TABLE IF NOT EXISTS `admin_user`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`nickname` varchar(30) NOT NULL DEFAULT '' COMMENT '昵称',
`username` varchar(30) NOT NULL DEFAULT '' COMMENT '用户名',
`password` varchar(255) NOT NULL DEFAULT '' COMMENT '密码',
`phone_number` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '手机号',
`full_phone_number` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '带区号的手机号',
`country_code` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '国际区号',
`email` varchar(120) NOT NULL DEFAULT '' COMMENT '邮箱',
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0禁用',
`is_super_admin` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否超级管理员(拥有所有权限) 1是 0不是',
`last_login` datetime DEFAULT NULL COMMENT '最后登录时间',
`last_ip` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '最后登录IP',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `adu_u_d` (`username`, `deleted_at`),
KEY `idx_status_deleted_at` (`status`, `deleted_at`),
KEY `idx_full_phone_number_deleted_at` (`full_phone_number`, `deleted_at`),
KEY `idx_email_deleted_at` (`email`, `deleted_at`),
KEY `idx_created_at_deleted_at` (`created_at`, `deleted_at`)
) ENGINE = InnoDB
AUTO_INCREMENT = 10000
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci COMMENT ='后台管理用户表';
-- 创建权限分组表
CREATE TABLE IF NOT EXISTS `api_group`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`pid` int unsigned NOT NULL DEFAULT '0' COMMENT '上级组织id',
`code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT 'code',
`name` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '分组名称',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `ag_code` (`code`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='权限分组表';
-- 创建权限表
CREATE TABLE IF NOT EXISTS `api`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '权限唯一code码',
`group_code` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '分组唯一code码',
`name` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '权限名称',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',
`method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '接口请求方法',
`route` varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '接口路由',
`func` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '接口方法',
`func_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '接口方法路径',
`is_auth` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '接口鉴权模式 0无需登录 1需要登录 2需要登录且需要API权限',
`is_effective` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '接口是否可用 1是 0否',
`sort` int unsigned NOT NULL DEFAULT '0' COMMENT '排序',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `api_uniq_code_del` (`code`, `deleted_at`) USING BTREE,
KEY `api_idx_route_method_deleted_at` (`route`, `method`, `deleted_at`) USING BTREE,
KEY `idx_group_code_deleted_at_sort` (`group_code`, `deleted_at`, `sort`) USING BTREE,
KEY `idx_is_auth_deleted_at` (`is_auth`, `deleted_at`) USING BTREE,
KEY `idx_updated_at` (`updated_at`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='权限表';
-- 创建菜单表
CREATE TABLE IF NOT EXISTS `menu`
(
`id` int NOT NULL AUTO_INCREMENT,
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '图标',
`code` varchar(120) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端权限标识',
`path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端路由路径',
`full_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '完整前端路由路径',
`redirect` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '重定向路由名称',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端路由名称',
`component` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端组件路径',
`animate_enter` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '进入动画,动画类参考https://animate.style/',
`animate_leave` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '离开动画,动画类参考https://animate.style/',
`animate_duration` float(2, 2) NOT NULL DEFAULT '0.00' COMMENT '动画持续时间',
`is_show` tinyint NOT NULL DEFAULT '0' COMMENT '是否显示,1是 0否',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态,1正常 0禁用',
`is_auth` tinyint NOT NULL DEFAULT '0' COMMENT '是否需要授权,1是 0否 ',
`is_external_links` tinyint NOT NULL DEFAULT '0' COMMENT '是否外链,1是 0否 ',
`is_new_window` tinyint NOT NULL DEFAULT '0' COMMENT '是否新窗口打开, 1是 0否',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序,数字越大,排名越靠前',
`type` tinyint NOT NULL DEFAULT '1' COMMENT '菜单类型,1目录,2菜单,3按钮',
`pid` int NOT NULL DEFAULT '0' COMMENT '上级菜单id',
`level` tinyint NOT NULL DEFAULT '0' COMMENT '层级',
`pids` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '层级序列,多个用英文逗号隔开',
`children_num` int NOT NULL DEFAULT '0' COMMENT '子集数量',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
KEY `uniq_code_del` (`code`, `deleted_at`) USING BTREE,
KEY `idx_name_del` (`name`, `deleted_at`) USING BTREE,
KEY `idx_path_del` (`path`, `deleted_at`) USING BTREE,
KEY `idx_is_auth_del` (`is_auth`, `deleted_at`) USING BTREE,
KEY `idx_status_del` (`status`, `deleted_at`) USING BTREE,
KEY `idx_pid_deleted_at_sort_id` (`pid`, `deleted_at`, `sort`, `id`) USING BTREE,
KEY `idx_pids_deleted_at` (`pids`, `deleted_at`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='菜单表';
-- 创建菜单多语言标题表
CREATE TABLE IF NOT EXISTS `menu_i18n`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`menu_id` int unsigned NOT NULL DEFAULT '0' COMMENT '菜单ID',
`locale` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '语言代码,如 zh-CN、en-US',
`title` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '菜单标题',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_menu_id_locale` (`menu_id`, `locale`) USING BTREE,
KEY `idx_locale_menu_id` (`locale`, `menu_id`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='菜单多语言标题表';
-- 创建组织表
CREATE TABLE IF NOT EXISTS `department`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '部门业务编码',
`is_system` tinyint NOT NULL DEFAULT '0' COMMENT '是否系统保留对象,1是 0否',
`pid` int unsigned NOT NULL DEFAULT '0' COMMENT '上级部门id',
`pids` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
`name` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '部门名称',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',
`level` tinyint NOT NULL DEFAULT '1' COMMENT '层级',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`children_num` int NOT NULL DEFAULT '0' COMMENT '子集数量',
`user_number` int NOT NULL DEFAULT '0' COMMENT '部门用户数量',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_code_deleted_at` (`code`, `deleted_at`),
KEY `idx_name_deleted_at` (`name`, `deleted_at`),
KEY `idx_is_system_deleted_at` (`is_system`, `deleted_at`),
KEY `idx_pid_deleted_at_sort_id` (`pid`, `deleted_at`, `sort`, `id`),
KEY `idx_pids_deleted_at` (`pids`, `deleted_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='部门表';
-- 创建角色表
CREATE TABLE IF NOT EXISTS `role`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '角色业务编码',
`is_system` tinyint NOT NULL DEFAULT '0' COMMENT '是否系统保留对象,1是 0否',
`pid` int unsigned NOT NULL DEFAULT '0' COMMENT '上级id',
`pids` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '上级id路径链',
`name` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '角色名称',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',
`level` tinyint NOT NULL DEFAULT '1' COMMENT '层级',
`sort` mediumint NOT NULL DEFAULT '0' COMMENT '排序',
`children_num` int unsigned NOT NULL DEFAULT '0' COMMENT '子集数量',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '是否启用状态,1是,0否',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_code_deleted_at` (`code`, `deleted_at`),
KEY `idx_name_deleted_at` (`name`, `deleted_at`),
KEY `idx_is_system_deleted_at` (`is_system`, `deleted_at`),
KEY `idx_pid_deleted_at_sort_id` (`pid`, `deleted_at`, `sort`, `id`),
KEY `idx_status_deleted_at` (`status`, `deleted_at`),
KEY `idx_pids_deleted_at` (`pids`, `deleted_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='角色表';
-- 创建用户部门映射表
CREATE TABLE IF NOT EXISTS `admin_user_department_map`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT 'admin_users表id',
`dept_id` int unsigned NOT NULL DEFAULT '0' COMMENT '部门id,department表id',
`is_admin` tinyint NOT NULL DEFAULT '0' COMMENT '是否管理员,1是,0否',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_uid_dept_id` (`uid`, `dept_id`),
KEY `idx_dept_id_uid` (`dept_id`, `uid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='用户部门映射表';
-- 创建菜单权限映射表
CREATE TABLE IF NOT EXISTS `menu_api_map`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`menu_id` int unsigned NOT NULL DEFAULT '0' COMMENT '菜单id,对应menu表id',
`api_id` int unsigned NOT NULL DEFAULT '0' COMMENT '接口id,对应api表id',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_menu_id_api_id` (`menu_id`, `api_id`),
KEY `idx_api_id_menu_id` (`api_id`, `menu_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='菜单权限映射表';
-- menu_api_map 维护菜单与 API 的静态关系。
-- api 表由 `go-layout command api-route` 或 `init-system` 写入后,
-- 默认菜单与 API 的初始绑定由 Go 初始化逻辑幂等补齐。
-- 创建角色菜单映射表
CREATE TABLE IF NOT EXISTS `role_menu_map`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`role_id` int unsigned NOT NULL DEFAULT '0' COMMENT '角色id,对应roles表id',
`menu_id` int unsigned NOT NULL DEFAULT '0' COMMENT '菜单id,对应menu表id',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_role_id_menu_id` (`role_id`, `menu_id`),
KEY `idx_menu_id_role_id` (`menu_id`, `role_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='角色菜单映射表';
-- 创建用户菜单映射表
CREATE TABLE IF NOT EXISTS `admin_user_menu_map`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT 'uid,admin_users表id',
`menu_id` int unsigned NOT NULL DEFAULT '0' COMMENT '菜单id',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_uid_menu_id` (`uid`, `menu_id`),
KEY `idx_menu_id_uid` (`menu_id`, `uid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='用户菜单映射表';
-- 创建部门角色映射表
CREATE TABLE IF NOT EXISTS `department_role_map`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`dept_id` int unsigned NOT NULL DEFAULT '0' COMMENT '部门id,对应department表id',
`role_id` int unsigned NOT NULL DEFAULT '0' COMMENT '角色id,对应roles表id',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_dept_id_role_id` (`dept_id`, `role_id`),
KEY `idx_role_id_dept_id` (`role_id`, `dept_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='部门角色映射表';
-- 创建用户角色映射表
CREATE TABLE IF NOT EXISTS `admin_user_role_map`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT 'uid,admin_users表id',
`role_id` int unsigned NOT NULL DEFAULT '0' COMMENT '角色id',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_uid_role_id` (`uid`, `role_id`),
KEY `idx_role_id_uid` (`role_id`, `uid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_bin
ROW_FORMAT = DYNAMIC COMMENT ='用户角色映射表';
-- 创建管理员登录日志表
CREATE TABLE IF NOT EXISTS `admin_login_logs`
(
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '用户ID(登录失败时为0)',
`username` varchar(50) NOT NULL DEFAULT '' COMMENT '登录账号',
`jwt_id` char(36) NOT NULL DEFAULT '' COMMENT 'JWT唯一标识(jti claim)',
`access_token` text DEFAULT NULL COMMENT '访问令牌(加密保存)',
`refresh_token` text DEFAULT NULL COMMENT '刷新令牌(加密保存)',
`token_hash` char(64) NOT NULL DEFAULT '' COMMENT 'Token的SHA256哈希值',
`refresh_token_hash` char(64) NOT NULL DEFAULT '' COMMENT 'Refresh Token的哈希值',
`ip` varchar(45) NOT NULL DEFAULT '' COMMENT '登录IP(支持IPv6)',
`user_agent` varchar(1024) NOT NULL DEFAULT '' COMMENT '用户代理(浏览器/设备信息)',
`os` varchar(50) NOT NULL DEFAULT '' COMMENT '操作系统',
`browser` varchar(50) NOT NULL DEFAULT '' COMMENT '浏览器',
`execution_time` int(11) NOT NULL DEFAULT '0' COMMENT '登录耗时(毫秒)',
`login_status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '登录状态:1=成功, 0=失败',
`login_fail_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '登录失败原因',
`type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '操作类型:1=登录操作, 2=刷新token',
`is_revoked` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否被撤销:0=否, 1=是',
`revoked_code` tinyint(1) NOT NULL DEFAULT '0' COMMENT '撤销原因码:1=用户主动登出(退出登录), 2=系统强制登出(账号被封), 3=系统刷新token, 4=用户禁用(针对某个设备下线操作), 5=其他原因, 6=用户自己修改密码, 7=管理员修改用户密码',
`revoked_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '撤销原因',
`revoked_at` datetime DEFAULT NULL COMMENT '撤销时间',
`token_expires` datetime DEFAULT NULL COMMENT 'Token过期时间',
`refresh_expires` datetime DEFAULT NULL COMMENT 'Refresh Token过期时间',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`),
KEY `aall_deleted_at_created_at` (`deleted_at`, `created_at`),
KEY `aall_login_status_deleted_at_created_at` (`login_status`, `deleted_at`, `created_at`),
KEY `aall_is_revoked_deleted_at_revoked_at` (`is_revoked`, `deleted_at`, `revoked_at`),
KEY `aall_uid_deleted_at_is_revoked_login_status_token_expires` (`uid`, `deleted_at`, `is_revoked`, `login_status`, `token_expires`),
KEY `aall_jwt_id_deleted_at_is_revoked` (`jwt_id`, `deleted_at`, `is_revoked`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='管理员登录日志表';
-- 创建登录安全状态表
CREATE TABLE IF NOT EXISTS `login_security_state`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL DEFAULT '' COMMENT '登录账号',
`fail_count` int unsigned NOT NULL DEFAULT '0' COMMENT '连续失败次数',
`lock_until` datetime DEFAULT NULL COMMENT '锁定截止时间',
`last_failed_at` datetime DEFAULT NULL COMMENT '最近失败时间',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `lss_username` (`username`),
KEY `lss_lock_until` (`lock_until`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='登录安全状态表';
-- 创建请求日志表
CREATE TABLE IF NOT EXISTS `request_logs`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`request_id` varchar(64) NOT NULL DEFAULT '' COMMENT '请求唯一标识',
`jwt_id` varchar(36) NOT NULL DEFAULT '' COMMENT '请求授权的jwtId',
`operator_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作ID(用户ID)',
`ip` varchar(45) NOT NULL DEFAULT '' COMMENT '客户端IP地址',
`user_agent` varchar(1024) NOT NULL DEFAULT '' COMMENT '用户代理(浏览器/设备信息)',
`os` varchar(50) NOT NULL DEFAULT '' COMMENT '操作系统',
`browser` varchar(50) NOT NULL DEFAULT '' COMMENT '浏览器',
`method` varchar(10) NOT NULL DEFAULT '' COMMENT 'HTTP请求方法(GET/POST等)',
`base_url` varchar(160) NOT NULL DEFAULT '' COMMENT '请求基础URL',
`operation_name` varchar(255) NOT NULL DEFAULT '' COMMENT '操作名称',
`operation_status` int(11) NOT NULL DEFAULT '0' COMMENT '操作状态码(响应返回的code,0=成功,其他=失败)',
`is_high_risk` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否高危操作 1是 0否',
`operator_account` varchar(50) NOT NULL DEFAULT '' COMMENT '操作账号',
`operator_name` varchar(50) NOT NULL DEFAULT '' COMMENT '操作人员',
`request_headers` text DEFAULT NULL COMMENT '请求头(JSON格式)',
`request_query` text DEFAULT NULL COMMENT '请求参数',
`request_body` text DEFAULT NULL COMMENT '请求体',
`change_diff` longtext DEFAULT NULL COMMENT '关键变更前后差异(JSON)',
`response_status` int(11) NOT NULL DEFAULT '0' COMMENT '响应状态码',
`response_body` text DEFAULT NULL COMMENT '响应体',
`response_header` text DEFAULT NULL COMMENT '响应头',
`execution_time` DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '执行时间(毫秒,支持小数,最多4位)',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `rl_request_id` (`request_id`),
KEY `rl_operator_id_created_at` (`operator_id`, `created_at`),
KEY `rl_base_url_method_created_at` (`base_url`, `method`, `created_at`),
KEY `rl_operation_status_created_at` (`operation_status`, `created_at`),
KEY `rl_response_status_operator_id_created_at` (`response_status`, `operator_id`, `created_at`),
KEY `rl_operator_account_created_at` (`operator_account`, `created_at`),
KEY `rl_created_at` (`created_at`),
KEY `rl_jwt_id` (`jwt_id`),
KEY `rl_is_high_risk_created_at` (`is_high_risk`, `created_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='请求日志表';
CREATE TABLE IF NOT EXISTS `casbin_rule`
(
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`ptype` varchar(100) DEFAULT NULL,
`v0` varchar(100) DEFAULT NULL,
`v1` varchar(100) DEFAULT NULL,
`v2` varchar(100) DEFAULT NULL,
`v3` varchar(100) DEFAULT NULL,
`v4` varchar(100) DEFAULT NULL,
`v5` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_casbin_rule` (`ptype`, `v0`, `v1`, `v2`, `v3`, `v4`, `v5`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_0900_ai_ci COMMENT ='casbin_rule表';
CREATE TABLE IF NOT EXISTS `upload_files`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`folder_id` int unsigned NOT NULL DEFAULT '0' COMMENT '逻辑目录ID',
`logical_path` varchar(1024) NOT NULL DEFAULT '/' COMMENT '逻辑路径快照',
`display_name` varchar(255) NOT NULL DEFAULT '' COMMENT '展示名称',
`origin_name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件源名称',
`name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件名称(UUID+扩展名)',
`path` varchar(255) NOT NULL DEFAULT '' COMMENT '文件相对路径(相对于storage/public或storage/private)',
`size` int unsigned NOT NULL DEFAULT '0' COMMENT '文件大小(字节)',
`ext` varchar(20) NOT NULL DEFAULT '' COMMENT '文件扩展名',
`hash` varchar(64) NOT NULL DEFAULT '' COMMENT '文件SHA256哈希值(用于去重)',
`uuid` varchar(32) NOT NULL DEFAULT '' COMMENT '文件UUID(用于URL访问,32位十六进制字符串,不带连字符)',
`mime_type` varchar(100) NOT NULL DEFAULT '' COMMENT 'MIME类型(如:image/jpeg, application/pdf)',
`file_type` varchar(20) NOT NULL DEFAULT '' COMMENT '文件类型:image,pdf,word,excel,ppt,archive,text,audio,video,other',
`is_public` tinyint NOT NULL DEFAULT '0' COMMENT '是否公开访问: 0否 1是',
`storage_driver` varchar(20) NOT NULL DEFAULT 'local' COMMENT '存储驱动:local,aliyun_oss,s3',
`storage_base` varchar(512) NOT NULL DEFAULT '' COMMENT '存储基础位置',
`bucket` varchar(128) NOT NULL DEFAULT '' COMMENT '存储桶',
`storage_path` varchar(512) NOT NULL DEFAULT '' COMMENT '实际存储路径',
`object_key` varchar(512) NOT NULL DEFAULT '' COMMENT '对象key',
`etag` varchar(128) NOT NULL DEFAULT '' COMMENT '对象ETag',
`storage_status` varchar(32) NOT NULL DEFAULT 'stored' COMMENT '存储状态:stored,delete_failed',
`upload_source` varchar(20) NOT NULL DEFAULT 'backend' COMMENT '上传来源:backend,direct,system',
`upload_scene` varchar(60) NOT NULL DEFAULT '' COMMENT '上传场景',
`upload_status` varchar(20) NOT NULL DEFAULT 'uploaded' COMMENT '上传状态:pending,uploaded,failed',
`last_accessed_at` datetime DEFAULT NULL COMMENT '最后访问时间',
`deleted_by` int unsigned NOT NULL DEFAULT '0' COMMENT '删除人',
`deleted_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '删除原因',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
`deleted_at` int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`),
KEY `idx_uid_created_at` (`uid`, `created_at`),
KEY `idx_folder_created_at` (`folder_id`, `created_at`),
KEY `idx_hash_is_public` (`hash`, `is_public`),
KEY `idx_file_type_created_at` (`file_type`, `created_at`),
KEY `idx_storage_driver_status` (`storage_driver`, `storage_status`),
KEY `idx_deleted_at_created_at` (`deleted_at`, `created_at`),
UNIQUE KEY `idx_uuid` (`uuid`),
KEY `idx_is_public_uuid` (`is_public`, `uuid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件表';
CREATE TABLE IF NOT EXISTS `upload_file_folders`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`parent_id` int unsigned NOT NULL DEFAULT '0' COMMENT '父目录ID',
`name` varchar(120) NOT NULL DEFAULT '' COMMENT '目录名称',
`logical_path` varchar(1024) NOT NULL DEFAULT '/' COMMENT '逻辑路径',
`sort` int NOT NULL DEFAULT '0' COMMENT '排序',
`created_by` int unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
`updated_by` int unsigned NOT NULL DEFAULT '0' COMMENT '更新人',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
`deleted_at` int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_parent_name_deleted` (`parent_id`, `name`, `deleted_at`),
KEY `idx_parent_sort` (`parent_id`, `sort`, `id`),
KEY `idx_logical_path` (`logical_path`(191))
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件逻辑目录表';
CREATE TABLE IF NOT EXISTS `upload_file_references`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`file_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'upload_files.id',
`uuid` varchar(32) NOT NULL DEFAULT '' COMMENT '文件UUID',
`owner_type` varchar(60) NOT NULL DEFAULT '' COMMENT '引用对象类型',
`owner_id` int unsigned NOT NULL DEFAULT '0' COMMENT '引用对象ID',
`owner_field` varchar(60) NOT NULL DEFAULT '' COMMENT '引用字段',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_owner_file_field` (`owner_type`, `owner_id`, `owner_field`, `file_id`),
KEY `idx_file_id` (`file_id`),
KEY `idx_uuid` (`uuid`),
KEY `idx_owner` (`owner_type`, `owner_id`, `owner_field`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件引用关系表';
CREATE TABLE IF NOT EXISTS `sys_config`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`config_key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '参数键名',
`config_value` text DEFAULT NULL COMMENT '参数值',
`value_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'string' COMMENT '值类型:string,number,bool,json',
`group_code` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default' COMMENT '参数分组',
`is_system` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否系统内置:0否,1是',
`is_sensitive` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否敏感配置:0否,1是',
`is_visible` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '是否在系统参数页展示:0否,1是',
`manage_tab` varchar(60) NOT NULL DEFAULT '' COMMENT '专属配置Tab',
`status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1启用',
`sort` int unsigned NOT NULL DEFAULT '0' COMMENT '排序',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_config_key_deleted_at` (`config_key`, `deleted_at`) USING BTREE,
KEY `idx_group_status_deleted_at_sort` (`group_code`, `status`, `deleted_at`, `sort`) USING BTREE,
KEY `idx_visible_deleted_at_sort` (`is_visible`, `deleted_at`, `sort`) USING BTREE,
KEY `idx_status_deleted_at` (`status`, `deleted_at`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统参数表';
CREATE TABLE IF NOT EXISTS `sys_dict_type`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`type_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '字典类型编码',
`is_system` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否系统内置:0否,1是',
`status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1启用',
`sort` int unsigned NOT NULL DEFAULT '0' COMMENT '排序',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_type_code_deleted_at` (`type_code`, `deleted_at`) USING BTREE,
KEY `idx_status_deleted_at_sort` (`status`, `deleted_at`, `sort`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典类型表';
CREATE TABLE IF NOT EXISTS `sys_dict_item`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`type_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '字典类型编码',
`value` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '字典值',
`color` varchar(30) NOT NULL DEFAULT '' COMMENT '展示颜色',
`tag_type` varchar(30) NOT NULL DEFAULT '' COMMENT '前端标签类型',
`is_default` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否默认项:0否,1是',
`is_system` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '是否系统内置:0否,1是',
`status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1启用',
`sort` int unsigned NOT NULL DEFAULT '0' COMMENT '排序',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_type_value_deleted_at` (`type_code`, `value`, `deleted_at`) USING BTREE,
KEY `idx_type_status_deleted_at_sort` (`type_code`, `status`, `deleted_at`, `sort`) USING BTREE,
KEY `idx_status_deleted_at` (`status`, `deleted_at`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典项表';
CREATE TABLE IF NOT EXISTS `sys_config_i18n`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`config_id` int unsigned NOT NULL DEFAULT '0' COMMENT '系统参数ID',
`locale` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '语言编码',
`config_name` varchar(100) NOT NULL DEFAULT '' COMMENT '参数名称',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_config_id_locale` (`config_id`, `locale`) USING BTREE,
KEY `idx_locale_config_name` (`locale`, `config_name`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统参数多语言表';
CREATE TABLE IF NOT EXISTS `sys_dict_type_i18n`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`dict_type_id` int unsigned NOT NULL DEFAULT '0' COMMENT '字典类型ID',
`locale` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '语言编码',
`type_name` varchar(100) NOT NULL DEFAULT '' COMMENT '字典类型名称',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_dict_type_id_locale` (`dict_type_id`, `locale`) USING BTREE,
KEY `idx_locale_type_name` (`locale`, `type_name`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典类型多语言表';
CREATE TABLE IF NOT EXISTS `sys_dict_item_i18n`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`dict_item_id` int unsigned NOT NULL DEFAULT '0' COMMENT '字典项ID',
`locale` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '语言编码',
`label` varchar(100) NOT NULL DEFAULT '' COMMENT '字典标签',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_dict_item_id_locale` (`dict_item_id`, `locale`) USING BTREE,
KEY `idx_locale_label` (`locale`, `label`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典项多语言表';
CREATE TABLE IF NOT EXISTS `task_definitions`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(120) NOT NULL DEFAULT '' COMMENT '任务唯一编码',
`name` varchar(120) NOT NULL DEFAULT '' COMMENT '任务名称',
`kind` varchar(20) NOT NULL DEFAULT '' COMMENT '任务类型 async/cron/manual',
`queue` varchar(60) NOT NULL DEFAULT '' COMMENT '队列名称',
`cron_spec` varchar(120) NOT NULL DEFAULT '' COMMENT 'Cron 表达式',
`handler` varchar(255) NOT NULL DEFAULT '' COMMENT '处理器标识',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用',
`allow_manual` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否允许手动触发 1是 0否',
`allow_retry` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否允许手动重试 1是 0否',
`is_high_risk` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否高危任务 1是 0否',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` int NOT NULL DEFAULT '0' COMMENT '删除时间戳',
PRIMARY KEY (`id`),
UNIQUE KEY `td_code_deleted_at` (`code`, `deleted_at`),
KEY `td_kind_status_deleted_at` (`kind`, `status`, `deleted_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='任务定义表';
CREATE TABLE IF NOT EXISTS `task_runs`
(
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`task_code` varchar(120) NOT NULL DEFAULT '' COMMENT '任务唯一编码',
`kind` varchar(20) NOT NULL DEFAULT '' COMMENT '任务类型 async/cron/manual',
`source` varchar(20) NOT NULL DEFAULT '' COMMENT '来源 queue/cron/manual',
`source_id` varchar(120) NOT NULL DEFAULT '' COMMENT '来源任务ID,如 Asynq task id',
`queue` varchar(60) NOT NULL DEFAULT '' COMMENT '队列名称',
`trigger_user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '触发人ID',
`trigger_account` varchar(60) NOT NULL DEFAULT '' COMMENT '触发人账号',
`status` varchar(20) NOT NULL DEFAULT '' COMMENT '执行状态 pending/running/success/failed/canceled/retrying',
`attempt` int NOT NULL DEFAULT '0' COMMENT '当前尝试次数',
`max_retry` int NOT NULL DEFAULT '0' COMMENT '最大重试次数',
`payload` mediumtext DEFAULT NULL COMMENT '任务 payload',
`error_message` text DEFAULT NULL COMMENT '失败原因',
`started_at` datetime DEFAULT NULL COMMENT '开始时间',
`finished_at` datetime DEFAULT NULL COMMENT '结束时间',
`duration_ms` decimal(10, 4) NOT NULL DEFAULT '0.0000' COMMENT '执行耗时毫秒',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tr_task_code_created_at` (`task_code`, `created_at`),
KEY `tr_status_created_at` (`status`, `created_at`),
KEY `tr_source_source_id` (`source`, `source_id`),
KEY `tr_kind_created_at` (`kind`, `created_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='任务执行记录表';
CREATE TABLE IF NOT EXISTS `task_run_events`
(
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`run_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '任务执行记录ID',
`event_type` varchar(30) NOT NULL DEFAULT '' COMMENT '事件类型 enqueue/start/retry/fail/success/cancel',
`message` text DEFAULT NULL COMMENT '事件说明',
`meta` text DEFAULT NULL COMMENT '事件元数据 JSON',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tre_run_id_created_at` (`run_id`, `created_at`),
KEY `tre_event_type_created_at` (`event_type`, `created_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='任务执行事件表';
CREATE TABLE IF NOT EXISTS `cron_task_states`
(
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`task_code` varchar(120) NOT NULL DEFAULT '' COMMENT '任务唯一编码',
`cron_spec` varchar(120) NOT NULL DEFAULT '' COMMENT 'Cron 表达式',
`last_run_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '最近执行记录ID',
`last_status` varchar(20) NOT NULL DEFAULT '' COMMENT '最近执行状态',
`last_started_at` datetime DEFAULT NULL COMMENT '最近开始时间',
`last_finished_at` datetime DEFAULT NULL COMMENT '最近结束时间',
`next_run_at` datetime DEFAULT NULL COMMENT '下次执行时间',
`last_error` text DEFAULT NULL COMMENT '最近失败原因',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `cts_task_code` (`task_code`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='定时任务最近状态表';
COMMIT;
================================================
FILE: data/migrations/20260425000002_init_data.down.sql
================================================
BEGIN;
-- 回滚系统数据
-- 删除任务中心/系统管理新增菜单与映射
DELETE FROM `role_menu_map` WHERE `menu_id` BETWEEN 42 AND 76;
DELETE FROM `menu_i18n` WHERE `menu_id` BETWEEN 42 AND 76;
DELETE FROM `menu` WHERE `id` BETWEEN 42 AND 76;
DELETE FROM `api_group` WHERE `code` IN ('system', 'sysConfig', 'sysDict', 'task', 'file', 'session');
-- 删除系统参数/字典初始化数据
DELETE FROM `sys_dict_item_i18n`
WHERE `dict_item_id` IN (
SELECT `id`
FROM `sys_dict_item`
WHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')
AND `deleted_at` = 0
);
DELETE FROM `sys_dict_type_i18n`
WHERE `dict_type_id` IN (
SELECT `id`
FROM `sys_dict_type`
WHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')
AND `deleted_at` = 0
);
DELETE FROM `sys_config_i18n`
WHERE `config_id` IN (
SELECT `id`
FROM `sys_config`
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')
AND `deleted_at` = 0
);
DELETE FROM `sys_dict_item`
WHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')
AND `deleted_at` = 0;
DELETE FROM `sys_dict_type`
WHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')
AND `deleted_at` = 0;
DELETE FROM `sys_config`
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')
AND `deleted_at` = 0;
-- 删除角色菜单映射
DELETE FROM `role_menu_map` WHERE `role_id` = 1;
-- 删除菜单翻译数据
DELETE FROM `menu_i18n` WHERE `menu_id` BETWEEN 1 AND 41;
-- 删除菜单数据
DELETE FROM `menu` WHERE `id` BETWEEN 1 AND 41;
-- 删除权限分组数据
DELETE FROM `api_group` WHERE `id` BETWEEN 1 AND 9;
-- 删除管理员用户部门/角色映射
DELETE FROM `admin_user_role_map` WHERE `uid` = 1 AND `role_id` = 1;
DELETE FROM `admin_user_department_map` WHERE `uid` = 1 AND `dept_id` = 1;
-- 删除默认角色和部门
DELETE FROM `role` WHERE `id` = 1;
DELETE FROM `department` WHERE `id` = 1;
-- 删除管理员用户数据
DELETE FROM `admin_user` WHERE `id` = 1;
COMMIT;
================================================
FILE: data/migrations/20260425000002_init_data.up.sql
================================================
BEGIN;
-- 初始化系统数据
-- 初始密码 123456
INSERT INTO `admin_user` (`id`, `nickname`, `username`, `password`, `phone_number`, `full_phone_number`,
`country_code`, `email`, `avatar`, `status`,
`is_super_admin`,
`created_at`, `updated_at`, `deleted_at`)
VALUES (1, '超级管理员', 'super_admin', '$2a$10$OuKQoJGH7xkCgwFISmDve.euBDbOCnYEJX6R22QMeLxCLwdoJ4iyi', '18888888888',
'8618888888888', '86', 'admin@go-layout.com', 'https://avatars.githubusercontent.com/u/48752601?v=4', 1, 1,
'2023-05-01 00:00:00', '2023-05-01 00:00:00', 0);
INSERT INTO `department` (`id`, `code`, `is_system`, `pid`, `pids`, `name`, `description`, `level`, `sort`,
`children_num`, `user_number`, `created_at`, `updated_at`, `deleted_at`)
VALUES (1, 'default_department', 1, 0, '0', '默认部门', '系统默认部门', 1, 100, 0, 1,
'2023-05-01 00:00:00', '2023-05-01 00:00:00', 0);
INSERT INTO `role` (`id`, `code`, `is_system`, `pid`, `pids`, `name`, `description`, `level`, `sort`, `children_num`,
`status`, `created_at`, `updated_at`, `deleted_at`)
VALUES (1, 'super_admin', 1, 0, '0', '超级管理员', '系统默认超级管理员角色', 1, 100, 0, 1,
'2023-05-01 00:00:00', '2023-05-01 00:00:00', 0);
INSERT INTO `admin_user_department_map` (`uid`, `dept_id`, `is_admin`, `created_at`, `updated_at`)
VALUES (1, 1, 1, '2023-05-01 00:00:00', '2023-05-01 00:00:00');
INSERT INTO `admin_user_role_map` (`uid`, `role_id`, `created_at`, `updated_at`)
VALUES (1, 1, '2023-05-01 00:00:00', '2023-05-01 00:00:00');
-- 初始化权限分组数据
INSERT INTO `api_group` (`id`, `pid`, `code`, `name`, `created_at`, `updated_at`)
VALUES (1, 0, 'other', '其他', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(2, 0, 'login', '登录模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(3, 0, 'auth', '权限模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(4, 3, 'adminUser', '管理员模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(5, 3, 'api', 'API模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(6, 3, 'role', '角色模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(7, 3, 'menu', '菜单模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(8, 0, 'common', '公共模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),
(9, 0, 'log', '日志模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00');
-- 初始化菜单数据
INSERT 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
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(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),
(42, 'ep:setting', '', 'system', '/system', 'SysConfig', 'System', '', '', '', 0.00, 1, 1, 1, 0, 0, 96, 1, 0, 1, '0', 3, '', NOW(), NOW(), 0),
(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),
(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),
(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),
(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),
(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),
(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),
(49, 'ep:refresh', 'sysConfig:refresh', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 60, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),
(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),
(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),
(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),
(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),
(54, 'ri:task-line', '', 'task', '/task', 'TaskCenter', 'Task', '', '', '', 0.00, 1, 1, 1, 0, 0, 97, 1, 0, 1, '0', 1, '', NOW(), NOW(), 0),
(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),
(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),
(57, 'ep:video-play', 'task:trigger', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),
(58, 'ep:refresh-right', 'task:retry', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),
(59, 'ep:circle-close', 'task:cancel', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),
(60, 'ep:view', 'task:detail', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 60, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),
(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),
(62, 'ep:setting', 'requestLog:maskConfig', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 30, 3, '0,29,30', 0, '', NOW(), NOW(), 0),
(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),
(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),
(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),
(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),
(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),
(68, 'ep:circle-close', 'session:revoke', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 66, 3, '0,29,66', 0, '', NOW(), NOW(), 0),
(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),
(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),
(72, 'ep:connection', 'storage:test', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),
(73, 'ep:refresh-left', 'file:restore', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0),
(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),
(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),
(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);
INSERT INTO `menu_i18n` (`menu_id`, `locale`, `title`, `created_at`, `updated_at`)
SELECT `id`,
'zh-CN',
CASE `id`
WHEN 1 THEN '首页'
WHEN 2 THEN '权限管理'
WHEN 3 THEN '接口'
WHEN 4 THEN '菜单'
WHEN 5 THEN '关于'
WHEN 7 THEN '管理员'
WHEN 8 THEN '角色'
WHEN 9 THEN '部门'
WHEN 10 THEN '编辑'
WHEN 11 THEN '新增管理员'
WHEN 12 THEN '绑定角色'
WHEN 13 THEN '删除'
WHEN 14 THEN '新增菜单'
WHEN 15 THEN '新增下级'
WHEN 16 THEN '编辑'
WHEN 17 THEN '删除'
WHEN 18 THEN '新增角色'
WHEN 19 THEN '编辑'
WHEN 20 THEN '删除'
WHEN 21 THEN '新增部门'
WHEN 22 THEN '新增'
WHEN 23 THEN '编辑'
WHEN 24 THEN '绑定角色'
WHEN 25 THEN '删除'
WHEN 26 THEN '编辑'
WHEN 28 THEN '新增'
WHEN 29 THEN '日志管理'
WHEN 30 THEN '请求日志'
WHEN 31 THEN '管理员登录日志'
WHEN 32 THEN '详情'
WHEN 33 THEN '详情'
WHEN 34 THEN '列表'
WHEN 35 THEN '列表'
WHEN 36 THEN '列表'
WHEN 37 THEN '列表'
WHEN 38 THEN '列表'
WHEN 39 THEN '列表'
WHEN 40 THEN '列表'
WHEN 42 THEN '系统管理'
WHEN 43 THEN '系统参数'
WHEN 44 THEN '字典管理'
WHEN 45 THEN '列表'
WHEN 46 THEN '新增'
WHEN 47 THEN '编辑'
WHEN 48 THEN '删除'
WHEN 49 THEN '刷新缓存'
WHEN 50 THEN '列表'
WHEN 51 THEN '新增'
WHEN 52 THEN '编辑'
WHEN 53 THEN '删除'
WHEN 54 THEN '任务中心'
WHEN 55 THEN '任务管理'
WHEN 56 THEN '列表'
WHEN 57 THEN '触发任务'
WHEN 58 THEN '重试任务'
WHEN 59 THEN '取消任务'
WHEN 60 THEN '详情'
WHEN 61 THEN '导出'
WHEN 62 THEN '脱敏配置'
WHEN 63 THEN '文件资源'
WHEN 64 THEN '列表'
WHEN 65 THEN '删除'
WHEN 66 THEN '在线会话'
WHEN 67 THEN '列表'
WHEN 68 THEN '撤销'
WHEN 70 THEN '配置'
WHEN 71 THEN '更新'
WHEN 72 THEN '测试'
WHEN 73 THEN '恢复'
WHEN 74 THEN '硬删除'
WHEN 75 THEN '编辑'
WHEN 76 THEN '新增'
ELSE ''
END,
`created_at`,
`updated_at`
FROM `menu`
WHERE `deleted_at` = 0;
INSERT INTO `menu_i18n` (`menu_id`, `locale`, `title`, `created_at`, `updated_at`)
SELECT `id`,
'en-US',
CASE `id`
WHEN 1 THEN 'Home'
WHEN 2 THEN 'Permission'
WHEN 3 THEN 'API'
WHEN 4 THEN 'Menu'
WHEN 5 THEN 'About'
WHEN 7 THEN 'Administrators'
WHEN 8 THEN 'Roles'
WHEN 9 THEN 'Departments'
WHEN 10 THEN 'Edit'
WHEN 11 THEN 'Add Administrator'
WHEN 12 THEN 'Bind Roles'
WHEN 13 THEN 'Delete'
WHEN 14 THEN 'Add Menu'
WHEN 15 THEN 'Add Child'
WHEN 16 THEN 'Edit'
WHEN 17 THEN 'Delete'
WHEN 18 THEN 'Add Role'
WHEN 19 THEN 'Edit'
WHEN 20 THEN 'Delete'
WHEN 21 THEN 'Add Department'
WHEN 22 THEN 'Add'
WHEN 23 THEN 'Edit'
WHEN 24 THEN 'Bind Roles'
WHEN 25 THEN 'Delete'
WHEN 26 THEN 'Edit'
WHEN 28 THEN 'Add'
WHEN 29 THEN 'Log Management'
WHEN 30 THEN 'Request Logs'
WHEN 31 THEN 'Admin Login Logs'
WHEN 32 THEN 'Detail'
WHEN 33 THEN 'Detail'
WHEN 34 THEN 'List'
WHEN 35 THEN 'List'
WHEN 36 THEN 'List'
WHEN 37 THEN 'List'
WHEN 38 THEN 'List'
WHEN 39 THEN 'List'
WHEN 40 THEN 'List'
WHEN 42 THEN 'System'
WHEN 43 THEN 'System Config'
WHEN 44 THEN 'Dictionary'
WHEN 45 THEN 'List'
WHEN 46 THEN 'Add'
WHEN 47 THEN 'Edit'
WHEN 48 THEN 'Delete'
WHEN 49 THEN 'Refresh Cache'
WHEN 50 THEN 'List'
WHEN 51 THEN 'Add'
WHEN 52 THEN 'Edit'
WHEN 53 THEN 'Delete'
WHEN 54 THEN 'Task Center'
WHEN 55 THEN 'Task Management'
WHEN 56 THEN 'List'
WHEN 57 THEN 'Trigger Task'
WHEN 58 THEN 'Retry Task'
WHEN 59 THEN 'Cancel Task'
WHEN 60 THEN 'Detail'
WHEN 61 THEN 'Export'
WHEN 62 THEN 'Mask Config'
WHEN 63 THEN 'File Resources'
WHEN 64 THEN 'List'
WHEN 65 THEN 'Delete'
WHEN 66 THEN 'Online Sessions'
WHEN 67 THEN 'List'
WHEN 68 THEN 'Revoke'
WHEN 70 THEN 'Config'
WHEN 71 THEN 'Update'
WHEN 72 THEN 'Test'
WHEN 73 THEN 'Restore'
WHEN 74 THEN 'Destroy'
WHEN 75 THEN 'Edit'
WHEN 76 THEN 'Create'
ELSE ''
END,
`created_at`,
`updated_at`
FROM `menu`
WHERE `deleted_at` = 0;
INSERT INTO `role_menu_map` (`role_id`, `menu_id`, `created_at`, `updated_at`)
SELECT 1, `id`, '2023-05-01 00:00:00', '2023-05-01 00:00:00'
FROM `menu`
WHERE `deleted_at` = 0;
INSERT INTO `sys_config` (`config_key`, `config_value`, `value_type`, `group_code`, `is_system`,
`is_sensitive`, `is_visible`, `manage_tab`, `status`, `sort`, `remark`, `created_at`, `updated_at`, `deleted_at`)
VALUES ('auth.login_lock_enabled', 'true', 'bool', 'auth', 1, 0, 1, '', 1, 89, '是否开启登录失败锁定', NOW(), NOW(), 0),
('auth.login_max_failures', '5', 'number', 'auth', 1, 0, 1, '', 1, 88, '登录连续失败阈值', NOW(), NOW(), 0),
('auth.login_lock_minutes', '15', 'number', 'auth', 1, 0, 1, '', 1, 87, '登录锁定时长(分钟)', NOW(), NOW(), 0),
('task.cron_demo_enabled', 'false', 'bool', 'task', 1, 0, 1, '', 1, 80, '是否启用演示定时任务,默认关闭', NOW(), NOW(), 0),
('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),
('storage.active_driver', 'local', 'string', 'storage', 1, 0, 0, 'storage', 1, 90, '当前启用的文件存储驱动', NOW(), NOW(), 0),
('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)
ON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_dict_type` (`type_code`, `is_system`, `status`, `sort`, `remark`, `created_at`,
`updated_at`, `deleted_at`)
VALUES ('common_status', 1, 1, 100, '0=禁用,1=启用', NOW(), NOW(), 0),
('yes_no', 1, 1, 90, '0=否,1=是', NOW(), NOW(), 0),
('menu_type', 1, 1, 80, '1=目录,2=菜单,3=按钮', NOW(), NOW(), 0),
('api_auth_mode', 1, 1, 70, '0=无需登录,1=需要登录,2=需要接口权限', NOW(), NOW(), 0),
('http_method', 1, 1, 60, '常用 HTTP 方法', NOW(), NOW(), 0),
('task_kind', 1, 1, 50, '任务类型', NOW(), NOW(), 0),
('task_source', 1, 1, 40, '任务来源', NOW(), NOW(), 0),
('task_run_status', 1, 1, 30, '任务执行状态', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_dict_item` (`type_code`, `value`, `color`, `tag_type`, `is_default`, `is_system`, `status`,
`sort`, `remark`, `created_at`, `updated_at`, `deleted_at`)
VALUES ('common_status', '0', '#909399', 'info', 0, 1, 1, 10, '', NOW(), NOW(), 0),
('common_status', '1', '#67c23a', 'success', 1, 1, 1, 20, '', NOW(), NOW(), 0),
('yes_no', '0', '#909399', 'info', 1, 1, 1, 10, '', NOW(), NOW(), 0),
('yes_no', '1', '#67c23a', 'success', 0, 1, 1, 20, '', NOW(), NOW(), 0),
('menu_type', '1', '#409eff', 'primary', 0, 1, 1, 30, '', NOW(), NOW(), 0),
('menu_type', '2', '#67c23a', 'success', 1, 1, 1, 20, '', NOW(), NOW(), 0),
('menu_type', '3', '#e6a23c', 'warning', 0, 1, 1, 10, '', NOW(), NOW(), 0),
('api_auth_mode', '0', '#909399', 'info', 0, 1, 1, 30, '', NOW(), NOW(), 0),
('api_auth_mode', '1', '#409eff', 'primary', 1, 1, 1, 20, '', NOW(), NOW(), 0),
('api_auth_mode', '2', '#f56c6c', 'danger', 0, 1, 1, 10, '', NOW(), NOW(), 0),
('http_method', 'GET', '#67c23a', 'success', 1, 1, 1, 70, '', NOW(), NOW(), 0),
('http_method', 'POST', '#409eff', 'primary', 0, 1, 1, 60, '', NOW(), NOW(), 0),
('http_method', 'PUT', '#e6a23c', 'warning', 0, 1, 1, 50, '', NOW(), NOW(), 0),
('http_method', 'DELETE', '#f56c6c', 'danger', 0, 1, 1, 40, '', NOW(), NOW(), 0),
('http_method', 'PATCH', '#909399', 'info', 0, 1, 1, 30, '', NOW(), NOW(), 0),
('http_method', 'OPTIONS', '#909399', 'info', 0, 1, 1, 20, '', NOW(), NOW(), 0),
('http_method', 'HEAD', '#909399', 'info', 0, 1, 1, 10, '', NOW(), NOW(), 0),
('task_kind', 'async', '#909399', 'info', 1, 1, 1, 20, '', NOW(), NOW(), 0),
('task_kind', 'cron', '#e6a23c', 'warning', 0, 1, 1, 10, '', NOW(), NOW(), 0),
('task_source', 'queue', '#409eff', 'primary', 1, 1, 1, 30, '', NOW(), NOW(), 0),
('task_source', 'cron', '#e6a23c', 'warning', 0, 1, 1, 20, '', NOW(), NOW(), 0),
('task_source', 'manual', '#67c23a', 'success', 0, 1, 1, 10, '', NOW(), NOW(), 0),
('task_run_status', 'pending', '#909399', 'info', 1, 1, 1, 60, '', NOW(), NOW(), 0),
('task_run_status', 'running', '#e6a23c', 'warning', 0, 1, 1, 50, '', NOW(), NOW(), 0),
('task_run_status', 'success', '#67c23a', 'success', 0, 1, 1, 40, '', NOW(), NOW(), 0),
('task_run_status', 'failed', '#f56c6c', 'danger', 0, 1, 1, 30, '', NOW(), NOW(), 0),
('task_run_status', 'canceled', '#909399', 'info', 0, 1, 1, 20, '', NOW(), NOW(), 0),
('task_run_status', 'retrying', '#e6a23c', 'warning', 0, 1, 1, 10, '', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_config_i18n` (`config_id`, `locale`, `config_name`, `created_at`, `updated_at`)
SELECT `id`,
'zh-CN',
CASE
WHEN `config_key` = 'auth.login_lock_enabled' THEN '登录失败锁定开关'
WHEN `config_key` = 'auth.login_max_failures' THEN '登录失败锁定阈值'
WHEN `config_key` = 'auth.login_lock_minutes' THEN '登录失败锁定时长(分钟)'
WHEN `config_key` = 'task.cron_demo_enabled' THEN '演示定时任务开关'
WHEN `config_key` = 'audit.sensitive_fields' THEN '请求日志脱敏配置'
WHEN `config_key` = 'storage.active_driver' THEN '当前存储驱动'
WHEN `config_key` = 'storage.config' THEN '文件存储配置'
ELSE ''
END,
NOW(),
NOW()
FROM `sys_config`
WHERE `deleted_at` = 0
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')
ON DUPLICATE KEY UPDATE `config_name` = VALUES(`config_name`), `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_config_i18n` (`config_id`, `locale`, `config_name`, `created_at`, `updated_at`)
SELECT `id`,
'en-US',
CASE
WHEN `config_key` = 'auth.login_lock_enabled' THEN 'Login Lock Enabled'
WHEN `config_key` = 'auth.login_max_failures' THEN 'Login Lock Max Failures'
WHEN `config_key` = 'auth.login_lock_minutes' THEN 'Login Lock Minutes'
WHEN `config_key` = 'task.cron_demo_enabled' THEN 'Cron Demo Enabled'
WHEN `config_key` = 'audit.sensitive_fields' THEN 'Request Log Mask Config'
WHEN `config_key` = 'storage.active_driver' THEN 'Active Storage Driver'
WHEN `config_key` = 'storage.config' THEN 'File Storage Config'
ELSE ''
END,
NOW(),
NOW()
FROM `sys_config`
WHERE `deleted_at` = 0
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')
ON DUPLICATE KEY UPDATE `config_name` = VALUES(`config_name`), `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_dict_type_i18n` (`dict_type_id`, `locale`, `type_name`, `created_at`, `updated_at`)
SELECT `id`,
'zh-CN',
CASE
WHEN `type_code` = 'common_status' THEN '通用状态'
WHEN `type_code` = 'yes_no' THEN '是否选项'
WHEN `type_code` = 'menu_type' THEN '菜单类型'
WHEN `type_code` = 'api_auth_mode' THEN '接口鉴权模式'
WHEN `type_code` = 'http_method' THEN 'HTTP 方法'
WHEN `type_code` = 'task_kind' THEN '任务类型'
WHEN `type_code` = 'task_source' THEN '任务来源'
WHEN `type_code` = 'task_run_status' THEN '任务执行状态'
ELSE ''
END,
NOW(),
NOW()
FROM `sys_dict_type`
WHERE `deleted_at` = 0
AND `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')
ON DUPLICATE KEY UPDATE `type_name` = VALUES(`type_name`), `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_dict_type_i18n` (`dict_type_id`, `locale`, `type_name`, `created_at`, `updated_at`)
SELECT `id`,
'en-US',
CASE
WHEN `type_code` = 'common_status' THEN 'Common Status'
WHEN `type_code` = 'yes_no' THEN 'Yes/No'
WHEN `type_code` = 'menu_type' THEN 'Menu Type'
WHEN `type_code` = 'api_auth_mode' THEN 'API Auth Mode'
WHEN `type_code` = 'http_method' THEN 'HTTP Method'
WHEN `type_code` = 'task_kind' THEN 'Task Kind'
WHEN `type_code` = 'task_source' THEN 'Task Source'
WHEN `type_code` = 'task_run_status' THEN 'Task Run Status'
ELSE ''
END,
NOW(),
NOW()
FROM `sys_dict_type`
WHERE `deleted_at` = 0
AND `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')
ON DUPLICATE KEY UPDATE `type_name` = VALUES(`type_name`), `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_dict_item_i18n` (`dict_item_id`, `locale`, `label`, `created_at`, `updated_at`)
SELECT `id`,
'zh-CN',
CASE
WHEN `type_code` = 'common_status' AND `value` = '0' THEN '禁用'
WHEN `type_code` = 'common_status' AND `value` = '1' THEN '启用'
WHEN `type_code` = 'yes_no' AND `value` = '0' THEN '否'
WHEN `type_code` = 'yes_no' AND `value` = '1' THEN '是'
WHEN `type_code` = 'menu_type' AND `value` = '1' THEN '目录'
WHEN `type_code` = 'menu_type' AND `value` = '2' THEN '菜单'
WHEN `type_code` = 'menu_type' AND `value` = '3' THEN '按钮'
WHEN `type_code` = 'api_auth_mode' AND `value` = '0' THEN '无需登录'
WHEN `type_code` = 'api_auth_mode' AND `value` = '1' THEN '需要登录'
WHEN `type_code` = 'api_auth_mode' AND `value` = '2' THEN '需要接口权限'
WHEN `type_code` = 'http_method' THEN `value`
WHEN `type_code` = 'task_kind' AND `value` = 'async' THEN '异步'
WHEN `type_code` = 'task_kind' AND `value` = 'cron' THEN '定时'
WHEN `type_code` = 'task_source' AND `value` = 'queue' THEN '队列'
WHEN `type_code` = 'task_source' AND `value` = 'cron' THEN '定时'
WHEN `type_code` = 'task_source' AND `value` = 'manual' THEN '手动'
WHEN `type_code` = 'task_run_status' AND `value` = 'pending' THEN '等待中'
WHEN `type_code` = 'task_run_status' AND `value` = 'running' THEN '执行中'
WHEN `type_code` = 'task_run_status' AND `value` = 'success' THEN '成功'
WHEN `type_code` = 'task_run_status' AND `value` = 'failed' THEN '失败'
WHEN `type_code` = 'task_run_status' AND `value` = 'canceled' THEN '已取消'
WHEN `type_code` = 'task_run_status' AND `value` = 'retrying' THEN '重试中'
ELSE ''
END,
NOW(),
NOW()
FROM `sys_dict_item`
WHERE `deleted_at` = 0
AND (
(`type_code` = 'common_status' AND `value` IN ('0', '1')) OR
(`type_code` = 'yes_no' AND `value` IN ('0', '1')) OR
(`type_code` = 'menu_type' AND `value` IN ('1', '2', '3')) OR
(`type_code` = 'api_auth_mode' AND `value` IN ('0', '1', '2')) OR
(`type_code` = 'http_method' AND `value` IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD')) OR
(`type_code` = 'task_kind' AND `value` IN ('async', 'cron')) OR
(`type_code` = 'task_source' AND `value` IN ('queue', 'cron', 'manual')) OR
(`type_code` = 'task_run_status' AND `value` IN ('pending', 'running', 'success', 'failed', 'canceled', 'retrying'))
)
ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `updated_at` = VALUES(`updated_at`);
INSERT INTO `sys_dict_item_i18n` (`dict_item_id`, `locale`, `label`, `created_at`, `updated_at`)
SELECT `id`,
'en-US',
CASE
WHEN `type_code` = 'common_status' AND `value` = '0' THEN 'Disabled'
WHEN `type_code` = 'common_status' AND `value` = '1' THEN 'Enabled'
WHEN `type_code` = 'yes_no' AND `value` = '0' THEN 'No'
WHEN `type_code` = 'yes_no' AND `value` = '1' THEN 'Yes'
WHEN `type_code` = 'menu_type' AND `value` = '1' THEN 'Directory'
WHEN `type_code` = 'menu_type' AND `value` = '2' THEN 'Menu'
WHEN `type_code` = 'menu_type' AND `value` = '3' THEN 'Button'
WHEN `type_code` = 'api_auth_mode' AND `value` = '0' THEN 'No Auth'
WHEN `type_code` = 'api_auth_mode' AND `value` = '1' THEN 'Login Required'
WHEN `type_code` = 'api_auth_mode' AND `value` = '2' THEN 'Permission Required'
WHEN `type_code` = 'http_method' THEN `value`
WHEN `type_code` = 'task_kind' AND `value` = 'async' THEN 'Async'
WHEN `type_code` = 'task_kind' AND `value` = 'cron' THEN 'Cron'
WHEN `type_code` = 'task_source' AND `value` = 'queue' THEN 'Queue'
WHEN `type_code` = 'task_source' AND `value` = 'cron' THEN 'Cron'
WHEN `type_code` = 'task_source' AND `value` = 'manual' THEN 'Manual'
WHEN `type_code` = 'task_run_status' AND `value` = 'pending' THEN 'Pending'
WHEN `type_code` = 'task_run_status' AND `value` = 'running' THEN 'Running'
WHEN `type_code` = 'task_run_status' AND `value` = 'success' THEN 'Success'
WHEN `type_code` = 'task_run_status' AND `value` = 'failed' THEN 'Failed'
WHEN `type_code` = 'task_run_status' AND `value` = 'canceled' THEN 'Canceled'
WHEN `type_code` = 'task_run_status' AND `value` = 'retrying' THEN 'Retrying'
ELSE ''
END,
NOW(),
NOW()
FROM `sys_dict_item`
WHERE `deleted_at` = 0
AND (
(`type_code` = 'common_status' AND `value` IN ('0', '1')) OR
(`type_code` = 'yes_no' AND `value` IN ('0', '1')) OR
(`type_code` = 'menu_type' AND `value` IN ('1', '2', '3')) OR
(`type_code` = 'api_auth_mode' AND `value` IN ('0', '1', '2')) OR
(`type_code` = 'http_method' AND `value` IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD')) OR
(`type_code` = 'task_kind' AND `value` IN ('async', 'cron')) OR
(`type_code` = 'task_source' AND `value` IN ('queue', 'cron', 'manual')) OR
(`type_code` = 'task_run_status' AND `value` IN ('pending', 'running', 'success', 'failed', 'canceled', 'retrying'))
)
ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `updated_at` = VALUES(`updated_at`);
INSERT INTO `api_group` (`id`, `pid`, `code`, `name`, `created_at`, `updated_at`)
VALUES (10, 0, 'system', '系统管理模块', NOW(), NOW()),
(11, 10, 'sysConfig', '系统参数模块', NOW(), NOW()),
(12, 10, 'sysDict', '系统字典模块', NOW(), NOW()),
(13, 0, 'task', '任务中心模块', NOW(), NOW()),
(14, 10, 'file', '文件资源模块', NOW(), NOW()),
(15, 3, 'session', '在线会话模块', NOW(), NOW())
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `updated_at` = VALUES(`updated_at`);
COMMIT;
================================================
FILE: data/migrations/20260515010000_upload_file_objects.down.sql
================================================
ALTER TABLE `upload_files`
DROP KEY `idx_file_object_id`,
DROP COLUMN `file_object_id`;
DROP TABLE IF EXISTS `upload_file_objects`;
================================================
FILE: data/migrations/20260515010000_upload_file_objects.up.sql
================================================
CREATE TABLE IF NOT EXISTS `upload_file_objects`
(
`id` int unsigned NOT NULL AUTO_INCREMENT,
`storage_driver` varchar(20) NOT NULL DEFAULT 'local' COMMENT '存储驱动:local,aliyun_oss,s3',
`storage_base` varchar(512) NOT NULL DEFAULT '' COMMENT '存储基础位置',
`bucket` varchar(128) NOT NULL DEFAULT '' COMMENT '存储桶',
`storage_path` varchar(512) NOT NULL DEFAULT '' COMMENT '实际存储路径',
`object_key` varchar(512) NOT NULL DEFAULT '' COMMENT '对象key',
`size` int unsigned NOT NULL DEFAULT '0' COMMENT '文件大小(字节)',
`hash` varchar(64) NOT NULL DEFAULT '' COMMENT '文件SHA256哈希值',
`mime_type` varchar(100) NOT NULL DEFAULT '' COMMENT 'MIME类型',
`etag` varchar(128) NOT NULL DEFAULT '' COMMENT '对象ETag',
`status` varchar(32) NOT NULL DEFAULT 'stored' COMMENT '物理对象状态:stored,delete_failed',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_driver_bucket_hash` (`storage_driver`, `bucket`, `hash`),
KEY `idx_local_hash` (`storage_driver`, `hash`),
KEY `idx_remote_bucket_hash` (`storage_driver`, `bucket`, `hash`),
KEY `idx_status` (`status`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件物理对象表';
ALTER TABLE `upload_files`
ADD COLUMN `file_object_id` int unsigned NOT NULL DEFAULT '0' COMMENT '物理对象ID' AFTER `id`,
ADD KEY `idx_file_object_id` (`file_object_id`);
================================================
FILE: data/mysql.go
================================================
package data
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
c "github.com/wannanbigpig/gin-layout/config"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"go.uber.org/zap"
)
var (
mysqlDB *gorm.DB
mysqlOnce sync.Once
mysqlInitError error
mysqlValue atomic.Value
mysqlMu sync.Mutex
mysqlHealth = newRuntimeHealthCache(defaultRuntimeHealthTTL)
)
type mysqlSlot struct {
db *gorm.DB
}
const mysqlProbeTimeout = 2 * time.Second
var mysqlRuntimeProbe = func(db *gorm.DB) error {
sqlDB := getSQLDB(db)
if sqlDB == nil {
return errors.New("mysql sql.DB is unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), mysqlProbeTimeout)
defer cancel()
return sqlDB.PingContext(ctx)
}
// Writer 定义 GORM 自定义日志写入接口。
type Writer interface {
Printf(string, ...interface{})
}
// WriterLog 将 GORM SQL 日志转发到项目日志组件。
type WriterLog struct{}
// Printf 实现 GORM logger.Writer 接口。
func (w WriterLog) Printf(format string, args ...interface{}) {
if c.GetConfig().Mysql.PrintSql {
log.Logger.Sugar().Infof(format, args...)
}
}
// GenerateDSN 生成带固定连接参数的 MySQL DSN。
func GenerateDSN(cfg *c.Conf) string {
// 防御性编码
if cfg == nil || cfg.Mysql.Host == "" || cfg.Mysql.Database == "" {
return ""
}
// 特殊字符处理
username := strings.Replace(url.QueryEscape(cfg.Mysql.Username), "%", "%25", -1)
password := strings.Replace(url.QueryEscape(cfg.Mysql.Password), "%", "%25", -1)
// IPv6处理
host := cfg.Mysql.Host
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
host = "[" + host + "]"
}
// 强制关键参数
charset := "utf8mb4"
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s",
username,
password,
host,
cfg.Mysql.Port,
cfg.Mysql.Database,
)
// 参数显式排序
params := url.Values{
"charset": []string{charset},
"parseTime": []string{"true"},
"loc": []string{"Local"},
"timeout": []string{"5s"},
"readTimeout": []string{"30s"},
"writeTimeout": []string{"60s"},
}
return dsn + "?" + params.Encode()
}
// initMysql 初始化当前配置下的 MySQL 连接。
func initMysql() error {
return reloadMysql(c.GetConfig())
}
func reloadMysql(cfg *c.Conf) error {
mysqlMu.Lock()
defer mysqlMu.Unlock()
next, err := openMysql(cfg)
if err != nil {
return err
}
old := currentMysql()
oldSQLDB := getSQLDB(old)
mysqlDB = next
mysqlValue.Store(mysqlSlot{db: next})
mysqlInitError = nil
if next != nil {
mysqlHealth.SeedReady()
} else {
mysqlHealth.Reset()
}
if oldSQLDB != nil {
if err := oldSQLDB.Close(); err != nil {
log.Logger.Warn("关闭旧 MySQL 连接池失败", zap.Error(err))
}
}
return nil
}
func openMysql(cfg *c.Conf) (*gorm.DB, error) {
if cfg == nil || !cfg.Mysql.Enable {
return nil, nil
}
// Validate configuration parameters
if cfg.Mysql.MaxIdleConns < 0 || cfg.Mysql.MaxOpenConns < 0 || cfg.Mysql.MaxLifetime < 0 {
return nil, fmt.Errorf("invalid MySQL configuration: MaxIdleConns, MaxOpenConns, and MaxLifetime must be non-negative")
}
// Initialize logger
logConfig := logger.New(
WriterLog{},
logger.Config{
SlowThreshold: 0,
LogLevel: logger.LogLevel(cfg.Mysql.LogLevel),
IgnoreRecordNotFoundError: false,
Colorful: false,
},
)
// Configure GORM settings
configs := &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: cfg.Mysql.TablePrefix,
},
Logger: logConfig,
SkipDefaultTransaction: true,
}
// Open database connection
dsn := GenerateDSN(cfg)
db, err := gorm.Open(mysql.Open(dsn), configs)
if err != nil {
return nil, fmt.Errorf("failed to connect to MySQL: %s", err.Error())
}
// Get underlying sql.DB and configure connection pool
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get sql.DB: %s", err.Error())
}
sqlDB.SetMaxIdleConns(cfg.Mysql.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Mysql.MaxOpenConns)
sqlDB.SetConnMaxLifetime(cfg.Mysql.MaxLifetime)
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("failed to ping MySQL: %s", err.Error())
}
return db, nil
}
// MysqlDB 返回当前生效的 MySQL 连接实例。
func MysqlDB() *gorm.DB {
if db := currentMysql(); db != nil {
return db
}
if mysqlDB == nil {
mysqlOnce.Do(func() {
mysqlInitError = initMysql()
})
}
return currentMysql()
}
// MysqlInitError 返回 MySQL 初始化阶段记录的错误。
func MysqlInitError() error {
return mysqlInitError
}
// MysqlRuntimeStatus 返回带缓存的 MySQL 运行时健康探测结果。
func MysqlRuntimeStatus() RuntimeHealthStatus {
db := MysqlDB()
if db == nil {
mysqlHealth.Reset()
return RuntimeHealthStatus{
Ready: false,
Error: mysqlUnavailableError(),
CheckedAt: time.Now(),
}
}
status := mysqlHealth.Check(func() error {
return mysqlRuntimeProbe(db)
})
if !status.Ready && status.Error == nil {
status.Error = mysqlUnavailableError()
}
return status
}
// MysqlReady 判断 MySQL 当前是否可用。
func MysqlReady() bool {
return MysqlRuntimeStatus().Ready
}
// ReloadMysql 重新加载 MySQL 连接。
func ReloadMysql(cfg *c.Conf) error {
return reloadMysql(cfg)
}
// CloseMysql 关闭当前 MySQL 连接池。
func CloseMysql() error {
mysqlMu.Lock()
defer mysqlMu.Unlock()
current := currentMysql()
mysqlDB = nil
mysqlValue.Store(mysqlSlot{})
mysqlInitError = nil
mysqlHealth.Reset()
if current == nil {
return nil
}
sqlDB := getSQLDB(current)
if sqlDB == nil {
return nil
}
return sqlDB.Close()
}
func currentMysql() *gorm.DB {
if slot, ok := mysqlValue.Load().(mysqlSlot); ok {
return slot.db
}
return mysqlDB
}
func getSQLDB(db *gorm.DB) *sql.DB {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return nil
}
return sqlDB
}
func mysqlUnavailableError() error {
if mysqlInitError != nil {
return mysqlInitError
}
return ErrDBUnavailable
}
// ErrDBUnavailable 表示 MySQL 连接当前不可用。
var ErrDBUnavailable = errors.New("mysql connection is unavailable")
================================================
FILE: data/redis.go
================================================
package data
import (
"context"
"errors"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
c "github.com/wannanbigpig/gin-layout/config"
)
var (
redisDb *redis.Client
redisOnce sync.Once
redisInitError error
redisValue atomic.Value
redisMu sync.Mutex
redisHealth = newRuntimeHealthCache(defaultRuntimeHealthTTL)
)
type redisSlot struct {
client *redis.Client
}
const redisProbeTimeout = 2 * time.Second
var redisRuntimeProbe = func(client *redis.Client) error {
ctx, cancel := context.WithTimeout(context.Background(), redisProbeTimeout)
defer cancel()
return client.Ping(ctx).Err()
}
func initRedis() error {
return reloadRedis(c.GetConfig())
}
func reloadRedis(cfg *c.Conf) error {
redisMu.Lock()
defer redisMu.Unlock()
next, err := openRedis(cfg)
if err != nil {
return err
}
old := currentRedis()
redisDb = next
redisValue.Store(redisSlot{client: next})
redisInitError = nil
if next != nil {
redisHealth.SeedReady()
} else {
redisHealth.Reset()
}
if old != nil {
_ = old.Close()
}
return nil
}
func openRedis(cfg *c.Conf) (*redis.Client, error) {
if cfg == nil || !cfg.Redis.Enable {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := &redis.Options{
Addr: cfg.Redis.Host + ":" + cfg.Redis.Port,
Password: cfg.Redis.Password,
DB: cfg.Redis.Database,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
ConnMaxLifetime: cfg.Redis.ConnMaxLifetime,
ConnMaxIdleTime: cfg.Redis.ConnMaxIdle,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
}
client := redis.NewClient(opts)
_, err := client.Ping(ctx).Result()
if err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
// RedisClient 返回 Redis 客户端和初始化错误
func RedisClient() *redis.Client {
if client := currentRedis(); client != nil {
return client
}
if redisDb == nil {
redisOnce.Do(func() {
redisInitError = initRedis()
})
}
return currentRedis()
}
// GetRedisInitError 返回 Redis 初始化错误,供外部检查
func GetRedisInitError() error {
return redisInitError
}
// RedisRuntimeStatus 返回带缓存的 Redis 运行时健康探测结果。
func RedisRuntimeStatus() RuntimeHealthStatus {
client := RedisClient()
if client == nil {
redisHealth.Reset()
return RuntimeHealthStatus{
Ready: false,
Error: redisUnavailableError(),
CheckedAt: time.Now(),
}
}
status := redisHealth.Check(func() error {
return redisRuntimeProbe(client)
})
if !status.Ready && status.Error == nil {
status.Error = redisUnavailableError()
}
return status
}
// RedisReady 判断 Redis 当前是否可用。
func RedisReady() bool {
return RedisRuntimeStatus().Ready
}
// ReloadRedis 重新加载 Redis 客户端。
func ReloadRedis(cfg *c.Conf) error {
return reloadRedis(cfg)
}
// CloseRedis 关闭当前 Redis 客户端。
func CloseRedis() error {
redisMu.Lock()
defer redisMu.Unlock()
current := currentRedis()
redisDb = nil
redisValue.Store(redisSlot{})
redisInitError = nil
redisHealth.Reset()
if current == nil {
return nil
}
return current.Close()
}
func currentRedis() *redis.Client {
if slot, ok := redisValue.Load().(redisSlot); ok {
return slot.client
}
return redisDb
}
func redisUnavailableError() error {
if redisInitError != nil {
return redisInitError
}
return ErrRedisUnavailable
}
// ErrRedisUnavailable 表示 Redis 客户端当前不可用。
var ErrRedisUnavailable = errors.New("redis client is unavailable")
================================================
FILE: data/runtime_health.go
================================================
package data
import (
"sync"
"time"
)
const defaultRuntimeHealthTTL = 3 * time.Second
// RuntimeHealthStatus 表示依赖最近一次运行时探测结果。
type RuntimeHealthStatus struct {
Ready bool
Error error
CheckedAt time.Time
}
type runtimeHealthCache struct {
ttl time.Duration
mu sync.Mutex
status RuntimeHealthStatus
}
func newRuntimeHealthCache(ttl time.Duration) *runtimeHealthCache {
if ttl <= 0 {
ttl = defaultRuntimeHealthTTL
}
return &runtimeHealthCache{ttl: ttl}
}
func (c *runtimeHealthCache) Check(probe func() error) RuntimeHealthStatus {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if !c.status.CheckedAt.IsZero() && now.Sub(c.status.CheckedAt) < c.ttl {
return c.status
}
err := probe()
c.status = RuntimeHealthStatus{
Ready: err == nil,
Error: err,
CheckedAt: now,
}
return c.status
}
func (c *runtimeHealthCache) SeedReady() {
c.mu.Lock()
defer c.mu.Unlock()
c.status = RuntimeHealthStatus{
Ready: true,
CheckedAt: time.Now(),
}
}
func (c *runtimeHealthCache) Reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.status = RuntimeHealthStatus{}
}
================================================
FILE: data/runtime_health_test.go
================================================
package data
import (
"errors"
"testing"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMysqlRuntimeStatusCachesProbeResult(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
restore := backupMysqlState()
defer restore()
mysqlDB = db
mysqlValue.Store(mysqlSlot{db: db})
mysqlInitError = nil
mysqlHealth = newRuntimeHealthCache(time.Hour)
probeCount := 0
originalProbe := mysqlRuntimeProbe
mysqlRuntimeProbe = func(current *gorm.DB) error {
if current != db {
t.Fatalf("unexpected db pointer")
}
probeCount++
return nil
}
defer func() {
mysqlRuntimeProbe = originalProbe
}()
status1 := MysqlRuntimeStatus()
status2 := MysqlRuntimeStatus()
if !status1.Ready || !status2.Ready {
t.Fatalf("expected mysql runtime status to stay ready, got %+v %+v", status1, status2)
}
if probeCount != 1 {
t.Fatalf("expected mysql probe to run once, got %d", probeCount)
}
}
func TestMysqlRuntimeStatusReturnsProbeFailure(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
restore := backupMysqlState()
defer restore()
mysqlDB = db
mysqlValue.Store(mysqlSlot{db: db})
mysqlInitError = nil
mysqlHealth = newRuntimeHealthCache(time.Hour)
wantErr := errors.New("mysql down")
originalProbe := mysqlRuntimeProbe
mysqlRuntimeProbe = func(*gorm.DB) error { return wantErr }
defer func() {
mysqlRuntimeProbe = originalProbe
}()
status := MysqlRuntimeStatus()
if status.Ready {
t.Fatal("expected mysql runtime status to be not ready")
}
if !errors.Is(status.Error, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, status.Error)
}
if MysqlReady() {
t.Fatal("expected MysqlReady to be false")
}
}
func TestRedisRuntimeStatusCachesProbeResult(t *testing.T) {
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
defer client.Close()
restore := backupRedisState()
defer restore()
redisDb = client
redisValue.Store(redisSlot{client: client})
redisInitError = nil
redisHealth = newRuntimeHealthCache(time.Hour)
probeCount := 0
originalProbe := redisRuntimeProbe
redisRuntimeProbe = func(current *redis.Client) error {
if current != client {
t.Fatalf("unexpected redis client pointer")
}
probeCount++
return nil
}
defer func() {
redisRuntimeProbe = originalProbe
}()
status1 := RedisRuntimeStatus()
status2 := RedisRuntimeStatus()
if !status1.Ready || !status2.Ready {
t.Fatalf("expected redis runtime status to stay ready, got %+v %+v", status1, status2)
}
if probeCount != 1 {
t.Fatalf("expected redis probe to run once, got %d", probeCount)
}
}
func TestRedisRuntimeStatusReturnsProbeFailure(t *testing.T) {
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
defer client.Close()
restore := backupRedisState()
defer restore()
redisDb = client
redisValue.Store(redisSlot{client: client})
redisInitError = nil
redisHealth = newRuntimeHealthCache(time.Hour)
wantErr := errors.New("redis down")
originalProbe := redisRuntimeProbe
redisRuntimeProbe = func(*redis.Client) error { return wantErr }
defer func() {
redisRuntimeProbe = originalProbe
}()
status := RedisRuntimeStatus()
if status.Ready {
t.Fatal("expected redis runtime status to be not ready")
}
if !errors.Is(status.Error, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, status.Error)
}
if RedisReady() {
t.Fatal("expected RedisReady to be false")
}
}
func backupMysqlState() func() {
previousDB := mysqlDB
previousInitErr := mysqlInitError
previousHealth := mysqlHealth
var previousSlot mysqlSlot
if slot, ok := mysqlValue.Load().(mysqlSlot); ok {
previousSlot = slot
}
return func() {
mysqlDB = previousDB
mysqlInitError = previousInitErr
mysqlHealth = previousHealth
mysqlValue.Store(previousSlot)
}
}
func backupRedisState() func() {
previousClient := redisDb
previousInitErr := redisInitError
previousHealth := redisHealth
var previousSlot redisSlot
if slot, ok := redisValue.Load().(redisSlot); ok {
previousSlot = slot
}
return func() {
redisDb = previousClient
redisInitError = previousInitErr
redisHealth = previousHealth
redisValue.Store(previousSlot)
}
}
================================================
FILE: docs/COMMANDS_AND_TASKS.en.md
================================================
# Commands, Scheduled Jobs, and Queue Usage
This document covers:
- Available project commands and what they do
- How to start `service`, `worker`, and `cron`
- How to add scheduled jobs
- How to create, publish, and consume async queue jobs
- How to send jobs to a specific queue and configure multiple queues
For 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).
If you are new to the project, start with "Runtime Model" and "Commands", then move to "Scheduled Jobs" and "Queue Usage".
## Runtime Model
The project currently splits runtime responsibilities into 3 process types:
- `service`
- serves the HTTP API
- handles login, permissions, menus, uploads, logs, and business requests
- can publish async jobs in some flows
- `worker`
- consumes async jobs
- currently handles async request-audit persistence
- `cron`
- handles recurring schedules
- currently uses `robfig/cron`
- fits fixed-time tasks such as "run every day at 2 AM"
In short:
- `service` serves requests
- `worker` consumes jobs
- `cron` triggers recurring tasks
## Commands
The root command entry is in [cmd/root.go](/Users/liuml/data/go/src/go-layout/cmd/root.go).
### 0. Migration Commands
Common entry points:
```bash
go run main.go -c ./config.yaml command migrate check
go run main.go -c ./config.yaml command migrate up
```
See [docs/MIGRATE_COMMANDS.en.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.en.md) for full details.
### 1. Show Help
```bash
go run main.go -h
go run main.go command -h
```
### 2. Start the API Service
```bash
go run main.go service
```
Used for:
- starting the Gin HTTP server
- loading config, logger, database, and other base resources
- serving business APIs
Common scenarios:
- local development
- single-node API deployment
- main application process in a container
### 3. Start the Async Worker
```bash
go run main.go worker
```
Entry file:
- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go)
Used for:
- connecting to Redis
- registering all async job handlers
- starting the Asynq worker and consuming jobs
Currently registered:
- `audit:request_log.write`
### 4. Start the Scheduler
```bash
go run main.go cron
```
Entry file:
- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)
Used for:
- starting the scheduler
- registering the current recurring jobs
- shutting down gracefully on process signals
Task definition boundary:
- `task_definitions` is currently a read-only mirror of built-in task definitions for the task center UI, manual trigger checks, and retry checks.
- The `cron` scheduler still uses `BuiltinTaskDefinitions(cfg)` as its source of truth and does not read manually edited `task_definitions` rows from DB.
- 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.
### 5. Run One-Off Commands
```bash
go run main.go command api-route
go run main.go command rebuild-user-permissions
go run main.go command init-system
go run main.go -c ./config.yaml command migrate up
```
Entry file:
- [cmd/command/command.go](/Users/liuml/data/go/src/go-layout/cmd/command/command.go)
Supported subcommands:
- `api-route`
- scans the declarative route tree and rebuilds the `api` route table
- `rebuild-user-permissions`
- rebuilds final user API permissions from database relationships
- `init-system`
- rolls back migrations, reruns migrations, initializes API routes, and rebuilds user permissions
- `demo`
- example command
- `migrate`
- migration management subcommands: `create/check/up/down/goto/force/version`
- full guide: [docs/MIGRATE_COMMANDS.en.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.en.md)
- `task scan-async`
- scans async queue tasks registered in code and compares them with the `task_definitions` mirror
- `task scan-cron`
- scans built-in cron task definitions and compares them with the `task_definitions` mirror
### 6. Show Version
```bash
go run main.go version
```
## Common Startup Combinations
### 1. API Only
Useful when:
- you only need to debug HTTP APIs locally
- your scenario does not depend on async jobs
```bash
go run main.go service
```
### 2. API + Worker
Useful when:
- async jobs need to be consumed
- Redis is enabled
```bash
go run main.go service
go run main.go worker
```
### 3. API + Worker + Cron
Useful when:
- you need the API, async jobs, and recurring tasks at the same time
```bash
go run main.go service
go run main.go worker
go run main.go cron
```
## Scheduled Jobs
The current scheduling code is split into 3 files:
- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)
- startup and shutdown only
- [cmd/cron/schedule.go](/Users/liuml/data/go/src/go-layout/cmd/cron/schedule.go)
- scheduling DSL
- [cmd/cron/tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)
- filters enabled cron definitions and registers them into the scheduler
- [internal/cron/registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go)
- owns built-in task definitions, cron handler registration, and task-center mirror sync
### Current Style
The 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:
```go
func BuiltinTaskDefinitions(cfg *config.Conf) []model.TaskDefinition {
return []model.TaskDefinition{
{
Code: "cron:cleanup-cache",
Kind: model.TaskKindCron,
CronSpec: "0 */10 * * * *",
Handler: "cron.cleanup-cache",
Status: model.TaskStatusEnabled,
},
}
}
```
Why this is simpler:
- task-center display, manual trigger checks, retry checks, and cron registration share the same built-in code definition
- name, schedule, handler, status, and high-risk flags are visible at a glance
- risky jobs can be explicitly enabled instead of being registered into cron by default
- no need to repeat `AddJob`, `Chain`, and `Recover` manually
### Available Methods
Currently supported:
- `Call(name, func())`
- register a function with no return value
- `CallE(name, func() error)`
- register a function returning `error`
- `Cron(spec)`
- use a raw cron expression
- `EveryFiveSeconds()`
- run every 5 seconds
- `DailyAt("02:00:00")`
- run at a fixed time every day
- `WithoutOverlapping()`
- skip the next run if the current run is still active
- `AllowOverlap()`
- allow overlapping executions
### Example 1: Run Every 10 Minutes
```go
schedule.Call("cleanup-cache", cleanupCache).
Cron("0 */10 * * * *").
WithoutOverlapping()
```
### Example 2: Run Every Day At 3 AM
```go
schedule.CallE("sync-report", reportService.SyncDailyReport).
DailyAt("03:00:00").
WithoutOverlapping()
```
### Example 3: Allow Overlap
```go
schedule.Call("heartbeat", heartbeat).
Cron("0/30 * * * * *").
AllowOverlap()
```
### Steps To Add A Scheduled Job
1. Prepare the task function in the business layer.
2. Register its handler in [registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go).
3. Add a `kind=cron` task definition to `BuiltinTaskDefinitions`.
4. Run `go run main.go -c ./config.yaml command task scan-cron` to compare built-in definitions with the DB mirror.
5. Restart the `cron` process.
## Queue Usage
The 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.
Core files:
- [internal/queue/queue.go](/Users/liuml/data/go/src/go-layout/internal/queue/queue.go)
- [internal/queue/asynqx/asynq.go](/Users/liuml/data/go/src/go-layout/internal/queue/asynqx/asynq.go)
- [internal/jobs/registry.go](/Users/liuml/data/go/src/go-layout/internal/jobs/registry.go)
### Queue Flow
A typical async job flows like this:
1. business code builds a payload
2. it calls `queue.PublishJSON(...)`
3. the job enters the target Redis queue
4. `worker` starts and registers handlers
5. Asynq pulls the job from Redis
6. the corresponding handler runs the business logic
### Recommended API
Publish a job:
```go
_, err := queue.PublishJSON(ctx, taskType, queueName, payload, opts...)
```
Register a consumer:
```go
queue.RegisterJSON(registry, taskType, func(ctx context.Context, payload PayloadType) error {
return handle(payload)
})
```
Compared to the older pattern, this is easier because:
- you do not need to implement a full custom `Job` type
- you do not need to manually `json.Marshal` and `json.Unmarshal`
- payloads are easier to read and reuse
- new jobs are easier to add by copying a small template
## How To Add A New Queue Job
The example below uses "send email".
### 1. Define The Payload
```go
type EmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
func (p EmailPayload) Validate() error {
if p.To == "" {
return errors.New("to is required")
}
return nil
}
```
If the payload implements `Validate() error`, it will be validated automatically before the handler runs. Validation failures are treated as non-retryable.
### 2. Publish The Job
```go
func EnqueueEmail(ctx context.Context, payload EmailPayload) error {
_, err := queue.PublishJSON(
ctx,
"notify:email.send",
"default",
payload,
queue.WithMaxRetry(5),
queue.WithTimeout(30*time.Second),
)
return err
}
```
The parameters mean:
- `"notify:email.send"`
- task type
- `"default"`
- queue name
- `payload`
- task data
- `WithMaxRetry`
- maximum retry count
- `WithTimeout`
- execution timeout
### 3. Register The Consumer
```go
func registerEmail(registry queue.Registry) {
queue.RegisterJSON(registry, "notify:email.send", func(ctx context.Context, payload EmailPayload) error {
return sendEmail(payload)
})
}
```
### 4. Register It In The Unified Entry
Current recommended pattern:
```go
func RegisterAll(registry queue.Registry) {
queue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)
registerEmail(registry)
}
```
### 5. Worker Consumes It Automatically
When the worker starts, it does this:
```go
registry := jobs.NewRegistry()
server, mux, err := asynqx.NewServer(cfg, registry)
```
So if your handler is already registered in `RegisterAll`, the worker will consume it automatically.
## How To Send A Job To A Specific Queue
The queue name is the third argument of `PublishJSON`:
```go
queue.PublishJSON(ctx, "notify:email.send", "critical", payload)
```
Here `"critical"` is the queue name.
Typical usage:
- audit logs go to `audit`
- normal jobs go to `default`
- high-priority jobs go to `critical`
- cleanup and low-priority jobs go to `low`
## How To Configure Multiple Queues
Example config:
```yaml
queue:
enable: true
namespace: go_layout
concurrency: 8
strict_priority: false
queues:
critical: 4
default: 2
audit: 2
low: 1
audit_max_retry: 3
audit_timeout_seconds: 10
```
Meaning:
- `queues`
- which queues the worker should listen to
- numbers
- relative weights for queue scheduling
- `concurrency`
- worker concurrency
- `strict_priority`
- whether to strictly prefer higher-priority queues
### Multi-Queue Example
```yaml
queue:
queues:
critical: 5
default: 3
audit: 2
low: 1
```
One possible split:
- `critical`
- permission repair, account-state sync
- `default`
- normal async business jobs
- `audit`
- request audit logs
- `low`
- cleanup, reporting, compensation jobs
## Real Example In This Project
The currently integrated job is the audit log job:
- task type: `audit:request_log.write`
- queue: `audit`
File:
- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go)
Producer:
```go
func EnqueueAuditLog(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
payload, err := NewAuditLogPayload(kind, snapshot)
if err != nil {
return err
}
_, err = queue.PublishJSON(ctx, AuditLogTaskType, AuditQueueName, payload, auditLogOptions()...)
return err
}
```
Consumer registration:
```go
func RegisterAll(registry queue.Registry) {
queue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)
}
```
Worker startup:
```go
registry := jobs.NewRegistry()
server, mux, err := asynqx.NewServer(cfg, registry)
```
## When To Use Cron vs Queue
Recommended split by responsibility:
- `cron`
- decides when something should run
- good for recurring schedules
- `queue`
- decides how something runs asynchronously
- good for retries, decoupling, and background consumption
### Typical Cases
Use `cron` for:
- resetting system data every day
- clearing cache every hour
- syncing statistics every 5 minutes
Use `queue` for:
- audit-log persistence
- email sending
- webhook delivery
- incremental permission sync
Use both together when needed:
- `cron` triggers on schedule
- `cron` publishes a queue job
- `worker` performs the actual work
## FAQ
### 1. Why Was The Job Published But Not Consumed?
Check these first:
- Redis is available
- `queue.enable` is enabled
- `worker` is running
- the task is registered in `RegisterAll`
### 2. Why Do Invalid Payloads Not Retry?
Because `RegisterJSON` treats JSON decode failures and `Validate()` failures as invalid input. Retrying those usually does not help.
### 3. Why Does The Physical Queue Name Include A Prefix?
Because the current implementation supports `namespace`, for example:
- configured queue name: `audit`
- actual Redis queue name: `go_layout:audit`
Business code should keep using the logical queue name.
### 4. When Should I Implement `queue.Job` Myself?
Most business scenarios do not need it.
Only consider it when you need:
- a custom non-JSON payload flow
- a special payload generation path
- unusual job object behavior
By default, prefer:
- `queue.PublishJSON`
- `queue.RegisterJSON`
## Recommended Practices
- keep scheduled jobs in [tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)
- keep async jobs under `internal/jobs`
- keep one clear payload per task type
- keep payloads small and stable
- separate high-priority and low-priority jobs by queue
- avoid calling Asynq APIs directly from business code
- prefer the project-level helpers: `PublishJSON` and `RegisterJSON`
================================================
FILE: docs/COMMANDS_AND_TASKS.md
================================================
# 命令、定时任务与队列使用说明
本文档集中说明以下内容:
- 项目支持的命令及用途
- 如何启动 `service`、`worker`、`cron`
- 如何新增定时任务
- 如何创建、发布、消费异步队列任务
- 如何把任务放入指定队列,以及如何配置多队列
系统配置与系统字典的接入边界见:[docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.md](/Users/liuml/data/go/src/go-layout/docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.md)。
如果你刚接手项目,建议先看“运行模型”和“命令说明”,再看“定时任务”和“队列”两节。
## 运行模型
当前项目把后台运行能力拆成了 3 类进程:
- `service`
- 提供 HTTP API
- 处理登录、权限、菜单、上传、日志等业务
- 某些场景下会往队列里发布异步任务
- `worker`
- 消费异步任务
- 当前已经接入请求审计日志异步落库
- `cron`
- 负责周期性调度
- 当前使用 `robfig/cron`
- 适合“每天凌晨 2 点执行一次”这类固定时间任务
可以把它理解成:
- `service` 负责对外服务
- `worker` 负责后台消费
- `cron` 负责按时间触发
## 命令说明
项目根命令入口在 [cmd/root.go](/Users/liuml/data/go/src/go-layout/cmd/root.go)。
### 0. 迁移命令
常用入口:
```bash
go run main.go -c ./config.yaml command migrate check
go run main.go -c ./config.yaml command migrate up
```
详细说明见:[docs/MIGRATE_COMMANDS.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.md)。
### 1. 查看帮助
```bash
go run main.go -h
go run main.go command -h
```
### 2. 启动 API 服务
```bash
go run main.go service
```
用途:
- 启动 Gin HTTP 服务
- 加载配置、日志、数据库等基础资源
- 提供业务 API
常见场景:
- 本地开发
- 单机部署 API 服务
- 容器内主应用进程
### 3. 启动异步任务消费进程
```bash
go run main.go worker
```
入口文件:
- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go)
用途:
- 连接 Redis
- 注册所有异步任务处理器
- 启动 Asynq worker,消费队列中的任务
当前已接入任务:
- `audit:request_log.write`
### 4. 启动定时任务调度器
```bash
go run main.go cron
```
入口文件:
- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)
用途:
- 启动周期调度器
- 注册当前定义的周期任务
- 收到退出信号后优雅关闭
任务定义边界:
- `task_definitions` 当前是代码内置任务定义的只读镜像,用于任务中心展示、手动触发与重试校验。
- `cron` 调度器的真实来源仍是 `BuiltinTaskDefinitions(cfg)`,不会读取 DB 中被人工修改的 `task_definitions`。
- 如需让后台成为调度配置入口,必须先调整 scheduler 读取 DB,并把内置同步策略从覆盖改为初始化缺失记录。
### 5. 运行一次性命令
```bash
go run main.go command api-route
go run main.go command rebuild-user-permissions
go run main.go command init-system
go run main.go -c ./config.yaml command migrate up
```
入口文件:
- [cmd/command/command.go](/Users/liuml/data/go/src/go-layout/cmd/command/command.go)
支持的子命令:
- `api-route`
- 扫描声明式路由树并重建 `api` 路由表
- `rebuild-user-permissions`
- 按数据库关系重建用户最终 API 权限
- `init-system`
- 回滚迁移、重新执行迁移、初始化 API 路由、重建用户权限
- `demo`
- 示例命令
- `migrate`
- 迁移管理子命令,支持 `create/check/up/down/goto/force/version`
- 详细说明见 [docs/MIGRATE_COMMANDS.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.md)
- `task scan-async`
- 扫描代码注册的异步队列任务,并与 `task_definitions` 镜像做对比
- `task scan-cron`
- 扫描代码内置的 cron 任务定义,并与 `task_definitions` 镜像做对比
### 6. 查看版本
```bash
go run main.go version
```
## 常见启动组合
### 1. 只启动 API
适合:
- 本地只调接口
- 不依赖异步任务的场景
```bash
go run main.go service
```
### 2. 启动 API + Worker
适合:
- 需要消费异步任务
- 已启用 Redis
```bash
go run main.go service
go run main.go worker
```
### 3. 启动 API + Worker + Cron
适合:
- 同时需要接口服务、异步消费和定时调度
```bash
go run main.go service
go run main.go worker
go run main.go cron
```
## 定时任务使用方式
当前定时任务代码分成 3 个文件:
- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)
- 只负责启动与关闭
- [cmd/cron/schedule.go](/Users/liuml/data/go/src/go-layout/cmd/cron/schedule.go)
- 提供任务声明 DSL
- [cmd/cron/tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)
- 从内置任务定义中筛选启用的 cron 任务并注册到调度器
- [internal/cron/registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go)
- 维护内置任务定义、cron handler 注册表和任务中心镜像同步
### 当前写法
当前项目推荐先在 `internal/cron/registry.go` 声明内置任务定义,再由 `cmd/cron/tasks.go` 统一注册启用的 cron 任务:
```go
func BuiltinTaskDefinitions(cfg *config.Conf) []model.TaskDefinition {
return []model.TaskDefinition{
{
Code: "cron:cleanup-cache",
Kind: model.TaskKindCron,
CronSpec: "0 */10 * * * *",
Handler: "cron.cleanup-cache",
Status: model.TaskStatusEnabled,
},
}
}
```
这样做的好处是:
- 任务中心展示、手动触发、重试校验和 cron 注册共用一份代码内置定义
- 名称、时间规则、handler、状态和高风险标记一眼能看出来
- 高风险任务可以显式配置启用,默认不参与 cron 注册
- 不需要每次手写 `AddJob`、`Chain`、`Recover`
### 可用方法
目前已经支持:
- `Call(name, func())`
- 注册无返回值任务
- `CallE(name, func() error)`
- 注册返回 `error` 的任务
- `Cron(spec)`
- 直接使用 cron 表达式
- `EveryFiveSeconds()`
- 每 5 秒执行一次
- `DailyAt("02:00:00")`
- 每天固定时间执行
- `WithoutOverlapping()`
- 任务运行期间不允许重入
- `AllowOverlap()`
- 允许重入
### 示例 1:每 10 分钟执行一次
```go
schedule.Call("cleanup-cache", cleanupCache).
Cron("0 */10 * * * *").
WithoutOverlapping()
```
### 示例 2:每天凌晨 3 点执行
```go
schedule.CallE("sync-report", reportService.SyncDailyReport).
DailyAt("03:00:00").
WithoutOverlapping()
```
### 示例 3:允许重入
```go
schedule.Call("heartbeat", heartbeat).
Cron("0/30 * * * * *").
AllowOverlap()
```
### 新增定时任务步骤
1. 在业务层准备好任务函数。
2. 在 [registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go) 注册 handler。
3. 在 `BuiltinTaskDefinitions` 中新增 `kind=cron` 的任务定义。
4. 运行 `go run main.go -c ./config.yaml command task scan-cron` 检查内置定义与 DB 镜像。
5. 重启 `cron` 进程。
## 队列使用说明
当前队列基于 `Asynq`,但业务层不直接依赖 `asynq.Client`,而是使用项目自己的统一接口。
核心代码:
- [internal/queue/queue.go](/Users/liuml/data/go/src/go-layout/internal/queue/queue.go)
- [internal/queue/asynqx/asynq.go](/Users/liuml/data/go/src/go-layout/internal/queue/asynqx/asynq.go)
- [internal/jobs/registry.go](/Users/liuml/data/go/src/go-layout/internal/jobs/registry.go)
### 队列完整链路
一条异步任务的执行链路如下:
1. 业务代码构造 payload
2. 调用 `queue.PublishJSON(...)` 发布任务
3. 任务进入 Redis 对应队列
4. `worker` 启动后注册任务处理器
5. Asynq 从 Redis 拉取任务
6. 调用对应 handler 执行业务逻辑
### 当前推荐 API
发布任务:
```go
_, err := queue.PublishJSON(ctx, taskType, queueName, payload, opts...)
```
注册消费:
```go
queue.RegisterJSON(registry, taskType, func(ctx context.Context, payload PayloadType) error {
return handle(payload)
})
```
相比旧写法,这样有几个好处:
- 不需要单独实现一个 `Job` 结构体
- 不需要手动做 `json.Marshal` / `json.Unmarshal`
- payload 结构更直观
- 新任务更容易复制模板
## 如何创建一个新队列任务
下面用“发送邮件”举例。
### 1. 定义 payload
```go
type EmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
func (p EmailPayload) Validate() error {
if p.To == "" {
return errors.New("to is required")
}
return nil
}
```
如果 payload 实现了 `Validate() error`,消费前会自动校验。校验失败会按“不重试”处理。
### 2. 发布任务
```go
func EnqueueEmail(ctx context.Context, payload EmailPayload) error {
_, err := queue.PublishJSON(
ctx,
"notify:email.send",
"default",
payload,
queue.WithMaxRetry(5),
queue.WithTimeout(30*time.Second),
)
return err
}
```
这里的参数分别表示:
- `"notify:email.send"`
- 任务类型
- `"default"`
- 队列名
- `payload`
- 任务数据
- `WithMaxRetry`
- 最大重试次数
- `WithTimeout`
- 执行超时时间
### 3. 注册消费处理器
```go
func registerEmail(registry queue.Registry) {
queue.RegisterJSON(registry, "notify:email.send", func(ctx context.Context, payload EmailPayload) error {
return sendEmail(payload)
})
}
```
### 4. 注册到统一入口
当前推荐把所有任务注册放在一个地方,例如:
```go
func RegisterAll(registry queue.Registry) {
queue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)
registerEmail(registry)
}
```
### 5. Worker 自动消费
`worker` 启动时会调用:
```go
registry := jobs.NewRegistry()
server, mux, err := asynqx.NewServer(cfg, registry)
```
所以只要你的任务已经注册进 `RegisterAll`,worker 就会自动消费。
## 如何把任务放到指定队列
看 `PublishJSON` 的第三个参数:
```go
queue.PublishJSON(ctx, "notify:email.send", "critical", payload)
```
这里的 `"critical"` 就是队列名。
常见用法:
- 审计日志放 `audit`
- 普通任务放 `default`
- 高优先级任务放 `critical`
- 低优先级清理任务放 `low`
## 如何配置多队列
配置文件示例:
```yaml
queue:
enable: true
namespace: go_layout
concurrency: 8
strict_priority: false
queues:
critical: 4
default: 2
audit: 2
low: 1
audit_max_retry: 3
audit_timeout_seconds: 10
```
含义:
- `queues`
- 声明 worker 需要监听哪些队列
- 数字
- 表示各队列的权重
- `concurrency`
- worker 并发度
- `strict_priority`
- 是否严格优先消费高优先级队列
### 多队列示例
```yaml
queue:
queues:
critical: 5
default: 3
audit: 2
low: 1
```
你可以这样分配:
- `critical`
- 权限修复、账号状态同步
- `default`
- 普通异步业务
- `audit`
- 请求审计日志
- `low`
- 清理、统计、补偿任务
## 当前项目中的真实示例
当前已经接入的任务是审计日志任务:
- 任务类型:`audit:request_log.write`
- 队列:`audit`
对应文件:
- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go)
发布端:
```go
func EnqueueAuditLog(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
payload, err := NewAuditLogPayload(kind, snapshot)
if err != nil {
return err
}
_, err = queue.PublishJSON(ctx, AuditLogTaskType, AuditQueueName, payload, auditLogOptions()...)
return err
}
```
消费端:
```go
func RegisterAll(registry queue.Registry) {
queue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)
}
```
worker 启动:
```go
registry := jobs.NewRegistry()
server, mux, err := asynqx.NewServer(cfg, registry)
```
## 何时使用 Cron,何时使用 Queue
建议按职责区分:
- `cron`
- 负责“什么时候触发”
- 适合固定时间周期任务
- `queue`
- 负责“任务如何异步执行”
- 适合削峰、重试、异步消费
### 典型场景
用 `cron`:
- 每天凌晨重置系统数据
- 每小时清理缓存
- 每 5 分钟同步统计
用 `queue`:
- 审计日志落库
- 邮件发送
- Webhook 投递
- 权限增量同步
组合使用:
- `cron` 到点触发
- `cron` 内部把任务投递到 `queue`
- `worker` 真正执行任务
## 常见问题
### 1. 为什么发布了任务,但没有被消费?
先检查:
- Redis 是否可用
- `queue.enable` 是否开启
- `worker` 是否已启动
- 任务是否已经注册到 `RegisterAll`
### 2. 为什么任务校验失败后不重试?
因为 `RegisterJSON` 会把 payload 反序列化失败或 `Validate()` 失败视为“无效任务”,这类问题通常重试没有意义。
### 3. 为什么任务进了带前缀的物理队列?
因为当前实现支持 `namespace`,例如:
- 配置队列名:`audit`
- 实际 Redis 队列名:`go_layout:audit`
业务代码里仍然使用逻辑队列名即可。
### 4. 什么时候需要自己实现 `queue.Job`?
大多数业务场景都不需要。
只有当你需要:
- 自定义更复杂的 payload 生成过程
- 不想走 JSON
- 需要对任务对象做特殊封装
才建议自己实现 `queue.Job`。
默认情况下,优先使用:
- `queue.PublishJSON`
- `queue.RegisterJSON`
## 推荐实践
- 定时任务统一写在 [tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)
- 异步任务统一写在 `internal/jobs`
- 一个任务类型只对应一个清晰的 payload
- payload 尽量保持小而稳定
- 高优先级任务和低优先级任务分队列
- 不要在业务代码里直接使用 Asynq 的底层 API
- 优先使用项目封装好的 `PublishJSON` / `RegisterJSON`
================================================
FILE: docs/DONATE.en.md
================================================
# Support gin-layout
Thank you for using `gin-layout`.
If this project helps you, you are welcome to support its ongoing development and maintenance through any of the options below.
## Alipay
## WeChat Pay
## ETH
Wallet address:
```text
0xf7bC2dc45f433D1Fa29D337Badc7174fE8251da3
```
## Notes
- Support is completely optional and does not affect access to any feature.
- Your support helps with ongoing maintenance, bug fixes, and documentation updates.
- If donating is not convenient right now, starring the repo, opening issues, or submitting pull requests also helps.
================================================
FILE: docs/DONATE.md
================================================
# 赞助 gin-layout
感谢你使用 `gin-layout`。
如果这个项目对你有帮助,欢迎通过下面的方式支持项目的持续开发与维护。
## 支付宝
## 微信支付
## ETH
钱包地址:
```text
0xf7bC2dc45f433D1Fa29D337Badc7174fE8251da3
```
## 说明
- 赞助完全自愿,不影响任何功能使用。
- 你的支持会用于项目的持续维护、问题修复和文档更新。
- 如果暂时不方便赞助,也欢迎通过 Star、Issue 或 Pull Request 支持项目。
================================================
FILE: docs/MIGRATE_COMMANDS.en.md
================================================
# Migration Command Guide
This document explains the built-in `command migrate` command group, including:
- why the project uses its own command entry
- migration filename rules
- concrete usage for `create/check/up/down/goto/force/version`
- practical execution notes
The 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).
## Why Use The Project Command
Use:
```bash
go run main.go -c ./config.yaml command migrate ...
```
This keeps migration operations:
- independent from an extra root-level `migrate` binary
- independent from shell wrapper scripts in `scripts/`
- aligned with the project's own config loading and migration path resolution
- unified across `create/check/up/down/goto/force/version`
## Filename Convention
Default timestamp-based filenames:
```text
YYYYMMDDHHMMSS_desc.up.sql
YYYYMMDDHHMMSS_desc.down.sql
```
Example:
```text
20260425143015_add_task_center_tables.up.sql
20260425143015_add_task_center_tables.down.sql
```
Rules:
- `version` must be a unique increasing integer
- `desc` is normalized into lower_snake_case
- every version must have exactly one `up` and one `down`
Default time format: `20060102150405`
Default timezone: `UTC`
## Command Overview
```bash
go run main.go command migrate
go run main.go -c ./config.yaml command migrate create
go run main.go -c ./config.yaml command migrate check
go run main.go -c ./config.yaml command migrate up [N]
go run main.go -c ./config.yaml command migrate down [N]
go run main.go -c ./config.yaml command migrate down --all
go run main.go -c ./config.yaml command migrate goto
go run main.go -c ./config.yaml command migrate force
go run main.go -c ./config.yaml command migrate version
```
Notes:
- `go run main.go command migrate` defaults to `go run main.go command migrate up`
Shared flags:
- `--path`, `-p`
- migration directory path
- defaults to auto-resolved `data/migrations`
- `--yes`, `-y`
- skip confirmation prompts
- mainly for destructive actions such as `down --all`
## create
Create a migration pair:
```bash
go run main.go -c ./config.yaml command migrate create add_task_center_tables
```
Default behavior:
- normalizes the name to `add_task_center_tables`
- creates:
- `data/migrations/_add_task_center_tables.up.sql`
- `data/migrations/_add_task_center_tables.down.sql`
Default template:
```sql
BEGIN;
-- TODO: write migration up SQL.
COMMIT;
```
Optional flags:
- `--seq`
- switch to sequential numbering
- `--digits`
- digit width for sequential numbering, default `6`
- `--format`
- timestamp format, default `20060102150405`
- `--tz`
- timestamp timezone, default `UTC`
- `--ext`
- file extension, default `sql`
Examples:
```bash
go run main.go -c ./config.yaml command migrate create add_menu_seed --format 20060102150405 --tz Asia/Shanghai
go run main.go -c ./config.yaml command migrate create backfill_demo_data --seq --digits 6
```
Recommendation:
- avoid `--seq` for parallel development
- 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
## check
Validate migration filenames and version pairing:
```bash
go run main.go -c ./config.yaml command migrate check
```
What it validates:
- filename pattern `^(\d+)_(.+)\.(up|down)\.([^.]+)$`
- exactly one `up` and one `down` per version
- first version, last version, and file counts
Optional flag:
- `--strict`
- default `true`
- if disabled, non-matching files can be tolerated more loosely
## up
Apply migrations:
```bash
go run main.go -c ./config.yaml command migrate up
```
Apply a specific count:
```bash
go run main.go -c ./config.yaml command migrate up 1
```
Notes:
- without `N`, all pending migrations are applied
- requires a valid database config in `config.yaml`
## down
Rollback migrations:
```bash
go run main.go -c ./config.yaml command migrate down 1
```
Rollback all:
```bash
go run main.go -c ./config.yaml command migrate down --all -y
```
Notes:
- without an argument, it rolls back one version
- `--all` rolls back to nil version and should usually be paired with `-y`
## goto
Migrate to a specific version:
```bash
go run main.go -c ./config.yaml command migrate goto 20260425143015
```
Useful for:
- local debugging against an intermediate version
- moving back to a known version before another verification step
## force
Force-set the migration version without running SQL:
```bash
go run main.go -c ./config.yaml command migrate force 20260425143015
```
Use cases:
- repairing a dirty migration state
- correcting the version cursor when the actual database state is already known to be correct
Notes:
- this is a repair command, not a normal migration workflow step
- verify the real database schema before using it
## version
Show the current migration version:
```bash
go run main.go -c ./config.yaml command migrate version
```
Example output:
```text
version: 20260425143015, dirty: false
```
If no migration has been applied yet:
```text
version: none, dirty: false
```
## Custom Migration Path
By default the command auto-resolves `data/migrations`. To override it:
```bash
go run main.go -c ./config.yaml command migrate --path ./data/migrations check
go run main.go -c ./config.yaml command migrate --path ./data/migrations up
```
## Recommended Workflow
### Add a new migration
```bash
go run main.go -c ./config.yaml command migrate create add_sys_notice_tables
go run main.go -c ./config.yaml command migrate check
go run main.go -c ./config.yaml command migrate up
```
### Adjust migrations during initial development
If a feature is still under development and has not been released:
- update the existing migration files for that feature directly
- do not create extra migration files for iterative local adjustments
- make sure `check` passes before merging
### Repair a dirty state
```bash
go run main.go -c ./config.yaml command migrate version
go run main.go -c ./config.yaml command migrate force
```
## Notes
- prefer explicit `-c ./config.yaml` when using `go run`
- use timestamp versions by default for parallel development
- use `force` only when you are certain about the real database state
- `down --all` is destructive
## References
- [README.en.md](/Users/liuml/data/go/src/go-layout/README.en.md)
- [docs/COMMANDS_AND_TASKS.en.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.en.md)
- [golang-migrate CLI README](https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md)
================================================
FILE: docs/MIGRATE_COMMANDS.md
================================================
# 迁移命令详细说明
本文档说明项目内置的 `command migrate` 命令组,包括:
- 为什么统一使用项目命令
- 迁移文件命名规范
- `create/check/up/down/goto/force/version` 的具体用法
- 常见执行建议与注意事项
命令入口注册于 [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)。
## 设计原则
项目已经内置迁移管理能力,推荐统一使用:
```bash
go run main.go -c ./config.yaml command migrate ...
```
这样做的原因:
- 不依赖仓库根目录额外放一个 `migrate` 二进制
- 不依赖 `scripts/` 里的 shell 包装脚本
- 与当前项目配置加载、迁移目录解析逻辑保持一致
- `up/down/goto/force/version` 与 `create/check` 入口统一
## 文件命名规范
默认使用时间戳版本:
```text
YYYYMMDDHHMMSS_desc.up.sql
YYYYMMDDHHMMSS_desc.down.sql
```
示例:
```text
20260425143015_add_task_center_tables.up.sql
20260425143015_add_task_center_tables.down.sql
```
约束:
- `version` 必须是递增且唯一的整数
- `desc` 会被规范化为小写下划线风格
- 每个版本必须恰好存在一对 `up/down`
默认时间格式为 `20060102150405`,默认时区为 `UTC`。
## 命令总览
```bash
go run main.go command migrate
go run main.go -c ./config.yaml command migrate create
go run main.go -c ./config.yaml command migrate check
go run main.go -c ./config.yaml command migrate up [N]
go run main.go -c ./config.yaml command migrate down [N]
go run main.go -c ./config.yaml command migrate down --all
go run main.go -c ./config.yaml command migrate goto
go run main.go -c ./config.yaml command migrate force
go run main.go -c ./config.yaml command migrate version
```
说明:
- `go run main.go command migrate` 默认等价于 `go run main.go command migrate up`
公共参数:
- `--path`, `-p`
- 指定迁移目录
- 默认自动解析 `data/migrations`
- `--yes`, `-y`
- 跳过确认提示
- 主要用于 `down --all` 这类破坏性操作
## create
创建一对迁移文件:
```bash
go run main.go -c ./config.yaml command migrate create add_task_center_tables
```
默认行为:
- 自动将名称规范化为 `add_task_center_tables`
- 生成:
- `data/migrations/_add_task_center_tables.up.sql`
- `data/migrations/_add_task_center_tables.down.sql`
默认模板内容:
```sql
BEGIN;
-- TODO: write migration up SQL.
COMMIT;
```
可选参数:
- `--seq`
- 改为顺序号模式
- `--digits`
- 顺序号位数,默认 `6`
- `--format`
- 时间戳格式,默认 `20060102150405`
- `--tz`
- 时间戳时区,默认 `UTC`
- `--ext`
- 文件扩展名,默认 `sql`
示例:
```bash
go run main.go -c ./config.yaml command migrate create add_menu_seed --format 20060102150405 --tz Asia/Shanghai
go run main.go -c ./config.yaml command migrate create backfill_demo_data --seq --digits 6
```
建议:
- 并行开发默认不要使用 `--seq`
- 未发布阶段如需调整同一功能下的迁移,可直接改当前未提交迁移文件,不必为了开发中微调新增多份迁移
## check
校验迁移目录中的文件格式和版本配对关系:
```bash
go run main.go -c ./config.yaml command migrate check
```
校验内容:
- 文件名是否匹配 `^(\d+)_(.+)\.(up|down)\.([^.]+)$`
- 每个 `version` 是否恰好一份 `up` 和一份 `down`
- 输出首个版本、末尾版本、总文件数
可选参数:
- `--strict`
- 默认为 `true`
- 若关闭,则遇到不匹配模式的文件时可放宽处理
## up
执行迁移:
```bash
go run main.go -c ./config.yaml command migrate up
```
执行指定数量:
```bash
go run main.go -c ./config.yaml command migrate up 1
```
说明:
- 不传 `N` 时执行全部未应用迁移
- 依赖当前 `config.yaml` 中可用的数据库配置
## down
回滚迁移:
```bash
go run main.go -c ./config.yaml command migrate down 1
```
全部回滚:
```bash
go run main.go -c ./config.yaml command migrate down --all -y
```
说明:
- 不传参数时默认回滚 1 个版本
- `--all` 会回滚到空版本,建议显式带 `-y`
## goto
迁移到指定版本:
```bash
go run main.go -c ./config.yaml command migrate goto 20260425143015
```
适合场景:
- 本地调试某个中间版本
- 需要回到指定版本再继续验证
## force
强制设置数据库迁移版本,不实际执行 SQL:
```bash
go run main.go -c ./config.yaml command migrate force 20260425143015
```
用途:
- 手动修复 dirty migration 状态
- 在已确认数据库状态正确时修正版本游标
注意:
- 这是修复命令,不是正常迁移流程常规入口
- 使用前应先确认数据库真实结构与迁移状态一致
## version
查看当前数据库迁移版本:
```bash
go run main.go -c ./config.yaml command migrate version
```
输出示例:
```text
version: 20260425143015, dirty: false
```
若数据库尚未执行任何迁移,会输出:
```text
version: none, dirty: false
```
## 指定迁移目录
默认会自动解析 `data/migrations`。若需要指定其他目录:
```bash
go run main.go -c ./config.yaml command migrate --path ./data/migrations check
go run main.go -c ./config.yaml command migrate --path ./data/migrations up
```
## 推荐工作流
### 新增迁移
```bash
go run main.go -c ./config.yaml command migrate create add_sys_notice_tables
go run main.go -c ./config.yaml command migrate check
go run main.go -c ./config.yaml command migrate up
```
### 调整未发布功能下的迁移
开发阶段同一功能若尚未提交发布:
- 直接修改当前功能对应的迁移文件
- 不为一次功能内的多次微调新增新的迁移文件
- 合并前确保 `check` 通过
### 排查 dirty 状态
```bash
go run main.go -c ./config.yaml command migrate version
go run main.go -c ./config.yaml command migrate force
```
## 注意事项
- 使用 `go run` 时建议显式带 `-c ./config.yaml`
- 并行开发默认使用时间戳版本,避免多个分支都生成 `000004_*`
- `force` 只能在你明确知道数据库真实状态时使用
- `down --all` 具有破坏性,不要在不明确的环境执行
## 参考
- [README.md](/Users/liuml/data/go/src/go-layout/README.md)
- [docs/COMMANDS_AND_TASKS.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.md)
- [golang-migrate 官方文档](https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md)
================================================
FILE: docs/SECURITY_PERMISSION_FIXES_2026-05.md
================================================
# Security and Permission Fixes - 2026-05
## Scope
- Backend project: `go-layout`
- Frontend project: `x-l-admin-vue3`
- Goal: keep existing business behavior, fix permission/token consistency risks, and improve readability around high-risk flows.
## Backend Changes
- User token revocation now runs after user mutation transactions commit successfully, avoiding blacklist state that is newer than database state.
- Disabled users can no longer refresh tokens.
- Token validation checks database revocation records when Redis reports a miss, reducing the window caused by cache loss or Redis flushes.
- Menu and role deletion now clean related mappings and synchronize affected user permissions inside the same transaction.
- API permission updates refresh route cache and synchronize affected user permissions after successful transaction commit.
- Menu uniqueness checks only allow known fields, preventing accidental dynamic column misuse.
- Admin user phone updates now validate and persist the final `full_phone_number` value, including clearing the value when phone number is cleared.
## Frontend Changes
- Login redirect targets are normalized through a shared helper to prevent external redirect injection.
- Auth store reset now removes the correct persisted key and guards stale `refreshUserInfo` responses.
- Route generation rejects duplicate route names and paths, and missing components fall back to a controlled `NotFound` component.
- Permission directive defaults unauthorized elements to hidden state and avoids duplicate disabled-click bindings.
- Sensitive admin-user field reveal now has per-row loading state, failure rollback, and a short-lived row cache.
- Blob JSON API errors now go through normal response handling, so 401 and business errors behave consistently.
- Upload requests no longer force `Content-Type`; the browser can attach the correct multipart boundary.
- Menu button form submission no longer silently overwrites `is_show`.
## Verification
- Backend: `go test ./...`
- Frontend: `npm run type-check`
- Frontend: `npm test -- --run`
- Frontend: `npm run lint`
- Frontend: `npm run build:production`
## Notes
- No intentional business behavior changes were introduced.
- Permission cache synchronization still depends on the existing coordinator and policy reload mechanisms.
================================================
FILE: docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.en.md
================================================
# Backend Guidelines for System Config and Dictionary
This 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.
## Core Principle
- `sys_config` owns runtime policy. Backend reads must have type constraints, defaults, and fallback behavior.
- `sys_dict` owns display options. Backend code should use it at most to return display data such as labels, tags, and colors.
- Model constants, validators, service state machines, and migrations own core rules.
Admins 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.
## When to Use `sys_config`
Use `sys_config` from backend code for:
- Runtime switches for low-risk features.
- Operational thresholds, such as login lock limits, lock duration, and log retention windows.
- Security policy configuration, such as request-log masking fields.
- Parameters that affect behavior, may be adjusted without a release, and have explicit fallback behavior.
Every new config should include:
- A config key constant. Do not scatter raw key strings in business code.
- A value type, default value, and allowed range.
- A domain read helper, such as `BoolValue`, `IntValue`, or a dedicated service method.
- Fallback behavior for read failures, missing config, or type errors.
- A sensitivity flag when needed.
- Cache refresh, startup warmup, or runtime reload behavior.
- Seed data in migrations.
- Unit tests or critical-path tests.
Backend `sys_config` integration must guarantee:
- The service layer keeps business fallbacks and does not treat config values as trusted input.
- Validators remain responsible for request parameter legality and are not replaced by config-table values.
- Sensitive configs must not leak through generic detail, value, request-log, or change-diff paths.
- Protected built-in configs must not allow stable fields such as key, value type, or group code to be changed.
- When config changes affect runtime behavior, it must be clear whether cache refresh or process restart is required.
## When Not to Use `sys_config`
Do not put these into `sys_config`:
- Permission rules.
- Route definitions.
- Database schema meaning.
- Core state transitions.
- The scheduler source of truth, unless scheduler has explicitly been redesigned to read DB definitions.
- Rules where an admin-side edit would silently change core system behavior and make troubleshooting harder.
These should remain enforced by code constants, validators, service state machines, database constraints, and migrations.
## When to Use `sys_dict`
Use `sys_dict` from backend code to provide:
- Display-layer select options.
- Table tag labels, colors, and tag types.
- Localized display labels.
- Enumerations whose changes mainly affect display, not backend rules.
Recommended display dictionaries:
- `common_status`: enabled/disabled labels.
- `yes_no`: yes/no labels.
- `menu_type`: menu type labels.
- `api_auth_mode`: API auth mode labels.
- `http_method`: HTTP method labels.
- `task_kind`: task kind labels.
- `task_source`: task source labels.
- `task_run_status`: task run status labels.
When 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.
## When Not to Use `sys_dict`
Do not use `sys_dict` as the backend source of truth for:
- Validator legal values.
- Task-run state transitions.
- Role, menu, or user enable/disable checks.
- Actual permission-auth-mode execution.
- Core audit diff decisions.
Backend 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.
## Backend Implementation Requirements
- Put new config or dictionary seed data in initialization migrations first.
- During unreleased development, do not add permanent runtime sync logic for seed data unless necessary.
- For shipped features or shared environments, evolve data through new migrations or explicit idempotent sync entry points.
- Keep config reads in services or helpers. Controllers should not assemble business rules directly.
- Core enums must be explicitly expressed in form validators and model constants.
- Audit diffs may use label mappings for readability, but diff fields and security masking rules must be controlled by code.
- 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.
- 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.
- API documentation must describe config/dictionary field semantics, enum meanings, and the current i18n PATCH update semantics.
## Display API Boundary
- `dict/options` returns display options only. It does not return backend business rules.
- 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.
- 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.
- Backend business semantics must not change when dictionary APIs fail, dictionary items are edited incorrectly, or dictionary items are missing.
## Decision Table
| Need | Recommendation |
| --- | --- |
| Select options, table tags, colors, localized labels | Use `sys_dict` |
| Login lock limits, request-log masking fields, operational thresholds | Use `sys_config` |
| Validator enums, permission modes, state transitions | Use code constants and validators |
| Real cron enable/disable from admin UI | Decide scheduler source of truth first |
| Backend needs runtime policy reads | Read `sys_config` through domain helpers and keep defaults |
| Display layer wants less enum hardcoding | Backend provides `dict/options`; caller fallbacks are maintained by the caller project |
================================================
FILE: docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.md
================================================
# 系统配置与系统字典后端接入规范
本文档从后端视角约定 `sys_config` 与 `sys_dict` 的使用边界。核心目标是避免把权限、状态机、数据结构、安全边界等后端核心规则做成后台可随意修改的配置,同时为运行时策略和展示型枚举提供清晰、可审计、可测试的接入方式。
## 总体原则
- `sys_config` 管运行时策略,后端读取后必须有类型约束、默认值和降级行为。
- `sys_dict` 管展示选项,后端最多用于返回 label、tag、颜色等展示数据。
- model 常量、validator、service 状态机和 migration 管核心规则。
也就是说,后台可以调整“某些策略阈值”和“怎么展示”,但不能绕过代码里的权限判断、状态流转、字段含义、审计语义和安全约束。
## `sys_config` 适用场景
适合由后端接入 `sys_config` 的内容:
- 运行期可调的开关,例如某个低风险功能是否开启。
- 运维阈值,例如登录失败锁定次数、锁定时长、日志保留天数。
- 安全策略配置,例如 request log 脱敏字段。
- 对业务行为有影响,但允许在不发版的情况下调整,且能接受明确降级策略的参数。
新增系统配置时必须同时补齐:
- 配置 key 常量,禁止在业务代码中散落字符串。
- value type、默认值和允许范围。
- 领域化读取 helper,例如 `BoolValue`、`IntValue` 或专门 service 方法。
- 读取失败、配置缺失、类型错误时的降级行为。
- 是否敏感的安全标记。
- 缓存刷新、启动预热或运行时 reload 策略。
- 初始化 migration 数据。
- 单元测试或关键链路测试。
后端接入 `sys_config` 时必须保证:
- service 层仍保留业务兜底,不能把配置值直接当作可信输入。
- validator 仍负责接口入参合法性,不能依赖配置表替代校验。
- 敏感配置不能通过 detail、value、request log、change diff 等通用链路泄露原值。
- protected 配置禁止修改稳定字段,例如 key、value type、group code。
- 配置变更影响运行态时,必须明确是否需要刷新缓存或重启进程。
## `sys_config` 不适用场景
以下内容不建议放入 `sys_config`:
- 权限判断规则。
- 路由定义。
- 数据库结构含义。
- 核心状态机流转规则。
- 任务调度器的真实 source-of-truth,除非已专门改造 scheduler 从 DB 读取。
- 会导致“后台一改,系统核心行为立即改变且难以审计”的规则。
这类规则应继续由代码常量、validator、service 状态机、数据库约束和 migration 负责。
## `sys_dict` 适用场景
适合由后端通过 `sys_dict` 提供的内容:
- 对外展示层下拉选项。
- 列表 tag 文案、颜色、tag type。
- 多语言展示 label。
- 变化主要影响展示,不改变后端业务规则的枚举。
当前推荐接入的展示型字典包括:
- `common_status`:通用启用/禁用展示。
- `yes_no`:是否展示。
- `menu_type`:菜单类型展示。
- `api_auth_mode`:接口鉴权模式展示。
- `http_method`:HTTP 方法展示。
- `task_kind`:任务类型展示。
- `task_source`:任务来源展示。
- `task_run_status`:任务执行状态展示。
后端提供字典 options 时只承担展示数据输出职责。即使字典项被禁用、删除或误改,后端核心接口仍必须由 validator、model 常量和 service 逻辑保证合法性。
## `sys_dict` 不适用场景
以下内容不建议把 `sys_dict` 作为后端唯一判断来源:
- validator 合法值。
- 任务执行状态流转。
- 角色、菜单、用户状态的后端启停判断。
- 权限鉴权模式的实际执行逻辑。
- 审计 diff 的核心字段判断。
后端可以复用字典做展示,但不能因为字典被误改就放宽核心业务规则。例如 `status=2` 不能因为字典中新增了一个选项就自动变成合法状态。
## 后端实现要求
- 新增配置或字典默认数据优先放初始化 migration。
- 未发布开发阶段不额外加常驻同步逻辑,避免把 seed 数据做成启动副作用。
- 已发布或共享环境需要演进时,通过新增 migration 或明确的幂等同步入口处理。
- 配置读取应集中在 service/helper,controller 不直接拼装业务规则。
- 核心枚举必须在 form validator 和 model 常量中显式表达。
- 审计 diff 可以使用 label 映射提升可读性,但 diff 字段集合和安全脱敏规则必须由代码控制。
- 敏感配置详情和列表只返回脱敏占位符;更新时收到未改动的脱敏占位符应保留原值,不能把占位符写成真实配置值。
- 系统配置名称、字典类型名称、字典项 label 的 i18n 更新采用 PATCH 语义:只新增/更新请求中出现的 locale,未传 locale 保留旧值。
- 接口文档必须说明配置/字典字段的业务语义、枚举含义和当前 i18n PATCH 更新语义。
## 对外展示接口边界
- `dict/options` 只输出展示选项,不输出后端业务规则。
- `status`、`is_*`、任务状态等字段可以通过字典返回展示 label,但合法值仍由后端 validator 和 model 常量决定。
- 新增字典 type code 时,后端必须同步默认数据、接口文档和接口测试;具体展示层 fallback 由调用方项目文档维护。
- 字典接口失败、字典项误改或字典项缺失时,后端核心业务语义不能改变。
## 决策表
| 需求 | 建议 |
| --- | --- |
| 页面下拉、tag、颜色、多语言 label | 放 `sys_dict` |
| 登录锁定次数、审计脱敏字段、可运营阈值 | 放 `sys_config` |
| validator 枚举、权限模式、状态机流转 | 放代码常量与校验 |
| 定时任务是否真实启停 | 先明确 scheduler source-of-truth,再决定是否进 DB 配置 |
| 后端需要运行时读取策略 | 通过领域化 helper 读取 `sys_config`,并保留默认值 |
| 展示层需要减少硬编码枚举 | 后端提供 `dict/options`;调用方 fallback 由调用方项目维护 |
================================================
FILE: go.mod
================================================
module github.com/wannanbigpig/gin-layout
go 1.26
require (
github.com/casbin/casbin/v3 v3.10.0
github.com/casbin/gorm-adapter/v3 v3.41.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.30.2
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/h2non/filetype v1.1.3
github.com/hibiken/asynq v0.26.0
github.com/jinzhu/copier v0.4.0
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/mojocn/base64Captcha v1.3.8
github.com/mssola/useragent v1.0.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/redis/go-redis/v9 v9.18.0
github.com/robfig/cron/v3 v3.0.1
github.com/samber/lo v1.53.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.49.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/soft_delete v1.2.1
)
require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/strftime v1.1.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microsoft/go-mssqldb v1.9.8 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.3 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
// SQLite dependencies (BSD-3-Clause licensed) - indirect dependencies via casbin/gorm-adapter/v3
// Note: Project only uses MySQL, these are pulled in by the adapter's SQLite support
// All modernc.org packages use BSD-3-Clause license (compatible with MIT)
modernc.org/libc v1.70.0 // indirect; BSD-3-Clause
modernc.org/mathutil v1.7.1 // indirect; BSD-3-Clause
modernc.org/memory v1.11.0 // indirect; BSD-3-Clause
modernc.org/sqlite v1.48.0 // indirect; BSD-3-Clause
)
// License Compliance Notes:
//
// 1. github.com/golang/freetype (FreeType License OR GPL-2.0) - ✅ COMPATIBLE:
// - Via: github.com/mojocn/base64Captcha (Apache-2.0) -> github.com/golang/freetype
// - Used for: Font rendering in captcha images (alphanumeric support)
// - Status: Uses FreeType License (not GPL) - compatible with MIT
// - Note: This package offers dual licensing; we use FreeType License
//
// 2. modernc.org/* packages (BSD-3-Clause) - ✅ COMPATIBLE:
// - Via: casbin/gorm-adapter/v3 -> SQLite support
// - Actual license: BSD-3-Clause (NOT GPL - see LICENSE files in mod cache)
// - Status: BSD-3-Clause is MIT-compatible
// - Note: Project uses MySQL, SQLite code is not executed at runtime
//
// 3. github.com/go-sql-driver/mysql (MPL-2.0) - ✅ COMPATIBLE:
// - Used for: MySQL database connection
// - Status: MPL-2.0 is MIT-compatible for library usage
// - Note: Only requires open source if you modify the driver itself
//
// Summary: All dependencies are compatible with MIT license.
// This project can be safely released under MIT.
================================================
FILE: go.sum
================================================
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4=
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible/go.mod h1:ZQnN8lSECaebrkQytbHj4xNgtg8CR7RYXnPok8e0EHA=
github.com/lestrrat-go/strftime v1.1.1 h1:zgf8QCsgj27GlKBy3SU9/8MMgegZ8UCzlCyHYrUF0QU=
github.com/lestrrat-go/strftime v1.1.1/go.mod h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM=
github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o=
github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
================================================
FILE: internal/access/casbin/adapter.go
================================================
package casbinx
import (
"github.com/casbin/casbin/v3"
"github.com/casbin/casbin/v3/model"
gormadapter "github.com/casbin/gorm-adapter/v3"
"gorm.io/gorm"
)
func newAdapter(db *gorm.DB) (*gormadapter.Adapter, error) {
gormadapter.TurnOffAutoMigrate(db)
return gormadapter.NewAdapterByDB(db)
}
func newEnforcerFromDB(m model.Model, db *gorm.DB) (*casbin.Enforcer, error) {
adapter, err := newAdapter(db)
if err != nil {
return nil, err
}
enforcer, err := casbin.NewEnforcer(m, adapter)
if err != nil {
return nil, err
}
enforcer.EnableAutoSave(true)
return enforcer, nil
}
================================================
FILE: internal/access/casbin/casbin.go
================================================
package casbinx
import (
"errors"
"sync"
"github.com/casbin/casbin/v3"
"github.com/casbin/casbin/v3/model"
_ "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
// CasbinEnforcer 封装共享 Enforcer 与事务态 Enforcer 的切换逻辑。
type CasbinEnforcer struct {
*casbin.Enforcer
errInit error
model model.Model
tx *gorm.DB
}
var (
casbinManager = &CasbinEnforcer{}
managerMu sync.RWMutex
)
// InitEnforcer 初始化 Casbin Enforcer(仅执行一次)
func InitEnforcer() error {
managerMu.Lock()
defer managerMu.Unlock()
if casbinManager.Enforcer != nil {
return casbinManager.errInit
}
return initEnforcerLocked()
}
// GetEnforcer 返回已初始化的 Enforcer 实例
func GetEnforcer() (*CasbinEnforcer, error) {
managerMu.RLock()
current := casbinManager
managerMu.RUnlock()
if current.Enforcer == nil {
if err := InitEnforcer(); err != nil {
return nil, err
}
}
managerMu.RLock()
defer managerMu.RUnlock()
if casbinManager.Enforcer == nil {
return nil, errors.New("casbin enforcer not initialized")
}
return casbinManager, nil
}
// ReloadPolicy 重新加载策略
func ReloadPolicy() error {
enforcer, err := GetEnforcer()
if err != nil {
return err
}
return enforcer.LoadPolicy()
}
// SetDB 返回一个绑定到指定事务的新 CasbinEnforcer。
func (e *CasbinEnforcer) SetDB(tx *gorm.DB) *CasbinEnforcer {
return &CasbinEnforcer{
Enforcer: e.Enforcer,
errInit: e.errInit,
model: e.model,
tx: tx,
}
}
// RegisterCustomFunctions 注册自定义函数
func (e *CasbinEnforcer) registerCustomFunctions() {
// 注册自定义函数
}
================================================
FILE: internal/access/casbin/enforcer_init.go
================================================
package casbinx
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/casbin/casbin/v3/model"
"gorm.io/gorm"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
)
// getModelPath 获取 rbac_model.conf 路径并校验是否存在
func getModelPath() (string, error) {
path := filepath.Join(c.GetConfig().BasePath, "rbac_model.conf")
if _, err := os.Stat(path); os.IsNotExist(err) {
return "", fmt.Errorf("模型文件不存在: %s", path)
}
return path, nil
}
// isInTransaction 判断是否在事务中
func isInTransaction(db *gorm.DB) bool {
return db != nil && db.Statement != nil && db.Statement.ConnPool != db.ConnPool
}
// ReloadEnforcer 重新加载 Casbin Enforcer。
func ReloadEnforcer() error {
managerMu.Lock()
defer managerMu.Unlock()
return initEnforcerLocked()
}
func initEnforcerLocked() error {
modelPath, err := getModelPath()
if err != nil {
casbinManager.errInit = err
return err
}
m, err := model.NewModelFromFile(modelPath)
if err != nil {
casbinManager.errInit = fmt.Errorf("加载模型失败: %w", err)
return casbinManager.errInit
}
db := data.MysqlDB()
if db == nil {
casbinManager.errInit = errors.New("mysql not initialized")
return casbinManager.errInit
}
enforcer, err := newEnforcerFromDB(m, db)
if err != nil {
casbinManager.errInit = fmt.Errorf("创建 Enforcer 失败: %w", err)
return casbinManager.errInit
}
next := &CasbinEnforcer{
Enforcer: enforcer,
model: m,
}
next.registerCustomFunctions()
casbinManager = next
return nil
}
================================================
FILE: internal/access/casbin/policy_ops.go
================================================
package casbinx
import (
"errors"
"github.com/casbin/casbin/v3"
"gorm.io/gorm"
)
// execute 使用共享 Enforcer 或事务级 Enforcer 执行 Casbin 操作。
func (e *CasbinEnforcer) execute(tx *gorm.DB, fn func(enforcer casbin.IEnforcer) error) error {
if tx == nil {
tx = e.tx
}
if tx == nil {
return fn(e.Enforcer)
}
if !isInTransaction(tx) {
return errors.New("请先通过 GORM 开启事务")
}
txEnforcer, err := newEnforcerFromDB(e.model, tx)
if err != nil {
return err
}
return fn(txEnforcer)
}
// EditPolicyPermissions 编辑策略权限
func (e *CasbinEnforcer) EditPolicyPermissions(user string, policy [][]string, tx ...*gorm.DB) error {
return e.execute(firstTx(tx), func(enforcer casbin.IEnforcer) error {
return replacePermissions(enforcer, user, policy)
})
}
// EditPolicyPermissionsBatch 批量覆盖多个 subject 的权限策略。
func (e *CasbinEnforcer) EditPolicyPermissionsBatch(subjectPolicies map[string][][]string, tx ...*gorm.DB) error {
return e.execute(firstTx(tx), func(enforcer casbin.IEnforcer) error {
for subject, policy := range subjectPolicies {
if err := replacePermissions(enforcer, subject, policy); err != nil {
return err
}
}
return nil
})
}
// EditPolicyRoles 编辑策略角色
func (e *CasbinEnforcer) EditPolicyRoles(user string, policy []string, tx ...*gorm.DB) error {
return e.execute(firstTx(tx), func(enforcer casbin.IEnforcer) error {
_, err := enforcer.DeleteRolesForUser(user)
if err != nil {
return err
}
rules := make([][]string, 0, len(policy))
for _, role := range policy {
if role != "" {
rules = append(rules, []string{user, role})
}
}
if len(rules) == 0 {
return nil
}
ok, err := enforcer.AddGroupingPolicies(rules)
if err != nil {
return err
}
if !ok {
return errors.New("添加权限失败~")
}
return nil
})
}
// WithTransaction 在指定事务下执行 Casbin 操作。
func (e *CasbinEnforcer) WithTransaction(tx *gorm.DB, fn func(enforcer casbin.IEnforcer) error) error {
return e.execute(tx, fn)
}
// firstTx 返回可选事务切片中的第一个事务。
func firstTx(tx []*gorm.DB) *gorm.DB {
if len(tx) == 0 {
return nil
}
return tx[0]
}
func replacePermissions(enforcer casbin.IEnforcer, subject string, policy [][]string) error {
if _, err := enforcer.DeletePermissionsForUser(subject); err != nil {
return err
}
policies := toSubjectPolicies(subject, policy)
if len(policies) == 0 {
return nil
}
ok, err := enforcer.AddPolicies(policies)
if err != nil {
return err
}
if !ok {
return errors.New("添加权限失败")
}
return nil
}
func toSubjectPolicies(subject string, policy [][]string) [][]string {
policies := make([][]string, 0, len(policy))
for _, item := range policy {
if len(item) > 0 {
policies = append(policies, append([]string{subject}, item...))
}
}
return policies
}
================================================
FILE: internal/console/confirm.go
================================================
package console
import (
"bufio"
"fmt"
"os"
"strings"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"go.uber.org/zap"
)
// ConfirmOperation 确认操作;assumeYes=true 时跳过交互确认。
func ConfirmOperation(prompt string, assumeYes bool) bool {
if assumeYes {
return true
}
scanner := bufio.NewScanner(os.Stdin)
fmt.Print(prompt)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
log.Logger.Error("Failed to read user input", zap.Error(err))
_, _ = fmt.Fprintln(os.Stderr, "reading standard input:", err)
}
return false
}
input := strings.TrimSpace(strings.ToLower(scanner.Text()))
return input == "y" || input == "yes"
}
================================================
FILE: internal/console/demo/demo.go
================================================
package demo
import (
"fmt"
"github.com/spf13/cobra"
)
var (
Cmd = &cobra.Command{
Use: "demo",
Short: "这是一个demo",
Example: "go-layout command demo",
RunE: func(cmd *cobra.Command, args []string) error {
demo()
return nil
},
}
test string
)
func init() {
Cmd.Flags().StringVarP(&test, "test", "t", "test", "测试接收参数")
}
func demo() {
fmt.Println("hello console!", test)
}
================================================
FILE: internal/console/init/init.go
================================================
package init
import (
"fmt"
"github.com/spf13/cobra"
"go.uber.org/zap"
consolex "github.com/wannanbigpig/gin-layout/internal/console"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/service/system"
)
const (
msgProcessingComplete = "Processing complete."
msgFailedToSaveRoute = "Failed to save the initial route data to the routing table."
msgUserPermissionComplete = "User API permissions rebuilt successfully."
msgFailedToRebuildPerms = "Failed to rebuild final user API permissions."
)
var (
apiRouteAssumeYes bool
rebuildPermissionsAssumeYes bool
ApiRouteCmd = &cobra.Command{
Use: "api-route",
Short: "Initialize API route table",
Long: "This command scans all defined API routes in the system and stores them in the api table for permission management and API documentation.",
RunE: func(cmd *cobra.Command, args []string) error {
return runInitApiRoute()
},
}
RebuildUserPermissionsCmd = &cobra.Command{
Use: "rebuild-user-permissions",
Short: "Rebuild final user API permissions from database relationships",
Long: "This command rebuilds final user API permissions from database user, department, role, menu, and API relationships.",
RunE: func(cmd *cobra.Command, args []string) error {
return runRebuildUserPermissions()
},
}
)
func init() {
ApiRouteCmd.Flags().BoolVarP(&apiRouteAssumeYes, "yes", "y", false, "Skip confirmation prompt")
RebuildUserPermissionsCmd.Flags().BoolVarP(&rebuildPermissionsAssumeYes, "yes", "y", false, "Skip confirmation prompt")
}
// runInitApiRoute 执行API路由表初始化
func runInitApiRoute() error {
// 用户确认
if !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) {
fmt.Println("Operation cancelled.")
return nil
}
// 调用服务层方法
if err := system.InitApiRoutes(); err != nil {
log.Logger.Error(msgFailedToSaveRoute, zap.Error(err))
fmt.Println(msgFailedToSaveRoute)
return err
}
fmt.Println(msgProcessingComplete)
return nil
}
// runRebuildUserPermissions 执行用户最终 API 权限重建。
func runRebuildUserPermissions() error {
// 用户确认
if !consolex.ConfirmOperation("This command rebuilds final user API permissions from database relationships. Are you sure to perform the operation? [Y/N]: ", rebuildPermissionsAssumeYes) {
fmt.Println("Operation cancelled.")
return nil
}
// 调用服务层方法
if err := system.RebuildUserPermissions(); err != nil {
log.Logger.Error(msgFailedToRebuildPerms, zap.Error(err))
fmt.Println(msgFailedToRebuildPerms)
return err
}
fmt.Println(msgUserPermissionComplete)
return nil
}
================================================
FILE: internal/console/migrate/migrate.go
================================================
package migrate
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/spf13/cobra"
consolex "github.com/wannanbigpig/gin-layout/internal/console"
"github.com/wannanbigpig/gin-layout/internal/service/system"
)
const (
defaultMigrationDir = "data/migrations"
defaultMigrationExt = "sql"
defaultTimeFormat = "20060102150405"
defaultMigrationDigits = 6
)
var migrationFilePattern = regexp.MustCompile(`^(\d+)_(.+)\.(up|down)\.([^.]+)$`)
var (
migratePath string
migrateAssumeYes bool
createUseSeq bool
createDigits int
createFormat string
createTZ string
createExt string
downAll bool
migrateCheckStrict bool
migrationNameCleaner = regexp.MustCompile(`[^a-z0-9_]+`)
// Cmd 迁移命令入口。
Cmd = &cobra.Command{
Use: "migrate",
Short: "Database migration management commands (defaults to up)",
RunE: func(cmd *cobra.Command, args []string) error {
return runUp(nil)
},
}
createCmd = &cobra.Command{
Use: "create NAME",
Short: "Create a migration pair (up/down)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runCreate(args[0])
},
}
checkCmd = &cobra.Command{
Use: "check",
Short: "Validate migration filename format and version pairing",
RunE: func(cmd *cobra.Command, args []string) error {
return runCheck()
},
}
upCmd = &cobra.Command{
Use: "up [N]",
Short: "Apply all or N up migrations",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runUp(args)
},
}
downCmd = &cobra.Command{
Use: "down [N]",
Short: "Apply 1, N, or all down migrations",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runDown(args)
},
}
gotoCmd = &cobra.Command{
Use: "goto VERSION",
Short: "Migrate to a specific version",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
version, err := parseUint64Arg(args[0], "VERSION")
if err != nil {
return err
}
return runWithMigrator(func(m *migrate.Migrate) error {
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
fmt.Printf("migrate goto %d complete\n", version)
return nil
})
},
}
forceCmd = &cobra.Command{
Use: "force VERSION",
Short: "Set migration version without running migrations",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
version, err := parseIntArg(args[0], "VERSION")
if err != nil {
return err
}
return runWithMigrator(func(m *migrate.Migrate) error {
if err := m.Force(version); err != nil {
return err
}
fmt.Printf("migrate force %d complete\n", version)
return nil
})
},
}
versionCmd = &cobra.Command{
Use: "version",
Short: "Print current migration version",
RunE: func(cmd *cobra.Command, args []string) error {
return runWithMigrator(func(m *migrate.Migrate) error {
version, dirty, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
fmt.Println("version: none, dirty: false")
return nil
}
if err != nil {
return err
}
fmt.Printf("version: %d, dirty: %v\n", version, dirty)
return nil
})
},
}
)
func init() {
registerFlags()
registerSubCommands()
}
func registerFlags() {
Cmd.PersistentFlags().StringVarP(&migratePath, "path", "p", "", "Migration directory path (default auto detect)")
Cmd.PersistentFlags().BoolVarP(&migrateAssumeYes, "yes", "y", false, "Skip confirmation prompt")
createCmd.Flags().BoolVar(&createUseSeq, "seq", false, "Generate sequential migration versions")
createCmd.Flags().IntVar(&createDigits, "digits", defaultMigrationDigits, "Number of digits for sequential versions")
createCmd.Flags().StringVar(&createFormat, "format", defaultTimeFormat, "Go time format for timestamp version")
createCmd.Flags().StringVar(&createTZ, "tz", "UTC", "Timezone used for timestamp version")
createCmd.Flags().StringVar(&createExt, "ext", defaultMigrationExt, "Migration file extension")
downCmd.Flags().BoolVar(&downAll, "all", false, "Apply all down migrations")
checkCmd.Flags().BoolVar(&migrateCheckStrict, "strict", true, "Fail if filename does not match migration pattern")
}
func registerSubCommands() {
Cmd.AddCommand(createCmd)
Cmd.AddCommand(checkCmd)
Cmd.AddCommand(upCmd)
Cmd.AddCommand(downCmd)
Cmd.AddCommand(gotoCmd)
Cmd.AddCommand(forceCmd)
Cmd.AddCommand(versionCmd)
}
func runCreate(rawName string) error {
dir, err := resolveMigrationDirForCreate()
if err != nil {
return err
}
name := normalizeMigrationName(rawName)
if name == "" {
return fmt.Errorf("migration name is empty after normalization")
}
ext := strings.TrimPrefix(strings.TrimSpace(createExt), ".")
if ext == "" {
ext = defaultMigrationExt
}
files, err := loadMigrationFiles(dir, migrateCheckStrict)
if err != nil {
return err
}
version, err := nextMigrationVersion(files)
if err != nil {
return err
}
upFile := filepath.Join(dir, fmt.Sprintf("%s_%s.up.%s", version, name, ext))
downFile := filepath.Join(dir, fmt.Sprintf("%s_%s.down.%s", version, name, ext))
if _, err := os.Stat(upFile); err == nil {
return fmt.Errorf("target file already exists: %s", upFile)
}
if _, err := os.Stat(downFile); err == nil {
return fmt.Errorf("target file already exists: %s", downFile)
}
if err := os.WriteFile(upFile, []byte("BEGIN;\n\n-- TODO: write migration up SQL.\n\nCOMMIT;\n"), 0o644); err != nil {
return fmt.Errorf("write up migration failed: %w", err)
}
if err := os.WriteFile(downFile, []byte("BEGIN;\n\n-- TODO: write migration down SQL.\n\nCOMMIT;\n"), 0o644); err != nil {
return fmt.Errorf("write down migration failed: %w", err)
}
fmt.Println(upFile)
fmt.Println(downFile)
return nil
}
func runCheck() error {
dir, err := resolveMigrationDirForCheck()
if err != nil {
return err
}
files, err := loadMigrationFiles(dir, migrateCheckStrict)
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("no migration files found in %s", dir)
}
grouped := make(map[string]map[string]int, len(files))
for _, file := range files {
if _, ok := grouped[file.Version]; !ok {
grouped[file.Version] = map[string]int{"up": 0, "down": 0}
}
grouped[file.Version][file.Direction]++
}
versions := make([]string, 0, len(grouped))
for version := range grouped {
versions = append(versions, version)
}
sort.Slice(versions, func(i, j int) bool {
left, _ := strconv.ParseUint(versions[i], 10, 64)
right, _ := strconv.ParseUint(versions[j], 10, 64)
return left < right
})
for _, version := range versions {
entry := grouped[version]
if entry["up"] != 1 || entry["down"] != 1 {
return fmt.Errorf("invalid version %s: up=%d down=%d (expect up=1 down=1)", version, entry["up"], entry["down"])
}
}
fmt.Printf("[OK] migration check passed: %d versions, %d files.\n", len(versions), len(files))
fmt.Printf(" first version: %s\n", versions[0])
fmt.Printf(" last version: %s\n", versions[len(versions)-1])
return nil
}
func runUp(args []string) error {
steps := 0
if len(args) == 1 {
n, err := parseIntArg(args[0], "N")
if err != nil {
return err
}
if n <= 0 {
return fmt.Errorf("N must be greater than 0")
}
steps = n
}
return runWithMigrator(func(m *migrate.Migrate) error {
if err := remapLegacySequentialVersionIfNeeded(m); err != nil {
return err
}
var err error
if steps > 0 {
err = m.Steps(steps)
} else {
err = m.Up()
}
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
if steps > 0 {
fmt.Printf("migrate up %d complete\n", steps)
} else {
fmt.Println("migrate up complete")
}
return nil
})
}
func runDown(args []string) error {
if downAll && len(args) > 0 {
return fmt.Errorf("cannot use N with --all")
}
steps := 1
if len(args) == 1 {
n, err := parseIntArg(args[0], "N")
if err != nil {
return err
}
if n <= 0 {
return fmt.Errorf("N must be greater than 0")
}
steps = n
}
if downAll {
if !consolex.ConfirmOperation("This will apply all down migrations. Continue? [Y/N]: ", migrateAssumeYes) {
fmt.Println("Operation cancelled.")
return nil
}
}
return runWithMigrator(func(m *migrate.Migrate) error {
if err := remapLegacySequentialVersionIfNeeded(m); err != nil {
return err
}
var err error
if downAll {
err = m.Down()
} else {
err = m.Steps(-steps)
}
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
if downAll {
fmt.Println("migrate down --all complete")
} else {
fmt.Printf("migrate down %d complete\n", steps)
}
return nil
})
}
func runWithMigrator(fn func(*migrate.Migrate) error) error {
m, err := buildMigrator()
if err != nil {
return err
}
defer m.Close()
return fn(m)
}
func buildMigrator() (*migrate.Migrate, error) {
path := strings.TrimSpace(migratePath)
if path == "" {
return system.NewMigrator()
}
return system.NewMigratorWithPath(path)
}
func resolveMigrationDirForCreate() (string, error) {
if strings.TrimSpace(migratePath) != "" {
return ensureDirExists(migratePath)
}
if _, err := os.Stat(defaultMigrationDir); err == nil {
return ensureDirExists(defaultMigrationDir)
}
return resolveMigrationDirForCheck()
}
func resolveMigrationDirForCheck() (string, error) {
if strings.TrimSpace(migratePath) != "" {
return ensureDirExists(migratePath)
}
path, err := system.ResolveMigrationsPath()
if err != nil {
return "", err
}
return ensureDirExists(path)
}
func ensureDirExists(path string) (string, error) {
trimmed := strings.TrimSpace(strings.TrimPrefix(path, "file://"))
if trimmed == "" {
return "", fmt.Errorf("migration path is empty")
}
absPath, err := filepath.Abs(trimmed)
if err != nil {
return "", fmt.Errorf("resolve migration path failed: %w", err)
}
info, err := os.Stat(absPath)
if err != nil {
return "", fmt.Errorf("migration path not found: %s", absPath)
}
if !info.IsDir() {
return "", fmt.Errorf("migration path is not a directory: %s", absPath)
}
return absPath, nil
}
func normalizeMigrationName(raw string) string {
name := strings.ToLower(strings.TrimSpace(raw))
name = strings.ReplaceAll(name, "-", "_")
name = strings.ReplaceAll(name, " ", "_")
name = migrationNameCleaner.ReplaceAllString(name, "_")
name = strings.Trim(name, "_")
name = strings.ReplaceAll(name, "__", "_")
return name
}
func nextMigrationVersion(files []migrationFile) (string, error) {
if createUseSeq {
if createDigits <= 0 {
return "", fmt.Errorf("digits must be greater than 0")
}
maxVersion := 0
for _, file := range files {
v, err := strconv.Atoi(file.Version)
if err != nil {
continue
}
if v > maxVersion {
maxVersion = v
}
}
return fmt.Sprintf("%0*d", createDigits, maxVersion+1), nil
}
loc, err := time.LoadLocation(strings.TrimSpace(createTZ))
if err != nil {
return "", fmt.Errorf("invalid timezone: %w", err)
}
version := time.Now().In(loc).Format(createFormat)
if _, err := strconv.ParseUint(version, 10, 64); err != nil {
return "", fmt.Errorf("invalid time format result: %s (must be uint64)", version)
}
for _, file := range files {
if file.Version == version {
return "", fmt.Errorf("duplicate migration version %s, please retry", version)
}
}
return version, nil
}
type migrationFile struct {
Version string
Direction string
Name string
}
func loadMigrationFiles(dir string, strict bool) ([]migrationFile, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
result := make([]migrationFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".sql") {
continue
}
match := migrationFilePattern.FindStringSubmatch(name)
if len(match) != 5 {
if strict {
return nil, fmt.Errorf("invalid migration filename format: %s", name)
}
continue
}
result = append(result, migrationFile{
Version: match[1],
Direction: match[3],
Name: name,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].Version == result[j].Version {
return result[i].Name < result[j].Name
}
return result[i].Version < result[j].Version
})
return result, nil
}
func parseIntArg(value string, argName string) (int, error) {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("invalid %s: %s", argName, value)
}
return parsed, nil
}
func parseUint64Arg(value string, argName string) (uint, error) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid %s: %s", argName, value)
}
if parsed > uint64(^uint(0)) {
return 0, fmt.Errorf("%s out of range: %s", argName, value)
}
return uint(parsed), nil
}
func remapLegacySequentialVersionIfNeeded(m *migrate.Migrate) error {
currentVersion, dirty, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
return nil
}
if err != nil || dirty {
return nil
}
dir, err := resolveMigrationDirForCheck()
if err != nil {
return nil
}
files, err := loadMigrationFiles(dir, migrateCheckStrict)
if err != nil || len(files) == 0 {
return nil
}
versionSet := make(map[uint64]struct{}, len(files))
uniqueVersions := make([]uint64, 0, len(files))
allTimestampStyle := true
for _, file := range files {
if len(file.Version) < 10 {
allTimestampStyle = false
}
v, parseErr := strconv.ParseUint(file.Version, 10, 64)
if parseErr != nil {
continue
}
if _, ok := versionSet[v]; ok {
continue
}
versionSet[v] = struct{}{}
uniqueVersions = append(uniqueVersions, v)
}
if len(uniqueVersions) == 0 || !allTimestampStyle {
return nil
}
sort.Slice(uniqueVersions, func(i, j int) bool { return uniqueVersions[i] < uniqueVersions[j] })
if _, ok := versionSet[uint64(currentVersion)]; ok {
return nil
}
if currentVersion == 0 || int(currentVersion) > len(uniqueVersions) {
return nil
}
target := uniqueVersions[int(currentVersion)-1]
if target <= 999999999 {
return nil
}
if err := m.Force(int(target)); err != nil {
return fmt.Errorf("failed to remap legacy migration version %d to %d: %w", currentVersion, target, err)
}
fmt.Printf("detected legacy sequential migration version %d, remapped to timestamp version %d\n", currentVersion, target)
return nil
}
================================================
FILE: internal/console/system_init/system_init.go
================================================
package system_init
import (
"fmt"
"github.com/spf13/cobra"
"go.uber.org/zap"
consolex "github.com/wannanbigpig/gin-layout/internal/console"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/service/system"
)
var (
initSystemAssumeYes bool
// InitSystemCmd 手动执行初始化系统命令
InitSystemCmd = &cobra.Command{
Use: "init-system",
Short: "Initialize system data manually",
Long: `This command manually initializes the system data, which includes:
1. Rollback all database migrations
2. Re-execute all migrations
3. Re-initialize API routes
4. Rebuild final user API permissions
This is the same task that runs automatically at 2:00 AM daily.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runInitSystem()
},
}
)
func init() {
InitSystemCmd.Flags().BoolVarP(&initSystemAssumeYes, "yes", "y", false, "Skip confirmation prompt")
}
// runInitSystem 执行初始化系统
func runInitSystem() error {
// 用户确认
if !consolex.ConfirmOperation("此命令将执行系统初始化,包括回滚迁移、重新执行迁移、重新初始化 API 路由并重建用户最终 API 权限。此操作会清空现有数据,确定要继续吗? [Y/N]: ", initSystemAssumeYes) {
fmt.Println("操作已取消。")
return nil
}
fmt.Println("开始执行初始化系统任务...")
log.Logger.Info("手动执行初始化系统任务")
if err := system.ReinitializeSystemData(); err != nil {
log.Logger.Error("初始化系统任务执行失败", zap.Error(err))
fmt.Printf("初始化系统失败: %v\n", err)
return err
}
fmt.Println("初始化系统任务执行完成!")
log.Logger.Info("手动执行初始化系统任务完成")
return nil
}
================================================
FILE: internal/console/task/task.go
================================================
package task
import (
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/wannanbigpig/gin-layout/config"
taskcron "github.com/wannanbigpig/gin-layout/internal/cron"
"github.com/wannanbigpig/gin-layout/internal/jobs"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/queue"
)
type asyncScanRow struct {
TaskType string
InBuiltin bool
InDB bool
Queue string
}
var (
Cmd = &cobra.Command{
Use: "task",
Short: "Task helper commands",
}
scanAsyncCmd = &cobra.Command{
Use: "scan-async",
Short: "Scan registered async queue tasks and compare definitions",
RunE: func(cmd *cobra.Command, args []string) error {
return runScanAsync()
},
}
scanCronCmd = &cobra.Command{
Use: "scan-cron",
Short: "Scan built-in cron tasks and compare definitions",
RunE: func(cmd *cobra.Command, args []string) error {
return runScanCron()
},
}
)
func init() {
Cmd.AddCommand(scanAsyncCmd)
Cmd.AddCommand(scanCronCmd)
}
func runScanAsync() error {
taskTypes := collectRegistryTaskTypes(jobs.NewRegistry())
if len(taskTypes) == 0 {
fmt.Println("未扫描到已注册的异步任务。")
return nil
}
builtinMap := collectBuiltinDefinitionsByKind(model.TaskKindAsync)
dbMap, dbReady, dbErr := loadDBDefinitionsByKind(model.TaskKindAsync)
if dbErr != nil {
return dbErr
}
rows := buildAsyncScanRows(taskTypes, builtinMap, dbMap, dbReady)
printScanRows(rows, dbReady)
printScanSummary("代码注册异步任务", taskTypes, rows, builtinMap, dbMap, dbReady)
return nil
}
func runScanCron() error {
builtinMap := collectBuiltinDefinitionsByKind(model.TaskKindCron)
taskTypes := sortedDefinitionCodes(builtinMap)
if len(taskTypes) == 0 {
fmt.Println("未扫描到内置定时任务。")
return nil
}
dbMap, dbReady, dbErr := loadDBDefinitionsByKind(model.TaskKindCron)
if dbErr != nil {
return dbErr
}
rows := buildAsyncScanRows(taskTypes, builtinMap, dbMap, dbReady)
printScanRows(rows, dbReady)
printScanSummary("内置定时任务", taskTypes, rows, builtinMap, dbMap, dbReady)
return nil
}
func collectRegistryTaskTypes(registry queue.Registry) []string {
if registry == nil {
return nil
}
entries := registry.Entries()
taskTypeSet := make(map[string]struct{}, len(entries))
for _, entry := range entries {
taskType := strings.TrimSpace(entry.TaskType)
if taskType == "" {
continue
}
taskTypeSet[taskType] = struct{}{}
}
taskTypes := make([]string, 0, len(taskTypeSet))
for taskType := range taskTypeSet {
taskTypes = append(taskTypes, taskType)
}
sort.Strings(taskTypes)
return taskTypes
}
func collectBuiltinDefinitionsByKind(kind string) map[string]model.TaskDefinition {
definitions := taskcron.BuiltinTaskDefinitions(config.GetConfig())
result := make(map[string]model.TaskDefinition, len(definitions))
for _, definition := range definitions {
if definition.Kind != kind {
continue
}
code := strings.TrimSpace(definition.Code)
if code == "" {
continue
}
result[code] = definition
}
return result
}
func sortedDefinitionCodes(definitions map[string]model.TaskDefinition) []string {
codes := make([]string, 0, len(definitions))
for code := range definitions {
codes = append(codes, code)
}
sort.Strings(codes)
return codes
}
func loadDBDefinitionsByKind(kind string) (map[string]model.TaskDefinition, bool, error) {
db, err := model.GetDB()
if err != nil {
return map[string]model.TaskDefinition{}, false, nil
}
if !db.Migrator().HasTable(model.NewTaskDefinition().TableName()) {
return map[string]model.TaskDefinition{}, false, nil
}
definitions := make([]model.TaskDefinition, 0)
if err := db.Where("kind = ? AND deleted_at = 0", kind).Find(&definitions).Error; err != nil {
return nil, true, err
}
result := make(map[string]model.TaskDefinition, len(definitions))
for _, definition := range definitions {
code := strings.TrimSpace(definition.Code)
if code == "" {
continue
}
result[code] = definition
}
return result, true, nil
}
func buildAsyncScanRows(taskTypes []string, builtinMap map[string]model.TaskDefinition, dbMap map[string]model.TaskDefinition, dbReady bool) []asyncScanRow {
rows := make([]asyncScanRow, 0, len(taskTypes))
for _, taskType := range taskTypes {
row := asyncScanRow{
TaskType: taskType,
InBuiltin: false,
InDB: false,
Queue: "-",
}
if definition, ok := builtinMap[taskType]; ok {
row.InBuiltin = true
if strings.TrimSpace(definition.Queue) != "" {
row.Queue = definition.Queue
}
}
if dbReady {
if definition, ok := dbMap[taskType]; ok {
row.InDB = true
if row.Queue == "-" && strings.TrimSpace(definition.Queue) != "" {
row.Queue = definition.Queue
}
}
}
rows = append(rows, row)
}
return rows
}
func printScanRows(rows []asyncScanRow, dbReady bool) {
writer := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
if dbReady {
_, _ = fmt.Fprintln(writer, "TASK_TYPE\tIN_BUILTIN\tIN_DB\tQUEUE")
} else {
_, _ = fmt.Fprintln(writer, "TASK_TYPE\tIN_BUILTIN\tQUEUE")
}
for _, row := range rows {
if dbReady {
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n",
row.TaskType, yesNo(row.InBuiltin), yesNo(row.InDB), row.Queue)
} else {
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n",
row.TaskType, yesNo(row.InBuiltin), row.Queue)
}
}
_ = writer.Flush()
}
func printScanSummary(sourceLabel string, taskTypes []string, rows []asyncScanRow, builtinMap map[string]model.TaskDefinition, dbMap map[string]model.TaskDefinition, dbReady bool) {
missingBuiltin := make([]string, 0)
missingDB := make([]string, 0)
for _, row := range rows {
if !row.InBuiltin {
missingBuiltin = append(missingBuiltin, row.TaskType)
}
if dbReady && !row.InDB {
missingDB = append(missingDB, row.TaskType)
}
}
staleBuiltin := make([]string, 0)
for code := range builtinMap {
if !contains(taskTypes, code) {
staleBuiltin = append(staleBuiltin, code)
}
}
sort.Strings(staleBuiltin)
staleDB := make([]string, 0)
if dbReady {
for code := range dbMap {
if !contains(taskTypes, code) {
staleDB = append(staleDB, code)
}
}
sort.Strings(staleDB)
}
fmt.Printf("\n%s: %d\n", sourceLabel, len(taskTypes))
fmt.Printf("缺失内置定义: %d\n", len(missingBuiltin))
if dbReady {
fmt.Printf("缺失数据库定义: %d\n", len(missingDB))
} else {
fmt.Println("数据库定义状态: 未连接或未初始化,已跳过 IN_DB 对比")
}
fmt.Printf("内置定义疑似冗余: %d\n", len(staleBuiltin))
if dbReady {
fmt.Printf("数据库定义疑似冗余: %d\n", len(staleDB))
}
if len(missingBuiltin) > 0 {
fmt.Printf("缺失内置定义任务: %s\n", strings.Join(missingBuiltin, ", "))
}
if dbReady && len(missingDB) > 0 {
fmt.Printf("缺失数据库定义任务: %s\n", strings.Join(missingDB, ", "))
}
if len(staleBuiltin) > 0 {
fmt.Printf("内置定义冗余任务: %s\n", strings.Join(staleBuiltin, ", "))
}
if dbReady && len(staleDB) > 0 {
fmt.Printf("数据库定义冗余任务: %s\n", strings.Join(staleDB, ", "))
}
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func yesNo(value bool) string {
if value {
return "Y"
}
return "N"
}
================================================
FILE: internal/console/task/task_test.go
================================================
package task
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestBuildAsyncScanRowsMarksMissingDefinitions(t *testing.T) {
taskTypes := []string{"audit:request_log.write", "demo:send"}
builtin := map[string]model.TaskDefinition{
"audit:request_log.write": {
Code: "audit:request_log.write",
Queue: "audit",
},
}
dbDefs := map[string]model.TaskDefinition{
"audit:request_log.write": {
Code: "audit:request_log.write",
Queue: "audit",
},
}
rows := buildAsyncScanRows(taskTypes, builtin, dbDefs, true)
if len(rows) != 2 {
t.Fatalf("unexpected row count: %d", len(rows))
}
if rows[0].TaskType != "audit:request_log.write" || !rows[0].InBuiltin || !rows[0].InDB {
t.Fatalf("unexpected first row: %+v", rows[0])
}
if rows[1].TaskType != "demo:send" || rows[1].InBuiltin || rows[1].InDB {
t.Fatalf("unexpected second row: %+v", rows[1])
}
}
func TestBuildAsyncScanRowsSkipsDBStateWhenDBNotReady(t *testing.T) {
taskTypes := []string{"audit:request_log.write"}
builtin := map[string]model.TaskDefinition{
"audit:request_log.write": {
Code: "audit:request_log.write",
Queue: "audit",
},
}
dbDefs := map[string]model.TaskDefinition{
"audit:request_log.write": {
Code: "audit:request_log.write",
Queue: "audit",
},
}
rows := buildAsyncScanRows(taskTypes, builtin, dbDefs, false)
if len(rows) != 1 {
t.Fatalf("unexpected row count: %d", len(rows))
}
if rows[0].InDB {
t.Fatalf("expected InDB=false when dbReady=false, got %+v", rows[0])
}
}
func TestSortedDefinitionCodes(t *testing.T) {
codes := sortedDefinitionCodes(map[string]model.TaskDefinition{
"cron:reset-system-data": {Code: "cron:reset-system-data"},
"cron:demo": {Code: "cron:demo"},
})
if len(codes) != 2 || codes[0] != "cron:demo" || codes[1] != "cron:reset-system-data" {
t.Fatalf("unexpected sorted codes: %#v", codes)
}
}
================================================
FILE: internal/controller/admin_v1/auth.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
"github.com/wannanbigpig/gin-layout/internal/pkg/errors"
req "github.com/wannanbigpig/gin-layout/internal/pkg/request"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"github.com/wannanbigpig/gin-layout/pkg/utils/captcha"
)
// LoginController 登录控制器
type LoginController struct {
controller.Api
}
// NewLoginController 创建登录控制器实例
func NewLoginController() *LoginController {
return &LoginController{}
}
// Login 管理员用户登录
func (api LoginController) Login(c *gin.Context) {
params := form.NewLoginForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
// 构建登录日志信息
loginService := auth.NewLoginService()
logInfo := loginService.BuildLoginLogInfo(c)
if err := loginService.CheckLoginAllowed(params.UserName); err != nil {
loginService.HandleLoginFailure(params.UserName, loginService.ExtractErrorMessage(err), logInfo, false)
api.Err(c, err)
return
}
// 校验验证码
if !captcha.Verify(params.CaptchaID, params.Captcha) {
// 记录验证码错误日志
loginService.HandleLoginFailure(params.UserName, "验证码错误", logInfo, true)
api.FailCode(c, errors.CaptchaErr)
return
}
// 执行登录
result, err := loginService.Login(params.UserName, params.PassWord, logInfo)
if err != nil {
api.Err(c, err)
return
}
diff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{
"action": "login",
"username": params.UserName,
}, []auditdiff.FieldRule{
{Field: "action", Label: "操作"},
{Field: "username", Label: "用户名"},
}))
middleware.SetAuditChangeDiffRaw(c, diff)
api.Success(c, result)
}
// LoginCaptcha 生成登录验证码
func (api LoginController) LoginCaptcha(c *gin.Context) {
result, err := captcha.Generate()
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
// Logout 管理员用户退出登录
func (api LoginController) Logout(c *gin.Context) {
accessToken, err := req.GetAccessToken(c)
if err != nil {
// Token提取失败,视为已退出
api.Success(c, nil)
return
}
if err := auth.NewLoginService().Logout(accessToken); err != nil {
api.Err(c, err)
return
}
diff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{
"action": "logout",
}, []auditdiff.FieldRule{
{Field: "action", Label: "操作"},
}))
middleware.SetAuditChangeDiffRaw(c, diff)
api.Success(c, nil)
}
// CheckToken 检查Token是否有效
func (api LoginController) CheckToken(c *gin.Context) {
accessToken, err := req.GetAccessToken(c)
if err != nil {
api.Err(c, err)
return
}
loginService := auth.NewLoginService()
loginService.SetCtx(c)
_, ok := loginService.CheckToken(accessToken)
api.Success(c, ok)
}
================================================
FILE: internal/controller/admin_v1/auth_admin_user.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/admin"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// AdminUserController 管理员用户控制器
type AdminUserController struct {
controller.Api
}
// NewAdminUserController 创建管理员用户控制器实例
func NewAdminUserController() *AdminUserController {
return &AdminUserController{}
}
// GetUserInfo 获取当前登录用户基本信息
func (api AdminUserController) GetUserInfo(c *gin.Context) {
result, err := api.GetCurrentAdminUserDetail(c)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
// UpdateProfile 更新个人资料(只能更新自己的手机号、邮箱、密码、昵称)
func (api AdminUserController) UpdateProfile(c *gin.Context) {
uid := api.GetCurrentUserID(c)
params := form.NewUpdateProfile()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := admin.NewAdminUserService().UpdateProfileWithAuditDiff(uid, params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// GetUserMenuInfo 获取当前登录用户权限信息
func (api AdminUserController) GetUserMenuInfo(c *gin.Context) {
uid := api.GetCurrentUserID(c)
result, err := admin.NewAdminUserService().GetUserMenuInfo(uid, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
// Create 新增管理员
func (api AdminUserController) Create(c *gin.Context) {
params := form.NewCreateAdminUser()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := admin.NewAdminUserService().CreateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Update 更新管理员
func (api AdminUserController) Update(c *gin.Context) {
params := form.NewUpdateAdminUser()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := admin.NewAdminUserService().UpdateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// List 分页查询管理员用户列表
func (api AdminUserController) List(c *gin.Context) {
params := form.NewAdminUserListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := admin.NewAdminUserService().List(params)
api.Success(c, result)
}
// Delete 删除管理员
func (api AdminUserController) Delete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := admin.NewAdminUserService().DeleteWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// BindRole 管理员绑定角色
func (api AdminUserController) BindRole(c *gin.Context) {
params := form.NewBindRole()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := admin.NewAdminUserService().BindRoleWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Detail 获取管理员详情
func (api AdminUserController) Detail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := admin.NewAdminUserService().GetUserInfo(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
// GetFullPhone 获取管理员完整手机号
func (api AdminUserController) GetFullPhone(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
userInfo, err := admin.NewAdminUserService().GetUserInfo(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, map[string]string{
"phone_number": userInfo.PhoneNumber,
})
}
// GetFullEmail 获取管理员完整邮箱
func (api AdminUserController) GetFullEmail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
userInfo, err := admin.NewAdminUserService().GetUserInfo(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, map[string]string{
"email": userInfo.Email,
})
}
================================================
FILE: internal/controller/admin_v1/auth_api.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/api_permission"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// ApiController API权限控制器
type ApiController struct {
controller.Api
}
// NewApiController 创建API控制器实例
func NewApiController() *ApiController {
return &ApiController{}
}
// Update 更新API权限
func (api ApiController) Update(c *gin.Context) {
params := form.NewUpdateApiForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := api_permission.NewApiService().UpdateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// List 分页查询API权限列表
func (api ApiController) List(c *gin.Context) {
params := form.NewListApiQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := api_permission.NewApiService().ListPage(params)
api.Success(c, result)
}
================================================
FILE: internal/controller/admin_v1/auth_dept.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/dept"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// DeptController 部门控制器
type DeptController struct {
controller.Api
}
// NewDeptController 创建部门控制器实例
func NewDeptController() *DeptController {
return &DeptController{}
}
// List 查询部门列表
func (api DeptController) List(c *gin.Context) {
params := form.NewDeptListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := dept.NewDeptService().List(params)
api.Success(c, result)
}
// Create 新增部门
func (api DeptController) Create(c *gin.Context) {
params := form.NewCreateDeptForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := dept.NewDeptService().CreateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Update 更新部门
func (api DeptController) Update(c *gin.Context) {
params := form.NewUpdateDeptForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := dept.NewDeptService().UpdateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Delete 删除部门
func (api DeptController) Delete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := dept.NewDeptService().DeleteWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Detail 获取部门详情
func (api DeptController) Detail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := dept.NewDeptService().Detail(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
// BindRole 绑定角色到部门
func (api DeptController) BindRole(c *gin.Context) {
params := form.NewDeptBindRole()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := dept.NewDeptService().BindRoleWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
================================================
FILE: internal/controller/admin_v1/auth_file_resource.go
================================================
package admin_v1
import (
stderrors "errors"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
pkgErrors "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// FileResourceController 文件资源管理控制器。
type FileResourceController struct {
controller.Api
}
func NewFileResourceController() *FileResourceController {
return &FileResourceController{}
}
// List 分页查询文件资源列表。
func (api FileResourceController) List(c *gin.Context) {
params := form.NewFileResourceListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := service.NewFileResourceService().List(params)
api.Success(c, result)
}
// Detail 查询文件资源详情。
func (api FileResourceController) Detail(c *gin.Context) {
query := form.NewFileResourceIDForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := service.NewFileResourceService().Detail(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
// Delete 删除文件资源。
func (api FileResourceController) Delete(c *gin.Context) {
params := form.NewFileResourceIDForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := service.NewFileResourceService().Delete(params.ID, api.GetCurrentUserID(c), params.DeletedReason); err != nil {
var referencedErr *service.FileReferencedDeleteError
if stderrors.As(err, &referencedErr) {
message := "文件存在引用,不能删除"
businessErr := referencedErr.BusinessError()
if businessErr != nil {
message = businessErr.GetMessage()
}
api.Fail(c, pkgErrors.FileReferenced, message, gin.H{"references": referencedErr.References})
return
}
api.Err(c, err)
return
}
api.Success(c, nil)
}
func (api FileResourceController) TrashList(c *gin.Context) {
params := form.NewFileResourceListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
deleted := uint8(1)
params.IsDeleted = &deleted
result := service.NewFileResourceService().List(params)
api.Success(c, result)
}
func (api FileResourceController) Restore(c *gin.Context) {
params := form.NewFileResourceIDForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := service.NewFileResourceService().Restore(params.ID); err != nil {
api.Err(c, err)
return
}
api.Success(c, nil)
}
func (api FileResourceController) Destroy(c *gin.Context) {
params := form.NewFileResourceIDForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := service.NewFileResourceService().Destroy(params.ID); err != nil {
api.Err(c, err)
return
}
api.Success(c, nil)
}
func (api FileResourceController) References(c *gin.Context) {
params := form.NewFileReferenceListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := service.NewFileResourceService().References(params)
api.Success(c, result)
}
func (api FileResourceController) FolderTree(c *gin.Context) {
result, err := service.NewFileResourceService().FolderTree()
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) FolderCreate(c *gin.Context) {
params := form.NewFileFolderCreateForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, err := service.NewFileResourceService().CreateFolder(params, api.GetCurrentUserID(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) FolderUpdate(c *gin.Context) {
params := form.NewFileFolderUpdateForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, err := service.NewFileResourceService().UpdateFolder(params, api.GetCurrentUserID(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) FolderDelete(c *gin.Context) {
params := form.NewFileFolderDeleteForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := service.NewFileResourceService().DeleteFolder(params.ID); err != nil {
api.Err(c, err)
return
}
api.Success(c, nil)
}
func (api FileResourceController) FolderMove(c *gin.Context) {
params := form.NewFileFolderMoveForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, err := service.NewFileResourceService().MoveFolder(params, api.GetCurrentUserID(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) Move(c *gin.Context) {
params := form.NewFileMoveForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, err := service.NewFileResourceService().MoveFiles(params)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) UploadLocal(c *gin.Context) {
params := form.NewFileLocalUploadForm()
if err := c.ShouldBind(params); err != nil {
api.FailCode(c, pkgErrors.InvalidParameter)
return
}
multipartForm, err := c.MultipartForm()
if err != nil {
api.FailCode(c, pkgErrors.InvalidParameter)
return
}
result, err := service.NewFileResourceService().UploadLocal(multipartForm.File["files"], params, api.GetCurrentUserID(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) UploadCredential(c *gin.Context) {
params := form.NewFileUploadCredentialForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, err := service.NewFileResourceService().UploadCredential(params)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
func (api FileResourceController) UploadComplete(c *gin.Context) {
params := form.NewFileUploadCompleteForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, err := service.NewFileResourceService().CompleteDirectUpload(params, api.GetCurrentUserID(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, result)
}
================================================
FILE: internal/controller/admin_v1/auth_login_log.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/service/audit"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// AdminLoginLogController 登录日志控制器
type AdminLoginLogController struct {
controller.Api
}
// NewAdminLoginLogController 创建登录日志控制器实例
func NewAdminLoginLogController() *AdminLoginLogController {
return &AdminLoginLogController{}
}
// List 分页查询管理员登录日志列表
func (api AdminLoginLogController) List(c *gin.Context) {
params := form.NewAdminLoginLogListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := audit.NewAdminLoginLogService().List(params)
api.Success(c, result)
}
// Detail 获取管理员登录日志详情
func (api AdminLoginLogController) Detail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := audit.NewAdminLoginLogService().Detail(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
================================================
FILE: internal/controller/admin_v1/auth_menu.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
"github.com/wannanbigpig/gin-layout/internal/service/menu"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// MenuController 菜单控制器
type MenuController struct {
controller.Api
}
// NewMenuController 创建菜单控制器实例
func NewMenuController() *MenuController {
return &MenuController{}
}
// Create 新增菜单
func (api MenuController) Create(c *gin.Context) {
params := form.NewCreateMenuForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := menu.NewMenuService().CreateWithAuditDiff(params, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Update 更新菜单
func (api MenuController) Update(c *gin.Context) {
params := form.NewUpdateMenuForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := menu.NewMenuService().UpdateWithAuditDiff(params, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// UpdateAllMenuPermissions 批量更新菜单权限到casbin
func (api MenuController) UpdateAllMenuPermissions(c *gin.Context) {
if err := menu.NewMenuService().UpdateAllMenuPermissions(); err != nil {
api.Err(c, err)
return
}
diff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{
"action": "sync_all_menu_permissions",
}, []auditdiff.FieldRule{
{Field: "action", Label: "操作"},
}))
middleware.SetAuditChangeDiffRaw(c, diff)
api.Success(c, nil)
}
// Detail 获取菜单详情
func (api MenuController) Detail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := menu.NewMenuService().Detail(query.ID, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
// List 查询菜单列表
func (api MenuController) List(c *gin.Context) {
params := form.NewMenuListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := menu.NewMenuService().List(params, middleware.LocaleFromContext(c))
api.Success(c, result)
}
// Delete 删除菜单
func (api MenuController) Delete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := menu.NewMenuService().DeleteWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
================================================
FILE: internal/controller/admin_v1/auth_request_log.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/audit"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// RequestLogController 请求日志控制器
type RequestLogController struct {
controller.Api
}
// NewRequestLogController 创建请求日志控制器实例
func NewRequestLogController() *RequestLogController {
return &RequestLogController{}
}
// List 分页查询请求日志列表
func (api RequestLogController) List(c *gin.Context) {
params := form.NewRequestLogListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := audit.NewRequestLogService().List(params)
api.Success(c, result)
}
// Detail 获取请求日志详情
func (api RequestLogController) Detail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := audit.NewRequestLogService().Detail(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
// Export 导出请求日志 CSV。
func (api RequestLogController) Export(c *gin.Context) {
params := form.NewRequestLogExportQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
content, fileName, err := audit.NewRequestLogService().ExportCSV(params)
if err != nil {
api.Err(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Data(200, "text/csv; charset=utf-8", content)
}
// MaskConfig 获取敏感字段脱敏配置。
func (api RequestLogController) MaskConfig(c *gin.Context) {
result := audit.NewRequestLogService().GetMaskConfig()
api.Success(c, result)
}
// UpdateMaskConfig 更新敏感字段脱敏配置(运行时生效)。
func (api RequestLogController) UpdateMaskConfig(c *gin.Context) {
params := form.NewRequestLogMaskConfigForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
result, changeDiff, err := audit.NewRequestLogService().UpdateMaskConfigWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, result)
}
================================================
FILE: internal/controller/admin_v1/auth_role.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/role"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// RoleController 角色控制器
type RoleController struct {
controller.Api
}
// NewRoleController 创建角色控制器实例
func NewRoleController() *RoleController {
return &RoleController{}
}
// List 分页查询角色列表
func (api RoleController) List(c *gin.Context) {
params := form.NewRoleListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := role.NewRoleService().List(params)
api.Success(c, result)
}
// Create 新增角色
func (api RoleController) Create(c *gin.Context) {
params := form.NewCreateRoleForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := role.NewRoleService().CreateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Update 更新角色
func (api RoleController) Update(c *gin.Context) {
params := form.NewUpdateRoleForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := role.NewRoleService().UpdateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Delete 删除角色
func (api RoleController) Delete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := role.NewRoleService().DeleteWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
// Detail 获取角色详情
func (api RoleController) Detail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := role.NewRoleService().Detail(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
================================================
FILE: internal/controller/admin_v1/auth_session.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// SessionController 在线会话管理控制器。
type SessionController struct {
controller.Api
}
func NewSessionController() *SessionController {
return &SessionController{}
}
// List 分页查询在线会话列表。
func (api SessionController) List(c *gin.Context) {
params := form.NewSessionListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := auth.NewLoginService().ListSessions(params)
api.Success(c, result)
}
// Revoke 撤销在线会话。
func (api SessionController) Revoke(c *gin.Context) {
params := form.NewSessionRevokeForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := auth.NewLoginService().RevokeSession(c.Request.Context(), params.ID, params.Reason); err != nil {
api.Err(c, err)
return
}
api.Success(c, nil)
}
================================================
FILE: internal/controller/admin_v1/auth_storage_config.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/sys_config"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
type StorageConfigController struct {
controller.Api
}
func NewStorageConfigController() *StorageConfigController {
return &StorageConfigController{}
}
func (api StorageConfigController) Config(c *gin.Context) {
settings, err := service.NewStorageConfigService().Get(true)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, settings)
}
func (api StorageConfigController) Save(c *gin.Context) {
params := form.NewStorageConfigPayload()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := service.NewStorageConfigService().Save(service.StorageSettings{
ActiveDriver: params.ActiveDriver,
Config: params.Config,
}); err != nil {
api.Err(c, err)
return
}
if err := sys_config.NewSysConfigService().RefreshCache(); err != nil {
api.Err(c, err)
return
}
api.Success(c, nil)
}
func (api StorageConfigController) Test(c *gin.Context) {
params := form.NewStorageConfigPayload()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
if err := service.NewStorageConfigService().Test(c.Request.Context(), service.StorageSettings{
ActiveDriver: params.ActiveDriver,
Config: params.Config,
}); err != nil {
api.Err(c, err)
return
}
api.Success(c, nil)
}
================================================
FILE: internal/controller/admin_v1/auth_sys_config.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
"github.com/wannanbigpig/gin-layout/internal/service/sys_config"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// SysConfigController 系统参数控制器。
type SysConfigController struct {
controller.Api
}
func NewSysConfigController() *SysConfigController {
return &SysConfigController{}
}
func (api SysConfigController) List(c *gin.Context) {
params := form.NewSysConfigListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
api.Success(c, sys_config.NewSysConfigService().List(params, middleware.LocaleFromContext(c)))
}
func (api SysConfigController) Detail(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
detail, err := sys_config.NewSysConfigService().Detail(params.ID, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
func (api SysConfigController) Value(c *gin.Context) {
params := form.NewSysConfigKeyQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
value, err := sys_config.NewSysConfigService().PublicValue(params.ConfigKey)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, value)
}
func (api SysConfigController) Create(c *gin.Context) {
params := form.NewCreateSysConfigForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
service := sys_config.NewSysConfigService()
middleware.SetAuditRequestBodyRaw(c, service.MaskedAuditRequestBody(0, ¶ms.SysConfigPayload))
changeDiff, err := service.CreateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysConfigController) Update(c *gin.Context) {
params := form.NewUpdateSysConfigForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
service := sys_config.NewSysConfigService()
middleware.SetAuditRequestBodyRaw(c, service.MaskedAuditRequestBody(params.Id, ¶ms.SysConfigPayload))
changeDiff, err := service.UpdateWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysConfigController) Delete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_config.NewSysConfigService().DeleteWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysConfigController) Refresh(c *gin.Context) {
if err := sys_config.NewSysConfigService().RefreshCache(); err != nil {
api.Err(c, err)
return
}
diff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{
"action": "refresh_cache",
}, []auditdiff.FieldRule{
{Field: "action", Label: "操作"},
}))
middleware.SetAuditChangeDiffRaw(c, diff)
api.Success(c, nil)
}
================================================
FILE: internal/controller/admin_v1/auth_sys_dict.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/sys_dict"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// SysDictController 系统字典控制器。
type SysDictController struct {
controller.Api
}
func NewSysDictController() *SysDictController {
return &SysDictController{}
}
func (api SysDictController) TypeList(c *gin.Context) {
params := form.NewSysDictTypeListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
api.Success(c, sys_dict.NewSysDictService().TypeList(params, middleware.LocaleFromContext(c)))
}
func (api SysDictController) TypeDetail(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
detail, err := sys_dict.NewSysDictService().TypeDetail(params.ID, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
func (api SysDictController) TypeCreate(c *gin.Context) {
params := form.NewCreateSysDictTypeForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_dict.NewSysDictService().CreateTypeWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysDictController) TypeUpdate(c *gin.Context) {
params := form.NewUpdateSysDictTypeForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_dict.NewSysDictService().UpdateTypeWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysDictController) TypeDelete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_dict.NewSysDictService().DeleteTypeWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysDictController) ItemList(c *gin.Context) {
params := form.NewSysDictItemListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
api.Success(c, sys_dict.NewSysDictService().ItemList(params, middleware.LocaleFromContext(c)))
}
func (api SysDictController) ItemCreate(c *gin.Context) {
params := form.NewCreateSysDictItemForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_dict.NewSysDictService().CreateItemWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysDictController) ItemUpdate(c *gin.Context) {
params := form.NewUpdateSysDictItemForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_dict.NewSysDictService().UpdateItemWithAuditDiff(params)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysDictController) ItemDelete(c *gin.Context) {
params := form.NewIdForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
changeDiff, err := sys_dict.NewSysDictService().DeleteItemWithAuditDiff(params.ID)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, changeDiff)
api.Success(c, nil)
}
func (api SysDictController) Options(c *gin.Context) {
params := form.NewSysDictOptionsQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
options, err := sys_dict.NewSysDictService().Options(params.TypeCode, middleware.LocaleFromContext(c))
if err != nil {
api.Err(c, err)
return
}
api.Success(c, options)
}
================================================
FILE: internal/controller/admin_v1/auth_task_center.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/service/taskcenter"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// TaskCenterController 任务中心控制器。
type TaskCenterController struct {
controller.Api
}
func NewTaskCenterController() *TaskCenterController {
return &TaskCenterController{}
}
// TaskList 分页查询任务定义列表。
func (api TaskCenterController) TaskList(c *gin.Context) {
params := form.NewTaskDefinitionListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := taskcenter.NewTaskCenterService().ListTaskDefinitions(params)
api.Success(c, result)
}
// RunList 分页查询任务执行记录列表。
func (api TaskCenterController) RunList(c *gin.Context) {
params := form.NewTaskRunListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := taskcenter.NewTaskCenterService().ListTaskRuns(params)
api.Success(c, result)
}
// RunDetail 查询任务执行记录详情。
func (api TaskCenterController) RunDetail(c *gin.Context) {
query := form.NewIdForm()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
detail, err := taskcenter.NewTaskCenterService().TaskRunDetail(query.ID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, detail)
}
// RunEvents 查询任务执行事件列表。
func (api TaskCenterController) RunEvents(c *gin.Context) {
query := form.NewTaskRunEventsQuery()
if err := validator.CheckQueryParams(c, &query); err != nil {
return
}
events, err := taskcenter.NewTaskCenterService().TaskRunEvents(query.RunID)
if err != nil {
api.Err(c, err)
return
}
api.Success(c, events)
}
// CronStateList 分页查询定时任务最近状态列表。
func (api TaskCenterController) CronStateList(c *gin.Context) {
params := form.NewCronTaskStateListQuery()
if err := validator.CheckQueryParams(c, ¶ms); err != nil {
return
}
result := taskcenter.NewTaskCenterService().ListCronTaskStates(params)
api.Success(c, result)
}
// Trigger 手动触发任务。
func (api TaskCenterController) Trigger(c *gin.Context) {
params := form.NewTaskTriggerForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
uid := api.GetCurrentUserID(c)
account := ""
if user := api.GetCurrentAdminUserSnapshot(c); user != nil {
account = user.Username
}
service := taskcenter.NewTaskCenterService()
result, err := service.TriggerTask(c.Request.Context(), params, uid, account)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, taskcenter.BuildTriggerAuditDiff(params, result))
api.Success(c, result)
}
// Retry 重试失败任务。
func (api TaskCenterController) Retry(c *gin.Context) {
params := form.NewTaskRetryForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
uid := api.GetCurrentUserID(c)
account := ""
if user := api.GetCurrentAdminUserSnapshot(c); user != nil {
account = user.Username
}
service := taskcenter.NewTaskCenterService()
before, _ := service.TaskRunAuditSnapshot(params.RunID)
result, err := service.RetryTask(c.Request.Context(), params.RunID, uid, account)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, taskcenter.BuildRetryAuditDiff(before, result))
api.Success(c, result)
}
// Cancel 取消任务。
func (api TaskCenterController) Cancel(c *gin.Context) {
params := form.NewTaskCancelForm()
if err := validator.CheckPostParams(c, ¶ms); err != nil {
return
}
uid := api.GetCurrentUserID(c)
account := ""
if user := api.GetCurrentAdminUserSnapshot(c); user != nil {
account = user.Username
}
service := taskcenter.NewTaskCenterService()
before, _ := service.TaskRunAuditSnapshot(params.RunID)
result, err := service.CancelTask(c.Request.Context(), params.RunID, uid, account, params.Reason)
if err != nil {
api.Err(c, err)
return
}
middleware.SetAuditChangeDiffRaw(c, taskcenter.BuildCancelAuditDiff(before, result))
api.Success(c, result)
}
================================================
FILE: internal/controller/admin_v1/auth_test.go
================================================
package admin_v1
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/logger"
req "github.com/wannanbigpig/gin-layout/internal/pkg/request"
authservice "github.com/wannanbigpig/gin-layout/internal/service/auth"
"github.com/wannanbigpig/gin-layout/internal/validator"
)
// TestExtractAccessToken 验证请求头中的访问令牌提取逻辑。
func TestExtractAccessToken(t *testing.T) {
initControllerAuthTest(t)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/admin/v1/auth/check-token", nil)
ctx.Request.Header.Set("Authorization", "Bearer token-value")
accessToken, err := req.GetAccessToken(ctx)
if err != nil {
t.Fatalf("expected token to be extracted, got error %v", err)
}
if accessToken != "token-value" {
t.Fatalf("unexpected token value %s", accessToken)
}
}
// TestCheckTokenWithoutAuthorization 验证缺少 token 时返回错误响应。
func TestCheckTokenWithoutAuthorization(t *testing.T) {
initControllerAuthTest(t)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/admin/v1/auth/check-token", nil)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "test-request-id")
NewLoginController().CheckToken(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("expected http status 200, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), `"code":500`) {
t.Fatalf("expected server error response, got %s", recorder.Body.String())
}
}
// TestLoginWithoutRequiredParams 验证登录参数校验失败路径。
func TestLoginWithoutRequiredParams(t *testing.T) {
initControllerAuthTest(t)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/admin/v1/login", nil)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "test-request-id")
NewLoginController().Login(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("expected http status 200, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), `"code":10000`) {
t.Fatalf("expected validation error response, got %s", recorder.Body.String())
}
}
// TestBuildLoginLogInfo 验证登录日志上下文构造。
func TestBuildLoginLogInfo(t *testing.T) {
initControllerAuthTest(t)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/admin/v1/login", nil)
ctx.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")
ctx.Request.RemoteAddr = "192.0.2.1:1234"
logInfo := authservice.NewLoginService().BuildLoginLogInfo(ctx)
if logInfo.IP == "" {
t.Fatal("expected client ip in login log info")
}
if logInfo.UserAgent == "" {
t.Fatal("expected user agent in login log info")
}
if logInfo.OS == "" || logInfo.OS == "Unknown" {
t.Fatalf("expected parsed os in login log info, got %q", logInfo.OS)
}
if logInfo.Browser != "Chrome" {
t.Fatalf("expected Chrome browser in login log info, got %q", logInfo.Browser)
}
}
func TestBuildLoginLogInfoFallbacksUnknownForMissingUserAgent(t *testing.T) {
initControllerAuthTest(t)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/admin/v1/login", nil)
ctx.Request.RemoteAddr = "192.0.2.1:1234"
logInfo := authservice.NewLoginService().BuildLoginLogInfo(ctx)
if logInfo.OS != "Unknown" {
t.Fatalf("expected unknown os fallback, got %q", logInfo.OS)
}
if logInfo.Browser != "Unknown" {
t.Fatalf("expected unknown browser fallback, got %q", logInfo.Browser)
}
}
func initControllerAuthTest(t *testing.T) {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to resolve test file path")
}
projectRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))
configPath := filepath.Join(projectRoot, "config.yaml")
if _, err := os.Stat(configPath); err != nil {
examplePath := filepath.Join(projectRoot, "config", "config.yaml.example")
content, readErr := os.ReadFile(examplePath)
if readErr != nil {
t.Fatalf("read example config failed: %v", readErr)
}
configPath = filepath.Join(t.TempDir(), "config.yaml")
if writeErr := os.WriteFile(configPath, content, 0o600); writeErr != nil {
t.Fatalf("write temp config failed: %v", writeErr)
}
}
if err := config.InitConfig(configPath); err != nil {
t.Fatalf("init config failed: %v", err)
}
if err := logger.InitLogger(); err != nil {
t.Fatalf("init logger failed: %v", err)
}
if err := validator.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator failed: %v", err)
}
}
================================================
FILE: internal/controller/admin_v1/dashboard.go
================================================
package admin_v1
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/service/dashboard"
)
type DashboardController struct {
controller.Api
}
func NewDashboardController() *DashboardController {
return &DashboardController{}
}
func (api DashboardController) Overview(c *gin.Context) {
service := dashboard.NewOverviewService()
service.SetAdminUserId(api.GetCurrentUserID(c))
overview, err := service.Overview()
if err != nil {
api.Err(c, err)
return
}
api.Success(c, overview)
}
================================================
FILE: internal/controller/admin_v1/sys_common.go
================================================
package admin_v1
import (
"os"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/controller"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service"
)
const (
defaultUploadPath = "default"
)
// CommonController 通用控制器
type CommonController struct {
controller.Api
}
// NewCommonController 创建通用控制器实例
func NewCommonController() *CommonController {
return &CommonController{}
}
// Upload 上传文件
func (api CommonController) Upload(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
api.FailCode(c, errors.InvalidParameter)
return
}
// 获取用户ID
uid := c.GetUint(global.ContextKeyUID)
commonService := service.NewCommonService()
commonService.SetAdminUserId(uid)
// 获取上传路径参数。
path := defaultUploadPath
if values := form.Value["path"]; len(values) > 0 && values[0] != "" {
path = values[0]
}
// 执行文件上传
result, err := commonService.UploadImages(form.File["files"], path)
if err != nil {
if service.IsPartialImageUploadError(err) {
api.FailCode(c, errors.FileUploadPartialFail, result)
return
}
api.Err(c, err)
return
}
api.Success(c, result)
}
// GetFile 获取文件(支持公开和私有文件访问)
// 公开文件:无需认证,直接访问
// 私有文件:需要认证,只能由文件所有者访问
// 路由: GET /admin/v1/file/:uuid
// 参数: uuid - 文件的UUID(32位十六进制字符串,不带连字符)
func (api CommonController) GetFile(c *gin.Context) {
fileUUID := c.Param("uuid")
if fileUUID == "" {
api.FailCode(c, errors.InvalidParameter)
return
}
commonService := service.NewCommonService()
// 获取当前用户ID(如果已登录)
var currentUID uint
var checkAuth bool
if uid, exists := c.Get(global.ContextKeyUID); exists {
currentUID = uid.(uint)
checkAuth = true
commonService.SetAdminUserId(currentUID)
} else {
checkAuth = false
}
// 获取文件访问方式(会自动检查权限)
access, err := commonService.GetFileAccessPath(fileUUID, checkAuth, currentUID)
if err != nil {
api.Err(c, err)
return
}
if access.RedirectURL != "" {
c.Redirect(302, access.RedirectURL)
return
}
// 检查文件是否存在
if _, err := os.Stat(access.LocalPath); os.IsNotExist(err) {
api.FailCode(c, errors.NotFound)
return
}
// 返回文件
c.File(access.LocalPath)
}
================================================
FILE: internal/controller/sys_base.go
================================================
package controller
import (
stderrors "errors"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
r "github.com/wannanbigpig/gin-layout/internal/pkg/response"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service/admin"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
)
// Api 控制器基类
type Api struct {
errors.Error
}
// Success 业务成功响应
func (api Api) Success(c *gin.Context, data ...any) {
response := r.Resp()
if len(data) > 0 && data[0] != nil {
response.WithDataSuccess(c, data[0])
return
}
response.Success(c)
}
// FailCode 业务失败响应(使用错误码)
func (api Api) FailCode(c *gin.Context, code int, data ...any) {
response := r.Resp()
if len(data) > 0 && data[0] != nil {
response.WithData(data[0]).FailCode(c, code)
return
}
response.FailCode(c, code)
}
// FailCodeByKey 业务失败响应(使用错误码 + 文案 key)。
func (api Api) FailCodeByKey(c *gin.Context, code int, key string, args ...any) {
r.Resp().FailCodeByKey(c, code, key, args...)
}
// Fail 业务失败响应(自定义错误消息)
func (api Api) Fail(c *gin.Context, code int, message string, data ...any) {
response := r.Resp()
if len(data) > 0 && data[0] != nil {
response.WithData(data[0]).Fail(c, code, message)
return
}
response.Fail(c, code, message)
}
// Err 统一错误处理
// 判断错误类型是自定义类型则自动返回错误中携带的code和message,否则返回服务器错误
func (api Api) Err(c *gin.Context, err error) {
businessError, parseErr := api.AsBusinessError(err)
if parseErr != nil {
requestID := c.GetString(global.ContextKeyRequestID)
if errors.IsDependencyNotReady(parseErr) || stderrors.Is(parseErr, model.ErrDBUninitialized) {
log.Logger.Warn("Service dependency not ready",
zap.String("requestId", requestID),
zap.Error(parseErr))
api.FailCode(c, errors.ServiceDependencyNotReady)
return
}
log.Logger.Warn("Unknown error:", zap.String("requestId", requestID), zap.Error(parseErr))
api.FailCode(c, errors.ServerErr)
return
}
if businessError.HasExplicitMessage() {
api.Fail(c, businessError.GetCode(), businessError.GetMessage())
return
}
if businessError.HasMessageKey() {
api.FailCodeByKey(c, businessError.GetCode(), businessError.GetMessageKey(), businessError.GetMessageArgs()...)
return
}
api.FailCode(c, businessError.GetCode())
}
// GetCurrentUserID 获取当前登录用户的ID
func (api Api) GetCurrentUserID(c *gin.Context) uint {
return c.GetUint(global.ContextKeyUID)
}
// GetCurrentAdminUserSnapshot 获取当前登录用户的 claims 快照投影,不代表数据库最新状态。
func (api Api) GetCurrentAdminUserSnapshot(c *gin.Context) *model.AdminUser {
if principal := auth.GetAuthPrincipal(c); principal != nil {
return principal.AdminUser()
}
return nil
}
// GetCurrentAdminUserDetail 获取当前登录用户的数据库最新详情。
func (api Api) GetCurrentAdminUserDetail(c *gin.Context) (*resources.AdminUserResources, error) {
uid := api.GetCurrentUserID(c)
if uid == 0 {
return nil, errors.NewBusinessError(errors.NotLogin)
}
return admin.NewAdminUserService().GetUserInfo(uid)
}
================================================
FILE: internal/controller/sys_base_test.go
================================================
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestApiErrMapsDBUninitializedToDependencyNotReady(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/admin/v1/demo", nil)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "test-request-id")
Api{}.Err(ctx, model.ErrDBUninitialized)
if recorder.Code != http.StatusOK {
t.Fatalf("expected http status 200, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), `"code":10003`) {
t.Fatalf("expected dependency not ready response, got %s", recorder.Body.String())
}
}
================================================
FILE: internal/controller/sys_demo.go
================================================
package controller
import (
"fmt"
"github.com/gin-gonic/gin"
)
// DemoController Demo控制器
type DemoController struct {
Api
}
// NewDemoController 创建Demo控制器实例
func NewDemoController() *DemoController {
return &DemoController{}
}
// HelloWorld Demo示例接口
func (api DemoController) HelloWorld(c *gin.Context) {
name, ok := c.GetQuery("name")
if !ok {
name = "gin-layout"
}
id := c.Param("id")
result := fmt.Sprintf("hello %s %s", name, id)
api.Success(c, result)
}
================================================
FILE: internal/cron/queue_fallback.go
================================================
package taskcron
import (
"context"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/queue"
)
// RegisterQueueFallbackHandlers registers non-high-risk cron handlers for
// historical Asynq tasks that were enqueued before cron/worker boundaries were split.
func RegisterQueueFallbackHandlers(registry queue.Registry, cfg *config.Conf) int {
if registry == nil {
return 0
}
registered := 0
for _, definition := range BuiltinTaskDefinitions(cfg) {
if definition.Kind != model.TaskKindCron || definition.IsHighRisk == model.TaskHighRisk {
continue
}
taskCode := definition.Code
handler := definition.Handler
if taskCode == "" || handler == "" {
continue
}
queue.RegisterJSON(registry, taskCode, func(ctx context.Context, payload map[string]any) error {
return ExecuteHandler(ctx, handler, payload)
})
registered++
}
return registered
}
================================================
FILE: internal/cron/queue_fallback_test.go
================================================
package taskcron
import (
"context"
"testing"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
"github.com/wannanbigpig/gin-layout/internal/queue"
)
func TestRegisterQueueFallbackHandlersRegistersDisabledNonHighRiskCron(t *testing.T) {
registry := queue.NewRegistry()
count := RegisterQueueFallbackHandlers(registry, &config.Conf{})
if count != 1 {
t.Fatalf("unexpected fallback handler count: got=%d want=1", count)
}
entry, ok := findRegistration(registry, TaskCodeCronDemo)
if !ok {
t.Fatalf("expected %s fallback handler to be registered", TaskCodeCronDemo)
}
if err := entry.Handler(context.Background(), []byte(`{}`)); err != nil {
t.Fatalf("fallback handler returned error: %v", err)
}
}
func TestRegisterQueueFallbackHandlersSkipsHighRiskCron(t *testing.T) {
registry := queue.NewRegistry()
RegisterQueueFallbackHandlers(registry, &config.Conf{
AppConfig: autoload.AppConfig{EnableResetSystemCron: true},
})
if _, ok := findRegistration(registry, TaskCodeCronResetSystemData); ok {
t.Fatalf("did not expect %s fallback handler to be registered", TaskCodeCronResetSystemData)
}
}
func findRegistration(registry queue.Registry, taskType string) (queue.Registration, bool) {
for _, entry := range registry.Entries() {
if entry.TaskType == taskType {
return entry, true
}
}
return queue.Registration{}, false
}
================================================
FILE: internal/cron/registry.go
================================================
package taskcron
import (
"context"
"fmt"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/jobs"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/service/sys_config"
"go.uber.org/zap"
)
const (
TaskCodeCronDemo = "cron:demo"
TaskCodeCronResetSystemData = "cron:reset-system-data"
HandlerCronDemo = "cron.demo"
HandlerCronResetSystemData = "cron.reset-system-data"
)
const cronLogTimeFormat = "2006-01-02 15:04:05"
type HandlerFunc func(ctx context.Context, payload map[string]any) error
var (
handlersMu sync.RWMutex
handlers = map[string]HandlerFunc{
HandlerCronDemo: func(_ context.Context, _ map[string]any) error {
log.Logger.Info("计划任务 demo 执行:", zap.String("time", time.Now().Format(cronLogTimeFormat)))
return nil
},
}
)
func RegisterHandler(handler string, fn HandlerFunc) {
handler = strings.TrimSpace(handler)
if handler == "" || fn == nil {
return
}
handlersMu.Lock()
handlers[handler] = fn
handlersMu.Unlock()
}
func ExecuteHandler(ctx context.Context, handler string, payload map[string]any) error {
handler = strings.TrimSpace(handler)
handlersMu.RLock()
fn, ok := handlers[handler]
handlersMu.RUnlock()
if !ok {
return fmt.Errorf("unsupported cron handler: %s", handler)
}
return fn(ctx, payload)
}
// BuiltinTaskDefinitions 返回系统内置任务定义(任务中心列表与 cron 注册共用此定义源)。
func BuiltinTaskDefinitions(cfg *config.Conf) []model.TaskDefinition {
// 高风险“系统重建”任务默认禁用,仅在配置显式开启时启用调度。
resetStatus := model.TaskStatusDisabled
if cfg != nil && cfg.EnableResetSystemCron {
resetStatus = model.TaskStatusEnabled
}
demoStatus := model.TaskStatusEnabled
if !sys_config.BoolValue(sys_config.TaskCronDemoEnabledConfigKey, false) {
demoStatus = model.TaskStatusDisabled
}
return []model.TaskDefinition{
{
Code: jobs.AuditLogTaskType,
Name: "请求日志异步落库",
Kind: model.TaskKindAsync,
Queue: jobs.AuditQueueName,
CronSpec: "",
Handler: "jobs.audit_log.write",
Status: model.TaskStatusEnabled,
AllowManual: model.TaskManualNotAllowed,
AllowRetry: model.TaskRetryAllowed,
IsHighRisk: model.TaskNotHighRisk,
Remark: "写入 request_logs 审计日志",
},
{
Code: TaskCodeCronDemo,
Name: "演示定时任务",
Kind: model.TaskKindCron,
Queue: "",
CronSpec: "0/5 * * * * *",
Handler: HandlerCronDemo,
Status: demoStatus,
AllowManual: model.TaskManualAllowed,
AllowRetry: model.TaskRetryNotAllowed,
IsHighRisk: model.TaskNotHighRisk,
Remark: "开发演示任务",
},
{
Code: TaskCodeCronResetSystemData,
Name: "系统重建定时任务",
Kind: model.TaskKindCron,
Queue: "",
CronSpec: "0 0 2 * * *",
Handler: HandlerCronResetSystemData,
// 任务定义保留在任务中心可见;是否参与 cron 调度由 status 决定。
Status: resetStatus,
AllowManual: model.TaskManualAllowed,
AllowRetry: model.TaskRetryNotAllowed,
IsHighRisk: model.TaskHighRisk,
Remark: "高风险任务,默认关闭",
},
}
}
// SyncBuiltinDefinitionsIfAvailable 在任务定义表存在时同步内置任务定义。
func SyncBuiltinDefinitionsIfAvailable(cfg *config.Conf) error {
db, err := model.GetDB()
if err != nil {
return err
}
if !db.Migrator().HasTable(model.NewTaskDefinition().TableName()) {
return nil
}
return syncBuiltinDefinitions(db, cfg)
}
func syncBuiltinDefinitions(db *gorm.DB, cfg *config.Conf) error {
if db == nil {
return nil
}
for _, definition := range BuiltinTaskDefinitions(cfg) {
if err := upsertTaskDefinition(db, definition); err != nil {
return err
}
}
return nil
}
func upsertTaskDefinition(db *gorm.DB, definition model.TaskDefinition) error {
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "code"},
{Name: "deleted_at"},
},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"kind",
"queue",
"cron_spec",
"handler",
"status",
"allow_manual",
"allow_retry",
"is_high_risk",
"remark",
"updated_at",
}),
}).Create(&definition).Error
}
================================================
FILE: internal/cron/registry_test.go
================================================
package taskcron
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/jobs"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestSyncBuiltinDefinitionsUpsert(t *testing.T) {
db := newTaskDefinitionSyncTestDB(t)
cfg := &config.Conf{}
cfg.EnableResetSystemCron = false
if err := syncBuiltinDefinitions(db, cfg); err != nil {
t.Fatalf("sync builtin definitions failed: %v", err)
}
assertTaskDefinitionCount(t, db, 3)
assertTaskStatus(t, db, TaskCodeCronResetSystemData, 0)
assertTaskStatus(t, db, TaskCodeCronDemo, 0)
assertTaskStatus(t, db, jobs.AuditLogTaskType, 1)
assertTaskAllowManual(t, db, TaskCodeCronDemo, 1)
assertTaskAllowManual(t, db, TaskCodeCronResetSystemData, 1)
assertTaskAllowManual(t, db, jobs.AuditLogTaskType, 0)
cfg.EnableResetSystemCron = true
if err := syncBuiltinDefinitions(db, cfg); err != nil {
t.Fatalf("sync builtin definitions on second run failed: %v", err)
}
assertTaskDefinitionCount(t, db, 3)
assertTaskStatus(t, db, TaskCodeCronResetSystemData, 1)
}
func assertTaskDefinitionCount(t *testing.T, db *gorm.DB, expected int64) {
t.Helper()
var count int64
if err := db.Model(&model.TaskDefinition{}).Count(&count).Error; err != nil {
t.Fatalf("count task definitions failed: %v", err)
}
if count != expected {
t.Fatalf("unexpected task definition count: got=%d want=%d", count, expected)
}
}
func assertTaskStatus(t *testing.T, db *gorm.DB, code string, expected uint8) {
t.Helper()
var definition model.TaskDefinition
if err := db.Where("code = ?", code).First(&definition).Error; err != nil {
t.Fatalf("query task definition(%s) failed: %v", code, err)
}
if definition.Status != expected {
t.Fatalf("unexpected status for %s: got=%d want=%d", code, definition.Status, expected)
}
}
func assertTaskAllowManual(t *testing.T, db *gorm.DB, code string, expected uint8) {
t.Helper()
var definition model.TaskDefinition
if err := db.Where("code = ?", code).First(&definition).Error; err != nil {
t.Fatalf("query task definition(%s) failed: %v", code, err)
}
if definition.AllowManual != expected {
t.Fatalf("unexpected allow_manual for %s: got=%d want=%d", code, definition.AllowManual, expected)
}
}
func newTaskDefinitionSyncTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
statement := `
CREATE TABLE task_definitions (
id integer primary key autoincrement,
code text NOT NULL DEFAULT '',
name text NOT NULL DEFAULT '',
kind text NOT NULL DEFAULT '',
queue text NOT NULL DEFAULT '',
cron_spec text NOT NULL DEFAULT '',
handler text NOT NULL DEFAULT '',
status integer NOT NULL DEFAULT 1,
allow_manual integer NOT NULL DEFAULT 0,
allow_retry integer NOT NULL DEFAULT 1,
is_high_risk integer NOT NULL DEFAULT 0,
remark text NOT NULL DEFAULT '',
created_at datetime,
updated_at datetime,
deleted_at integer NOT NULL DEFAULT 0,
UNIQUE(code, deleted_at)
)`
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("create task_definitions table failed: %v", err)
}
return db
}
================================================
FILE: internal/filestorage/aliyun_oss.go
================================================
package filestorage
import (
"context"
"io"
"strings"
"time"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
type AliyunOSSDriver struct {
client *oss.Client
bucket string
publicDomain string
}
func NewAliyunOSSDriver(config AliyunOSSConfig) *AliyunOSSDriver {
endpoint := firstNonEmpty(config.InternalEndpoint, config.Endpoint)
cfg := oss.LoadDefaultConfig().
WithRegion(config.Region).
WithEndpoint(endpoint).
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)).
WithUsePathStyle(config.ForcePathStyle)
return &AliyunOSSDriver{
client: oss.NewClient(cfg),
bucket: config.Bucket,
publicDomain: strings.TrimRight(config.PublicDomain, "/"),
}
}
func (d *AliyunOSSDriver) Name() string { return "aliyun_oss" }
func (d *AliyunOSSDriver) Put(ctx context.Context, input PutInput) (PutResult, error) {
bucket := firstNonEmpty(input.Bucket, d.bucket)
out, err := d.client.PutObject(ctx, &oss.PutObjectRequest{
Bucket: oss.Ptr(bucket),
Key: oss.Ptr(input.ObjectKey),
Body: input.Reader,
ContentLength: oss.Ptr(input.Size),
ContentType: oss.Ptr(input.ContentType),
})
if err != nil {
return PutResult{}, err
}
return PutResult{Bucket: bucket, ObjectKey: input.ObjectKey, ETag: strings.Trim(oss.ToString(out.ETag), "\"")}, nil
}
func (d *AliyunOSSDriver) Open(ctx context.Context, bucket, objectKey string) (io.ReadCloser, error) {
out, err := d.client.GetObject(ctx, &oss.GetObjectRequest{Bucket: oss.Ptr(firstNonEmpty(bucket, d.bucket)), Key: oss.Ptr(objectKey)})
if err != nil {
return nil, err
}
return out.Body, nil
}
func (d *AliyunOSSDriver) Exists(ctx context.Context, bucket, objectKey string) (bool, error) {
return d.client.IsObjectExist(ctx, firstNonEmpty(bucket, d.bucket), objectKey)
}
func (d *AliyunOSSDriver) Delete(ctx context.Context, bucket, objectKey string) error {
_, err := d.client.DeleteObject(ctx, &oss.DeleteObjectRequest{Bucket: oss.Ptr(firstNonEmpty(bucket, d.bucket)), Key: oss.Ptr(objectKey)})
return err
}
func (d *AliyunOSSDriver) URL(bucket, objectKey string, isPublic bool) string {
if !isPublic || d.publicDomain == "" || objectKey == "" {
return ""
}
return d.publicDomain + "/" + strings.TrimLeft(objectKey, "/")
}
func (d *AliyunOSSDriver) SignedURL(ctx context.Context, bucket, objectKey string, ttl time.Duration) (string, error) {
out, err := d.client.Presign(ctx, &oss.GetObjectRequest{Bucket: oss.Ptr(firstNonEmpty(bucket, d.bucket)), Key: oss.Ptr(objectKey)}, func(options *oss.PresignOptions) {
options.Expires = ttl
})
if err != nil {
return "", err
}
return out.URL, nil
}
================================================
FILE: internal/filestorage/local.go
================================================
package filestorage
import (
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
type LocalDriver struct {
publicBasePath string
privateBasePath string
}
func NewLocalDriver(config LocalConfig, fallbackPublicPath, fallbackPrivatePath string) *LocalDriver {
publicPath := firstNonEmpty(config.PublicBasePath, config.BasePath, fallbackPublicPath)
privatePath := firstNonEmpty(config.PrivateBasePath, config.BasePath, fallbackPrivatePath)
return &LocalDriver{publicBasePath: publicPath, privateBasePath: privatePath}
}
func (d *LocalDriver) Name() string { return "local" }
func (d *LocalDriver) Put(_ context.Context, input PutInput) (PutResult, error) {
target, err := d.resolve(input.Bucket, input.ObjectKey)
if err != nil {
return PutResult{}, err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return PutResult{}, err
}
file, err := os.Create(target)
if err != nil {
return PutResult{}, err
}
defer file.Close()
if _, err := io.Copy(file, input.Reader); err != nil {
return PutResult{}, err
}
return PutResult{Bucket: input.Bucket, ObjectKey: input.ObjectKey}, nil
}
func (d *LocalDriver) Open(_ context.Context, bucket, objectKey string) (io.ReadCloser, error) {
path, err := d.resolve(bucket, objectKey)
if err != nil {
return nil, err
}
return os.Open(path)
}
func (d *LocalDriver) Exists(_ context.Context, bucket, objectKey string) (bool, error) {
path, err := d.resolve(bucket, objectKey)
if err != nil {
return false, err
}
_, err = os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func (d *LocalDriver) Delete(_ context.Context, bucket, objectKey string) error {
path, err := d.resolve(bucket, objectKey)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *LocalDriver) URL(bucket, objectKey string, _ bool) string {
if objectKey == "" {
return ""
}
return "/" + strings.TrimLeft(url.PathEscape(objectKey), "/")
}
func (d *LocalDriver) SignedURL(_ context.Context, bucket, objectKey string, _ time.Duration) (string, error) {
return d.URL(bucket, objectKey, false), nil
}
func (d *LocalDriver) resolve(bucket, objectKey string) (string, error) {
if objectKey == "" {
return "", fmt.Errorf("object_key is required")
}
base := d.privateBasePath
if bucket == "public" {
base = d.publicBasePath
}
absBase, err := filepath.Abs(base)
if err != nil {
return "", err
}
target := filepath.Join(absBase, filepath.Clean(strings.ReplaceAll(objectKey, "\\", "/")))
absTarget, err := filepath.Abs(target)
if err != nil {
return "", err
}
if absTarget != absBase && !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) {
return "", fmt.Errorf("object_key escapes storage root")
}
return absTarget, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
================================================
FILE: internal/filestorage/local_test.go
================================================
package filestorage
import (
"context"
"io"
"strings"
"testing"
"time"
)
func TestLocalDriverPutExistsOpenDelete(t *testing.T) {
publicDir := t.TempDir()
privateDir := t.TempDir()
driver := NewLocalDriver(LocalConfig{}, publicDir, privateDir)
ctx := context.Background()
result, err := driver.Put(ctx, PutInput{Bucket: "public", ObjectKey: "avatars/a.txt", Reader: strings.NewReader("ok"), Size: 2, ContentType: "text/plain"})
if err != nil {
t.Fatalf("put failed: %v", err)
}
if result.ObjectKey != "avatars/a.txt" {
t.Fatalf("unexpected object key: %s", result.ObjectKey)
}
exists, err := driver.Exists(ctx, "public", "avatars/a.txt")
if err != nil || !exists {
t.Fatalf("expected object to exist, exists=%v err=%v", exists, err)
}
body, err := driver.Open(ctx, "public", "avatars/a.txt")
if err != nil {
t.Fatalf("open failed: %v", err)
}
defer body.Close()
content, _ := io.ReadAll(body)
if string(content) != "ok" {
t.Fatalf("unexpected content: %s", string(content))
}
if _, err := driver.SignedURL(ctx, "public", "avatars/a.txt", time.Minute); err != nil {
t.Fatalf("signed url failed: %v", err)
}
if err := driver.Delete(ctx, "public", "avatars/a.txt"); err != nil {
t.Fatalf("delete failed: %v", err)
}
exists, err = driver.Exists(ctx, "public", "avatars/a.txt")
if err != nil || exists {
t.Fatalf("expected object to be deleted, exists=%v err=%v", exists, err)
}
}
================================================
FILE: internal/filestorage/s3.go
================================================
package filestorage
import (
"context"
"io"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type S3Driver struct {
client *s3.Client
presign *s3.PresignClient
bucket string
publicDomain string
}
func NewS3Driver(ctx context.Context, config S3Config) (*S3Driver, error) {
cfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(config.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.SecretAccessKey, "")),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if config.Endpoint != "" {
o.BaseEndpoint = aws.String(config.Endpoint)
}
o.UsePathStyle = config.ForcePathStyle
})
return &S3Driver{
client: client,
presign: s3.NewPresignClient(client),
bucket: config.Bucket,
publicDomain: strings.TrimRight(config.PublicDomain, "/"),
}, nil
}
func (d *S3Driver) Name() string { return "s3" }
func (d *S3Driver) Put(ctx context.Context, input PutInput) (PutResult, error) {
bucket := firstNonEmpty(input.Bucket, d.bucket)
out, err := d.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(input.ObjectKey),
Body: input.Reader,
ContentLength: aws.Int64(input.Size),
ContentType: aws.String(input.ContentType),
})
if err != nil {
return PutResult{}, err
}
return PutResult{Bucket: bucket, ObjectKey: input.ObjectKey, ETag: strings.Trim(aws.ToString(out.ETag), "\"")}, nil
}
func (d *S3Driver) Open(ctx context.Context, bucket, objectKey string) (io.ReadCloser, error) {
out, err := d.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)})
if err != nil {
return nil, err
}
return out.Body, nil
}
func (d *S3Driver) Exists(ctx context.Context, bucket, objectKey string) (bool, error) {
_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)})
if err != nil {
return false, err
}
return true, nil
}
func (d *S3Driver) Delete(ctx context.Context, bucket, objectKey string) error {
_, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)})
return err
}
func (d *S3Driver) URL(bucket, objectKey string, isPublic bool) string {
if !isPublic || d.publicDomain == "" || objectKey == "" {
return ""
}
return d.publicDomain + "/" + strings.TrimLeft(objectKey, "/")
}
func (d *S3Driver) SignedURL(ctx context.Context, bucket, objectKey string, ttl time.Duration) (string, error) {
out, err := d.presign.PresignGetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)}, func(options *s3.PresignOptions) {
options.Expires = ttl
})
if err != nil {
return "", err
}
return out.URL, nil
}
================================================
FILE: internal/filestorage/types.go
================================================
package filestorage
import (
"context"
"io"
"time"
)
const MaskPlaceholder = "******"
type Config struct {
Local LocalConfig `json:"local"`
AliyunOSS AliyunOSSConfig `json:"aliyun_oss"`
S3 S3Config `json:"s3"`
SignedURLTTLSeconds int `json:"signed_url_ttl_seconds"`
MaxFileSizeMB int `json:"max_file_size_mb"`
AllowedMimeTypes []string `json:"allowed_mime_types"`
}
type LocalConfig struct {
BasePath string `json:"base_path"`
PublicBasePath string `json:"public_base_path"`
PrivateBasePath string `json:"private_base_path"`
}
type AliyunOSSConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
PublicDomain string `json:"public_domain"`
InternalEndpoint string `json:"internal_endpoint"`
ForcePathStyle bool `json:"force_path_style"`
}
type S3Config struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
PublicDomain string `json:"public_domain"`
ForcePathStyle bool `json:"force_path_style"`
}
type PutInput struct {
Bucket string
ObjectKey string
Reader io.Reader
Size int64
ContentType string
}
type PutResult struct {
Bucket string
ObjectKey string
ETag string
}
type Driver interface {
Name() string
Put(ctx context.Context, input PutInput) (PutResult, error)
Open(ctx context.Context, bucket, objectKey string) (io.ReadCloser, error)
Exists(ctx context.Context, bucket, objectKey string) (bool, error)
Delete(ctx context.Context, bucket, objectKey string) error
URL(bucket, objectKey string, isPublic bool) string
SignedURL(ctx context.Context, bucket, objectKey string, ttl time.Duration) (string, error)
}
func DefaultConfig() Config {
return Config{
SignedURLTTLSeconds: 300,
MaxFileSizeMB: 10,
AllowedMimeTypes: []string{},
}
}
================================================
FILE: internal/global/api_auth_mode.go
================================================
package global
// ApiAuthMode 定义 API 路由的鉴权模式。
type ApiAuthMode uint8
const (
// ApiAuthModeNone 无需登录,无需 API 权限校验。
ApiAuthModeNone ApiAuthMode = iota
// ApiAuthModeLogin 需要登录,但无需 API 权限校验。
ApiAuthModeLogin
// ApiAuthModeAuth 需要登录且需要 API 权限校验。
ApiAuthModeAuth
)
// RequiresLogin 返回该模式是否要求用户先登录。
func (m ApiAuthMode) RequiresLogin() bool {
return m != ApiAuthModeNone
}
// RequiresAPIPermission 返回该模式是否要求 API 权限校验。
func (m ApiAuthMode) RequiresAPIPermission() bool {
return m == ApiAuthModeAuth
}
// Label 返回该模式的人类可读名称。
func (m ApiAuthMode) Label() string {
switch m {
case ApiAuthModeNone:
return "无需登录"
case ApiAuthModeLogin:
return "需要登录"
case ApiAuthModeAuth:
return "需要登录和API权限"
default:
return "-"
}
}
================================================
FILE: internal/global/api_auth_mode_test.go
================================================
package global
import "testing"
func TestApiAuthModeRequiresLogin(t *testing.T) {
if ApiAuthModeNone.RequiresLogin() {
t.Fatal("expected none mode to not require login")
}
if !ApiAuthModeLogin.RequiresLogin() {
t.Fatal("expected login mode to require login")
}
if !ApiAuthModeAuth.RequiresLogin() {
t.Fatal("expected authz mode to require login")
}
}
func TestApiAuthModeRequiresAPIPermission(t *testing.T) {
if ApiAuthModeNone.RequiresAPIPermission() {
t.Fatal("expected none mode to not require api permission")
}
if ApiAuthModeLogin.RequiresAPIPermission() {
t.Fatal("expected login mode to not require api permission")
}
if !ApiAuthModeAuth.RequiresAPIPermission() {
t.Fatal("expected authz mode to require api permission")
}
}
func TestApiAuthModeLabel(t *testing.T) {
cases := map[ApiAuthMode]string{
ApiAuthModeNone: "无需登录",
ApiAuthModeLogin: "需要登录",
ApiAuthModeAuth: "需要登录和API权限",
ApiAuthMode(99): "-",
}
for mode, expected := range cases {
if got := mode.Label(); got != expected {
t.Fatalf("unexpected label for mode %d: got %q want %q", mode, got, expected)
}
}
}
================================================
FILE: internal/global/auth.go
================================================
package global
const (
SuperAdminId uint = 1
Issuer = "go-layout"
PcAdminSubject = "pc-admin-token"
CasbinAdminUserPrefix = "adminUser"
CasbinRolePrefix = "role"
CasbinMenuPrefix = "menu"
CasbinDeptPrefix = "dept"
CasbinSeparator = ":"
)
================================================
FILE: internal/global/common.go
================================================
package global
const (
// Version is the current gin-layout version.
Version = "0.9.2"
// PerPage is the default per page size
PerPage = 10
// Yes is the value of yes
Yes uint8 = 1
// No is the value of no
No uint8 = 0
// ChinaCountryCode is the value of China country code
ChinaCountryCode = "86"
// SUCCESS is the value of success
SUCCESS = "SUCCESS"
// ERROR is the value of error
ERROR = "ERROR"
)
================================================
FILE: internal/global/context_keys.go
================================================
package global
const (
ContextKeyUID = "uid"
ContextKeyAdminUser = "admin_user"
ContextKeyAuthPrincipal = "auth_principal"
ContextKeyLocale = "locale"
ContextKeyRequestID = "requestId"
ContextKeyRequestStartTime = "requestStartTime"
ContextKeyAuditChangeDiff = "auditChangeDiff"
ContextKeyAuditHighRisk = "auditHighRisk"
ContextKeyAuditRequestBody = "auditRequestBody"
)
================================================
FILE: internal/global/system_defaults.go
================================================
package global
const (
DefaultDepartmentCode = "default_department"
SuperAdminRoleCode = "super_admin"
)
================================================
FILE: internal/jobs/audit_log.go
================================================
package jobs
import (
"context"
"fmt"
"time"
"github.com/wannanbigpig/gin-layout/config"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue"
auditsvc "github.com/wannanbigpig/gin-layout/internal/service/audit"
"go.uber.org/zap"
)
const (
AuditLogTaskType = "audit:request_log.write"
AuditQueueName = "audit"
AuditLogKindRequest = "request"
AuditLogKindPanic = "panic"
)
// AuditLogHandlerDeps 描述审计日志任务处理器可注入依赖。
type AuditLogHandlerDeps struct {
Persist func(snapshot *auditsvc.AuditLogSnapshot) error
}
// AuditLogPayload 表示异步审计日志任务 payload。
type AuditLogPayload struct {
Kind string `json:"kind"`
Snapshot *auditsvc.AuditLogSnapshot `json:"snapshot"`
}
// NewAuditLogPayload 创建审计日志 payload。
func NewAuditLogPayload(kind string, snapshot *auditsvc.AuditLogSnapshot) (AuditLogPayload, error) {
payload := AuditLogPayload{
Kind: kind,
Snapshot: snapshot,
}
if err := payload.Validate(); err != nil {
return AuditLogPayload{}, err
}
return payload, nil
}
// Validate 校验 payload 是否满足最小要求。
func (p AuditLogPayload) Validate() error {
if p.Kind != AuditLogKindRequest && p.Kind != AuditLogKindPanic {
return fmt.Errorf("invalid audit log kind %q", p.Kind)
}
if p.Snapshot == nil || p.Snapshot.RequestID == "" {
return fmt.Errorf("audit log snapshot is invalid")
}
return nil
}
// EnqueueAuditLog 发布异步审计日志任务。
func EnqueueAuditLog(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
payload, err := NewAuditLogPayload(kind, snapshot)
if err != nil {
return err
}
_, err = queue.PublishJSON(ctx, AuditLogTaskType, AuditQueueName, payload, auditLogOptions()...)
return err
}
func auditLogOptions() []queue.JobOption {
cfg := config.GetConfig()
maxRetry := 3
timeout := 10 * time.Second
if cfg != nil {
if cfg.Queue.AuditMaxRetry > 0 {
maxRetry = cfg.Queue.AuditMaxRetry
}
if cfg.Queue.AuditTimeoutSeconds > 0 {
timeout = time.Duration(cfg.Queue.AuditTimeoutSeconds) * time.Second
}
}
return []queue.JobOption{
queue.WithMaxRetry(maxRetry),
queue.WithTimeout(timeout),
}
}
// RegisterAll 注册当前版本的全部异步任务。
func RegisterAll(registry queue.Registry) {
RegisterAllWithDeps(registry, AuditLogHandlerDeps{})
}
// RegisterAllWithDeps 注册全部异步任务并支持依赖注入。
func RegisterAllWithDeps(registry queue.Registry, deps AuditLogHandlerDeps) {
if registry == nil {
return
}
persistFn := deps.Persist
if persistFn == nil {
persistFn = auditsvc.PersistAuditLog
}
queue.RegisterJSON(registry, AuditLogTaskType, func(ctx context.Context, payload AuditLogPayload) error {
_ = ctx
if err := persistFn(payload.Snapshot); err != nil {
log.Error("Persist audit log failed",
zap.String("request_id", payload.Snapshot.RequestID),
zap.Error(err))
return err
}
return nil
})
}
================================================
FILE: internal/jobs/audit_log_test.go
================================================
package jobs
import (
"context"
"errors"
"testing"
"github.com/wannanbigpig/gin-layout/internal/queue"
auditsvc "github.com/wannanbigpig/gin-layout/internal/service/audit"
)
func TestNewAuditLogPayload(t *testing.T) {
payload, err := NewAuditLogPayload(AuditLogKindRequest, &auditsvc.AuditLogSnapshot{RequestID: "req-1"})
if err != nil {
t.Fatalf("NewAuditLogPayload returned error: %v", err)
}
if payload.Kind != AuditLogKindRequest {
t.Fatalf("expected kind %q, got %q", AuditLogKindRequest, payload.Kind)
}
if payload.Snapshot == nil || payload.Snapshot.RequestID != "req-1" {
t.Fatalf("unexpected snapshot: %#v", payload.Snapshot)
}
}
func TestAuditLogHandlerReturnsSkipRetryForInvalidPayload(t *testing.T) {
registry := queue.NewRegistry()
RegisterAll(registry)
entries := registry.Entries()
if len(entries) != 1 {
t.Fatalf("expected 1 registry entry, got %d", len(entries))
}
err := entries[0].Handler(context.Background(), []byte(`{"kind":"invalid"}`))
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, queue.ErrSkipRetry) {
t.Fatalf("expected skip retry error, got %v", err)
}
}
func TestAuditLogHandlerPersistsSnapshot(t *testing.T) {
called := false
deps := AuditLogHandlerDeps{
Persist: func(snapshot *auditsvc.AuditLogSnapshot) error {
called = true
if snapshot == nil || snapshot.RequestID != "req-2" {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
return nil
},
}
registry := queue.NewRegistry()
RegisterAllWithDeps(registry, deps)
entries := registry.Entries()
if len(entries) != 1 {
t.Fatalf("expected 1 registry entry, got %d", len(entries))
}
payload, err := NewAuditLogPayload(AuditLogKindPanic, &auditsvc.AuditLogSnapshot{RequestID: "req-2"})
if err != nil {
t.Fatalf("NewAuditLogPayload returned error: %v", err)
}
job := queue.NewJSONJob(AuditLogTaskType, AuditQueueName, payload)
raw, err := job.Payload()
if err != nil {
t.Fatalf("Payload returned error: %v", err)
}
if err := entries[0].Handler(context.Background(), raw); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
if !called {
t.Fatal("expected persist function to be called")
}
}
================================================
FILE: internal/jobs/registry.go
================================================
package jobs
import "github.com/wannanbigpig/gin-layout/internal/queue"
// NewRegistry 创建并注册当前版本的全部任务处理器。
func NewRegistry() queue.Registry {
registry := queue.NewRegistry()
RegisterAll(registry)
return registry
}
================================================
FILE: internal/middleware/admin_auth.go
================================================
package middleware
import (
"fmt"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
casbinx "github.com/wannanbigpig/gin-layout/internal/access/casbin"
"github.com/wannanbigpig/gin-layout/internal/global"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
accesssvc "github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
)
type routeAuthChecker interface {
CheckoutRouteIsAuth(route string, method string) bool
}
type permissionDeps struct {
// loadEnforcer 加载 Casbin enforcer。
loadEnforcer func() (*casbinx.CasbinEnforcer, error)
// routeChecker 判断路由是否需要权限拦截。
routeChecker routeAuthChecker
}
func newDefaultPermissionDeps() permissionDeps {
return permissionDeps{
loadEnforcer: casbinx.GetEnforcer,
routeChecker: accesssvc.NewApiRouteCacheService(),
}
}
// AdminAuthHandler 依赖 ParseTokenHandler 预先写入用户上下文。
func AdminAuthHandler() gin.HandlerFunc {
return AdminAuthHandlerWithDeps(newDefaultPermissionDeps())
}
// AdminAuthHandlerWithDeps 支持注入依赖,便于测试隔离。
func AdminAuthHandlerWithDeps(deps permissionDeps) gin.HandlerFunc {
if deps.loadEnforcer == nil {
deps.loadEnforcer = casbinx.GetEnforcer
}
if deps.routeChecker == nil {
deps.routeChecker = accesssvc.NewApiRouteCacheService()
}
return func(c *gin.Context) {
uid := c.GetUint(global.ContextKeyUID)
if uid == 0 {
response.FailCode(c, e.NotLogin)
c.Abort()
return
}
principal := auth.GetAuthPrincipal(c)
if principal == nil {
response.FailCodeByKey(c, e.NotLogin, e.MsgKeyAuthSessionExpired)
c.Abort()
return
}
if !isSuperAdmin(principal) {
if err := checkPermission(c, principal.UserID, deps); err != nil {
if businessErr, ok := err.(*e.BusinessError); ok {
if businessErr.HasMessageKey() {
response.FailCodeByKey(c, businessErr.GetCode(), businessErr.GetMessageKey(), businessErr.GetMessageArgs()...)
} else {
response.FailCode(c, businessErr.GetCode())
}
} else {
response.FailCode(c, e.ServerErr)
}
c.Abort()
return
}
}
c.Next()
}
}
// isSuperAdmin 判断是否为超级管理员
func isSuperAdmin(principal *auth.AuthPrincipal) bool {
return principal != nil && (principal.IsSuperAdmin == global.Yes || principal.UserID == global.SuperAdminId)
}
// checkPermission 检查接口权限
func checkPermission(c *gin.Context, userID uint, deps permissionDeps) error {
enforcer, err := loadEnforcer(deps)
if err != nil {
return err
}
return enforcePermission(enforcer, c, userID, deps)
}
func loadEnforcer(deps permissionDeps) (*casbinx.CasbinEnforcer, error) {
enforcer, err := deps.loadEnforcer()
if err != nil {
log.Logger.Error("权限验证初始化失败", zap.Error(err))
return nil, e.NewBusinessErrorWithKey(e.ServerErr, e.MsgKeyAuthPermissionInitFailed)
}
return enforcer, nil
}
func enforcePermission(enforcer *casbinx.CasbinEnforcer, c *gin.Context, userID uint, deps permissionDeps) error {
userKey := fmt.Sprintf("%s%s%d", global.CasbinAdminUserPrefix, global.CasbinSeparator, userID)
path := c.Request.URL.Path
method := c.Request.Method
ok, err := enforcer.Enforce(userKey, path, method)
if err != nil {
log.Logger.Error("权限验证失败", zap.Error(err))
return e.NewBusinessErrorWithKey(e.ServerErr, e.MsgKeyAuthPermissionCheckFailed)
}
if !ok {
if deps.routeChecker.CheckoutRouteIsAuth(path, method) {
return e.NewBusinessErrorWithKey(e.AuthorizationErr, e.MsgKeyAuthAPIOperationDenied)
}
}
return nil
}
================================================
FILE: internal/middleware/admin_auth_test.go
================================================
package middleware
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/casbin/casbin/v3"
casbinmodel "github.com/casbin/casbin/v3/model"
"github.com/gin-gonic/gin"
casbinx "github.com/wannanbigpig/gin-layout/internal/access/casbin"
"github.com/wannanbigpig/gin-layout/internal/global"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
)
type stubRouteChecker struct {
requiresAuth bool
}
func (s stubRouteChecker) CheckoutRouteIsAuth(string, string) bool {
return s.requiresAuth
}
func TestAdminAuthHandlerWithDepsAllowsPublicRouteWhenDenied(t *testing.T) {
gin.SetMode(gin.TestMode)
enforcer := buildTestEnforcer(t)
deps := permissionDeps{
loadEnforcer: func() (*casbinx.CasbinEnforcer, error) {
return &casbinx.CasbinEnforcer{Enforcer: enforcer}, nil
},
routeChecker: stubRouteChecker{requiresAuth: false},
}
router := gin.New()
router.Use(func(c *gin.Context) {
auth.StoreAuthPrincipal(c, &auth.AuthPrincipal{
UserID: 2,
IsSuperAdmin: global.No,
})
c.Next()
})
router.Use(AdminAuthHandlerWithDeps(deps))
router.GET("/public", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
if recorder.Body.String() != "ok" {
t.Fatalf("expected body ok, got %q", recorder.Body.String())
}
}
func TestAdminAuthHandlerWithDepsReturnsServerErrWhenEnforcerLoadFails(t *testing.T) {
gin.SetMode(gin.TestMode)
deps := permissionDeps{
loadEnforcer: func() (*casbinx.CasbinEnforcer, error) {
return nil, errors.New("casbin unavailable")
},
routeChecker: stubRouteChecker{requiresAuth: true},
}
router := gin.New()
router.Use(func(c *gin.Context) {
auth.StoreAuthPrincipal(c, &auth.AuthPrincipal{
UserID: 2,
IsSuperAdmin: global.No,
})
c.Next()
})
router.Use(AdminAuthHandlerWithDeps(deps))
router.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
result := decodeResult(t, recorder.Body.Bytes())
if result.Code != e.ServerErr {
t.Fatalf("expected code %d, got %d", e.ServerErr, result.Code)
}
if result.Msg != "权限验证初始化失败" {
t.Fatalf("expected localized msg, got %q", result.Msg)
}
}
func TestAdminAuthHandlerWithDepsReturnsAuthorizationMessageWhenDenied(t *testing.T) {
gin.SetMode(gin.TestMode)
enforcer := buildTestEnforcer(t)
deps := permissionDeps{
loadEnforcer: func() (*casbinx.CasbinEnforcer, error) {
return &casbinx.CasbinEnforcer{Enforcer: enforcer}, nil
},
routeChecker: stubRouteChecker{requiresAuth: true},
}
router := gin.New()
router.Use(func(c *gin.Context) {
auth.StoreAuthPrincipal(c, &auth.AuthPrincipal{
UserID: 2,
IsSuperAdmin: global.No,
})
c.Next()
})
router.Use(AdminAuthHandlerWithDeps(deps))
router.GET("/protected", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
result := decodeResult(t, recorder.Body.Bytes())
if result.Code != e.AuthorizationErr {
t.Fatalf("expected code %d, got %d", e.AuthorizationErr, result.Code)
}
if result.Msg != "暂无接口操作权限" {
t.Fatalf("expected localized msg, got %q", result.Msg)
}
}
func buildTestEnforcer(t *testing.T) *casbin.Enforcer {
t.Helper()
m, err := casbinmodel.NewModelFromString(`
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
`)
if err != nil {
t.Fatalf("build casbin model failed: %v", err)
}
enforcer, err := casbin.NewEnforcer(m)
if err != nil {
t.Fatalf("build casbin enforcer failed: %v", err)
}
return enforcer
}
func decodeResult(t *testing.T, body []byte) *response.Result {
t.Helper()
result := new(response.Result)
if err := json.Unmarshal(body, result); err != nil {
t.Fatalf("decode response failed: %v, body=%s", err, string(body))
}
return result
}
================================================
FILE: internal/middleware/audit_context.go
================================================
package middleware
import (
"encoding/json"
"strings"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
)
// SetAuditChangeDiff 设置本次请求的关键变更前后差异。
func SetAuditChangeDiff(c *gin.Context, before any, after any) {
if c == nil {
return
}
raw, err := json.Marshal(map[string]any{
"before": before,
"after": after,
})
if err != nil {
return
}
c.Set(global.ContextKeyAuditChangeDiff, string(raw))
}
// SetAuditChangeDiffRaw 直接写入变更差异 JSON 字符串。
func SetAuditChangeDiffRaw(c *gin.Context, rawJSON string) {
if c == nil {
return
}
c.Set(global.ContextKeyAuditChangeDiff, rawJSON)
}
// SetAuditHighRisk 设置本次请求是否按高危操作记录。
func SetAuditHighRisk(c *gin.Context, highRisk bool) {
if c == nil {
return
}
c.Set(global.ContextKeyAuditHighRisk, highRisk)
}
// SetAuditRequestBodyRaw 覆盖本次请求日志中的请求体快照。
func SetAuditRequestBodyRaw(c *gin.Context, rawJSON string) {
if c == nil || strings.TrimSpace(rawJSON) == "" {
return
}
c.Set(global.ContextKeyAuditRequestBody, rawJSON)
}
================================================
FILE: internal/middleware/audit_queue.go
================================================
package middleware
import (
"context"
"errors"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/jobs"
perrors "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue"
audit "github.com/wannanbigpig/gin-layout/internal/service/audit"
)
const auditEnqueueTimeout = 2 * time.Second
var (
enqueueAuditTaskFn = jobs.EnqueueAuditLog
persistAuditLogFn = audit.PersistAuditLog
queueUnavailableLogged atomic.Bool
storageUnavailable atomic.Bool
)
type auditQueueDeps struct {
Enqueue func(ctx context.Context, kind string, snapshot *audit.AuditLogSnapshot) error
Persist func(snapshot *audit.AuditLogSnapshot) error
}
func setAuditQueueDepsForTesting(deps auditQueueDeps) func() {
previousEnqueue := enqueueAuditTaskFn
previousPersist := persistAuditLogFn
previousQueueUnavailable := queueUnavailableLogged.Load()
previousStorageUnavailable := storageUnavailable.Load()
enqueueAuditTaskFn = deps.Enqueue
if enqueueAuditTaskFn == nil {
enqueueAuditTaskFn = jobs.EnqueueAuditLog
}
persistAuditLogFn = deps.Persist
if persistAuditLogFn == nil {
persistAuditLogFn = audit.PersistAuditLog
}
queueUnavailableLogged.Store(false)
storageUnavailable.Store(false)
return func() {
enqueueAuditTaskFn = previousEnqueue
persistAuditLogFn = previousPersist
queueUnavailableLogged.Store(previousQueueUnavailable)
storageUnavailable.Store(previousStorageUnavailable)
}
}
func enqueueAuditLog(c *gin.Context, kind string, snapshot *audit.AuditLogSnapshot) {
if snapshot == nil {
return
}
cfg := config.GetConfig()
if cfg == nil || !cfg.Queue.Enable {
reportAuditPersistenceResult(kind, snapshot, "sync_direct")
return
}
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
}
ctx, cancel := context.WithTimeout(ctx, auditEnqueueTimeout)
defer cancel()
if err := enqueueAuditTaskFn(ctx, kind, snapshot); err != nil {
if errors.Is(err, queue.ErrPublisherUnavailable) {
if queueUnavailableLogged.CompareAndSwap(false, true) {
log.Warn("Audit queue publisher unavailable, fallback to sync persist",
zap.String("operation", "enqueue_audit_log"),
zap.String("kind", kind),
zap.String("request_id", snapshot.RequestID))
}
} else {
log.Warn("Enqueue audit log failed, fallback to sync persist",
zap.String("operation", "enqueue_audit_log"),
zap.String("kind", kind),
zap.String("request_id", snapshot.RequestID),
zap.Error(err))
}
reportAuditPersistenceResult(kind, snapshot, "sync_fallback")
return
}
// 队列恢复后复位告警开关,保证下次不可用时仍能打首条告警。
queueUnavailableLogged.Store(false)
}
func reportAuditPersistenceResult(kind string, snapshot *audit.AuditLogSnapshot, mode string) {
if snapshot == nil {
return
}
if err := persistAuditLogFn(snapshot); err != nil {
if perrors.IsDependencyNotReady(err) {
if storageUnavailable.CompareAndSwap(false, true) {
log.Warn("Audit log storage unavailable, skip persistence",
zap.String("kind", kind),
zap.String("mode", mode),
zap.String("request_id", snapshot.RequestID),
zap.Error(err))
}
return
}
log.Error("Persist audit log failed",
zap.String("kind", kind),
zap.String("mode", mode),
zap.String("request_id", snapshot.RequestID),
zap.Error(err))
return
}
storageUnavailable.Store(false)
}
================================================
FILE: internal/middleware/audit_queue_test.go
================================================
package middleware
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue"
auditsvc "github.com/wannanbigpig/gin-layout/internal/service/audit"
)
func TestEnqueueAuditLogDelegatesToPublisher(t *testing.T) {
called := false
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Enqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
called = true
if kind != "request" {
t.Fatalf("unexpected kind: %s", kind)
}
if snapshot == nil || snapshot.RequestID != "req-1" {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
return nil
},
})
defer restoreDeps()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Queue.Enable = true
})
defer restoreConfig()
restoreLogger := log.ReplaceLoggerForTesting(zap.NewNop())
defer restoreLogger()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", bytes.NewBufferString(`{"name":"codex"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(global.ContextKeyRequestID, "req-1")
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
cacheRequestBody(ctx)
respRecorder := createResponseRecorder(ctx)
respRecorder.body.WriteString(`{"code":0,"msg":"ok","data":{}}`)
logRequest(ctx, respRecorder)
if !called {
t.Fatal("expected enqueue to be called")
}
}
func TestEnqueueAuditLogFailureDoesNotPanic(t *testing.T) {
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Enqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
return errors.New("enqueue failed")
},
})
defer restoreDeps()
enqueueAuditLog(nil, "request", &auditsvc.AuditLogSnapshot{RequestID: "req-2"})
}
func TestEnqueueAuditLogResetsUnavailableFlagAfterSuccess(t *testing.T) {
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Enqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
return nil
},
})
defer restoreDeps()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Queue.Enable = true
})
defer restoreConfig()
queueUnavailableLogged.Store(true)
enqueueAuditLog(nil, "request", &auditsvc.AuditLogSnapshot{RequestID: "req-3"})
if queueUnavailableLogged.Load() {
t.Fatal("expected unavailable flag to be reset after successful enqueue")
}
}
func TestEnqueueAuditLogMarksUnavailableWhenPublisherUnavailable(t *testing.T) {
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Enqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
return queue.ErrPublisherUnavailable
},
})
defer restoreDeps()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Queue.Enable = true
})
defer restoreConfig()
queueUnavailableLogged.Store(false)
enqueueAuditLog(nil, "request", &auditsvc.AuditLogSnapshot{RequestID: "req-4"})
if !queueUnavailableLogged.Load() {
t.Fatal("expected unavailable flag to be set when publisher unavailable")
}
}
func TestEnqueueAuditLogPersistsSynchronouslyWhenQueueDisabled(t *testing.T) {
persistCalled := false
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Enqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {
t.Fatal("enqueue should not be called when queue is disabled")
return nil
},
Persist: func(snapshot *auditsvc.AuditLogSnapshot) error {
persistCalled = true
if snapshot == nil || snapshot.RequestID != "req-db-unavailable" {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
return nil
},
})
defer restoreDeps()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Queue.Enable = false
})
defer restoreConfig()
storageUnavailable.Store(false)
enqueueAuditLog(nil, "request", &auditsvc.AuditLogSnapshot{RequestID: "req-db-unavailable"})
if !persistCalled {
t.Fatal("expected synchronous persistence when queue is disabled")
}
}
func TestEnqueueAuditLogHandlesStorageUnavailableWhenQueueDisabled(t *testing.T) {
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Persist: func(snapshot *auditsvc.AuditLogSnapshot) error {
return model.ErrDBUninitialized
},
})
defer restoreDeps()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Queue.Enable = false
})
defer restoreConfig()
storageUnavailable.Store(false)
enqueueAuditLog(nil, "request", &auditsvc.AuditLogSnapshot{RequestID: "req-db-unavailable"})
if !storageUnavailable.Load() {
t.Fatal("expected storage unavailable flag to be set when sync persistence fails")
}
}
func TestReportAuditPersistenceResultMarksStorageUnavailable(t *testing.T) {
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Persist: func(snapshot *auditsvc.AuditLogSnapshot) error {
return model.ErrDBUninitialized
},
})
defer restoreDeps()
storageUnavailable.Store(false)
reportAuditPersistenceResult("request", &auditsvc.AuditLogSnapshot{RequestID: "req-db-unavailable"}, "sync_direct")
if !storageUnavailable.Load() {
t.Fatal("expected storage unavailable flag to be set when db is unavailable")
}
}
func TestReportAuditPersistenceResultResetsStorageUnavailableAfterSuccess(t *testing.T) {
restoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{
Persist: func(snapshot *auditsvc.AuditLogSnapshot) error {
return nil
},
})
defer restoreDeps()
storageUnavailable.Store(true)
reportAuditPersistenceResult("request", &auditsvc.AuditLogSnapshot{RequestID: "req-db-ok"}, "sync_direct")
if storageUnavailable.Load() {
t.Fatal("expected storage unavailable flag to be reset after successful persistence")
}
}
================================================
FILE: internal/middleware/cors.go
================================================
package middleware
import (
"fmt"
"path"
"strings"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/config"
)
var builtInDefaultMethods = [...]string{
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
}
func CorsHandler() gin.HandlerFunc {
return func(c *gin.Context) {
cfg := config.GetConfig()
origin := c.Request.Header.Get("Origin")
if c.Request.Method == "OPTIONS" {
if origin != "" {
if allowOrigin, ok := resolveAllowOrigin(origin, cfg); ok {
c.Header("Access-Control-Allow-Origin", allowOrigin)
c.Header("Access-Control-Allow-Methods", strings.Join(getAllowedMethods(cfg), ", "))
c.Header("Access-Control-Allow-Headers", getAllowedHeaders(c, cfg))
c.Header("Access-Control-Max-Age", fmt.Sprintf("%d", getMaxAge(cfg)))
c.Header("Access-Control-Allow-Credentials", fmt.Sprintf("%t", cfg.CorsCredentials))
} else {
c.AbortWithStatus(403)
return
}
}
c.AbortWithStatus(204)
return
}
if origin != "" {
if allowOrigin, ok := resolveAllowOrigin(origin, cfg); ok {
c.Header("Access-Control-Allow-Origin", allowOrigin)
c.Header("Access-Control-Expose-Headers", getExposeHeaders(cfg))
c.Header("Access-Control-Allow-Credentials", fmt.Sprintf("%t", cfg.CorsCredentials))
} else {
c.AbortWithStatus(403)
return
}
}
c.Next()
}
}
func resolveAllowOrigin(origin string, cfg *config.Conf) (string, bool) {
if !isOriginAllowed(origin, cfg) {
return "", false
}
if hasWildcardOrigin(cfg) && !cfg.CorsCredentials {
return "*", true
}
return origin, true
}
func getAllowedMethods(cfg *config.Conf) []string {
if len(cfg.CorsMethods) > 0 {
if hasWildcardValue(cfg.CorsMethods) {
return defaultMethods()
}
return cfg.CorsMethods
}
return defaultMethods()
}
func defaultMethods() []string {
methods := make([]string, len(builtInDefaultMethods))
copy(methods, builtInDefaultMethods[:])
return methods
}
func getAllowedHeaders(c *gin.Context, cfg *config.Conf) string {
if len(cfg.CorsHeaders) > 0 {
if hasWildcardValue(cfg.CorsHeaders) {
requestHeaders := c.Request.Header.Get("Access-Control-Request-Headers")
if requestHeaders != "" {
return requestHeaders
}
return "*"
}
return strings.Join(cfg.CorsHeaders, ", ")
}
requestHeaders := c.Request.Header.Get("Access-Control-Request-Headers")
if requestHeaders != "" {
return requestHeaders
}
return "*"
}
func getExposeHeaders(cfg *config.Conf) string {
if len(cfg.CorsExposeHeaders) > 0 {
if hasWildcardValue(cfg.CorsExposeHeaders) {
return "*"
}
return strings.Join(cfg.CorsExposeHeaders, ", ")
}
return "*"
}
func getMaxAge(cfg *config.Conf) int {
if cfg.CorsMaxAge > 0 {
return cfg.CorsMaxAge
}
return 43200
}
func isOriginAllowed(origin string, cfg *config.Conf) bool {
if len(cfg.CorsOrigins) == 0 {
return false
}
for _, allowedOrigin := range cfg.CorsOrigins {
if allowedOrigin == "*" {
return true
}
if origin == allowedOrigin {
return true
}
if strings.Contains(allowedOrigin, "*") {
if matched, _ := path.Match(allowedOrigin, origin); matched {
return true
}
}
}
return false
}
func hasWildcardOrigin(cfg *config.Conf) bool {
for _, allowedOrigin := range cfg.CorsOrigins {
if allowedOrigin == "*" {
return true
}
}
return false
}
func hasWildcardValue(values []string) bool {
for _, value := range values {
if value == "*" {
return true
}
}
return false
}
================================================
FILE: internal/middleware/cors_test.go
================================================
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
)
func TestCorsHandlerAllowsWildcardOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreConfig := config.ReplaceConfigForTesting(&config.Conf{
AppConfig: autoload.AppConfig{
CorsOrigins: []string{"*"},
CorsCredentials: false,
},
})
t.Cleanup(restoreConfig)
recorder := httptest.NewRecorder()
ctx, engine := gin.CreateTestContext(recorder)
engine.Use(CorsHandler())
engine.GET("/demo", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/demo", nil)
req.Header.Set("Origin", "http://localhost:3000")
ctx.Request = req
engine.HandleContext(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("expected wildcard allow origin, got %q", got)
}
}
func TestCorsHandlerReflectsOriginWhenCredentialsEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreConfig := config.ReplaceConfigForTesting(&config.Conf{
AppConfig: autoload.AppConfig{
CorsOrigins: []string{"*"},
CorsCredentials: true,
},
})
t.Cleanup(restoreConfig)
recorder := httptest.NewRecorder()
ctx, engine := gin.CreateTestContext(recorder)
engine.Use(CorsHandler())
engine.GET("/demo", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/demo", nil)
req.Header.Set("Origin", "http://localhost:3000")
ctx.Request = req
engine.HandleContext(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("expected reflected allow origin, got %q", got)
}
if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("expected credentials header true, got %q", got)
}
}
func TestCorsHandlerRejectsUnknownOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreConfig := config.ReplaceConfigForTesting(&config.Conf{
AppConfig: autoload.AppConfig{
CorsOrigins: []string{"https://example.com"},
},
})
t.Cleanup(restoreConfig)
recorder := httptest.NewRecorder()
ctx, engine := gin.CreateTestContext(recorder)
engine.Use(CorsHandler())
engine.GET("/demo", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/demo", nil)
req.Header.Set("Origin", "http://localhost:3000")
ctx.Request = req
engine.HandleContext(ctx)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", recorder.Code)
}
}
func TestCorsHandlerAllowsWildcardMethodsAndHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreConfig := config.ReplaceConfigForTesting(&config.Conf{
AppConfig: autoload.AppConfig{
CorsOrigins: []string{"*"},
CorsMethods: []string{"*"},
CorsHeaders: []string{"*"},
},
})
t.Cleanup(restoreConfig)
recorder := httptest.NewRecorder()
ctx, engine := gin.CreateTestContext(recorder)
engine.Use(CorsHandler())
engine.OPTIONS("/demo", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodOptions, "/demo", nil)
req.Header.Set("Origin", "http://localhost:3000")
req.Header.Set("Access-Control-Request-Method", http.MethodPatch)
req.Header.Set("Access-Control-Request-Headers", "Authorization,Content-Type")
ctx.Request = req
engine.HandleContext(ctx)
if recorder.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", recorder.Code)
}
if got := recorder.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" {
t.Fatalf("unexpected allow methods: %q", got)
}
if got := recorder.Header().Get("Access-Control-Allow-Headers"); got != "Authorization,Content-Type" {
t.Fatalf("unexpected allow headers: %q", got)
}
}
func TestGetExposeHeadersSupportsWildcard(t *testing.T) {
cfg := &config.Conf{
AppConfig: autoload.AppConfig{
CorsExposeHeaders: []string{"*"},
},
}
if got := getExposeHeaders(cfg); got != "*" {
t.Fatalf("expected wildcard expose headers, got %q", got)
}
}
================================================
FILE: internal/middleware/database_ready.go
================================================
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/global"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
)
// DatabaseReadyGuard 强制要求 MySQL 已就绪,否则直接返回统一业务错误。
func DatabaseReadyGuard() gin.HandlerFunc {
return databaseReadyGuard(false)
}
// OptionalDatabaseReadyGuard 仅在请求已识别出登录用户时校验 MySQL 就绪状态。
// 用于需要先保留“未登录”语义的受保护路由。
func OptionalDatabaseReadyGuard() gin.HandlerFunc {
return databaseReadyGuard(true)
}
func databaseReadyGuard(skipWhenNoUID bool) gin.HandlerFunc {
return func(c *gin.Context) {
if skipWhenNoUID && c.GetUint(global.ContextKeyUID) == 0 {
c.Next()
return
}
if data.MysqlReady() {
c.Next()
return
}
response.FailCode(c, e.ServiceDependencyNotReady)
}
}
================================================
FILE: internal/middleware/database_ready_test.go
================================================
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/global"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
type dependencyErrorResponse struct {
Code int `json:"code"`
}
func TestDatabaseReadyGuardBlocksWhenMysqlUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreMysql := disableMysqlForGuardTest(t)
defer restoreMysql()
engine := gin.New()
nextCalled := false
engine.Use(DatabaseReadyGuard())
engine.GET("/guarded", func(c *gin.Context) {
nextCalled = true
c.Status(http.StatusNoContent)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/guarded", nil)
engine.ServeHTTP(recorder, request)
if nextCalled {
t.Fatal("expected strict guard to block request when mysql is unavailable")
}
assertDependencyNotReadyCode(t, recorder)
}
func TestOptionalDatabaseReadyGuardKeepsUnauthenticatedRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreMysql := disableMysqlForGuardTest(t)
defer restoreMysql()
engine := gin.New()
nextCalled := false
engine.Use(OptionalDatabaseReadyGuard())
engine.GET("/guarded", func(c *gin.Context) {
nextCalled = true
c.Status(http.StatusNoContent)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/guarded", nil)
engine.ServeHTTP(recorder, request)
if !nextCalled {
t.Fatal("expected optional guard to skip unauthenticated request")
}
if recorder.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code)
}
}
func TestOptionalDatabaseReadyGuardBlocksAuthenticatedRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
restoreMysql := disableMysqlForGuardTest(t)
defer restoreMysql()
engine := gin.New()
nextCalled := false
engine.Use(func(c *gin.Context) {
c.Set(global.ContextKeyUID, uint(1))
c.Next()
})
engine.Use(OptionalDatabaseReadyGuard())
engine.GET("/guarded", func(c *gin.Context) {
nextCalled = true
c.Status(http.StatusNoContent)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/guarded", nil)
engine.ServeHTTP(recorder, request)
if nextCalled {
t.Fatal("expected optional guard to block authenticated request when mysql is unavailable")
}
assertDependencyNotReadyCode(t, recorder)
}
func disableMysqlForGuardTest(t *testing.T) func() {
t.Helper()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Mysql.Enable = false
})
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
return func() {
restoreConfig()
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
}
}
func assertDependencyNotReadyCode(t *testing.T, recorder *httptest.ResponseRecorder) {
t.Helper()
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var result dependencyErrorResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if result.Code != e.ServiceDependencyNotReady {
t.Fatalf("expected code %d, got %d", e.ServiceDependencyNotReady, result.Code)
}
}
================================================
FILE: internal/middleware/logger.go
================================================
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/jobs"
)
const (
defaultStatusCode = http.StatusOK
maxRequestBodySize = 16 * 1024 // 请求体最大记录大小:16KB
maxResponseBodySize = 32 * 1024 // 响应体最大记录大小:32KB
)
// CustomLogger 自定义日志中间件
func CustomLogger() gin.HandlerFunc {
return func(c *gin.Context) {
recorder := prepareRequestLogging(c)
c.Next()
finalizeRequestLogging(c, recorder)
}
}
// prepareRequestLogging 在业务处理前完成日志采集准备(请求体快照 + 响应录制器)。
func prepareRequestLogging(c *gin.Context) *responseRecorder {
// 在请求处理前缓存请求体,避免后续读取时丢失原始内容。
cacheRequestBody(c)
// 替换 Writer 以捕获响应状态码和响应体快照。
recorder := createResponseRecorder(c)
c.Writer = recorder
return recorder
}
// finalizeRequestLogging 在业务处理后统一执行日志收尾(仅审计日志)。
func finalizeRequestLogging(c *gin.Context, recorder *responseRecorder) {
if shouldSkipRequestLogging(c, recorder) {
return
}
publishRequestAuditLog(c, recorder)
}
// logRequest 兼容旧调用入口,统一委托到收尾阶段。
func logRequest(c *gin.Context, recorder *responseRecorder) {
finalizeRequestLogging(c, recorder)
}
// shouldSkipRequestLogging 判定是否跳过本次请求的日志记录。
func shouldSkipRequestLogging(c *gin.Context, recorder *responseRecorder) bool {
if c == nil || c.Request == nil || recorder == nil {
return true
}
// ping 请求不记录日志
if c.Request.URL.Path == "/ping" {
return true
}
// 404 请求不记录日志(避免过多无效请求干扰日志分析)
return recorder.statusCode == http.StatusNotFound
}
// publishRequestAuditLog 构建请求审计快照并投递到审计日志链路。
func publishRequestAuditLog(c *gin.Context, recorder *responseRecorder) {
// 先提取不可变快照,再发布审计日志,避免在日志中间件内耦合过多细节。
resp := parseResponse(c, recorder)
snapshot := buildRequestAuditLogSnapshot(c, recorder, resp)
enqueueAuditLog(c, jobs.AuditLogKindRequest, snapshot)
}
================================================
FILE: internal/middleware/logger_bench_test.go
================================================
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
)
func BenchmarkCustomLoggerJSONPost(b *testing.B) {
gin.SetMode(gin.TestMode)
body := []byte(`{"name":"codex","email":"codex@example.com","password":"secret"}`)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/admin/v1/demo", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "bench-request")
cacheRequestBody(ctx)
respRecorder := createResponseRecorder(ctx)
respRecorder.Header().Set("Content-Type", "application/json")
_, _ = respRecorder.Write([]byte(`{"code":0,"msg":"ok","data":{"id":1}}`))
resp := parseResponse(ctx, respRecorder)
if resp == nil {
resp = &response.Result{Code: 0}
}
snapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, resp)
if snapshot == nil {
b.Fatal("expected snapshot")
}
}
}
================================================
FILE: internal/middleware/logger_recorder.go
================================================
package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
)
type requestBodyCache struct {
body []byte
totalBytes int
truncated bool
}
// responseRecorder 响应记录器,用于记录响应内容。
type responseRecorder struct {
gin.ResponseWriter
captureBody bool
body *bytes.Buffer
statusCode int
responseBytes int
truncated bool
}
// Write 写入响应数据。
func (r *responseRecorder) Write(b []byte) (int, error) {
r.cacheBody(b)
return r.ResponseWriter.Write(b)
}
// WriteString 写入字符串响应。
func (r *responseRecorder) WriteString(s string) (int, error) {
r.cacheBody([]byte(s))
return r.ResponseWriter.WriteString(s)
}
// WriteHeader 写入HTTP状态码。
func (r *responseRecorder) WriteHeader(statusCode int) {
r.statusCode = statusCode
r.ResponseWriter.WriteHeader(statusCode)
}
// cacheRequestBody 缓存请求体到上下文。
func cacheRequestBody(c *gin.Context) {
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead {
return
}
if _, exists := c.Get("requestBody"); exists {
return
}
if c.Request.Body == nil {
return
}
if shouldSkipRequestBodyCache(c) {
return
}
bodyBytes, totalBytes, truncated, err := snapshotRequestBody(c.Request)
if err != nil || len(bodyBytes) == 0 {
return
}
c.Set("requestBody", &requestBodyCache{
body: truncateBytes(bodyBytes, maxRequestBodySize),
totalBytes: totalBytes,
truncated: truncated,
})
}
// createResponseRecorder 创建响应记录器。
func createResponseRecorder(c *gin.Context) *responseRecorder {
return &responseRecorder{
ResponseWriter: c.Writer,
captureBody: shouldCaptureResponseBody(c),
body: bytes.NewBuffer(nil),
statusCode: defaultStatusCode,
}
}
func (r *responseRecorder) cacheBody(b []byte) {
if !r.captureBody {
return
}
r.responseBytes += len(b)
if r.truncated || len(b) == 0 {
return
}
remaining := maxResponseBodySize - r.body.Len()
if remaining <= 0 {
r.truncated = true
return
}
if len(b) <= remaining {
_, _ = r.body.Write(b)
return
}
_, _ = r.body.Write(b[:remaining])
r.truncated = true
}
// parseResponse 解析响应数据。
func parseResponse(c *gin.Context, recorder *responseRecorder) *response.Result {
if recorder == nil || !recorder.captureBody || recorder.body.Len() == 0 {
return nil
}
if !strings.Contains(strings.ToLower(recorder.Header().Get("Content-Type")), "json") {
return nil
}
var resp response.Result
if err := json.Unmarshal(recorder.body.Bytes(), &resp); err != nil {
return nil
}
if c.Request.Method == http.MethodGet {
resp.Data = nil
}
return &resp
}
func shouldCaptureResponseBody(c *gin.Context) bool {
if c == nil || c.Request == nil {
return true
}
if c.Request.URL.Path == "/ping" {
return false
}
return c.Request.Method != http.MethodGet
}
// readRequestBody 读取请求体缓存。
func readRequestBody(c *gin.Context) []byte {
if body, exists := c.Get("requestBody"); exists {
if cached, ok := body.(*requestBodyCache); ok && len(cached.body) > 0 {
return cached.body
}
}
if c.Request.Body == nil {
return nil
}
if shouldSkipRequestBodyCache(c) {
return nil
}
bodyBytes, totalBytes, truncated, err := snapshotRequestBody(c.Request)
if err != nil || len(bodyBytes) == 0 {
return nil
}
cached := &requestBodyCache{
body: truncateBytes(bodyBytes, maxRequestBodySize),
totalBytes: totalBytes,
truncated: truncated,
}
c.Set("requestBody", cached)
return cached.body
}
func getRequestBodyCache(c *gin.Context) *requestBodyCache {
if body, exists := c.Get("requestBody"); exists {
if cached, ok := body.(*requestBodyCache); ok {
return cached
}
}
return nil
}
func truncateBytes(body []byte, maxSize int) []byte {
if len(body) <= maxSize {
return body
}
return body[:maxSize]
}
func shouldSkipRequestBodyCache(c *gin.Context) bool {
contentType := strings.ToLower(c.GetHeader("Content-Type"))
switch {
case strings.HasPrefix(contentType, "multipart/form-data"):
return true
case strings.HasPrefix(contentType, "application/octet-stream"):
return true
default:
return false
}
}
func snapshotRequestBody(req *http.Request) ([]byte, int, bool, error) {
if req == nil || req.Body == nil {
return nil, 0, false, nil
}
if req.ContentLength >= 0 && req.ContentLength <= maxRequestBodySize {
bodyBytes, err := io.ReadAll(req.Body)
if err != nil || len(bodyBytes) == 0 {
return nil, 0, false, err
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return bodyBytes, len(bodyBytes), false, nil
}
peekLimit := int64(maxRequestBodySize + 1)
bodyBytes, err := io.ReadAll(io.LimitReader(req.Body, peekLimit))
if err != nil || len(bodyBytes) == 0 {
return nil, 0, false, err
}
req.Body = io.NopCloser(io.MultiReader(bytes.NewReader(bodyBytes), req.Body))
truncated := len(bodyBytes) > maxRequestBodySize
totalBytes := len(bodyBytes)
if req.ContentLength > int64(totalBytes) {
totalBytes = int(req.ContentLength)
truncated = true
}
return bodyBytes, totalBytes, truncated, nil
}
================================================
FILE: internal/middleware/logger_storage.go
================================================
package middleware
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mssola/useragent"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive"
accesssvc "github.com/wannanbigpig/gin-layout/internal/service/access"
auditsvc "github.com/wannanbigpig/gin-layout/internal/service/audit"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
)
func buildRequestAuditLogSnapshot(c *gin.Context, recorder *responseRecorder, resp *response.Result) *auditsvc.AuditLogSnapshot {
if c == nil {
return nil
}
return buildAuditLogSnapshot(c, recorder, operationStatusFromResponse(recorder, resp), buildMaskedResponseBody(recorder), sensitive.GetMaskedResponseHeaders(recorder.Header()))
}
func buildPanicAuditLogSnapshot(c *gin.Context, panicMessage string) *auditsvc.AuditLogSnapshot {
if c == nil {
return nil
}
responseBody, err := json.Marshal(response.Result{
Code: http.StatusInternalServerError,
Msg: panicMessage,
Data: map[string]any{},
RequestId: c.GetString(global.ContextKeyRequestID),
})
if err != nil {
responseBody = []byte{}
}
return buildAuditLogSnapshot(c, nil, http.StatusInternalServerError, string(responseBody), "")
}
type auditRequestMeta struct {
requestID string
method string
path string
ip string
userAgent string
os string
browser string
operationName string
requestHeaders string
requestQuery string
requestBody string
executionTime float64
}
type auditOperatorMeta struct {
operatorID uint
jwtID string
operatorAccount string
operatorName string
}
// buildAuditLogSnapshot 组装审计快照(仅负责编排,不包含具体字段提取细节)。
func buildAuditLogSnapshot(c *gin.Context, recorder *responseRecorder, operationStatus int, responseBody string, responseHeader string) *auditsvc.AuditLogSnapshot {
requestMeta := collectAuditRequestMeta(c)
if requestMeta == nil {
return nil
}
operatorMeta := collectAuditOperatorMeta(c)
isHighRisk := resolveAuditHighRisk(c, requestMeta.method)
changeDiff := resolveAuditChangeDiff(c, isHighRisk, requestMeta.requestBody, responseBody)
return &auditsvc.AuditLogSnapshot{
RequestID: requestMeta.requestID,
JwtID: operatorMeta.jwtID,
OperatorID: operatorMeta.operatorID,
OperatorAccount: operatorMeta.operatorAccount,
OperatorName: operatorMeta.operatorName,
IP: requestMeta.ip,
UserAgent: requestMeta.userAgent,
OS: requestMeta.os,
Browser: requestMeta.browser,
Method: requestMeta.method,
BaseURL: requestMeta.path,
OperationName: requestMeta.operationName,
OperationStatus: operationStatus,
IsHighRisk: isHighRisk,
RequestHeaders: requestMeta.requestHeaders,
RequestQuery: requestMeta.requestQuery,
RequestBody: requestMeta.requestBody,
ChangeDiff: changeDiff,
ResponseStatus: resolveAuditResponseStatus(recorder),
ResponseBody: responseBody,
ResponseHeader: responseHeader,
ExecutionTime: requestMeta.executionTime,
}
}
// collectAuditRequestMeta 提取请求侧审计信息(请求标识、UA、请求体、耗时等)。
func collectAuditRequestMeta(c *gin.Context) *auditRequestMeta {
requestID := c.GetString(global.ContextKeyRequestID)
if requestID == "" {
return nil
}
method := c.Request.Method
path := c.Request.URL.Path
userAgentStr := c.Request.UserAgent()
ua := useragent.New(userAgentStr)
browser, _ := ua.Browser()
return &auditRequestMeta{
requestID: requestID,
method: method,
path: path,
ip: c.ClientIP(),
userAgent: userAgentStr,
os: ua.OS(),
browser: browser,
operationName: getOperationName(path, method, c.GetHeader("X-Operation-Name")),
requestHeaders: sensitive.GetMaskedRequestHeaders(c.Request.Header),
requestQuery: sensitive.MaskQueryString(c.Request.URL.RawQuery),
requestBody: resolveAuditRequestBody(c),
executionTime: calculateExecutionTimeMs(c),
}
}
// collectAuditOperatorMeta 提取操作者信息(优先 principal,回退到上下文 uid)。
func collectAuditOperatorMeta(c *gin.Context) auditOperatorMeta {
if principal := auth.GetAuthPrincipal(c); principal != nil {
return auditOperatorMeta{
operatorID: principal.UserID,
jwtID: principal.JWTID,
operatorAccount: principal.Username,
operatorName: principal.Nickname,
}
}
return auditOperatorMeta{
operatorID: c.GetUint(global.ContextKeyUID),
}
}
func calculateExecutionTimeMs(c *gin.Context) float64 {
duration := time.Since(c.GetTime(global.ContextKeyRequestStartTime))
executionTime := float64(duration.Nanoseconds()) / 1000000.0
return float64(int(executionTime*10000+0.5)) / 10000.0
}
func resolveAuditResponseStatus(recorder *responseRecorder) int {
if recorder == nil {
return http.StatusOK
}
return recorder.statusCode
}
func buildMaskedRequestBody(c *gin.Context) string {
cached := getRequestBodyCache(c)
if cached == nil {
bodyBytes := readRequestBody(c)
if bodyBytes == nil {
return ""
}
cached = getRequestBodyCache(c)
}
if cached == nil || len(cached.body) == 0 {
return ""
}
contentType := c.Request.Header.Get("Content-Type")
if !cached.truncated {
return sensitive.GetMaskedRequestBody(cached.body, contentType)
}
return sensitive.GetMaskedRequestBody(cached.body, contentType) + "...(truncated,total_size=" + strconv.Itoa(cached.totalBytes) + "B)"
}
func resolveAuditRequestBody(c *gin.Context) string {
if c != nil {
if raw, exists := c.Get(global.ContextKeyAuditRequestBody); exists {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
return value
}
}
}
return buildMaskedRequestBody(c)
}
func buildMaskedResponseBody(recorder *responseRecorder) string {
if recorder == nil {
return ""
}
bodyBytes := recorder.body.Bytes()
if len(bodyBytes) == 0 {
return ""
}
if !recorder.truncated {
return sensitive.GetMaskedResponseBody(bodyBytes)
}
return sensitive.GetMaskedResponseBody(bodyBytes) + "...(truncated,total_size=" + strconv.Itoa(recorder.responseBytes) + "B)"
}
func resolveAuditHighRisk(c *gin.Context, method string) uint8 {
if c != nil {
if raw, exists := c.Get(global.ContextKeyAuditHighRisk); exists {
switch value := raw.(type) {
case bool:
if value {
return 1
}
return 0
case uint8:
if value > 0 {
return 1
}
return 0
case int:
if value > 0 {
return 1
}
return 0
case string:
if strings.EqualFold(value, "1") || strings.EqualFold(value, "true") {
return 1
}
if strings.EqualFold(value, "0") || strings.EqualFold(value, "false") {
return 0
}
}
}
}
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return 0
default:
return 1
}
}
func resolveAuditChangeDiff(c *gin.Context, isHighRisk uint8, requestBody, responseBody string) string {
if c != nil {
if raw, exists := c.Get(global.ContextKeyAuditChangeDiff); exists {
switch value := raw.(type) {
case string:
if strings.TrimSpace(value) != "" {
return value
}
default:
bytes, err := json.Marshal(value)
if err == nil {
return string(bytes)
}
}
}
}
if isHighRisk == 0 || (requestBody == "" && responseBody == "") {
return ""
}
payload := map[string]any{
"request_body": requestBody,
"response_body": responseBody,
}
bytes, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(bytes)
}
func operationStatusFromResponse(recorder *responseRecorder, resp *response.Result) int {
if resp != nil {
return resp.Code
}
if recorder != nil && recorder.statusCode >= http.StatusBadRequest {
return recorder.statusCode
}
return 0
}
// getOperationName 获取操作名称。
func getOperationName(route string, method string, headerOperationName string) string {
if operationName := accesssvc.NewApiRouteCacheService().GetApiName(route, method); operationName != "" {
return operationName
}
if headerOperationName != "" {
return headerOperationName
}
return route
}
================================================
FILE: internal/middleware/logger_test.go
================================================
package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
)
// TestCacheRequestBody 验证请求体缓存逻辑。
func TestCacheRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", bytes.NewBufferString(`{"name":"codex"}`))
cacheRequestBody(ctx)
body := readRequestBody(ctx)
if string(body) != `{"name":"codex"}` {
t.Fatalf("unexpected cached body: %s", string(body))
}
}
// TestCacheRequestBodySkipsGet 验证 GET 请求不会缓存请求体。
func TestCacheRequestBodySkipsGet(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/demo", nil)
cacheRequestBody(ctx)
if body := readRequestBody(ctx); body != nil {
t.Fatalf("expected nil body for get request, got %q", string(body))
}
}
func TestCacheRequestBodySkipsMultipartRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/upload", bytes.NewBufferString("file-content"))
ctx.Request.Header.Set("Content-Type", "multipart/form-data; boundary=demo")
cacheRequestBody(ctx)
if body := readRequestBody(ctx); body != nil {
t.Fatalf("expected multipart body to be skipped, got %q", string(body))
}
}
// TestParseResponse 验证 JSON 响应解析逻辑。
func TestParseResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", nil)
respRecorder := createResponseRecorder(ctx)
respRecorder.Header().Set("Content-Type", "application/json")
respRecorder.body.WriteString(`{"code":0,"msg":"ok","data":{"name":"demo"}}`)
resp := parseResponse(ctx, respRecorder)
if resp == nil {
t.Fatal("expected parsed response")
}
if resp.Code != 0 {
t.Fatalf("expected code 0, got %d", resp.Code)
}
}
// TestParseResponseForNonJSON 验证非 JSON 响应不会解析成功。
func TestParseResponseForNonJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", nil)
respRecorder := createResponseRecorder(ctx)
respRecorder.Header().Set("Content-Type", "text/plain")
respRecorder.body.WriteString("pong")
if resp := parseResponse(ctx, respRecorder); resp != nil {
t.Fatal("expected nil response for non-json body")
}
}
// TestBuildMaskedBodies 验证请求体和响应体截断逻辑。
func TestBuildMaskedBodies(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", bytes.NewBuffer(bytes.Repeat([]byte("a"), maxRequestBodySize+10)))
ctx.Request.Header.Set("Content-Type", "application/json")
cacheRequestBody(ctx)
requestBody := buildMaskedRequestBody(ctx)
if len(requestBody) == 0 {
t.Fatal("expected masked request body")
}
cached := getRequestBodyCache(ctx)
if cached == nil {
t.Fatal("expected cached request body")
}
if !cached.truncated {
t.Fatal("expected request body cache to be marked truncated")
}
if len(cached.body) != maxRequestBodySize {
t.Fatalf("expected cached request body length %d, got %d", maxRequestBodySize, len(cached.body))
}
if !bytes.Contains([]byte(requestBody), []byte("truncated")) {
t.Fatalf("expected truncated marker in request body, got %s", requestBody)
}
respRecorder := createResponseRecorder(ctx)
_, _ = respRecorder.Write(bytes.Repeat([]byte("b"), maxResponseBodySize+10))
responseBody := buildMaskedResponseBody(respRecorder)
if len(responseBody) == 0 {
t.Fatal("expected masked response body")
}
if !respRecorder.truncated {
t.Fatal("expected response recorder to mark body as truncated")
}
if respRecorder.body.Len() != maxResponseBodySize {
t.Fatalf("expected cached response body length %d, got %d", maxResponseBodySize, respRecorder.body.Len())
}
if !bytes.Contains([]byte(responseBody), []byte("truncated")) {
t.Fatalf("expected truncated marker in response body, got %s", responseBody)
}
}
func TestReadRequestBodyPreservesLargeRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
originalBody := bytes.Repeat([]byte("x"), maxRequestBodySize+128)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", bytes.NewReader(originalBody))
ctx.Request.Header.Set("Content-Type", "application/json")
cacheRequestBody(ctx)
cached := readRequestBody(ctx)
if len(cached) != maxRequestBodySize {
t.Fatalf("expected cached body length %d, got %d", maxRequestBodySize, len(cached))
}
remaining, err := io.ReadAll(ctx.Request.Body)
if err != nil {
t.Fatalf("unexpected read error: %v", err)
}
if !bytes.Equal(remaining, originalBody) {
t.Fatal("expected request body to remain readable after snapshot")
}
}
// TestOperationStatusFromResponse 验证操作状态选择逻辑。
func TestOperationStatusFromResponse(t *testing.T) {
recorder := &responseRecorder{statusCode: http.StatusBadRequest}
if got := operationStatusFromResponse(recorder, &response.Result{Code: 10000}); got != 10000 {
t.Fatalf("expected business code, got %d", got)
}
if got := operationStatusFromResponse(recorder, nil); got != http.StatusBadRequest {
t.Fatalf("expected http status, got %d", got)
}
recorder.statusCode = http.StatusOK
if got := operationStatusFromResponse(recorder, nil); got != 0 {
t.Fatalf("expected default status 0, got %d", got)
}
}
// TestLogRequestSkipsPing 验证 ping 请求不会触发后续日志处理。
func TestLogRequestSkipsPing(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/ping", nil)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
respRecorder := createResponseRecorder(ctx)
logRequest(ctx, respRecorder)
}
func TestBuildRequestAuditLogSnapshotUsesPrincipal(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", bytes.NewBufferString(`{"name":"codex"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-1")
cacheRequestBody(ctx)
auth.StoreAuthPrincipal(ctx, &auth.AuthPrincipal{
UserID: 12,
JWTID: "jwt-1",
Username: "tester",
Nickname: "Tester",
})
respRecorder := createResponseRecorder(ctx)
respRecorder.body.WriteString(`{"code":0,"msg":"ok","data":{"name":"demo"}}`)
snapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})
if snapshot == nil {
t.Fatal("expected audit snapshot")
}
if snapshot.OperatorID != 12 || snapshot.JwtID != "jwt-1" {
t.Fatalf("unexpected operator fields: %#v", snapshot)
}
if snapshot.OperatorAccount != "tester" || snapshot.OperatorName != "Tester" {
t.Fatalf("unexpected operator names: %#v", snapshot)
}
}
func TestBuildRequestAuditLogSnapshotUsesAuditRequestBodyOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/admin/v1/system/config/create", bytes.NewBufferString(`{"config_value":"plain-secret"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-override")
cacheRequestBody(ctx)
SetAuditRequestBodyRaw(ctx, `{"config_value":"******"}`)
respRecorder := createResponseRecorder(ctx)
respRecorder.body.WriteString(`{"code":0,"msg":"ok","data":{}}`)
snapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})
if snapshot == nil {
t.Fatal("expected audit snapshot")
}
if snapshot.RequestBody != `{"config_value":"******"}` {
t.Fatalf("unexpected request body override: %s", snapshot.RequestBody)
}
}
func TestBuildRequestAuditLogSnapshotMarksHighRiskAndDiff(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/demo", bytes.NewBufferString(`{"name":"after"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-2")
cacheRequestBody(ctx)
SetAuditHighRisk(ctx, true)
SetAuditChangeDiff(ctx, map[string]any{"name": "before"}, map[string]any{"name": "after"})
respRecorder := createResponseRecorder(ctx)
respRecorder.body.WriteString(`{"code":0,"msg":"ok"}`)
snapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})
if snapshot == nil {
t.Fatal("expected audit snapshot")
}
if snapshot.IsHighRisk != 1 {
t.Fatalf("expected high risk audit snapshot, got %#v", snapshot.IsHighRisk)
}
diff := map[string]any{}
if err := json.Unmarshal([]byte(snapshot.ChangeDiff), &diff); err != nil {
t.Fatalf("expected valid change diff json, got err=%v raw=%s", err, snapshot.ChangeDiff)
}
if _, ok := diff["before"]; !ok {
t.Fatalf("expected before section in change diff, got %#v", diff)
}
if _, ok := diff["after"]; !ok {
t.Fatalf("expected after section in change diff, got %#v", diff)
}
}
func TestBuildRequestAuditLogSnapshotGetRequestIsNotHighRiskByDefault(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/demo", nil)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-3")
respRecorder := createResponseRecorder(ctx)
respRecorder.body.WriteString(`{"code":0,"msg":"ok"}`)
snapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})
if snapshot == nil {
t.Fatal("expected audit snapshot")
}
if snapshot.IsHighRisk != 0 {
t.Fatalf("expected get request not high risk, got %#v", snapshot.IsHighRisk)
}
if snapshot.ChangeDiff != "" {
t.Fatalf("expected empty change diff for get request, got %s", snapshot.ChangeDiff)
}
}
================================================
FILE: internal/middleware/parse_token.go
================================================
package middleware
import (
"github.com/gin-gonic/gin"
req "github.com/wannanbigpig/gin-layout/internal/pkg/request"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
)
// ParseTokenHandler 全局token解析中间件(所有路由都走)
// 功能:
// - 尝试从请求头提取token(不强制要求)
// - 如果token存在且有效,解析并设置用户信息到context
// - 如果token不存在或无效,静默继续执行(用于可选认证的路由)
//
// 注意:此中间件不会阻止请求,即使token无效也会继续执行
func ParseTokenHandler() gin.HandlerFunc {
return func(c *gin.Context) {
// 提前返回:如果没有token,直接继续执行
accessToken, err := req.GetAccessToken(c)
if err != nil || accessToken == "" {
c.Next()
return
}
loginService := auth.NewLoginService()
loginService.SetCtx(c)
principal, ok := loginService.ResolvePrincipal(accessToken)
if !ok || principal == nil {
// token无效,静默继续(可选认证)
c.Next()
return
}
// token有效,设置认证主体到上下文
auth.StoreAuthPrincipal(c, principal)
c.Next()
}
}
================================================
FILE: internal/middleware/recovery.go
================================================
package middleware
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/jobs"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
)
const (
// panicErrorPrefix 服务器内部错误前缀
panicErrorPrefix = "An error occurred in the server's internal code: "
// panicRecoveredMsg panic恢复日志消息
panicRecoveredMsg = "panic recovered"
)
// CustomRecovery 自定义错误 (panic) 拦截中间件
// 对可能发生的 panic 进行拦截、统一记录并返回友好的错误响应
func CustomRecovery() gin.HandlerFunc {
errorWriter := &PanicExceptionRecord{}
return gin.RecoveryWithWriter(errorWriter, handlePanic)
}
// handlePanic 处理 panic 恢复逻辑
func handlePanic(c *gin.Context, err interface{}) {
errStr := "服务器内部错误"
cfg := config.GetConfig()
if cfg != nil && cfg.Debug {
errStr = fmt.Sprintf("%v", err)
}
// 记录错误日志
cost := time.Since(c.GetTime(global.ContextKeyRequestStartTime))
requestID := c.GetString(global.ContextKeyRequestID)
logFields := []zap.Field{
zap.String("requestId", requestID),
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("query", c.Request.URL.RawQuery),
zap.String("ip", c.ClientIP()),
zap.String("user-agent", c.Request.UserAgent()),
zap.String("errors", errStr),
zap.Duration("cost", cost),
}
if gin.Mode() != gin.ReleaseMode {
if requestBody := readRequestBody(c); requestBody != nil {
logFields = append(logFields, zap.ByteString("body", requestBody))
}
}
logger.Error(panicRecoveredMsg, logFields...)
// 为 panic 请求补充异步审计日志,避免绕过 CustomLogger 的落库流程。
snapshot := buildPanicAuditLogSnapshot(c, errStr)
enqueueAuditLog(c, jobs.AuditLogKindPanic, snapshot)
// 返回错误响应
response.Resp().
SetHttpCode(http.StatusInternalServerError).
FailCode(c, e.ServerErr, errStr)
}
// PanicExceptionRecord panic 异常记录器
// 实现 io.Writer 接口,用于记录 panic 的完整堆栈信息
type PanicExceptionRecord struct{}
// Write 写入 panic 异常信息
func (p *PanicExceptionRecord) Write(b []byte) (n int, err error) {
var builder strings.Builder
builder.Grow(len(panicErrorPrefix) + len(b))
builder.WriteString(panicErrorPrefix)
builder.Write(b)
errStr := builder.String()
logger.Error(errStr)
return len(errStr), errors.New(errStr)
}
================================================
FILE: internal/middleware/request_cost.go
================================================
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/wannanbigpig/gin-layout/internal/global"
)
// RequestCostHandler 请求耗时和请求ID中间件
// 功能:
// 1. 记录请求开始时间,用于后续计算请求耗时
// 2. 为每个请求生成唯一的请求ID,用于日志追踪
func RequestCostHandler() gin.HandlerFunc {
return func(c *gin.Context) {
// 设置请求上下文信息:开始时间 + 请求ID。
c.Set(global.ContextKeyRequestStartTime, time.Now())
c.Set(global.ContextKeyRequestID, uuid.New().String())
c.Next()
}
}
================================================
FILE: internal/middleware/request_locale.go
================================================
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
)
const acceptLanguageHeader = "Accept-Language"
// RequestLocaleHandler 解析请求语言并写入上下文。
func RequestLocaleHandler() gin.HandlerFunc {
return func(c *gin.Context) {
locale := i18n.ParseAcceptLanguage(c.GetHeader(acceptLanguageHeader))
c.Set(global.ContextKeyLocale, locale)
c.Next()
}
}
// LocaleFromContext 从请求上下文中读取归一化语言。
func LocaleFromContext(c *gin.Context) string {
if c == nil {
return i18n.DefaultLocale
}
if locale, exists := c.Get(global.ContextKeyLocale); exists {
if localeText, ok := locale.(string); ok {
return i18n.NormalizeLocale(localeText)
}
}
return i18n.DefaultLocale
}
================================================
FILE: internal/middleware/request_locale_test.go
================================================
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
)
func TestRequestLocaleHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(RequestLocaleHandler())
engine.GET("/demo", func(c *gin.Context) {
c.String(http.StatusOK, LocaleFromContext(c))
})
request := httptest.NewRequest(http.MethodGet, "/demo", nil)
request.Header.Set("Accept-Language", "en-US,en;q=0.9")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
if recorder.Body.String() != i18n.LocaleEnUS {
t.Fatalf("expected locale %q, got %q", i18n.LocaleEnUS, recorder.Body.String())
}
}
func TestLocaleFromContextFallback(t *testing.T) {
if got := LocaleFromContext(nil); got != i18n.DefaultLocale {
t.Fatalf("expected default locale %q, got %q", i18n.DefaultLocale, got)
}
}
================================================
FILE: internal/model/admin_login_logs.go
================================================
package model
import (
"time"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model/modelDict"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// 登录操作类型常量
const (
LoginTypeLogin uint8 = 1 // 登录操作
LoginTypeRefresh uint8 = 2 // 刷新token
)
// 登录状态常量
const (
LoginStatusSuccess uint8 = 1 // 登录成功
LoginStatusFail uint8 = 0 // 登录失败
)
// 登录状态字典
var LoginStatusDict modelDict.Dict = map[uint8]string{
LoginStatusFail: "失败",
LoginStatusSuccess: "成功",
}
// 登录操作类型字典
var LoginTypeDict modelDict.Dict = map[uint8]string{
LoginTypeLogin: "登录操作",
LoginTypeRefresh: "刷新token",
}
// 是否被撤销常量(使用 global.Yes/No,这里定义别名以便使用)
const (
IsRevokedNo = global.No // 否
IsRevokedYes = global.Yes // 是
)
// 撤销原因码常量
const (
RevokedCodeUserLogout uint8 = 1 // 用户主动登出(退出登录)
RevokedCodeSystemForce uint8 = 2 // 系统强制登出(账号被封)
RevokedCodeTokenRefresh uint8 = 3 // 系统刷新token
RevokedCodeUserDisable uint8 = 4 // 用户禁用(针对某个设备下线操作)
RevokedCodeOther uint8 = 5 // 其他原因
RevokedCodePasswordChangeSelf uint8 = 6 // 用户自己修改密码
RevokedCodePasswordChangeAdmin uint8 = 7 // 管理员修改密码
)
// RevokedCodeDict 撤销原因码字典
var RevokedCodeDict modelDict.Dict = map[uint8]string{
RevokedCodeUserLogout: "用户主动登出(退出登录)",
RevokedCodeSystemForce: "系统强制登出(账号被封)",
RevokedCodeTokenRefresh: "系统刷新token",
RevokedCodeUserDisable: "用户禁用(针对某个设备下线操作)",
RevokedCodeOther: "其他原因",
RevokedCodePasswordChangeSelf: "用户自己修改密码",
RevokedCodePasswordChangeAdmin: "管理员修改密码",
}
// AdminLoginLogs 登录日志表
type AdminLoginLogs struct {
ContainsDeleteBaseModel
UID uint `json:"uid"` // 用户ID(登录失败时为0)
Username string `json:"username"` // 登录账号
JwtID string `json:"jwt_id"` // JWT唯一标识(jti claim)
AccessToken string `json:"access_token"` // 访问令牌
RefreshToken string `json:"refresh_token"` // 刷新令牌
TokenHash string `json:"token_hash"` // Token的SHA256哈希值
RefreshTokenHash string `json:"refresh_token_hash"` // Refresh Token的哈希值
IP string `json:"ip"` // 登录IP(支持IPv6)
UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息)
OS string `json:"os"` // 操作系统
Browser string `json:"browser"` // 浏览器
ExecutionTime int `json:"execution_time"` // 登录耗时(毫秒)
LoginStatus uint8 `json:"login_status"` // 登录状态:1=成功, 0=失败
LoginFailReason string `json:"login_fail_reason"` // 登录失败原因
Type uint8 `json:"type"` // 操作类型:1=登录操作, 2=刷新token
IsRevoked uint8 `json:"is_revoked"` // 是否被撤销:0=否, 1=是
RevokedCode uint8 `json:"revoked_code"` // 撤销原因码:1=用户主动登出(退出登录), 2=系统强制登出(账号被封), 3=系统刷新token, 4=用户禁用(针对某个设备下线操作) 5=其他原因
RevokedReason string `json:"revoked_reason"` // 撤销原因
RevokedAt *utils.FormatDate `json:"revoked_at"` // 撤销时间
TokenExpires *utils.FormatDate `json:"token_expires"` // Token过期时间
RefreshExpires *utils.FormatDate `json:"refresh_expires"` // Refresh Token过期时间
}
func NewAdminLoginLogs() *AdminLoginLogs {
return BindModel(&AdminLoginLogs{})
}
// TableName 获取表名
func (m *AdminLoginLogs) TableName() string {
return "admin_login_logs"
}
// LoginStatusMap 登录状态映射
func (m *AdminLoginLogs) LoginStatusMap() string {
return LoginStatusDict.Map(m.LoginStatus)
}
// TypeMap 操作类型映射
func (m *AdminLoginLogs) TypeMap() string {
return LoginTypeDict.Map(m.Type)
}
// IsRevokedMap 是否被撤销映射
func (m *AdminLoginLogs) IsRevokedMap() string {
return modelDict.IsMap.Map(m.IsRevoked)
}
// RevokedCodeMap 撤销原因码映射
func (m *AdminLoginLogs) RevokedCodeMap() string {
return RevokedCodeDict.Map(m.RevokedCode)
}
// Create 创建单条登录日志记录。
func (m *AdminLoginLogs) Create() error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(m).Error
}
// FindByJwtId 根据 jwtId 查找登录日志。
func (m *AdminLoginLogs) FindByJwtId(jwtId string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("jwt_id = ? AND deleted_at = 0", jwtId).First(m).Error
}
// UpdateRevokedStatusByJwtIds 批量更新 token 撤销状态。
func (m *AdminLoginLogs) UpdateRevokedStatusByJwtIds(jwtIds []string, revokedCode uint8, revokedReason string, revokedAt utils.FormatDate) error {
if len(jwtIds) == 0 {
return nil
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Model(&AdminLoginLogs{}).Where("jwt_id IN ? AND deleted_at = 0 AND is_revoked = ?", jwtIds, IsRevokedNo).
Updates(map[string]interface{}{
"is_revoked": IsRevokedYes,
"revoked_code": revokedCode,
"revoked_reason": revokedReason,
"revoked_at": revokedAt,
}).Error
}
// FindActiveTokensByUserId 查询用户未过期的活跃 token 列表。
func (m *AdminLoginLogs) FindActiveTokensByUserId(userId uint, now time.Time) ([]AdminLoginLogs, error) {
db, err := m.GetDB()
if err != nil {
return nil, err
}
var loginLogs []AdminLoginLogs
err = db.Where("uid = ? AND deleted_at = 0 AND is_revoked = ? AND login_status = ? AND token_expires IS NOT NULL AND token_expires > ?",
userId, IsRevokedNo, LoginStatusSuccess, now).Find(&loginLogs).Error
return loginLogs, err
}
================================================
FILE: internal/model/admin_user_dept_map.go
================================================
package model
// AdminUserDeptMap 管理员用户部门关系表
type AdminUserDeptMap struct {
BaseModel
Uid uint `json:"uid"` // admin_user用户ID
DeptId uint `json:"dept_id"` // 部门ID
}
func NewAdminUserDeptMap() *AdminUserDeptMap {
return BindModel(&AdminUserDeptMap{})
}
// TableName 获取表名
func (m *AdminUserDeptMap) TableName() string {
return "admin_user_department_map"
}
func (m *AdminUserDeptMap) CreateBatch(mappings []*AdminUserDeptMap) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(&mappings).Error
}
// DeptIdsByUid 根据用户 ID 查询其关联的部门 ID 列表。
func (m *AdminUserDeptMap) DeptIdsByUid(uid uint) ([]uint, error) {
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("uid = ?", uid).Pluck("dept_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// UidsByDeptIds 根据部门 ID 列表查询关联的用户 ID 列表。
func (m *AdminUserDeptMap) UidsByDeptIds(deptIds []uint) ([]uint, error) {
if len(deptIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("dept_id IN ?", deptIds).Pluck("uid", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// UserDeptMapByUids 批量查询多个用户的部门关系,返回 map[uid][]deptId。
func (m *AdminUserDeptMap) UserDeptMapByUids(uids []uint) (map[uint][]uint, []uint, error) {
result := make(map[uint][]uint, len(uids))
if len(uids) == 0 {
return result, nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, nil, err
}
type row struct {
Uid uint
DeptId uint
}
var rows []row
if err := db.Table(m.TableName()).Select("uid,dept_id").Where("uid IN ?", uids).Scan(&rows).Error; err != nil {
return nil, nil, err
}
deptIds := make([]uint, 0, len(rows))
for _, r := range rows {
result[r.Uid] = append(result[r.Uid], r.DeptId)
deptIds = append(deptIds, r.DeptId)
}
return result, deptIds, nil
}
// CountByCondition 根据条件统计数量。
func (m *AdminUserDeptMap) CountByCondition(condition string, args ...any) (int64, error) {
db, err := m.GetDB(m)
if err != nil {
return 0, err
}
var count int64
if err := db.Where(condition, args...).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreateOne 创建单条记录。
func (m *AdminUserDeptMap) CreateOne() error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(m).Error
}
================================================
FILE: internal/model/admin_user_role_map.go
================================================
package model
// AdminUserRoleMap 管理员用户角色关系表
type AdminUserRoleMap struct {
BaseModel
Uid uint `json:"uid"` // admin_user用户ID
RoleId uint `json:"role_id"` // RoleID
}
func NewAdminUserRoleMap() *AdminUserRoleMap {
return BindModel(&AdminUserRoleMap{})
}
// TableName 获取表名
func (m *AdminUserRoleMap) TableName() string {
return "admin_user_role_map"
}
func (m *AdminUserRoleMap) CreateBatch(mappings []*AdminUserRoleMap) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(&mappings).Error
}
// RoleIdsByUid 根据用户 ID 查询其关联的角色 ID 列表。
func (m *AdminUserRoleMap) RoleIdsByUid(uid uint) ([]uint, error) {
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("uid = ?", uid).Pluck("role_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// UidsByRoleIds 根据角色 ID 列表查询关联的用户 ID 列表。
func (m *AdminUserRoleMap) UidsByRoleIds(roleIds []uint) ([]uint, error) {
if len(roleIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("role_id IN ?", roleIds).Pluck("uid", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// UserRoleMapByUids 批量查询多个用户的角色关系,返回 map[uid][]roleId。
func (m *AdminUserRoleMap) UserRoleMapByUids(uids []uint) (map[uint][]uint, error) {
result := make(map[uint][]uint, len(uids))
if len(uids) == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
type row struct {
Uid uint
RoleId uint
}
var rows []row
if err := db.Table(m.TableName()).Select("uid,role_id").Where("uid IN ?", uids).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.Uid] = append(result[r.Uid], r.RoleId)
}
return result, nil
}
// CountByCondition 根据条件统计数量。
func (m *AdminUserRoleMap) CountByCondition(condition string, args ...any) (int64, error) {
db, err := m.GetDB(m)
if err != nil {
return 0, err
}
var count int64
if err := db.Where(condition, args...).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreateOne 创建单条记录。
func (m *AdminUserRoleMap) CreateOne() error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(m).Error
}
================================================
FILE: internal/model/admin_users.go
================================================
package model
import (
"fmt"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/model/modelDict"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// 管理员状态常量
const (
AdminUserStatusEnabled uint8 = 1 // 启用
AdminUserStatusDisabled uint8 = 0 // 禁用(数据库定义:1启用 0禁用)
)
// 管理员状态字典
var AdminUserStatusDict modelDict.Dict = map[uint8]string{
AdminUserStatusEnabled: "启用",
AdminUserStatusDisabled: "禁用",
}
var adminUserUniqueFieldAllowList = map[string]struct{}{
"username": {},
"full_phone_number": {},
"email": {},
}
// AdminUser 总管理员表
type AdminUser struct {
ContainsDeleteBaseModel
IsSuperAdmin uint8 `json:"is_super_admin"` // 是否是总管理员
Nickname string `json:"nickname"` // 用户昵称
Username string `json:"username"` // 用户名
Password string `json:"password"` // 密码
PhoneNumber string `json:"phone_number"` // 手机号
FullPhoneNumber string `json:"full_phone_number"` // 完整手机号
CountryCode string `json:"country_code"` // 国际区号
Email string `json:"email"` // 邮箱
Avatar string `json:"avatar"` // 头像
Status uint8 `json:"status"` // 状态 1启用 0禁用
LastLogin utils.FormatDate `json:"last_login"` // 最后登录时间
LastIp string `json:"last_ip"` // 最后登录IP
Department []Department `json:"department" gorm:"many2many:admin_user_department_map;foreignKey:ID;joinForeignKey:Uid;References:ID;joinReferences:DeptId"`
RoleList []AdminUserRoleMap `json:"role_list" gorm:"foreignKey:uid;references:id"`
}
func NewAdminUsers() *AdminUser {
return BindModel(&AdminUser{})
}
// TableName 获取表名
func (m *AdminUser) TableName() string {
return "admin_user"
}
// GetUserInfo 根据名称获取用户信息
func (m *AdminUser) GetUserInfo(username string) error {
db, err := m.GetDB()
if err != nil {
return err
}
if err := db.Where("username", username).First(m).Error; err != nil {
return err
}
return nil
}
// IsSuperAdminMap 是否为超级管理员映射
func (m *AdminUser) IsSuperAdminMap() string {
return modelDict.IsMap.Map(m.IsSuperAdmin)
}
// StatusMap 状态映射
func (m *AdminUser) StatusMap() string {
return AdminUserStatusDict.Map(m.Status)
}
// SyncUserRow 权限同步时需要的用户简要信息。
type SyncUserRow struct {
ID uint
Status uint8
IsSuperAdmin uint8
}
// SyncUserRows 根据用户 ID 列表查询未删除用户的同步信息(id, status, is_super_admin)。
func (m *AdminUser) SyncUserRows(userIDs []uint) ([]SyncUserRow, error) {
if len(userIDs) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []SyncUserRow
if err := db.Table(m.TableName()).
Select("id,status,is_super_admin").
Where("id IN ? AND deleted_at = 0", userIDs).
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// AllIds 查询所有未删除用户的 ID 列表。
func (m *AdminUser) AllIds() ([]uint, error) {
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("deleted_at = 0").Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// ExistsWithLock 带行锁检查指定条件的记录是否存在。
func (m *AdminUser) ExistsWithLock(condition string, args ...any) (bool, error) {
db, err := m.GetDB()
if err != nil {
return false, err
}
var exists bool
if err := db.Model(m).
Select("1").
Where(condition, args...).
Clauses(clause.Locking{Strength: "UPDATE"}).
Limit(1).
Scan(&exists).Error; err != nil {
return false, err
}
return exists, nil
}
// ExistsWithLockExcludeId 带行锁检查指定条件的记录是否存在(排除指定 ID)。
func (m *AdminUser) ExistsWithLockExcludeId(field string, value string, excludeId uint) (bool, error) {
if _, ok := adminUserUniqueFieldAllowList[field]; !ok {
return false, fmt.Errorf("field is not allowed for unique check: %s", field)
}
db, err := m.GetDB()
if err != nil {
return false, err
}
var exists bool
if err := db.Model(m).
Select("1").
Where(clause.Eq{Column: clause.Column{Name: field}, Value: value}).
Where("id != ? AND deleted_at = 0", excludeId).
Clauses(clause.Locking{Strength: "UPDATE"}).
Limit(1).
Scan(&exists).Error; err != nil {
return false, err
}
return exists, nil
}
// GetByIdWithPreload 根据 ID 获取用户并预加载指定关联。
func (m *AdminUser) GetByIdWithPreload(id uint, relations ...string) error {
db, err := m.GetDB()
if err != nil {
return err
}
for _, relation := range relations {
db = db.Preload(relation)
}
return db.First(m, id).Error
}
================================================
FILE: internal/model/admin_users_test.go
================================================
package model
import (
"errors"
"testing"
)
func TestExistsWithLockExcludeIdRejectsUnknownField(t *testing.T) {
adminUser := NewAdminUsers()
_, err := adminUser.ExistsWithLockExcludeId("status", "1", 1)
if err == nil {
t.Fatal("expected unknown field to return error")
}
}
func TestExistsWithLockExcludeIdAllowedFieldReturnsDBErrorWhenUninitialized(t *testing.T) {
adminUser := NewAdminUsers()
_, err := adminUser.ExistsWithLockExcludeId("username", "tester", 1)
if !errors.Is(err, ErrDBUninitialized) {
t.Fatalf("expected ErrDBUninitialized, got %v", err)
}
}
================================================
FILE: internal/model/api.go
================================================
package model
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model/modelDict"
)
// Api 权限路由表
type Api struct {
ContainsDeleteBaseModel
Code string `json:"code"` // 权限唯一code
GroupCode string `json:"group_code"` // 分组code
Name string `json:"name"` // 权限名称
Description string `json:"description"` // 描述
Method string `json:"method"` // 接口请求方法
Route string `json:"route"` // 接口路由
Func string `json:"func"` // 接口方法
FuncPath string `json:"func_path"` // 接口方法路径
IsAuth uint8 `json:"is_auth"` // 接口鉴权模式 0:无需登录 1:需要登录 2:需要登录且需要API权限
IsEffective uint8 `json:"is_effective"` // 是否有效 0:否 1:是
Sort int `json:"sort"` // 排序,数字越大优先级越高
}
func NewApi() *Api {
return BindModel(&Api{})
}
// TableName 获取表名
func (m *Api) TableName() string {
return "api"
}
// InitRegisters 注册接口,写入到DB
func (m *Api) InitRegisters(data []map[string]any, date string) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB(self)
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "code"}},
DoUpdates: clause.AssignmentColumns([]string{"func", "group_code", "func_path", "is_effective", "updated_at"}),
}).Create(data).Error
if err != nil {
return err
}
return tx.Model(self).Where("updated_at != ?", date).Update("is_effective", 0).Error
})
}
// IsAuthMap 接口鉴权模式映射
func (m *Api) IsAuthMap() string {
return global.ApiAuthMode(m.IsAuth).Label()
}
// IsEffectiveMap 是否有效映射
func (m *Api) IsEffectiveMap() string {
return modelDict.IsMap.Map(m.IsEffective)
}
// FindIdsByRouteAndMethod 根据路由和方法列表查询未删除接口的 ID 列表。
func (m *Api) FindIdsByRouteAndMethod(routes []string, methods []string) ([]Api, error) {
if len(routes) == 0 || len(methods) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var apis []Api
if err := db.Select("id", "route", "method").Where("route IN ? AND method IN ? AND deleted_at = 0", routes, methods).Find(&apis).Error; err != nil {
return nil, err
}
return apis, nil
}
// FindByIds 根据 ID 列表查询未删除的接口。
func (m *Api) FindByIds(ids []uint) ([]Api, error) {
if len(ids) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var apis []Api
if err := db.Where("id IN ?", ids).Find(&apis).Error; err != nil {
return nil, err
}
return apis, nil
}
================================================
FILE: internal/model/base.go
================================================
package model
import (
"errors"
"fmt"
"reflect"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// BaseModel 提供模型通用字段与基础 CRUD 能力。
type BaseModel struct {
dbInstance *gorm.DB
owner any
ID uint `gorm:"column:id;type:int(11) unsigned AUTO_INCREMENT;not null;primarykey" json:"id"`
CreatedAt utils.FormatDate `gorm:"column:created_at;type:datetime;<-:create" json:"created_at"`
UpdatedAt utils.FormatDate `gorm:"column:updated_at;type:datetime" json:"updated_at"`
}
// ErrDBUninitialized 表示数据库连接尚未初始化。
var ErrDBUninitialized = errors.New("database connection is not initialized")
// ErrModelPtrNotImplemented 表示模型尚未完成 owner 绑定。
var ErrModelPtrNotImplemented = errors.New("model owner binding is not initialized")
// ErrInvalidModelArg 表示传入 GetDB 的模型参数无效(如 typed nil 指针)。
var ErrInvalidModelArg = errors.New("invalid model argument")
// ContainsDeleteBaseModel 在 BaseModel 基础上增加软删除字段。
type ContainsDeleteBaseModel struct {
BaseModel
DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at;type:int(11) unsigned;not null;default:0;index;" json:"-"`
}
// GetDB 返回全局数据库实例,传入 model 时会附带 Model 上下文。
func GetDB(model ...any) (*gorm.DB, error) {
if len(model) > 0 {
if err := validateModelArg(model[0]); err != nil {
return nil, err
}
}
db := data.MysqlDB()
if db == nil {
if initErr := data.MysqlInitError(); initErr != nil {
return nil, fmt.Errorf("%w: %v", ErrDBUninitialized, initErr)
}
return nil, ErrDBUninitialized
}
if len(model) > 0 && model[0] != nil {
return db.Model(model[0]), nil
}
return db, nil
}
func validateModelArg(model any) error {
if model == nil {
return nil
}
value := reflect.ValueOf(model)
switch value.Kind() {
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Interface, reflect.Func, reflect.Chan:
if value.IsNil() {
return ErrInvalidModelArg
}
}
return nil
}
================================================
FILE: internal/model/base_crud.go
================================================
package model
import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/global"
)
// Paginate 返回 GORM 分页作用域,页码小于 1 时会自动修正为第 1 页。
func (m *BaseModel) Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page < 1 {
page = 1
}
limit := global.PerPage
if pageSize > 0 {
limit = pageSize
}
offset := (page - 1) * limit
return db.Offset(offset).Limit(limit)
}
}
// Count 按条件统计当前模型记录总数。
func (m *BaseModel) Count(condition string, args ...any) (count int64, err error) {
self, err := m.self()
if err != nil {
return 0, err
}
query, err := m.GetDB(self)
if err != nil {
return 0, err
}
if condition != "" {
query = query.Where(condition, args...)
}
err = query.Count(&count).Error
if err != nil {
return 0, err
}
return
}
// GetById 根据 ID 获取当前模型信息。
func (m *BaseModel) GetById(id uint) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.First(self, id).Error
}
// GetAllById 根据 ID 获取当前模型及全部关联表信息。
func (m *BaseModel) GetAllById(id uint) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Preload(clause.Associations).First(self, id).Error
}
// GetDetail 按条件查询当前模型的单条详情记录。
func (m *BaseModel) GetDetail(condition string, val ...any) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where(condition, val...).First(self).Error
}
// ExistsById 判断指定 ID 的记录是否存在。
func (m *BaseModel) ExistsById(id uint) (bool, error) {
if id == 0 {
return false, nil
}
return m.Exists("id = ?", id)
}
// Exists 判断是否存在满足条件的记录。
func (m *BaseModel) Exists(condition string, args ...any) (bool, error) {
self, err := m.self()
if err != nil {
return false, err
}
db, err := m.GetDB()
if err != nil {
return false, err
}
var count int64
err = db.Model(self).Where(condition, args...).Limit(1).Count(&count).Error
return count > 0, err
}
// UpdateById 根据 ID 更新当前模型记录。
func (m *BaseModel) UpdateById(id uint, data map[string]any) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Model(self).Where("id = ?", id).Updates(data).Error
}
// DeleteByID 根据 ID 删除当前模型记录。
func (m *BaseModel) DeleteByID(id uint) (int64, error) {
self, err := m.self()
if err != nil {
return 0, err
}
db, err := m.GetDB()
if err != nil {
return 0, err
}
result := db.Delete(self, id)
return result.RowsAffected, result.Error
}
// DeleteWhere 按条件删除当前模型记录,空条件会被拒绝以防误删全表。
func (m *BaseModel) DeleteWhere(condition string, args ...any) error {
if condition == "" {
return fmt.Errorf("delete condition is empty, operation refused to prevent full table deletion")
}
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where(condition, args...).Delete(self).Error
}
// Create 使用字段映射创建一条当前模型记录。
func (m *BaseModel) Create(data map[string]any) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB(self)
if err != nil {
return err
}
return db.Create(data).Error
}
// CreateBatch 使用字段映射批量创建当前模型记录。
func (m *BaseModel) CreateBatch(data []map[string]any) error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB(self)
if err != nil {
return err
}
return db.Create(data).Error
}
// Save 保存当前模型实例,存在主键时会执行更新。
func (m *BaseModel) Save() error {
self, err := m.self()
if err != nil {
return err
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Save(self).Error
}
================================================
FILE: internal/model/base_list.go
================================================
package model
import (
"fmt"
"regexp"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// BaseModelInterface 定义一个接口约束,包含所需的方法。
type BaseModelInterface[T any] interface {
Count(condition string, args ...any) (int64, error)
Paginate(page, perPage int) func(*gorm.DB) *gorm.DB
TableName() string
*T
}
// ListOptionalParams 定义列表查询的可选参数。
type ListOptionalParams struct {
SelectFields []string
Preload map[string]func(db *gorm.DB) *gorm.DB
AllPreLoad bool
OrderBy string
OrderAllowMap map[string]struct{}
Joins []string
Distinct string
CountDistinct string
}
var orderByPattern = regexp.MustCompile(`(?i)^([a-z_][a-z0-9_]*)(?:\.([a-z_][a-z0-9_]*))?(?:\s+(asc|desc))?$`)
var selectFieldPattern = regexp.MustCompile(`(?i)^([a-z_][a-z0-9_]*)(?:\.([a-z_][a-z0-9_]*))?$`)
func normalizeOrderBy(orderBy string, allowed map[string]struct{}) (string, error) {
orderBy = strings.TrimSpace(orderBy)
if orderBy == "" {
return "", nil
}
parts := strings.Split(orderBy, ",")
res := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
matches := orderByPattern.FindStringSubmatch(part)
if len(matches) == 0 {
return "", fmt.Errorf("invalid order by clause: %s", part)
}
field := matches[1]
column := field
if matches[2] != "" {
column = matches[2]
}
if len(allowed) > 0 {
if _, ok := allowed[column]; !ok {
return "", fmt.Errorf("order field not allowed: %s", column)
}
}
direction := "ASC"
if strings.EqualFold(matches[3], "desc") {
direction = "DESC"
}
res = append(res, fmt.Sprintf("%s %s", field, direction))
}
if len(res) == 0 {
return "", nil
}
return strings.Join(res, ", "), nil
}
func normalizeSelectFields(fields string) (string, error) {
fields = strings.TrimSpace(fields)
if fields == "" {
return "", nil
}
parts := strings.Split(fields, ",")
res := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
matches := selectFieldPattern.FindStringSubmatch(part)
if len(matches) == 0 {
return "", fmt.Errorf("invalid select field: %s", part)
}
if matches[2] != "" {
res = append(res, fmt.Sprintf("%s.%s", matches[1], matches[2]))
continue
}
res = append(res, matches[1])
}
if len(res) == 0 {
return "", nil
}
return strings.Join(res, ", "), nil
}
func buildListQuery[T any, M BaseModelInterface[T]](model M, condition string, args []any, listParams ListOptionalParams) (*gorm.DB, error) {
query, err := GetDB(model)
if err != nil {
return nil, err
}
if len(listParams.Joins) > 0 {
for _, join := range listParams.Joins {
query = query.Joins(join)
}
}
if condition != "" {
query = query.Where(condition, args...)
}
if listParams.Distinct != "" {
distinctFields, err := normalizeSelectFields(listParams.Distinct)
if err != nil {
return nil, err
}
if distinctFields != "" {
query = query.Distinct(distinctFields)
}
}
if len(listParams.SelectFields) > 0 {
query = query.Select(listParams.SelectFields)
}
if listParams.OrderBy != "" {
safeOrderBy, err := normalizeOrderBy(listParams.OrderBy, listParams.OrderAllowMap)
if err != nil {
return nil, err
}
if safeOrderBy != "" {
query = query.Order(safeOrderBy)
} else {
query = query.Order("id desc")
}
} else {
query = query.Order("id desc")
}
if listParams.AllPreLoad {
query = query.Preload(clause.Associations)
} else if len(listParams.Preload) > 0 {
for key, value := range listParams.Preload {
if value == nil {
query = query.Preload(key)
} else {
query = query.Preload(key, value)
}
}
}
return query, nil
}
func countListTotal[T any, M BaseModelInterface[T]](model M, baseQuery *gorm.DB, condition string, args []any, listParams ListOptionalParams) (int64, error) {
if listParams.CountDistinct != "" {
countDistinctFields, err := normalizeSelectFields(listParams.CountDistinct)
if err != nil {
return 0, err
}
if countDistinctFields == "" {
return 0, fmt.Errorf("count distinct fields are required")
}
countQuery := baseQuery
if condition != "" {
countQuery = countQuery.Where(condition, args...)
}
var total int64
err = countQuery.Model(model).
Select(fmt.Sprintf("COUNT(DISTINCT %s)", countDistinctFields)).
Scan(&total).Error
return total, err
}
if len(listParams.Joins) > 0 {
countQuery := baseQuery
if condition != "" {
countQuery = countQuery.Where(condition, args...)
}
var total int64
err := countQuery.Model(model).Count(&total).Error
return total, err
}
if condition != "" {
return model.Count(condition, args...)
}
return model.Count("")
}
// ListPageE 按条件分页查询列表,并返回总数与结果集。
func ListPageE[T any, M BaseModelInterface[T]](model M, page, perPage int, condition string, args []any, optional ...ListOptionalParams) (int64, []*T, error) {
if condition != "" {
condition = utils.TrimPrefixAndSuffixAND(condition)
}
var listParams ListOptionalParams
if len(optional) > 0 {
listParams = optional[0]
}
baseQuery, err := GetDB(model)
if err != nil {
return 0, nil, err
}
if len(listParams.Joins) > 0 {
for _, join := range listParams.Joins {
baseQuery = baseQuery.Joins(join)
}
}
total, err := countListTotal(model, baseQuery, condition, args, listParams)
if err != nil || total == 0 {
return total, nil, err
}
query, err := buildListQuery(model, condition, args, listParams)
if err != nil {
return total, nil, err
}
query = query.Scopes(model.Paginate(page, perPage))
res := make([]*T, 0, perPage)
err = query.Find(&res).Error
if err != nil {
return total, nil, err
}
return total, res, nil
}
// ListE 按条件查询列表,支持预加载、排序、字段选择等可选参数。
func ListE[T any, M BaseModelInterface[T]](model M, condition string, args []any, optional ...ListOptionalParams) ([]*T, error) {
if condition != "" {
condition = utils.TrimPrefixAndSuffixAND(condition)
}
var listParams ListOptionalParams
if len(optional) > 0 {
listParams = optional[0]
}
query, err := buildListQuery(model, condition, args, listParams)
if err != nil {
return nil, err
}
var res []*T
err = query.Find(&res).Error
if err != nil {
return nil, err
}
return res, nil
}
// VerifyExistingIDs 返回输入 ID 中数据库实际存在的 ID 列表。
func VerifyExistingIDs[T any, M BaseModelInterface[T]](model M, ids []uint) ([]uint, error) {
if len(ids) == 0 {
return ids, nil
}
var existIds []uint
db, err := GetDB(model)
if err != nil {
return nil, err
}
if err := db.Where("id IN (?)", ids).Pluck("id", &existIds).Error; err != nil {
return nil, err
}
return existIds, nil
}
// ExtractColumnsByCondition 按条件提取指定列的值列表。
func ExtractColumnsByCondition[T any, M BaseModelInterface[T], R any](model M, column string, condition string, args ...any) ([]R, error) {
var columns []R
if condition == "" {
return nil, fmt.Errorf("condition is required")
}
if column == "" {
return nil, fmt.Errorf("column name is required")
}
db, err := GetDB(model)
if err != nil {
return nil, err
}
if err := db.Where(condition, args...).Pluck(column, &columns).Error; err != nil {
return nil, err
}
return columns, nil
}
================================================
FILE: internal/model/base_list_test.go
================================================
package model
import (
"strings"
"testing"
)
func TestNormalizeOrderByAcceptsValidFields(t *testing.T) {
orderBy, err := normalizeOrderBy("sort desc, id asc", nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if orderBy != "sort DESC, id ASC" {
t.Fatalf("unexpected normalized order by: %s", orderBy)
}
}
func TestNormalizeOrderByRejectsInjection(t *testing.T) {
_, err := normalizeOrderBy("id desc; drop table admin_user", nil)
if err == nil {
t.Fatal("expected invalid order by to return error")
}
}
func TestNormalizeOrderByChecksAllowList(t *testing.T) {
allowed := map[string]struct{}{
"id": {},
}
_, err := normalizeOrderBy("created_at desc", allowed)
if err == nil {
t.Fatal("expected order field allow-list error")
}
if !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected not allowed error, got %v", err)
}
}
func TestNormalizeSelectFieldsAcceptsValidFields(t *testing.T) {
fields, err := normalizeSelectFields("id, created_at, admin_user.id")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if fields != "id, created_at, admin_user.id" {
t.Fatalf("unexpected normalized fields: %s", fields)
}
}
func TestNormalizeSelectFieldsRejectsInjection(t *testing.T) {
_, err := normalizeSelectFields("id;drop table admin_user")
if err == nil {
t.Fatal("expected invalid select fields to return error")
}
}
================================================
FILE: internal/model/base_owner.go
================================================
package model
import "gorm.io/gorm"
type ownerBinder interface {
bindOwner(any)
}
// SetDB 为当前模型绑定事务或指定数据库连接。
func (m *BaseModel) SetDB(tx *gorm.DB) *BaseModel {
m.dbInstance = tx
return m
}
func (m *BaseModel) bindOwner(owner any) {
m.owner = owner
}
// BindModel 为嵌入 BaseModel 的模型绑定自身实例,供通用方法回写使用。
func BindModel[T any](m T) T {
if binder, ok := any(m).(ownerBinder); ok {
binder.bindOwner(m)
}
return m
}
func (m *BaseModel) self() (any, error) {
if m.owner == nil {
return nil, ErrModelPtrNotImplemented
}
return m.owner, nil
}
// GetDB 返回当前模型可用的数据库实例,传入 model 时会附带 Model 上下文。
func (m *BaseModel) GetDB(model ...any) (*gorm.DB, error) {
if m.dbInstance != nil {
if len(model) > 0 {
if err := validateModelArg(model[0]); err != nil {
return nil, err
}
return m.dbInstance.Model(model[0]), nil
}
return m.dbInstance, nil
}
return GetDB(model...)
}
================================================
FILE: internal/model/base_test.go
================================================
package model
import (
"errors"
"testing"
)
func TestGetDBReturnsErrorWhenUninitialized(t *testing.T) {
_, err := GetDB()
if err == nil {
t.Fatal("expected GetDB to return error when database is uninitialized")
}
}
func TestGetDBRejectsTypedNilModelArg(t *testing.T) {
var role *Role
_, err := GetDB(role)
if !errors.Is(err, ErrInvalidModelArg) {
t.Fatalf("expected ErrInvalidModelArg, got %v", err)
}
}
func TestListEReturnsErrorWhenUninitialized(t *testing.T) {
_, err := ListE(NewApi(), "", nil)
if err == nil {
t.Fatal("expected ListE to return error when database is uninitialized")
}
}
func TestListPageEReturnsErrorWhenUninitialized(t *testing.T) {
_, _, err := ListPageE(NewApi(), 1, 10, "", nil)
if err == nil {
t.Fatal("expected ListPageE to return error when database is uninitialized")
}
}
func TestBaseModelSelfReturnsErrorWithoutBinding(t *testing.T) {
var role Role
_, err := role.self()
if !errors.Is(err, ErrModelPtrNotImplemented) {
t.Fatalf("expected ErrModelPtrNotImplemented, got %v", err)
}
}
func TestNewModelBindsOwner(t *testing.T) {
role := NewRole()
self, err := role.self()
if err != nil {
t.Fatalf("expected bound owner, got error %v", err)
}
if self != role {
t.Fatalf("expected owner to point to role itself")
}
}
func TestInstanceMethodReturnsBindingErrorBeforeDBError(t *testing.T) {
var role Role
err := role.GetById(1)
if !errors.Is(err, ErrModelPtrNotImplemented) {
t.Fatalf("expected ErrModelPtrNotImplemented, got %v", err)
}
}
func TestCountReturnsBindingErrorBeforeDBError(t *testing.T) {
var role Role
_, err := role.Count("pid = ?", 1)
if !errors.Is(err, ErrModelPtrNotImplemented) {
t.Fatalf("expected ErrModelPtrNotImplemented, got %v", err)
}
}
func TestBoundCountReturnsDBErrorWhenUninitialized(t *testing.T) {
role := NewRole()
_, err := role.Count("pid = ?", 1)
if !errors.Is(err, ErrDBUninitialized) {
t.Fatalf("expected ErrDBUninitialized, got %v", err)
}
}
================================================
FILE: internal/model/base_tree.go
================================================
package model
import "gorm.io/gorm"
// HasChildren 判断指定父节点是否存在子节点。
func HasChildren[T any, M BaseModelInterface[T]](model M, pid uint) (bool, error) {
count, err := model.Count("pid = ?", pid)
if err != nil {
return false, err
}
return count > 0, nil
}
// UpdateChildrenNum 统计并更新父节点的 children_num 字段。
func UpdateChildrenNum[T any, M BaseModelInterface[T]](model M, pid uint, tx *gorm.DB) error {
if pid == 0 {
return nil
}
getDB := func() (*gorm.DB, error) {
if tx != nil {
return tx.Model(model), nil
}
return GetDB(model)
}
var count int64
queryDB, err := getDB()
if err != nil {
return err
}
if err := queryDB.Where("pid = ?", pid).Count(&count).Error; err != nil {
return err
}
updateDB, err := getDB()
if err != nil {
return err
}
return updateDB.Where("id = ?", pid).Update("children_num", count).Error
}
================================================
FILE: internal/model/dept.go
================================================
package model
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/global"
)
// Department 部门表
type Department struct {
ContainsDeleteBaseModel
Code string `json:"code" gorm:"column:code;type:varchar(60);not null;default:'';comment:部门业务编码"`
IsSystem uint8 `json:"is_system" gorm:"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统保留对象"`
Pid uint `json:"pid" gorm:"column:pid;type:int unsigned;not null;default:0;comment:上级id"`
Pids string `json:"pids" gorm:"column:pids;type:varchar(255);not null;default:'';comment:所有上级id"`
Name string `json:"name" gorm:"column:name;type:varchar(60);not null;default:'';comment:部门名称"`
Description string `json:"description" gorm:"column:description;type:varchar(255);not null;default:'';comment:描述"`
Level uint8 `json:"level" gorm:"column:level;type:tinyint unsigned;not null;default:1;comment:层级"`
Sort uint `json:"sort" gorm:"column:sort;type:mediumint unsigned;not null;default:0;comment:排序"`
ChildrenNum uint `json:"children_num" gorm:"column:children_num;type:int unsigned;not null;default:0;comment:子集数量"`
UserNumber uint `json:"user_number" gorm:"column:user_number;type:int unsigned;not null;default:0;comment:用户数量"`
RoleList []DeptRoleMap `json:"role_list" gorm:"foreignKey:dept_id;references:id"`
}
func NewDepartment() *Department {
return BindModel(&Department{})
}
// TableName 获取表名
func (m *Department) TableName() string {
return "department"
}
func (m *Department) IsSystemDepartment() bool {
return m.IsSystem == global.Yes
}
// FindByCode 根据 code 查找未删除的部门,结果写入自身。
func (m *Department) FindByCode(code string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("code = ? AND deleted_at = 0", code).First(m).Error
}
// UpdateUserNumberByIds 批量更新指定部门的用户数量。
func (m *Department) UpdateUserNumberByIds(deptIds []uint, updateExpr string) error {
if len(deptIds) == 0 {
return nil
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Model(m).
Where("id IN (?)", deptIds).
Update("user_number", gorm.Expr(updateExpr)).Error
}
// UpdateChildrenPidsByParent 批量更新指定父节点下所有子部门的 pids 和 level。
func (m *Department) UpdateChildrenPidsByParent(parentID uint, updateExpr string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Model(m).
Where("FIND_IN_SET(?,pids)", parentID).
Updates(map[string]interface{}{
"pids": gorm.Expr(updateExpr),
"level": gorm.Expr("length(pids) - length(replace(pids, ',', '')) + 1"),
}).Error
}
================================================
FILE: internal/model/dept_role_map.go
================================================
package model
// DeptRoleMap 部门角色关联表
type DeptRoleMap struct {
BaseModel
DeptId uint `json:"dept_id"` // 菜单ID
RoleId uint `json:"role_id"` // RoleID
}
func NewDeptRoleMap() *DeptRoleMap {
return BindModel(&DeptRoleMap{})
}
// TableName 获取表名
func (m *DeptRoleMap) TableName() string {
return "department_role_map"
}
func (m *DeptRoleMap) DeleteByDeptId(deptId uint) error {
return m.DeleteWhere("dept_id = ?", deptId)
}
func (m *DeptRoleMap) CreateBatch(mappings []*DeptRoleMap) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(&mappings).Error
}
// RoleIdsByDeptIds 根据部门 ID 列表查询关联的角色 ID 列表。
func (m *DeptRoleMap) RoleIdsByDeptIds(deptIds []uint) ([]uint, error) {
if len(deptIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("dept_id IN ?", deptIds).Pluck("role_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// DeptIdsByRoleIds 根据角色 ID 列表查询关联的部门 ID 列表。
func (m *DeptRoleMap) DeptIdsByRoleIds(roleIds []uint) ([]uint, error) {
if len(roleIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("role_id IN ?", roleIds).Pluck("dept_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// DeptRoleMapByDeptIds 批量查询多个部门的角色关系,返回 map[deptId][]roleId。
func (m *DeptRoleMap) DeptRoleMapByDeptIds(deptIds []uint) (map[uint][]uint, error) {
result := make(map[uint][]uint, len(deptIds))
if len(deptIds) == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
type row struct {
DeptId uint
RoleId uint
}
var rows []row
if err := db.Table(m.TableName()).Select("dept_id,role_id").Where("dept_id IN ?", deptIds).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.DeptId] = append(result[r.DeptId], r.RoleId)
}
return result, nil
}
================================================
FILE: internal/model/file_upload.go
================================================
package model
import "github.com/wannanbigpig/gin-layout/internal/pkg/utils"
const (
StorageDriverLocal = "local"
StorageDriverAliyunOSS = "aliyun_oss"
StorageDriverS3 = "s3"
StorageStatusStored = "stored"
StorageStatusDeleteFailed = "delete_failed"
)
type UploadFiles struct {
ContainsDeleteBaseModel
FileObjectID uint `json:"file_object_id"` // 物理对象ID
UID uint `json:"uid"` // 用户ID
FolderID uint `json:"folder_id"` // 逻辑目录ID
LogicalPath string `json:"logical_path"` // 逻辑路径快照
DisplayName string `json:"display_name"` // 展示名称
OriginName string `json:"origin_name"` // 原始文件名
Name string `json:"name"` // 存储的文件名(UUID+扩展名)
Path string `json:"path"` // 文件相对路径(相对于storage/public或storage/private)
Size uint `json:"size"` // 文件大小(字节)
Ext string `json:"ext"` // 文件扩展名
Hash string `json:"hash"` // 文件SHA256哈希值(用于去重)
UUID string `json:"uuid"` // 文件UUID(用于URL访问,32位十六进制字符串,不带连字符)
MimeType string `json:"mime_type"` // MIME类型(如:image/jpeg, application/pdf)
FileType string `json:"file_type"` // 文件类型:image,pdf,word,excel,ppt,archive,text,audio,video,other
IsPublic uint8 `json:"is_public"` // 是否公开访问:0否 1是
StorageDriver string `json:"storage_driver"` // 存储驱动:local,aliyun_oss,s3
StorageBase string `json:"storage_base"` // 存储基础位置
Bucket string `json:"bucket"` // 存储桶
StoragePath string `json:"storage_path"` // 实际存储路径
ObjectKey string `json:"object_key"` // 对象 key
ETag string `json:"etag" gorm:"column:etag"` // 对象 ETag
StorageStatus string `json:"storage_status"` // 存储状态
UploadSource string `json:"upload_source"` // 上传来源
UploadScene string `json:"upload_scene"` // 上传场景
UploadStatus string `json:"upload_status"` // 上传状态
LastAccessedAt utils.FormatDate `json:"last_accessed_at"` // 最后访问时间
DeletedBy uint `json:"deleted_by"` // 删除人
DeletedReason string `json:"deleted_reason"` // 删除原因
ReferenceCount int64 `json:"reference_count" gorm:"-:all"`
ObjectReuseCount int64 `json:"object_reuse_count" gorm:"-:all"`
ObjectStatus string `json:"object_status" gorm:"-:all"`
}
func NewUploadFiles() *UploadFiles {
return BindModel(&UploadFiles{})
}
// TableName 获取表名
func (m *UploadFiles) TableName() string {
return "upload_files"
}
// Create 创建单条文件记录。
func (m *UploadFiles) Create() error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(m).Error
}
const (
UploadSourceBackend = "backend"
UploadSourceDirect = "direct"
UploadSourceSystem = "system"
UploadStatusPending = "pending"
UploadStatusUploaded = "uploaded"
UploadStatusFailed = "failed"
)
type UploadFileObject struct {
BaseModel
StorageDriver string `json:"storage_driver"`
StorageBase string `json:"storage_base"`
Bucket string `json:"bucket"`
StoragePath string `json:"storage_path"`
ObjectKey string `json:"object_key"`
Size uint `json:"size"`
Hash string `json:"hash"`
MimeType string `json:"mime_type"`
ETag string `json:"etag" gorm:"column:etag"`
Status string `json:"status"`
}
func NewUploadFileObject() *UploadFileObject {
return BindModel(&UploadFileObject{})
}
func (m *UploadFileObject) TableName() string {
return "upload_file_objects"
}
type UploadFileFolder struct {
ContainsDeleteBaseModel
ParentID uint `json:"parent_id"`
Name string `json:"name"`
LogicalPath string `json:"logical_path"`
Sort int `json:"sort"`
CreatedBy uint `json:"created_by"`
UpdatedBy uint `json:"updated_by"`
}
func NewUploadFileFolder() *UploadFileFolder {
return BindModel(&UploadFileFolder{})
}
func (m *UploadFileFolder) TableName() string {
return "upload_file_folders"
}
type UploadFileReference struct {
BaseModel
FileID uint `json:"file_id"`
UUID string `json:"uuid"`
OwnerType string `json:"owner_type"`
OwnerID uint `json:"owner_id"`
OwnerField string `json:"owner_field"`
}
func NewUploadFileReference() *UploadFileReference {
return BindModel(&UploadFileReference{})
}
func (m *UploadFileReference) TableName() string {
return "upload_file_references"
}
================================================
FILE: internal/model/file_upload_test.go
================================================
package model
import (
"sync"
"testing"
"gorm.io/gorm/schema"
)
func TestUploadFilesETagColumnName(t *testing.T) {
parsed, err := schema.Parse(&UploadFiles{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatalf("parse upload files schema: %v", err)
}
field := parsed.LookUpField("ETag")
if field == nil {
t.Fatal("expected ETag field to exist")
}
if field.DBName != "etag" {
t.Fatalf("expected ETag DB column to be etag, got %s", field.DBName)
}
}
================================================
FILE: internal/model/login_security_state.go
================================================
package model
import (
"strings"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// LoginSecurityState 记录登录失败计数与锁定状态。
type LoginSecurityState struct {
BaseModel
Username string `json:"username" gorm:"column:username;type:varchar(50);not null;default:'';uniqueIndex:lss_username;comment:登录账号"`
FailCount uint `json:"fail_count" gorm:"column:fail_count;type:int unsigned;not null;default:0;comment:连续失败次数"`
LockUntil *utils.FormatDate `json:"lock_until" gorm:"column:lock_until;type:datetime;comment:锁定截止时间"`
LastFailedAt *utils.FormatDate `json:"last_failed_at" gorm:"column:last_failed_at;type:datetime;comment:最近失败时间"`
}
func NewLoginSecurityState() *LoginSecurityState {
return BindModel(&LoginSecurityState{})
}
func (m *LoginSecurityState) TableName() string {
return "login_security_state"
}
// FindByUsername 查询指定账号的登录安全状态。
func (m *LoginSecurityState) FindByUsername(username string) error {
username = strings.TrimSpace(username)
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("username = ?", username).First(m).Error
}
================================================
FILE: internal/model/menu.go
================================================
package model
import (
"fmt"
"github.com/wannanbigpig/gin-layout/internal/model/modelDict"
)
// Menu 权限路由表
type Menu struct {
ContainsDeleteBaseModel
Icon string `json:"icon"` // 图标
Code string `json:"code"` // 前端权限标识
Path string `json:"path"` // 前端路由
FullPath string `json:"full_path"` // 完整前端路由
IsShow uint8 `json:"is_show"` // 是否显示,1是 0否
IsNewWindow uint8 `json:"is_new_window"` // 是否新窗口打开, 1是 0否
Sort uint `json:"sort"` // 排序,数字越大,排名越靠前
Type uint8 `json:"type"` // 菜单类型,1目录,2菜单,3按钮
Pid uint `json:"pid"` // 上级菜单id
Level uint8 `json:"level"` // 层级
Pids string `json:"pids"` // 层级序列,多个用英文逗号隔开
ChildrenNum uint `json:"children_num"` // 子集数量
Description string `json:"description"` // 描述
IsAuth uint8 `json:"is_auth"` // 是否鉴权 0:否 1:是
IsExternalLinks uint8 `json:"is_external_links"` // 是否外链 0:否 1:是
Name string `json:"name"` // 路由名称
Component string `json:"component"` // 组件路径
AnimateEnter string `json:"animate_enter"` // 进入动画
AnimateLeave string `json:"animate_leave"` // 离开动画
AnimateDuration float32 `json:"animate_duration"` // 动画时长
ApiList []MenuApiMap `json:"api_list" gorm:"foreignkey:menu_id;references:id"`
Status uint8 `json:"status"` // 状态,0禁用,1启用
Redirect string `json:"redirect"` // 重定向路由名称
}
const CATALOGUE uint8 = 1
const MENU uint8 = 2
const BUTTON uint8 = 3
var MenuType modelDict.Dict = map[uint8]string{
CATALOGUE: "目录",
MENU: "菜单",
BUTTON: "按钮",
}
func (m *Menu) MenuTypeMap() string {
return MenuType.Map(m.Type)
}
func (m *Menu) IsExternalLinksMap() string {
return modelDict.IsMap.Map(m.IsExternalLinks)
}
func (m *Menu) IsAuthMap() string {
return modelDict.IsMap.Map(m.IsAuth)
}
func (m *Menu) IsShowMap() string {
return modelDict.IsMap.Map(m.IsShow)
}
func (m *Menu) IsNewWindowMap() string {
return modelDict.IsMap.Map(m.IsNewWindow)
}
// StatusMap 状态映射
func (m *Menu) StatusMap() string {
return modelDict.IsMap.Map(m.Status)
}
func (m *Menu) GetApiIds() []uint {
// 如果 ApiList 为空,直接返回空切片
if len(m.ApiList) == 0 {
return []uint{}
}
// 预分配切片容量,避免多次内存分配
apiIds := make([]uint, 0, len(m.ApiList))
for _, v := range m.ApiList {
apiIds = append(apiIds, v.ApiId)
}
return apiIds
}
func NewMenu() *Menu {
return BindModel(&Menu{})
}
// TableName 获取表名
func (m *Menu) TableName() string {
return "menu"
}
// AllIds 查询所有未删除菜单的 ID 列表。
func (m *Menu) AllIds() ([]uint, error) {
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("deleted_at = 0").Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// EnabledIdsByIds 根据 ID 列表查询启用状态(status=1)且未删除的菜单 ID。
func (m *Menu) EnabledIdsByIds(ids []uint) ([]uint, error) {
if len(ids) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var result []uint
if err := db.Where("id IN ? AND status = 1 AND deleted_at = 0", ids).Pluck("id", &result).Error; err != nil {
return nil, err
}
return result, nil
}
// ExistsExcludeId 检查指定字段值的记录是否存在(排除指定 ID)。
func (m *Menu) ExistsExcludeId(field string, value string, excludeId uint) (bool, error) {
if !isAllowedMenuUniqueField(field) {
return false, fmt.Errorf("unsupported menu unique field: %s", field)
}
db, err := m.GetDB()
if err != nil {
return false, err
}
var exists bool
if err := db.Model(m).
Select("1").
Where(field+" = ? AND id != ? AND deleted_at = 0", value, excludeId).
Limit(1).
Scan(&exists).Error; err != nil {
return false, err
}
return exists, nil
}
func isAllowedMenuUniqueField(field string) bool {
switch field {
case "code", "name", "full_path":
return true
default:
return false
}
}
// MenuTreeNode 菜单树节点,用于展开父级。
type MenuTreeNode struct {
ID uint
Pids string
}
// FindPidsByIds 根据 ID 列表查询未删除菜单的 id 和 pids 信息。
func (m *Menu) FindPidsByIds(ids []uint) ([]MenuTreeNode, error) {
if len(ids) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []MenuTreeNode
if err := db.Table(m.TableName()).Select("id,pids").Where("id IN ? AND deleted_at = 0", ids).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// FindIdsByCodes 根据代码列表查询未删除菜单的 ID 列表。
func (m *Menu) FindIdsByCodes(codes []string) ([]Menu, error) {
if len(codes) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var menus []Menu
if err := db.Select("id", "code").Where("code IN ? AND deleted_at = 0", codes).Find(&menus).Error; err != nil {
return nil, err
}
return menus, nil
}
// FindDescendantsById 查询指定菜单 ID 的所有后代菜单(用于更新子菜单层级)。
func (m *Menu) FindDescendantsById(id uint) ([]Menu, error) {
db, err := m.GetDB()
if err != nil {
return nil, err
}
var menus []Menu
if err := db.Where("FIND_IN_SET(?,pids)", id).Order("level asc, id asc").Find(&menus).Error; err != nil {
return nil, err
}
return menus, nil
}
// UpdateById 根据 ID 更新菜单字段。
func (m *Menu) UpdateById(id uint, data map[string]any) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Model(m).Where("id = ?", id).Updates(data).Error
}
================================================
FILE: internal/model/menu_api_map.go
================================================
package model
import "github.com/wannanbigpig/gin-layout/internal/global"
// MenuApiMap 权限路由表
type MenuApiMap struct {
BaseModel
MenuId uint `json:"menu_id"` // 菜单ID
ApiId uint `json:"api_id"` // API ID
}
func NewMenuApiMap() *MenuApiMap {
return BindModel(&MenuApiMap{})
}
// TableName 获取表名
func (m *MenuApiMap) TableName() string {
return "menu_api_map"
}
func (m *MenuApiMap) CreateBatch(mappings []*MenuApiMap) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(&mappings).Error
}
// ApiIdsByMenuId 根据菜单 ID 查询关联的 API ID 列表。
func (m *MenuApiMap) ApiIdsByMenuId(menuId uint) ([]uint, error) {
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("menu_id = ?", menuId).Pluck("api_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// MenuIdsByApiIds 根据 API ID 列表查询关联的菜单 ID 列表。
func (m *MenuApiMap) MenuIdsByApiIds(apiIds []uint) ([]uint, error) {
if len(apiIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("api_id IN ?", apiIds).Pluck("menu_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// ApiPermission 接口权限信息(路由+方法)。
type ApiPermission struct {
Route string
Method string
}
// ApiPermissionsByMenuIds 根据菜单 ID 列表查询去重后的接口权限(JOIN api 表)。
func (m *MenuApiMap) ApiPermissionsByMenuIds(menuIds []uint) ([]ApiPermission, error) {
if len(menuIds) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var permissions []ApiPermission
err = db.Table(m.TableName()+" m").
Select("DISTINCT a.route, a.method").
Joins("JOIN api a ON a.id = m.api_id").
Where("m.menu_id IN ? AND a.deleted_at = 0 AND a.is_auth = ? AND a.is_effective = 1", menuIds, global.ApiAuthModeAuth).
Find(&permissions).Error
if err != nil {
return nil, err
}
return permissions, nil
}
// MenuApiPermission 按菜单分组的接口权限信息。
type MenuApiPermission struct {
MenuId uint
Route string
Method string
}
// MenuApiPermissionsByMenuIds 根据菜单 ID 列表查询按菜单分组的接口权限(JOIN api 表)。
func (m *MenuApiMap) MenuApiPermissionsByMenuIds(menuIds []uint) ([]MenuApiPermission, error) {
if len(menuIds) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []MenuApiPermission
err = db.Table(m.TableName()+" m").
Select("m.menu_id, a.route, a.method").
Joins("JOIN api a ON a.id = m.api_id").
Where("m.menu_id IN ? AND a.deleted_at = 0 AND a.is_auth = ? AND a.is_effective = 1", menuIds, global.ApiAuthModeAuth).
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
================================================
FILE: internal/model/menu_i18n.go
================================================
package model
import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// MenuI18n 菜单标题多语言表。
type MenuI18n struct {
BaseModel
MenuID 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"`
Locale string `json:"locale" gorm:"column:locale;type:varchar(10);not null;default:'';index:uniq_menu_id_locale,unique;index:idx_locale_menu_id"`
Title string `json:"title" gorm:"column:title;type:varchar(60);not null;default:''"`
}
func NewMenuI18n() *MenuI18n {
return BindModel(&MenuI18n{})
}
// TableName 获取表名。
func (m *MenuI18n) TableName() string {
return "menu_i18n"
}
// UpsertMenuTitles 按 menu_id + locale 幂等写入标题翻译。
func (m *MenuI18n) UpsertMenuTitles(menuID uint, localeTitles map[string]string, tx ...*gorm.DB) error {
if menuID == 0 || len(localeTitles) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
rows := make([]MenuI18n, 0, len(localeTitles))
for locale, title := range localeTitles {
trimmedLocale := strings.TrimSpace(locale)
trimmedTitle := strings.TrimSpace(title)
if trimmedLocale == "" || trimmedTitle == "" {
continue
}
rows = append(rows, MenuI18n{
MenuID: menuID,
Locale: trimmedLocale,
Title: trimmedTitle,
})
}
if len(rows) == 0 {
return nil
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "menu_id"}, {Name: "locale"}},
DoUpdates: clause.AssignmentColumns([]string{"title", "updated_at"}),
}).Create(&rows).Error
}
// LocalizedTitleMapByMenuIDs 按语言优先级批量查询菜单标题。
func (m *MenuI18n) LocalizedTitleMapByMenuIDs(menuIDs []uint, localePriority []string) (map[uint]string, error) {
result := make(map[uint]string, len(menuIDs))
if len(menuIDs) == 0 {
return result, nil
}
priorities := make([]string, 0, len(localePriority))
seen := make(map[string]struct{}, len(localePriority))
for _, locale := range localePriority {
trimmedLocale := strings.TrimSpace(locale)
if trimmedLocale == "" {
continue
}
if _, ok := seen[trimmedLocale]; ok {
continue
}
seen[trimmedLocale] = struct{}{}
priorities = append(priorities, trimmedLocale)
}
if len(priorities) == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []MenuI18n
if err := db.Where("menu_id IN ? AND locale IN ?", menuIDs, priorities).Find(&rows).Error; err != nil {
return nil, err
}
grouped := make(map[uint]map[string]string, len(menuIDs))
for _, row := range rows {
if _, ok := grouped[row.MenuID]; !ok {
grouped[row.MenuID] = make(map[string]string)
}
grouped[row.MenuID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.Title)
}
for _, menuID := range menuIDs {
localizedMap := grouped[menuID]
if len(localizedMap) == 0 {
continue
}
for _, locale := range priorities {
if title := strings.TrimSpace(localizedMap[locale]); title != "" {
result[menuID] = title
break
}
}
}
return result, nil
}
// LocaleTitleMapByMenuID 查询指定菜单的全部翻译。
func (m *MenuI18n) LocaleTitleMapByMenuID(menuID uint) (map[string]string, error) {
result := make(map[string]string)
if menuID == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []MenuI18n
if err := db.Where("menu_id = ?", menuID).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
trimmedLocale := strings.TrimSpace(row.Locale)
trimmedTitle := strings.TrimSpace(row.Title)
if trimmedLocale == "" || trimmedTitle == "" {
continue
}
result[trimmedLocale] = trimmedTitle
}
return result, nil
}
// DeleteByMenuIDs 删除菜单对应的翻译数据。
func (m *MenuI18n) DeleteByMenuIDs(menuIDs []uint, tx ...*gorm.DB) error {
if len(menuIDs) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("menu_id IN ?", menuIDs).Delete(&MenuI18n{}).Error
}
================================================
FILE: internal/model/modelDict/base.go
================================================
package modelDict
import "github.com/wannanbigpig/gin-layout/internal/global"
type Dict map[uint8]string
func (d Dict) Map(k uint8) string {
// 先判断 d 是否为 nil,防止 nil 指针解引用
if d == nil {
return "-"
}
if v, ok := d[k]; ok {
return v
}
return "-"
}
var IsMap Dict = map[uint8]string{
global.No: "否",
global.Yes: "是",
}
================================================
FILE: internal/model/request_logs.go
================================================
package model
// RequestLogs 请求日志表
type RequestLogs struct {
BaseModel
RequestID string `json:"request_id"` // 请求唯一标识
JwtID string `json:"jwt_id"` // 请求授权的jwtId
OperatorID uint `json:"operator_id"` // 操作ID(用户ID)
IP string `json:"ip"` // 客户端IP地址
UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息)
OS string `json:"os"` // 操作系统
Browser string `json:"browser"` // 浏览器
Method string `json:"method"` // HTTP请求方法(GET/POST等)
BaseURL string `json:"base_url"` // 请求基础URL
OperationName string `json:"operation_name"` // 操作名称
OperationStatus int `json:"operation_status"` // 操作状态码(响应返回的code,0=成功,其他=失败)
IsHighRisk uint8 `json:"is_high_risk"` // 是否高危操作 1是 0否
OperatorAccount string `json:"operator_account"` // 操作账号
OperatorName string `json:"operator_name"` // 操作人员
RequestHeaders string `json:"request_headers"` // 请求头(JSON格式)
RequestQuery string `json:"request_query"` // 请求参数
RequestBody string `json:"request_body"` // 请求体
ChangeDiff string `json:"change_diff"` // 关键变更前后差异(JSON)
ResponseStatus int `json:"response_status"` // 响应状态码
ResponseBody string `json:"response_body"` // 响应体
ResponseHeader string `json:"response_header"` // 响应头
ExecutionTime float64 `json:"execution_time"` // 执行时间(毫秒,支持小数,最多4位)
}
func NewRequestLogs() *RequestLogs {
return BindModel(&RequestLogs{})
}
// TableName 获取表名
func (m *RequestLogs) TableName() string {
return "request_logs"
}
// Create 创建单条请求日志记录。
func (m *RequestLogs) Create() error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(m).Error
}
================================================
FILE: internal/model/role.go
================================================
package model
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model/modelDict"
)
// 角色状态字典
var RoleStatusDict modelDict.Dict = map[uint8]string{
1: "启用",
0: "禁用",
}
// Role 角色表
type Role struct {
ContainsDeleteBaseModel
Code string `json:"code" gorm:"column:code;type:varchar(60);not null;default:'';comment:角色业务编码"`
IsSystem uint8 `json:"is_system" gorm:"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统保留对象"`
Pid uint `json:"pid" gorm:"column:pid;type:int unsigned;not null;default:0;comment:上级id"`
Pids string `json:"pids" gorm:"column:pids;type:varchar(255);not null;default:'';comment:所有上级id"`
Name string `json:"name" gorm:"column:name;type:varchar(60);not null;default:'';comment:角色名称"`
Description string `json:"description" gorm:"column:description;type:varchar(255);not null;default:'';comment:描述"`
Level uint8 `json:"level" gorm:"column:level;type:tinyint unsigned;not null;default:1;comment:层级"`
Sort uint `json:"sort" gorm:"column:sort;type:mediumint unsigned;not null;default:0;comment:排序"`
ChildrenNum uint `json:"children_num" gorm:"column:children_num;type:int unsigned;not null;default:0;comment:子集数量"`
MenuList []RoleMenuMap `json:"menu_list,omitempty" gorm:"foreignkey:role_id;references:id;comment:菜单列表"`
Status uint8 `json:"status" gorm:"column:status;type:tinyint unsigned;not null;default:1;comment:是否启用状态,1启用,0禁用"`
}
func NewRole() *Role {
return BindModel(&Role{})
}
// TableName 获取表名
func (m *Role) TableName() string {
return "role"
}
// StatusMap 状态映射
func (m *Role) StatusMap() string {
return RoleStatusDict.Map(m.Status)
}
func (m *Role) IsSystemRole() bool {
return m.IsSystem == global.Yes
}
// RoleStatusInfo 角色状态简要信息。
type RoleStatusInfo struct {
ID uint
Pids string
Status uint8
}
// AllRoleStatusInfos 查询所有未删除角色的 id、pids、status 信息。
func (m *Role) AllRoleStatusInfos() ([]RoleStatusInfo, error) {
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []RoleStatusInfo
if err := db.Table(m.TableName()).Select("id,pids,status").Where("deleted_at = 0").Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// RoleTreeNode 角色树节点,用于展开子树。
type RoleTreeNode struct {
ID uint
Pids string
}
// AllTreeNodes 查询所有未删除角色的 id、pids,用于角色子树展开。
func (m *Role) AllTreeNodes() ([]RoleTreeNode, error) {
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []RoleTreeNode
if err := db.Table(m.TableName()).Select("id,pids").Where("deleted_at = 0").Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// EnabledIdsByIds 根据 ID 列表查询启用状态(status=1)且未删除的角色 ID。
func (m *Role) EnabledIdsByIds(ids []uint) ([]uint, error) {
if len(ids) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var result []uint
if err := db.Where("id IN ? AND status = 1 AND deleted_at = 0", ids).Pluck("id", &result).Error; err != nil {
return nil, err
}
return result, nil
}
// FindByCode 根据 code 查找未删除的角色,结果写入自身。
func (m *Role) FindByCode(code string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("code = ? AND deleted_at = 0", code).First(m).Error
}
// FindPidsByIds 根据 ID 列表查询未删除角色的 id 和 pids 信息。
func (m *Role) FindPidsByIds(ids []uint) ([]RoleTreeNode, error) {
if len(ids) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []RoleTreeNode
if err := db.Table(m.TableName()).Select("id,pids").Where("id IN ? AND deleted_at = 0", ids).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// SubtreeIdsByRootIds 查询指定角色及其全部后代角色 ID。
func (m *Role) SubtreeIdsByRootIds(rootIDs []uint) ([]uint, error) {
if len(rootIDs) == 0 {
return nil, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
query := db.Table(m.TableName()).Where("deleted_at = 0").Where("id IN ?", rootIDs)
for _, rootID := range rootIDs {
query = query.Or("deleted_at = 0 AND FIND_IN_SET(?, pids)", rootID)
}
var ids []uint
if err := query.Pluck("id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// UpdateChildrenPidsByParent 批量更新指定父节点下所有子角色的 pids 和 level。
func (m *Role) UpdateChildrenPidsByParent(parentID uint, updateExpr string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Model(m).
Where("FIND_IN_SET(?,pids)", parentID).
Updates(map[string]interface{}{
"pids": gorm.Expr(updateExpr),
"level": gorm.Expr("length(pids) - length(replace(pids, ',', '')) + 1"),
}).Error
}
================================================
FILE: internal/model/role_menu_map.go
================================================
package model
// RoleMenuMap 角色菜单关联表
type RoleMenuMap struct {
BaseModel
MenuId uint `json:"menu_id"` // 菜单ID
RoleId uint `json:"role_id"` // RoleID
}
func NewRoleMenuMap() *RoleMenuMap {
return BindModel(&RoleMenuMap{})
}
// TableName 获取表名
func (m *RoleMenuMap) TableName() string {
return "role_menu_map"
}
func (m *RoleMenuMap) CreateBatch(mappings []*RoleMenuMap) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Create(&mappings).Error
}
// MenuIdsByRoleIds 根据角色 ID 列表查询关联的菜单 ID 列表。
func (m *RoleMenuMap) MenuIdsByRoleIds(roleIds []uint) ([]uint, error) {
if len(roleIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("role_id IN ?", roleIds).Pluck("menu_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// RoleIdsByMenuIds 根据菜单 ID 列表查询关联的角色 ID 列表。
func (m *RoleMenuMap) RoleIdsByMenuIds(menuIds []uint) ([]uint, error) {
if len(menuIds) == 0 {
return nil, nil
}
db, err := m.GetDB(m)
if err != nil {
return nil, err
}
var ids []uint
if err := db.Where("menu_id IN ?", menuIds).Pluck("role_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// RoleMenuMapByRoleIds 批量查询多个角色的菜单关系,返回 map[roleId][]menuId。
func (m *RoleMenuMap) RoleMenuMapByRoleIds(roleIds []uint) (map[uint][]uint, error) {
result := make(map[uint][]uint)
if len(roleIds) == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
type row struct {
RoleId uint
MenuId uint
}
var rows []row
if err := db.Table(m.TableName()).Select("role_id,menu_id").Where("role_id IN ?", roleIds).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.RoleId] = append(result[r.RoleId], r.MenuId)
}
return result, nil
}
================================================
FILE: internal/model/sys_config.go
================================================
package model
import (
"strings"
"gorm.io/gorm"
)
const (
SysConfigValueTypeString = "string"
SysConfigValueTypeNumber = "number"
SysConfigValueTypeBool = "bool"
SysConfigValueTypeJSON = "json"
)
// SysConfig 系统参数表。
type SysConfig struct {
ContainsDeleteBaseModel
ConfigKey string `json:"config_key" gorm:"column:config_key;type:varchar(100);not null;default:'';comment:参数键名"`
ConfigName string `json:"config_name" gorm:"-:all"`
ConfigNameI18n map[string]string `json:"config_name_i18n" gorm:"-:all"`
ConfigValue string `json:"config_value" gorm:"column:config_value;type:text;comment:参数值"`
ValueType string `json:"value_type" gorm:"column:value_type;type:varchar(20);not null;default:'string';comment:值类型"`
GroupCode string `json:"group_code" gorm:"column:group_code;type:varchar(60);not null;default:'default';comment:参数分组"`
IsSystem uint8 `json:"is_system" gorm:"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统内置"`
IsSensitive uint8 `json:"is_sensitive" gorm:"column:is_sensitive;type:tinyint unsigned;not null;default:0;comment:是否敏感配置"`
IsVisible uint8 `json:"is_visible" gorm:"column:is_visible;type:tinyint unsigned;not null;default:1;comment:是否在系统参数页展示"`
ManageTab string `json:"manage_tab" gorm:"column:manage_tab;type:varchar(60);not null;default:'';comment:专属配置Tab"`
Status uint8 `json:"status" gorm:"column:status;type:tinyint unsigned;not null;default:1;comment:状态"`
Sort uint `json:"sort" gorm:"column:sort;type:int unsigned;not null;default:0;comment:排序"`
Remark string `json:"remark" gorm:"column:remark;type:varchar(255);not null;default:'';comment:备注"`
}
func NewSysConfig() *SysConfig {
return BindModel(&SysConfig{})
}
func (m *SysConfig) TableName() string {
return "sys_config"
}
// IsProtected 判断参数是否为系统内置保护项。
func (m *SysConfig) IsProtected() bool {
return m != nil && m.IsSystem == 1
}
// NormalizeValueType 归一化参数值类型。
func NormalizeValueType(valueType string) string {
valueType = strings.TrimSpace(strings.ToLower(valueType))
if valueType == "" {
return SysConfigValueTypeString
}
return valueType
}
// FindByKey 根据参数键查询未删除参数。
func (m *SysConfig) FindByKey(key string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("config_key = ? AND deleted_at = 0", key).First(m).Error
}
// ExistsByKeyExcludeID 检查参数键是否已被其他记录占用。
func (m *SysConfig) ExistsByKeyExcludeID(key string, excludeID uint) (bool, error) {
db, err := m.GetDB()
if err != nil {
return false, err
}
var count int64
query := db.Model(m).Where("config_key = ? AND deleted_at = 0", key)
if excludeID > 0 {
query = query.Where("id <> ?", excludeID)
}
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// EnabledConfigs 查询所有启用参数,用于刷新缓存。
func (m *SysConfig) EnabledConfigs(tx ...*gorm.DB) ([]SysConfig, error) {
db, err := m.GetDB()
if err != nil {
return nil, err
}
if len(tx) > 0 && tx[0] != nil {
db = tx[0]
}
var configs []SysConfig
err = db.Where("status = 1 AND deleted_at = 0").Order("sort desc, id desc").Find(&configs).Error
return configs, err
}
================================================
FILE: internal/model/sys_dict.go
================================================
package model
// SysDictType 系统字典类型表。
type SysDictType struct {
ContainsDeleteBaseModel
TypeCode string `json:"type_code" gorm:"column:type_code;type:varchar(100);not null;default:'';comment:字典类型编码"`
TypeName string `json:"type_name" gorm:"-:all"`
TypeNameI18n map[string]string `json:"type_name_i18n" gorm:"-:all"`
IsSystem uint8 `json:"is_system" gorm:"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统内置"`
Status uint8 `json:"status" gorm:"column:status;type:tinyint unsigned;not null;default:1;comment:状态"`
Sort uint `json:"sort" gorm:"column:sort;type:int unsigned;not null;default:0;comment:排序"`
Remark string `json:"remark" gorm:"column:remark;type:varchar(255);not null;default:'';comment:备注"`
}
// SysDictItem 系统字典项表。
type SysDictItem struct {
ContainsDeleteBaseModel
TypeCode string `json:"type_code" gorm:"column:type_code;type:varchar(100);not null;default:'';comment:字典类型编码"`
Label string `json:"label" gorm:"-:all"`
LabelI18n map[string]string `json:"label_i18n" gorm:"-:all"`
Value string `json:"value" gorm:"column:value;type:varchar(100);not null;default:'';comment:字典值"`
Color string `json:"color" gorm:"column:color;type:varchar(30);not null;default:'';comment:展示颜色"`
TagType string `json:"tag_type" gorm:"column:tag_type;type:varchar(30);not null;default:'';comment:前端标签类型"`
IsDefault uint8 `json:"is_default" gorm:"column:is_default;type:tinyint unsigned;not null;default:0;comment:是否默认项"`
IsSystem uint8 `json:"is_system" gorm:"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统内置"`
Status uint8 `json:"status" gorm:"column:status;type:tinyint unsigned;not null;default:1;comment:状态"`
Sort uint `json:"sort" gorm:"column:sort;type:int unsigned;not null;default:0;comment:排序"`
Remark string `json:"remark" gorm:"column:remark;type:varchar(255);not null;default:'';comment:备注"`
}
func NewSysDictType() *SysDictType {
return BindModel(&SysDictType{})
}
func NewSysDictItem() *SysDictItem {
return BindModel(&SysDictItem{})
}
func (m *SysDictType) TableName() string {
return "sys_dict_type"
}
func (m *SysDictItem) TableName() string {
return "sys_dict_item"
}
// IsProtected 判断字典类型是否为系统内置保护项。
func (m *SysDictType) IsProtected() bool {
return m != nil && m.IsSystem == 1
}
// IsProtected 判断字典项是否为系统内置保护项。
func (m *SysDictItem) IsProtected() bool {
return m != nil && m.IsSystem == 1
}
// FindByTypeCode 根据类型编码查询未删除字典类型。
func (m *SysDictType) FindByTypeCode(typeCode string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("type_code = ? AND deleted_at = 0", typeCode).First(m).Error
}
// ExistsByTypeCodeExcludeID 检查类型编码是否已被其他记录占用。
func (m *SysDictType) ExistsByTypeCodeExcludeID(typeCode string, excludeID uint) (bool, error) {
db, err := m.GetDB()
if err != nil {
return false, err
}
var count int64
query := db.Model(m).Where("type_code = ? AND deleted_at = 0", typeCode)
if excludeID > 0 {
query = query.Where("id <> ?", excludeID)
}
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// ExistsByValueExcludeID 检查同类型下字典值是否已被其他记录占用。
func (m *SysDictItem) ExistsByValueExcludeID(typeCode, value string, excludeID uint) (bool, error) {
db, err := m.GetDB()
if err != nil {
return false, err
}
var count int64
query := db.Model(m).Where("type_code = ? AND value = ? AND deleted_at = 0", typeCode, value)
if excludeID > 0 {
query = query.Where("id <> ?", excludeID)
}
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// FindByTypeCodeAndValue 根据类型编码和字典值查询未删除字典项。
func (m *SysDictItem) FindByTypeCodeAndValue(typeCode, value string) error {
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("type_code = ? AND value = ? AND deleted_at = 0", typeCode, value).First(m).Error
}
// EnabledItemsByTypeCode 查询指定类型下启用字典项。
func (m *SysDictItem) EnabledItemsByTypeCode(typeCode string) ([]SysDictItem, error) {
db, err := m.GetDB()
if err != nil {
return nil, err
}
var items []SysDictItem
err = db.Where("type_code = ? AND status = 1 AND deleted_at = 0", typeCode).
Order("sort desc, id asc").
Find(&items).Error
return items, err
}
// CountByTypeCode 统计指定类型下未删除字典项数量。
func (m *SysDictItem) CountByTypeCode(typeCode string) (int64, error) {
db, err := m.GetDB()
if err != nil {
return 0, err
}
var count int64
err = db.Model(m).Where("type_code = ? AND deleted_at = 0", typeCode).Count(&count).Error
return count, err
}
================================================
FILE: internal/model/sys_i18n.go
================================================
package model
import (
"sort"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type SysConfigI18n struct {
BaseModel
ConfigID uint `json:"config_id" gorm:"column:config_id;type:int unsigned;not null;default:0;index:uniq_config_id_locale,unique"`
Locale string `json:"locale" gorm:"column:locale;type:varchar(20);not null;default:'';index:uniq_config_id_locale,unique;index:idx_locale_config_name"`
ConfigName string `json:"config_name" gorm:"column:config_name;type:varchar(100);not null;default:''"`
}
type SysDictTypeI18n struct {
BaseModel
DictTypeID uint `json:"dict_type_id" gorm:"column:dict_type_id;type:int unsigned;not null;default:0;index:uniq_dict_type_id_locale,unique"`
Locale string `json:"locale" gorm:"column:locale;type:varchar(20);not null;default:'';index:uniq_dict_type_id_locale,unique;index:idx_locale_type_name"`
TypeName string `json:"type_name" gorm:"column:type_name;type:varchar(100);not null;default:''"`
}
type SysDictItemI18n struct {
BaseModel
DictItemID uint `json:"dict_item_id" gorm:"column:dict_item_id;type:int unsigned;not null;default:0;index:uniq_dict_item_id_locale,unique"`
Locale string `json:"locale" gorm:"column:locale;type:varchar(20);not null;default:'';index:uniq_dict_item_id_locale,unique;index:idx_locale_label"`
Label string `json:"label" gorm:"column:label;type:varchar(100);not null;default:''"`
}
func NewSysConfigI18n() *SysConfigI18n {
return BindModel(&SysConfigI18n{})
}
func NewSysDictTypeI18n() *SysDictTypeI18n {
return BindModel(&SysDictTypeI18n{})
}
func NewSysDictItemI18n() *SysDictItemI18n {
return BindModel(&SysDictItemI18n{})
}
func (m *SysConfigI18n) TableName() string {
return "sys_config_i18n"
}
func (m *SysDictTypeI18n) TableName() string {
return "sys_dict_type_i18n"
}
func (m *SysDictItemI18n) TableName() string {
return "sys_dict_item_i18n"
}
func (m *SysConfigI18n) UpsertConfigNames(configID uint, localeNames map[string]string, tx ...*gorm.DB) error {
if configID == 0 || len(localeNames) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
rows := make([]SysConfigI18n, 0, len(localeNames))
for locale, name := range localeNames {
trimmedLocale := strings.TrimSpace(locale)
trimmedName := strings.TrimSpace(name)
if trimmedLocale == "" || trimmedName == "" {
continue
}
rows = append(rows, SysConfigI18n{
ConfigID: configID,
Locale: trimmedLocale,
ConfigName: trimmedName,
})
}
if len(rows) == 0 {
return nil
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "config_id"}, {Name: "locale"}},
DoUpdates: clause.AssignmentColumns([]string{"config_name", "updated_at"}),
}).Create(&rows).Error
}
func (m *SysDictTypeI18n) UpsertTypeNames(dictTypeID uint, localeNames map[string]string, tx ...*gorm.DB) error {
if dictTypeID == 0 || len(localeNames) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
rows := make([]SysDictTypeI18n, 0, len(localeNames))
for locale, name := range localeNames {
trimmedLocale := strings.TrimSpace(locale)
trimmedName := strings.TrimSpace(name)
if trimmedLocale == "" || trimmedName == "" {
continue
}
rows = append(rows, SysDictTypeI18n{
DictTypeID: dictTypeID,
Locale: trimmedLocale,
TypeName: trimmedName,
})
}
if len(rows) == 0 {
return nil
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dict_type_id"}, {Name: "locale"}},
DoUpdates: clause.AssignmentColumns([]string{"type_name", "updated_at"}),
}).Create(&rows).Error
}
func (m *SysDictItemI18n) UpsertLabels(dictItemID uint, localeLabels map[string]string, tx ...*gorm.DB) error {
if dictItemID == 0 || len(localeLabels) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
rows := make([]SysDictItemI18n, 0, len(localeLabels))
for locale, label := range localeLabels {
trimmedLocale := strings.TrimSpace(locale)
trimmedLabel := strings.TrimSpace(label)
if trimmedLocale == "" || trimmedLabel == "" {
continue
}
rows = append(rows, SysDictItemI18n{
DictItemID: dictItemID,
Locale: trimmedLocale,
Label: trimmedLabel,
})
}
if len(rows) == 0 {
return nil
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dict_item_id"}, {Name: "locale"}},
DoUpdates: clause.AssignmentColumns([]string{"label", "updated_at"}),
}).Create(&rows).Error
}
func (m *SysConfigI18n) LocalizedNameMapByConfigIDs(configIDs []uint, localePriority []string) (map[uint]string, error) {
result := make(map[uint]string, len(configIDs))
rows, priorities, err := m.listRowsByConfigIDs(configIDs, localePriority)
if err != nil || len(rows) == 0 {
return result, err
}
grouped := make(map[uint]map[string]string, len(configIDs))
for _, row := range rows {
if _, ok := grouped[row.ConfigID]; !ok {
grouped[row.ConfigID] = make(map[string]string)
}
grouped[row.ConfigID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.ConfigName)
}
for _, id := range configIDs {
result[id] = pickLocalizedText(grouped[id], priorities)
}
return result, nil
}
func (m *SysConfigI18n) LocaleNameMapByConfigID(configID uint) (map[string]string, error) {
result := make(map[string]string)
if configID == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []SysConfigI18n
if err := db.Where("config_id = ?", configID).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
locale := strings.TrimSpace(row.Locale)
name := strings.TrimSpace(row.ConfigName)
if locale == "" || name == "" {
continue
}
result[locale] = name
}
return result, nil
}
func (m *SysConfigI18n) DeleteByConfigIDs(configIDs []uint, tx ...*gorm.DB) error {
if len(configIDs) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("config_id IN ?", configIDs).Delete(&SysConfigI18n{}).Error
}
func (m *SysDictTypeI18n) LocalizedNameMapByTypeIDs(typeIDs []uint, localePriority []string) (map[uint]string, error) {
result := make(map[uint]string, len(typeIDs))
rows, priorities, err := m.listRowsByTypeIDs(typeIDs, localePriority)
if err != nil || len(rows) == 0 {
return result, err
}
grouped := make(map[uint]map[string]string, len(typeIDs))
for _, row := range rows {
if _, ok := grouped[row.DictTypeID]; !ok {
grouped[row.DictTypeID] = make(map[string]string)
}
grouped[row.DictTypeID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.TypeName)
}
for _, id := range typeIDs {
result[id] = pickLocalizedText(grouped[id], priorities)
}
return result, nil
}
func (m *SysDictTypeI18n) LocaleNameMapByTypeID(dictTypeID uint) (map[string]string, error) {
result := make(map[string]string)
if dictTypeID == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []SysDictTypeI18n
if err := db.Where("dict_type_id = ?", dictTypeID).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
locale := strings.TrimSpace(row.Locale)
name := strings.TrimSpace(row.TypeName)
if locale == "" || name == "" {
continue
}
result[locale] = name
}
return result, nil
}
func (m *SysDictTypeI18n) DeleteByTypeIDs(dictTypeIDs []uint, tx ...*gorm.DB) error {
if len(dictTypeIDs) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("dict_type_id IN ?", dictTypeIDs).Delete(&SysDictTypeI18n{}).Error
}
func (m *SysDictItemI18n) LocalizedLabelMapByItemIDs(itemIDs []uint, localePriority []string) (map[uint]string, error) {
result := make(map[uint]string, len(itemIDs))
rows, priorities, err := m.listRowsByItemIDs(itemIDs, localePriority)
if err != nil || len(rows) == 0 {
return result, err
}
grouped := make(map[uint]map[string]string, len(itemIDs))
for _, row := range rows {
if _, ok := grouped[row.DictItemID]; !ok {
grouped[row.DictItemID] = make(map[string]string)
}
grouped[row.DictItemID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.Label)
}
for _, id := range itemIDs {
result[id] = pickLocalizedText(grouped[id], priorities)
}
return result, nil
}
func (m *SysDictItemI18n) LocaleLabelMapByItemID(dictItemID uint) (map[string]string, error) {
result := make(map[string]string)
if dictItemID == 0 {
return result, nil
}
db, err := m.GetDB()
if err != nil {
return nil, err
}
var rows []SysDictItemI18n
if err := db.Where("dict_item_id = ?", dictItemID).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
locale := strings.TrimSpace(row.Locale)
label := strings.TrimSpace(row.Label)
if locale == "" || label == "" {
continue
}
result[locale] = label
}
return result, nil
}
func (m *SysDictItemI18n) DeleteByItemIDs(dictItemIDs []uint, tx ...*gorm.DB) error {
if len(dictItemIDs) == 0 {
return nil
}
if len(tx) > 0 && tx[0] != nil {
m.SetDB(tx[0])
}
db, err := m.GetDB()
if err != nil {
return err
}
return db.Where("dict_item_id IN ?", dictItemIDs).Delete(&SysDictItemI18n{}).Error
}
func (m *SysConfigI18n) listRowsByConfigIDs(configIDs []uint, localePriority []string) ([]SysConfigI18n, []string, error) {
priorities := normalizeLocalePriority(localePriority)
if len(configIDs) == 0 || len(priorities) == 0 {
return nil, priorities, nil
}
db, err := m.GetDB()
if err != nil {
return nil, priorities, err
}
var rows []SysConfigI18n
if err := db.Where("config_id IN ? AND locale IN ?", configIDs, priorities).Find(&rows).Error; err != nil {
return nil, priorities, err
}
return rows, priorities, nil
}
func (m *SysDictTypeI18n) listRowsByTypeIDs(typeIDs []uint, localePriority []string) ([]SysDictTypeI18n, []string, error) {
priorities := normalizeLocalePriority(localePriority)
if len(typeIDs) == 0 || len(priorities) == 0 {
return nil, priorities, nil
}
db, err := m.GetDB()
if err != nil {
return nil, priorities, err
}
var rows []SysDictTypeI18n
if err := db.Where("dict_type_id IN ? AND locale IN ?", typeIDs, priorities).Find(&rows).Error; err != nil {
return nil, priorities, err
}
return rows, priorities, nil
}
func (m *SysDictItemI18n) listRowsByItemIDs(itemIDs []uint, localePriority []string) ([]SysDictItemI18n, []string, error) {
priorities := normalizeLocalePriority(localePriority)
if len(itemIDs) == 0 || len(priorities) == 0 {
return nil, priorities, nil
}
db, err := m.GetDB()
if err != nil {
return nil, priorities, err
}
var rows []SysDictItemI18n
if err := db.Where("dict_item_id IN ? AND locale IN ?", itemIDs, priorities).Find(&rows).Error; err != nil {
return nil, priorities, err
}
return rows, priorities, nil
}
func normalizeLocalePriority(localePriority []string) []string {
priorities := make([]string, 0, len(localePriority))
seen := make(map[string]struct{}, len(localePriority))
for _, locale := range localePriority {
trimmedLocale := strings.TrimSpace(locale)
if trimmedLocale == "" {
continue
}
if _, ok := seen[trimmedLocale]; ok {
continue
}
seen[trimmedLocale] = struct{}{}
priorities = append(priorities, trimmedLocale)
}
return priorities
}
func pickLocalizedText(localeText map[string]string, priorities []string) string {
if len(localeText) == 0 {
return ""
}
for _, locale := range priorities {
if text := strings.TrimSpace(localeText[locale]); text != "" {
return text
}
}
keys := make([]string, 0, len(localeText))
for key := range localeText {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if text := strings.TrimSpace(localeText[key]); text != "" {
return text
}
}
return ""
}
================================================
FILE: internal/model/task_center.go
================================================
package model
import (
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
const (
TaskKindAsync = "async"
TaskKindCron = "cron"
TaskStatusEnabled uint8 = 1
TaskStatusDisabled uint8 = 0
TaskManualAllowed uint8 = 1
TaskManualNotAllowed uint8 = 0
TaskRetryAllowed uint8 = 1
TaskRetryNotAllowed uint8 = 0
TaskHighRisk uint8 = 1
TaskNotHighRisk uint8 = 0
TaskSourceQueue = "queue"
TaskSourceCron = "cron"
TaskSourceManual = "manual"
TaskRunStatusPending = "pending"
TaskRunStatusRunning = "running"
TaskRunStatusSuccess = "success"
TaskRunStatusFailed = "failed"
TaskRunStatusCanceled = "canceled"
TaskRunStatusRetrying = "retrying"
TaskEventEnqueue = "enqueue"
TaskEventStart = "start"
TaskEventRetry = "retry"
TaskEventFail = "fail"
TaskEventSuccess = "success"
TaskEventCancel = "cancel"
)
// TaskDefinition 描述一个可被后台管理识别的任务。
type TaskDefinition struct {
ContainsDeleteBaseModel
Code string `json:"code"` // 任务唯一编码
Name string `json:"name"` // 任务名称
Kind string `json:"kind"` // async/cron
Queue string `json:"queue"` // 队列名称
CronSpec string `json:"cron_spec"` // Cron 表达式
Handler string `json:"handler"` // 处理器标识
Status uint8 `json:"status"` // 状态 1启用 0停用
AllowManual uint8 `json:"allow_manual"` // 是否允许手动触发
AllowRetry uint8 `json:"allow_retry"` // 是否允许手动重试
IsHighRisk uint8 `json:"is_high_risk"` // 是否高危任务
Remark string `json:"remark"` // 备注
}
func NewTaskDefinition() *TaskDefinition {
return BindModel(&TaskDefinition{})
}
func (m *TaskDefinition) TableName() string {
return "task_definitions"
}
// TaskRun 表示一次任务执行记录。
type TaskRun struct {
BaseModel
TaskCode string `json:"task_code"` // 任务唯一编码
Kind string `json:"kind"` // async/cron
Source string `json:"source"` // queue/cron/manual
SourceID string `json:"source_id"` // 来源任务ID
Queue string `json:"queue"` // 队列名称
TriggerUserID uint `json:"trigger_user_id"` // 触发人ID
TriggerAccount string `json:"trigger_account"` // 触发人账号
Status string `json:"status"` // 执行状态
Attempt int `json:"attempt"` // 当前尝试次数
MaxRetry int `json:"max_retry"` // 最大重试次数
Payload string `json:"payload"` // 任务 payload
ErrorMessage string `json:"error_message"` // 失败原因
StartedAt *utils.FormatDate `json:"started_at"` // 开始时间
FinishedAt *utils.FormatDate `json:"finished_at"` // 结束时间
DurationMS float64 `json:"duration_ms"` // 执行耗时毫秒
}
func NewTaskRun() *TaskRun {
return BindModel(&TaskRun{})
}
func (m *TaskRun) TableName() string {
return "task_runs"
}
// TaskRunEvent 表示任务执行过程中的状态事件。
type TaskRunEvent struct {
BaseModel
RunID uint `json:"run_id"` // 任务执行记录ID
EventType string `json:"event_type"` // 事件类型
Message string `json:"message"` // 事件说明
Meta string `json:"meta"` // 事件元数据 JSON
}
func NewTaskRunEvent() *TaskRunEvent {
return BindModel(&TaskRunEvent{})
}
func (m *TaskRunEvent) TableName() string {
return "task_run_events"
}
// CronTaskState 保存定时任务最近一次执行状态。
type CronTaskState struct {
BaseModel
TaskCode string `json:"task_code"` // 任务唯一编码
CronSpec string `json:"cron_spec"` // Cron 表达式
LastRunID uint `json:"last_run_id"` // 最近执行记录ID
LastStatus string `json:"last_status"` // 最近执行状态
LastStartedAt *utils.FormatDate `json:"last_started_at"` // 最近开始时间
LastFinishedAt *utils.FormatDate `json:"last_finished_at"` // 最近结束时间
NextRunAt *utils.FormatDate `json:"next_run_at"` // 下次执行时间
LastError string `json:"last_error"` // 最近失败原因
}
func NewCronTaskState() *CronTaskState {
return BindModel(&CronTaskState{})
}
func (m *CronTaskState) TableName() string {
return "cron_task_states"
}
================================================
FILE: internal/pkg/auditdiff/diff.go
================================================
package auditdiff
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
)
// ChangeDiffItem 表示单个字段变更。
type ChangeDiffItem struct {
Field string `json:"field"`
Label string `json:"label,omitempty"`
Before any `json:"before,omitempty"`
After any `json:"after,omitempty"`
BeforeDisplay string `json:"before_display,omitempty"`
AfterDisplay string `json:"after_display,omitempty"`
}
// FieldRule 描述字段 diff 规则。
type FieldRule struct {
Field string
Label string
ValueLabels map[string]string
Formatter func(value any) string
}
// BuildFieldDiff 按字段规则构建 before/after 差异。
func BuildFieldDiff(before, after map[string]any, rules []FieldRule) []ChangeDiffItem {
if len(rules) == 0 {
return nil
}
result := make([]ChangeDiffItem, 0, len(rules))
for _, rule := range rules {
if strings.TrimSpace(rule.Field) == "" {
continue
}
beforeValue, hasBefore := before[rule.Field]
afterValue, hasAfter := after[rule.Field]
if !hasBefore && !hasAfter {
continue
}
if valuesEqual(beforeValue, afterValue) {
continue
}
item := ChangeDiffItem{
Field: rule.Field,
Label: strings.TrimSpace(rule.Label),
}
if item.Label == "" {
item.Label = rule.Field
}
if hasBefore {
item.Before = beforeValue
item.BeforeDisplay = formatDisplayValue(rule, beforeValue)
}
if hasAfter {
item.After = afterValue
item.AfterDisplay = formatDisplayValue(rule, afterValue)
}
result = append(result, item)
}
return result
}
// Marshal 将 diff 项编码为 JSON 字符串;空 diff 返回 []。
func Marshal(items []ChangeDiffItem) string {
if len(items) == 0 {
return "[]"
}
raw, err := json.Marshal(items)
if err != nil {
return "[]"
}
return string(raw)
}
func formatDisplayValue(rule FieldRule, value any) string {
if rule.Formatter != nil {
return strings.TrimSpace(rule.Formatter(value))
}
if len(rule.ValueLabels) == 0 {
return ""
}
key := valueLabelKey(value)
if key == "" {
return ""
}
return strings.TrimSpace(rule.ValueLabels[key])
}
func valueLabelKey(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case bool:
return strconv.FormatBool(v)
case int:
return strconv.Itoa(v)
case int8:
return strconv.FormatInt(int64(v), 10)
case int16:
return strconv.FormatInt(int64(v), 10)
case int32:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case uint:
return strconv.FormatUint(uint64(v), 10)
case uint8:
return strconv.FormatUint(uint64(v), 10)
case uint16:
return strconv.FormatUint(uint64(v), 10)
case uint32:
return strconv.FormatUint(uint64(v), 10)
case uint64:
return strconv.FormatUint(v, 10)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
default:
return strings.TrimSpace(fmt.Sprintf("%v", value))
}
}
func valuesEqual(before, after any) bool {
if reflect.DeepEqual(before, after) {
return true
}
beforeKey := valueLabelKey(before)
afterKey := valueLabelKey(after)
if beforeKey != "" || afterKey != "" {
return beforeKey == afterKey
}
beforeRaw, beforeErr := json.Marshal(before)
afterRaw, afterErr := json.Marshal(after)
if beforeErr != nil || afterErr != nil {
return false
}
return string(beforeRaw) == string(afterRaw)
}
================================================
FILE: internal/pkg/auditdiff/diff_test.go
================================================
package auditdiff
import (
"encoding/json"
"testing"
)
func TestBuildFieldDiffMapsStatusLabel(t *testing.T) {
items := BuildFieldDiff(
map[string]any{"status": uint8(0), "remark": "old"},
map[string]any{"status": uint8(1), "remark": "old"},
[]FieldRule{
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{Field: "remark", Label: "备注"},
},
)
if len(items) != 1 {
t.Fatalf("expected 1 diff item, got %d", len(items))
}
if items[0].Field != "status" {
t.Fatalf("expected field status, got %s", items[0].Field)
}
if items[0].BeforeDisplay != "禁用" || items[0].AfterDisplay != "启用" {
t.Fatalf("unexpected display mapping: before=%s after=%s", items[0].BeforeDisplay, items[0].AfterDisplay)
}
}
func TestMarshalReturnsJSONString(t *testing.T) {
raw := Marshal([]ChangeDiffItem{{
Field: "status",
Label: "状态",
Before: 0,
After: 1,
}})
if raw == "" {
t.Fatal("expected non-empty json")
}
var decoded []map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("expected valid json, got %v", err)
}
if len(decoded) != 1 {
t.Fatalf("expected 1 decoded item, got %d", len(decoded))
}
}
func TestMarshalReturnsEmptyArrayWhenNoDiff(t *testing.T) {
if got := Marshal(nil); got != "[]" {
t.Fatalf("expected [] for nil diff, got %s", got)
}
}
================================================
FILE: internal/pkg/errors/code.go
================================================
package errors
import (
"fmt"
"strings"
)
const (
SUCCESS = 0
FAILURE = 1
AuthorizationErr = 403
NotFound = 404
CaptchaErr = 400
NotLogin = 401
ServerErr = 500
InvalidParameter = 10000
UserDoesNotExist = 10001
UserDisable = 10002
ServiceDependencyNotReady = 10003
TooManyRequests = 10102
// 文件相关错误码 11000-11999
FileIdentifierInvalid = 11001
FilePrivateAuthNeeded = 11002
FileAccessDenied = 11003
FileUploadPartialFail = 11004
FileReferenced = 11005
// 业务错误码 20000-29999
UserPasswordWrong = 20001
UserExists = 20002
PhoneNumberExists = 20003
EmailExists = 20004
UsernameRequired = 20005
NicknameRequired = 20006
PasswordProcessFailed = 20007
SuperAdminCannotModify = 20008
SuperAdminCannotDisable = 20009
SuperAdminCannotDelete = 20010
SamePassword = 20011
RoleNotFound = 20012
RoleExists = 20013
RoleHasChildren = 20014
RoleCannotDelete = 20015
ParentRoleNotExists = 20016
ParentRoleInvalid = 20017
MaxRoleDepth = 20018
MaxChildRoles = 20019
MenuNotFound = 20020
MenuExists = 20021
MenuHasChildren = 20022
MenuCannotDelete = 20023
DepartmentNotFound = 20024
DepartmentExists = 20025
DepartmentHasChildren = 20026
DepartmentCannotDelete = 20027
ParentDeptNotExists = 20028
ParentDeptInvalid = 20029
MaxDeptDepth = 20030
CasbinInitFailed = 20031
TokenGenerateFailed = 20032
LoginFailed = 20033
CreateUserFailed = 20034
UpdateUserFailed = 20035
DeleteUserFailed = 20036
QueryUserDeptFailed = 20037
SuperAdminMustKeepRole = 20038
MaxMenuDepth = 20039
ParentMenuNotExists = 20040
ParentMenuTypeInvalid = 20041
ParentMenuInvalid = 20042
MenuCodeExists = 20043
MenuRouteNameExists = 20044
MenuPathExists = 20045
LoginAccountLocked = 20046
PasswordRequired = 20047
)
const (
MsgKeyAuthSessionExpired = "auth.session.expired"
MsgKeyAuthPermissionInitFailed = "auth.permission.init_failed"
MsgKeyAuthPermissionCheckFailed = "auth.permission.check_failed"
MsgKeyAuthAPIOperationDenied = "auth.api.operation_denied"
MsgKeyAuthAccountLocked = "auth.account.locked"
)
// ErrorText 根据语言返回业务错误文案。
type ErrorText struct {
Language string
}
// NewErrorText 创建错误文案解析器。
func NewErrorText(language string) *ErrorText {
return &ErrorText{
Language: language,
}
}
// Text 按错误码和语言返回错误消息。
func (et *ErrorText) Text(code int) (str string) {
var ok bool
switch et.Language {
case "zh_CN":
str, ok = zhCNText[code]
case "en":
str, ok = enUSText[code]
default:
str, ok = zhCNText[code]
}
if !ok {
return "unknown error"
}
return
}
// TextByKey 按语言和文案 key 返回错误消息。
func (et *ErrorText) TextByKey(key string, args ...any) (string, bool) {
key = strings.TrimSpace(key)
if key == "" {
return "", false
}
var (
template string
ok bool
)
switch et.Language {
case "zh_CN":
template, ok = zhCNTextKey[key]
case "en":
template, ok = enUSTextKey[key]
default:
template, ok = zhCNTextKey[key]
}
if !ok {
return "", false
}
if len(args) == 0 {
return template, true
}
return fmt.Sprintf(template, args...), true
}
================================================
FILE: internal/pkg/errors/code_test.go
================================================
package errors
import (
"testing"
)
func TestText(t *testing.T) {
var errorText = NewErrorText("zh_CN")
if "OK" != errorText.Text(0) {
t.Error("text 返回 msg 不是预期的")
}
if "文件存在引用,不能删除" != errorText.Text(FileReferenced) {
t.Error("文件引用错误码文案不是预期的")
}
if "unknown error" != errorText.Text(1202389) {
t.Error("text 返回 msg 不是预期的")
}
}
func TestTextByKey(t *testing.T) {
errorText := NewErrorText("zh_CN")
msg, ok := errorText.TextByKey(MsgKeyAuthPermissionInitFailed)
if !ok {
t.Fatal("expected key exists")
}
if msg != "权限验证初始化失败" {
t.Fatalf("unexpected msg: %s", msg)
}
if _, ok := errorText.TextByKey("not.exists"); ok {
t.Fatal("expected missing key")
}
}
================================================
FILE: internal/pkg/errors/en-us.go
================================================
package errors
var enUSText = map[int]string{
SUCCESS: "OK",
FAILURE: "FAIL",
NotFound: "resources not found",
ServerErr: "Internal server error",
TooManyRequests: "Too many requests",
InvalidParameter: "Parameter error",
UserDoesNotExist: "user does not exist",
UserDisable: "User is disabled",
ServiceDependencyNotReady: "service database is not configured, please contact the administrator",
AuthorizationErr: "You have no permission",
NotLogin: "Please login first",
CaptchaErr: "Captcha error",
// File-related error messages 11000-11999
FileIdentifierInvalid: "invalid file identifier",
FilePrivateAuthNeeded: "login required for private file access",
FileAccessDenied: "no permission to access this file",
FileUploadPartialFail: "partial image upload failure",
FileReferenced: "file is referenced and cannot be deleted",
// Business error messages 20000-29999
UserPasswordWrong: "Incorrect password",
UserExists: "User already exists",
PhoneNumberExists: "Phone number already exists",
EmailExists: "Email already exists",
UsernameRequired: "Username is required",
NicknameRequired: "Nickname is required",
PasswordRequired: "Password is required",
PasswordProcessFailed: "Password processing failed",
SuperAdminCannotModify: "Cannot modify default super admin password",
SuperAdminCannotDisable: "Cannot disable default super admin",
SuperAdminCannotDelete: "Cannot delete default super admin",
SamePassword: "New password cannot be the same as current password",
RoleNotFound: "Role not found",
RoleExists: "Role already exists",
RoleHasChildren: "Cannot delete role with child roles",
RoleCannotDelete: "Failed to delete role",
ParentRoleNotExists: "Parent role not exists",
ParentRoleInvalid: "Parent role cannot be itself or its child role",
MaxRoleDepth: "Can only create up to 2 levels of roles",
MaxChildRoles: "Each top-level role can have up to 5 child roles",
MenuNotFound: "Menu not found",
MenuExists: "Menu already exists",
MenuHasChildren: "Cannot delete menu with child menus",
MenuCannotDelete: "Failed to delete menu",
DepartmentNotFound: "Department not found",
DepartmentExists: "Department already exists",
DepartmentHasChildren: "Cannot delete department with child departments",
DepartmentCannotDelete: "Failed to delete department",
ParentDeptNotExists: "Parent department not exists",
ParentDeptInvalid: "Parent department cannot be itself or its child department",
MaxDeptDepth: "Department level exceeds limit",
CasbinInitFailed: "Permission initialization failed",
TokenGenerateFailed: "Failed to generate token",
LoginFailed: "Login failed, please try again later",
CreateUserFailed: "Failed to create user, please try again",
UpdateUserFailed: "Failed to update user, please try again",
DeleteUserFailed: "Failed to delete user, please try again",
QueryUserDeptFailed: "Failed to query user department association",
SuperAdminMustKeepRole: "Default super admin must keep super admin role",
MaxMenuDepth: "Can only create up to 4 levels of menu",
ParentMenuNotExists: "Parent menu not exists",
ParentMenuTypeInvalid: "Parent menu cannot be a button",
ParentMenuInvalid: "Parent menu cannot be itself or its child menu",
MenuCodeExists: "Permission code already exists",
MenuRouteNameExists: "Route name already exists",
MenuPathExists: "Route already exists",
LoginAccountLocked: "Account is locked, please try again later",
}
var enUSTextKey = map[string]string{
MsgKeyAuthSessionExpired: "Session expired, please log in again",
MsgKeyAuthPermissionInitFailed: "Permission engine initialization failed",
MsgKeyAuthPermissionCheckFailed: "Permission check failed",
MsgKeyAuthAPIOperationDenied: "No permission to operate this API",
MsgKeyAuthAccountLocked: "Account is locked, please try again in %d minutes",
}
================================================
FILE: internal/pkg/errors/error.go
================================================
package errors
import (
stderrors "errors"
"fmt"
"strings"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/model"
)
// BusinessError 表示带业务码的可控错误。
type BusinessError struct {
code int
message string
messageKey string
messageArgs []any
explicitMessage bool
contextErrs []error
}
// Error 实现 error 接口。
func (e *BusinessError) Error() string {
if len(e.contextErrs) == 0 {
return fmt.Sprintf("[Code]:%d [Msg]:%s", e.code, e.message)
}
msgs := make([]string, 0, len(e.contextErrs))
for _, err := range e.contextErrs {
msgs = append(msgs, err.Error())
}
return fmt.Sprintf("[Code]:%d [Msg]:%s, [context error] %s", e.code, e.message, strings.Join(msgs, "; "))
}
// GetCode 返回业务错误码。
func (e *BusinessError) GetCode() int {
return e.code
}
// GetMessage 返回业务错误消息。
func (e *BusinessError) GetMessage() string {
return e.message
}
// GetMessageKey 返回业务错误文案 key。
func (e *BusinessError) GetMessageKey() string {
return e.messageKey
}
// GetMessageArgs 返回业务错误文案参数。
func (e *BusinessError) GetMessageArgs() []any {
return e.messageArgs
}
// HasExplicitMessage 返回是否为业务代码调用方显式提供的消息文本。
func (e *BusinessError) HasExplicitMessage() bool {
return e.explicitMessage
}
// HasMessageKey 返回是否携带文案 key。
func (e *BusinessError) HasMessageKey() bool {
return strings.TrimSpace(e.messageKey) != ""
}
// SetCode 设置业务错误码。
func (e *BusinessError) SetCode(code int) {
e.code = code
}
// SetMessage 设置业务错误消息。
func (e *BusinessError) SetMessage(message string) {
e.message = message
e.messageKey = ""
e.messageArgs = nil
e.explicitMessage = strings.TrimSpace(message) != ""
}
// AppendContextErr 追加底层上下文错误。
func (e *BusinessError) AppendContextErr(err error) {
e.contextErrs = append(e.contextErrs, err)
}
// GetContextErr 返回附带的上下文错误列表。
func (e *BusinessError) GetContextErr() []error {
return e.contextErrs
}
// NewBusinessError 创建业务错误。
func NewBusinessError(code int, message ...string) *BusinessError {
msg := ""
explicitMessage := false
if len(message) > 0 && strings.TrimSpace(message[0]) != "" {
msg = message[0]
explicitMessage = true
} else {
msg = NewErrorText(c.GetConfig().Language).Text(code)
}
return &BusinessError{
code: code,
message: msg,
explicitMessage: explicitMessage,
}
}
// NewBusinessErrorWithKey 创建带文案 key 的业务错误。
func NewBusinessErrorWithKey(code int, messageKey string, messageArgs ...any) *BusinessError {
msg := ""
if key := strings.TrimSpace(messageKey); key != "" {
if translated, ok := NewErrorText(c.GetConfig().Language).TextByKey(key, messageArgs...); ok {
msg = translated
}
}
if msg == "" {
msg = NewErrorText(c.GetConfig().Language).Text(code)
}
return &BusinessError{
code: code,
message: msg,
messageKey: strings.TrimSpace(messageKey),
messageArgs: append([]any(nil), messageArgs...),
explicitMessage: false,
}
}
// Error 提供错误转换辅助方法。
type Error struct{}
// AsBusinessError 尝试把任意错误转换为 BusinessError。
func (e *Error) AsBusinessError(err error) (*BusinessError, error) {
var be *BusinessError
if stderrors.As(err, &be) {
return be, nil
}
return nil, err
}
// NewDependencyNotReadyError 返回统一的依赖未就绪业务错误。
func NewDependencyNotReadyError(message ...string) *BusinessError {
return NewBusinessError(ServiceDependencyNotReady, message...)
}
// IsDependencyNotReady 判断错误是否表示底层依赖尚未就绪。
func IsDependencyNotReady(err error) bool {
if err == nil {
return false
}
if stderrors.Is(err, model.ErrDBUninitialized) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "mysql not initialized")
}
================================================
FILE: internal/pkg/errors/zh-cn.go
================================================
package errors
var zhCNText = map[int]string{
SUCCESS: "OK",
FAILURE: "FAIL",
NotFound: "资源不存在",
InvalidParameter: "参数错误",
ServerErr: "服务器内部错误",
TooManyRequests: "请求过多",
UserDoesNotExist: "用户不存在",
UserDisable: "用户已被禁用",
ServiceDependencyNotReady: "服务暂未配置数据库,请联系管理员",
AuthorizationErr: "暂无权限",
NotLogin: "请先登录",
CaptchaErr: "验证码错误",
// 文件相关错误消息 11000-11999
FileIdentifierInvalid: "文件标识错误",
FilePrivateAuthNeeded: "访问私有文件需要登录认证",
FileAccessDenied: "无权访问该文件",
FileUploadPartialFail: "部分图片上传失败",
FileReferenced: "文件存在引用,不能删除",
// 业务错误消息 20000-29999
UserPasswordWrong: "用户密码错误",
UserExists: "用户已存在",
PhoneNumberExists: "手机号已存在",
EmailExists: "邮箱已存在",
UsernameRequired: "用户名必填",
NicknameRequired: "昵称必填",
PasswordRequired: "密码必填",
PasswordProcessFailed: "密码处理失败",
SuperAdminCannotModify: "系统默认超级管理员不允许修改密码",
SuperAdminCannotDisable: "系统默认超级管理员不允许被禁用",
SuperAdminCannotDelete: "系统默认超级管理员不允许删除",
SamePassword: "新密码不能与当前密码相同",
RoleNotFound: "角色不存在",
RoleExists: "角色已存在",
RoleHasChildren: "该角色有子角色,无法删除",
RoleCannotDelete: "删除角色失败",
ParentRoleNotExists: "上级角色不存在",
ParentRoleInvalid: "上级角色不能是当前角色自身或其子角色",
MaxRoleDepth: "最多只能创建2层角色",
MaxChildRoles: "每个顶级角色下最多只能创建5个子角色",
MenuNotFound: "菜单不存在",
MenuExists: "菜单已存在",
MenuHasChildren: "该菜单有子菜单,无法删除",
MenuCannotDelete: "删除菜单失败",
DepartmentNotFound: "部门不存在",
DepartmentExists: "部门已存在",
DepartmentHasChildren: "该部门有子部门,无法删除",
DepartmentCannotDelete: "删除部门失败",
ParentDeptNotExists: "上级部门不存在",
ParentDeptInvalid: "上级部门不能是当前部门自身或其子部门",
MaxDeptDepth: "部门层级超出限制",
CasbinInitFailed: "权限验证初始化失败",
TokenGenerateFailed: "生成Token失败",
LoginFailed: "登录失败,请稍后重试",
CreateUserFailed: "创建用户失败,请重试",
UpdateUserFailed: "更新用户失败,请重试",
DeleteUserFailed: "删除用户失败,请重试",
QueryUserDeptFailed: "查询用户部门关联失败",
SuperAdminMustKeepRole: "系统默认超级管理员必须保留超级管理员角色",
MaxMenuDepth: "最多只能创建 4 层菜单",
ParentMenuNotExists: "上级菜单不存在",
ParentMenuTypeInvalid: "上级菜单不能是按钮类型",
ParentMenuInvalid: "上级菜单不能是当前菜单自身或其子菜单",
MenuCodeExists: "权限标识已存在",
MenuRouteNameExists: "路由名称已存在",
MenuPathExists: "路由已存在",
LoginAccountLocked: "账号已被锁定,请稍后重试",
}
var zhCNTextKey = map[string]string{
MsgKeyAuthSessionExpired: "登录已失效,请重新登录",
MsgKeyAuthPermissionInitFailed: "权限验证初始化失败",
MsgKeyAuthPermissionCheckFailed: "权限验证失败",
MsgKeyAuthAPIOperationDenied: "暂无接口操作权限",
MsgKeyAuthAccountLocked: "账号已被锁定,请在 %d 分钟后重试",
}
================================================
FILE: internal/pkg/func_make/func_make.go
================================================
package func_make
import (
"errors"
"reflect"
)
// FuncMap 保存可按名称调用的函数映射。
type FuncMap map[string]reflect.Value
// New 创建一个空的函数映射表。
func New() FuncMap {
return make(FuncMap, 2)
}
// Register 注册单个函数。
func (f FuncMap) Register(name string, fn any) error {
v := reflect.ValueOf(fn)
if v.Kind() != reflect.Func {
return errors.New(name + " is not a function type.")
}
f[name] = v
return nil
}
// Registers 批量注册函数。
func (f FuncMap) Registers(funcMap map[string]any) (err error) {
for k, v := range funcMap {
err = f.Register(k, v)
if err != nil {
break
}
}
return
}
// Call 按名称调用已注册函数。
func (f FuncMap) Call(name string, params ...any) (result []reflect.Value, err error) {
if _, ok := f[name]; !ok {
err = errors.New(name + " method does not exist.")
return
}
in := make([]reflect.Value, len(params))
for k, param := range params {
in[k] = reflect.ValueOf(param)
}
defer func() {
if e := recover(); e != nil {
err = errors.New("call " + name + " method fail. " + e.(string))
}
}()
result = f[name].Call(in)
return
}
================================================
FILE: internal/pkg/func_make/func_make_test.go
================================================
package func_make
import (
"testing"
)
var (
funcMap = map[string]interface{}{
"test": func(str string) string {
return str
},
}
funcMake = New()
)
func TestRegisters(t *testing.T) {
err := funcMake.Registers(funcMap)
if err != nil {
t.Errorf("绑定失败")
}
}
func TestRegister(t *testing.T) {
err := funcMake.Register("test1", func(str ...string) string {
var res string
for _, v := range str {
res += v
}
return res
})
if err != nil {
t.Errorf("绑定失败")
}
}
func TestCall(t *testing.T) {
TestRegisters(t)
TestRegister(t)
if _, err := funcMake.Call("test", "1"); err != nil {
t.Errorf("请求test方法失败:%s", err)
}
if _, err := funcMake.Call("test1", "2323", "ddd"); err != nil {
t.Errorf("请求test1方法失败:%s", err)
}
}
================================================
FILE: internal/pkg/i18n/locale.go
================================================
package i18n
import (
"encoding/json"
"sort"
"strings"
)
const (
LocaleZhCN = "zh-CN"
LocaleEnUS = "en-US"
DefaultLocale = LocaleZhCN
)
// NormalizeLocale 归一化语言标签,仅支持项目当前定义的语言集合。
func NormalizeLocale(locale string) string {
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(locale), "_", "-"))
if normalized == "" {
return DefaultLocale
}
switch {
case strings.HasPrefix(normalized, "zh"):
return LocaleZhCN
case strings.HasPrefix(normalized, "en"):
return LocaleEnUS
default:
return DefaultLocale
}
}
// ParseAcceptLanguage 从 Accept-Language 请求头中解析语言。
func ParseAcceptLanguage(headerValue string) string {
if strings.TrimSpace(headerValue) == "" {
return DefaultLocale
}
items := strings.Split(headerValue, ",")
for _, item := range items {
segment := strings.TrimSpace(item)
if segment == "" {
continue
}
tag := strings.TrimSpace(strings.Split(segment, ";")[0])
if tag == "" {
continue
}
return NormalizeLocale(tag)
}
return DefaultLocale
}
// ParseLocaleMap 将 title_i18n 的 JSON 字符串解析为归一化 map。
func ParseLocaleMap(raw string) map[string]string {
result := make(map[string]string)
if strings.TrimSpace(raw) == "" {
return result
}
parsed := make(map[string]string)
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return result
}
for key, value := range parsed {
trimmedValue := strings.TrimSpace(value)
if trimmedValue == "" {
continue
}
result[NormalizeLocale(key)] = trimmedValue
}
return result
}
// MarshalLocaleMap 将多语言 map 序列化为 JSON 字符串。
func MarshalLocaleMap(data map[string]string) string {
if len(data) == 0 {
return ""
}
normalized := make(map[string]string, len(data))
for key, value := range data {
trimmedValue := strings.TrimSpace(value)
if trimmedValue == "" {
continue
}
normalized[NormalizeLocale(key)] = trimmedValue
}
if len(normalized) == 0 {
return ""
}
encoded, err := json.Marshal(normalized)
if err != nil {
return ""
}
return string(encoded)
}
// ResolveLocalizedText 根据请求语言从多语言文案中解析最终展示文本。
func ResolveLocalizedText(defaultText string, i18nRaw string, locale string) string {
translations := ParseLocaleMap(i18nRaw)
if len(translations) > 0 {
if text := strings.TrimSpace(translations[NormalizeLocale(locale)]); text != "" {
return text
}
if text := strings.TrimSpace(translations[LocaleZhCN]); text != "" {
return text
}
if text := strings.TrimSpace(translations[LocaleEnUS]); text != "" {
return text
}
keys := make([]string, 0, len(translations))
for key := range translations {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if text := strings.TrimSpace(translations[key]); text != "" {
return text
}
}
}
return strings.TrimSpace(defaultText)
}
// MergeLocaleJSON 合并历史与本次提交的多语言文案,并返回持久化 JSON 及默认标题字段值。
func MergeLocaleJSON(existingRaw string, incoming map[string]string, locale string, fallbackTitle string) (string, string) {
next := ParseLocaleMap(existingRaw)
for key, value := range incoming {
trimmedValue := strings.TrimSpace(value)
if trimmedValue == "" {
continue
}
next[NormalizeLocale(key)] = trimmedValue
}
normalizedLocale := NormalizeLocale(locale)
trimmedFallback := strings.TrimSpace(fallbackTitle)
if trimmedFallback != "" {
if _, exists := next[normalizedLocale]; !exists {
next[normalizedLocale] = trimmedFallback
}
}
defaultTitle := strings.TrimSpace(next[LocaleZhCN])
if defaultTitle == "" {
defaultTitle = strings.TrimSpace(trimmedFallback)
}
if defaultTitle == "" {
defaultTitle = ResolveLocalizedText("", MarshalLocaleMap(next), normalizedLocale)
}
return MarshalLocaleMap(next), defaultTitle
}
// ToErrorLanguage 将请求语言转换为错误文案模块使用的语言代码。
func ToErrorLanguage(locale string) string {
if NormalizeLocale(locale) == LocaleEnUS {
return "en"
}
return "zh_CN"
}
================================================
FILE: internal/pkg/i18n/locale_test.go
================================================
package i18n
import "testing"
func TestParseAcceptLanguage(t *testing.T) {
tests := []struct {
name string
header string
want string
}{
{name: "empty", header: "", want: LocaleZhCN},
{name: "english list", header: "en-US,en;q=0.9,zh;q=0.8", want: LocaleEnUS},
{name: "zh underscore", header: "zh_CN", want: LocaleZhCN},
{name: "unsupported", header: "fr-FR,fr;q=0.8", want: LocaleZhCN},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ParseAcceptLanguage(tt.header); got != tt.want {
t.Fatalf("ParseAcceptLanguage() = %q, want %q", got, tt.want)
}
})
}
}
func TestResolveLocalizedText(t *testing.T) {
raw := `{"zh-CN":"菜单","en-US":"Menu"}`
if got := ResolveLocalizedText("默认", raw, LocaleEnUS); got != "Menu" {
t.Fatalf("expected english text, got %q", got)
}
if got := ResolveLocalizedText("默认", raw, "zh_CN"); got != "菜单" {
t.Fatalf("expected chinese text, got %q", got)
}
if got := ResolveLocalizedText("默认", raw, "fr-FR"); got != "菜单" {
t.Fatalf("expected fallback chinese text, got %q", got)
}
}
func TestMergeLocaleJSON(t *testing.T) {
existing := `{"zh-CN":"菜单"}`
incoming := map[string]string{"en-US": "Menu"}
raw, title := MergeLocaleJSON(existing, incoming, LocaleEnUS, "Menu")
if title != "菜单" {
t.Fatalf("default title should prefer zh-CN, got %q", title)
}
localized := ResolveLocalizedText("", raw, LocaleEnUS)
if localized != "Menu" {
t.Fatalf("expected merged english text, got %q", localized)
}
}
================================================
FILE: internal/pkg/logger/logger.go
================================================
package logger
import (
"fmt"
"io"
"path/filepath"
"sync"
"sync/atomic"
"time"
rotatelogs "github.com/lestrrat-go/file-rotatelogs"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
"github.com/wannanbigpig/gin-layout/config"
)
var (
nopLogger = zap.NewNop()
Logger = nopLogger
loggerOnce sync.Once
loggerVal atomic.Value
loggerMu sync.Mutex
initErr error
)
// InitLogger 初始化全局日志实例。
func InitLogger() error {
loggerOnce.Do(func() {
logger, err := buildLogger(config.GetConfig())
if err != nil {
initErr = fmt.Errorf("创建zap日志包失败: %w", err)
return
}
setLogger(logger)
})
return initErr
}
// ReloadLogger 根据新配置重建全局日志实例。
func ReloadLogger(cfg *config.Conf) error {
loggerMu.Lock()
defer loggerMu.Unlock()
next, err := buildLogger(cfg)
if err != nil {
return err
}
old := current()
setLogger(next)
if old != nil {
_ = old.Sync()
}
return nil
}
func current() *zap.Logger {
if logger, ok := loggerVal.Load().(*zap.Logger); ok && logger != nil {
return logger
}
if Logger != nil {
return Logger
}
return nopLogger
}
func setLogger(logger *zap.Logger) {
if logger == nil {
logger = nopLogger
}
Logger = logger
loggerVal.Store(logger)
}
// ReplaceLoggerForTesting 临时替换全局 logger,并返回恢复函数。
func ReplaceLoggerForTesting(logger *zap.Logger) func() {
loggerMu.Lock()
previous := current()
setLogger(logger)
loggerMu.Unlock()
return func() {
loggerMu.Lock()
setLogger(previous)
loggerMu.Unlock()
}
}
// Info 使用当前全局 logger 记录 info 日志。
func Info(msg string, fields ...zap.Field) {
current().Info(msg, fields...)
}
// Error 使用当前全局 logger 记录 error 日志。
func Error(msg string, fields ...zap.Field) {
current().Error(msg, fields...)
}
// Warn 使用当前全局 logger 记录 warn 日志。
func Warn(msg string, fields ...zap.Field) {
current().Warn(msg, fields...)
}
// buildLogger 初始化 zap 日志
func buildLogger(cfg *config.Conf) (*zap.Logger, error) {
if cfg == nil {
cfg = config.GetConfig()
}
if cfg.Logger.Output == "stderr" {
return zap.NewDevelopment()
}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format("2006-01-02 15:04:05.000"))
}
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
encoder := zapcore.NewConsoleEncoder(encoderConfig)
filename := filepath.Join(cfg.BasePath, "logs", cfg.Logger.Filename)
var writer zapcore.WriteSyncer
if cfg.Logger.DefaultDivision == "size" {
writer = zapcore.AddSync(getLumberJackWriter(cfg, filename))
} else {
rotateWriter, err := getRotateWriter(cfg, filename)
if err != nil {
return nil, err
}
writer = zapcore.AddSync(rotateWriter)
}
zapCore := zapcore.NewCore(encoder, writer, zap.InfoLevel)
return zap.New(zapCore, zap.AddCaller()), nil
}
// getRotateWriter 按日期切割日志
func getRotateWriter(cfg *config.Conf, filename string) (io.Writer, error) {
maxAge := time.Duration(cfg.Logger.DivisionTime.MaxAge)
rotationTime := time.Duration(cfg.Logger.DivisionTime.RotationTime)
hook, err := rotatelogs.New(
filename+".%Y%m%d",
rotatelogs.WithLinkName(filename),
rotatelogs.WithMaxAge(time.Hour*24*maxAge),
rotatelogs.WithRotationTime(time.Hour*rotationTime),
)
if err != nil {
return nil, err
}
return hook, nil
}
// getLumberJackWriter 按文件切割日志
func getLumberJackWriter(cfg *config.Conf, filename string) io.Writer {
return &lumberjack.Logger{
Filename: filename,
MaxSize: cfg.Logger.DivisionSize.MaxSize,
MaxBackups: cfg.Logger.DivisionSize.MaxBackups,
MaxAge: cfg.Logger.DivisionSize.MaxAge,
Compress: cfg.Logger.DivisionSize.Compress,
}
}
================================================
FILE: internal/pkg/logger/logger_test.go
================================================
package logger
import "testing"
func TestLoggerDefaultIsNotNil(t *testing.T) {
if Logger == nil {
t.Fatal("expected default logger to be non-nil")
}
}
func TestLoggerWrappersDoNotPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("expected wrappers not to panic, got %v", r)
}
}()
Info("info test")
Warn("warn test")
Error("error test")
}
================================================
FILE: internal/pkg/query_builder/query_builder.go
================================================
package query_builder
import "strings"
type QueryBuilder struct {
conditions []string
args []any
}
func New() *QueryBuilder {
return &QueryBuilder{
conditions: make([]string, 0),
args: make([]any, 0),
}
}
func (qb *QueryBuilder) AddCondition(cond string, args ...any) *QueryBuilder {
if cond != "" {
qb.conditions = append(qb.conditions, cond)
qb.args = append(qb.args, args...)
}
return qb
}
func (qb *QueryBuilder) AddLike(field, value string) *QueryBuilder {
if value != "" {
qb.conditions = append(qb.conditions, field+" like ?")
qb.args = append(qb.args, "%"+value+"%")
}
return qb
}
func (qb *QueryBuilder) AddEq(field string, value any) *QueryBuilder {
if hasValue(value) {
qb.conditions = append(qb.conditions, field+" = ?")
qb.args = append(qb.args, value)
}
return qb
}
func (qb *QueryBuilder) AddIn(field string, values []uint) *QueryBuilder {
if len(values) > 0 {
qb.conditions = append(qb.conditions, field+" IN (?)")
qb.args = append(qb.args, values)
}
return qb
}
func (qb *QueryBuilder) AddExists(subQuery string) *QueryBuilder {
if subQuery != "" {
qb.conditions = append(qb.conditions, "EXISTS ("+subQuery+")")
}
return qb
}
func (qb *QueryBuilder) AddConditionf(cond string, args ...any) *QueryBuilder {
return qb.AddCondition(cond, args...)
}
func (qb *QueryBuilder) AddKeywordLike(keyword string, fields ...string) *QueryBuilder {
if keyword == "" || len(fields) == 0 {
return qb
}
clauses := make([]string, 0, len(fields))
for range fields {
qb.args = append(qb.args, "%"+keyword+"%")
}
for _, field := range fields {
clauses = append(clauses, field+" like ?")
}
qb.conditions = append(qb.conditions, "("+strings.Join(clauses, " OR ")+")")
return qb
}
func (qb *QueryBuilder) Build() (string, []any) {
if len(qb.conditions) == 0 {
return "", nil
}
cond := qb.conditions[0]
for i := 1; i < len(qb.conditions); i++ {
cond += " AND " + qb.conditions[i]
}
return cond, qb.args
}
func hasValue(value any) bool {
if value == nil {
return false
}
switch typed := value.(type) {
case string:
return typed != ""
case *string:
return typed != nil && *typed != ""
case *int8:
return typed != nil
case *uint8:
return typed != nil
case *uint:
return typed != nil
case *int:
return typed != nil
default:
return true
}
}
================================================
FILE: internal/pkg/query_builder/query_builder_test.go
================================================
package query_builder
import "testing"
func TestQueryBuilderBuildsExpectedCondition(t *testing.T) {
status := int8(1)
pid := uint(3)
condition, args := New().
AddKeywordLike("dashboard", "title", "path").
AddEq("status", &status).
AddEq("pid", &pid).
Build()
expected := "(title like ? OR path like ?) AND status = ? AND pid = ?"
if condition != expected {
t.Fatalf("unexpected condition: %s", condition)
}
if len(args) != 4 {
t.Fatalf("unexpected args len: %d", len(args))
}
}
func TestQueryBuilderSkipsEmptyValues(t *testing.T) {
empty := ""
condition, args := New().
AddLike("name", "").
AddEq("code", &empty).
Build()
if condition != "" {
t.Fatalf("expected empty condition, got %s", condition)
}
if args != nil {
t.Fatalf("expected nil args, got %#v", args)
}
}
================================================
FILE: internal/pkg/request/request.go
================================================
package request
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
)
// GetQueryParams 提取当前请求的查询参数。
// 说明:
// - 不做字段白名单过滤,调用方需自行约束参数使用范围;
// - 仅保留每个 key 的首个值(与 c.Query 行为一致)。
func GetQueryParams(c *gin.Context) map[string]any {
if c == nil {
return map[string]any{}
}
query := c.Request.URL.Query()
var queryMap = make(map[string]any, len(query))
for k := range query {
queryMap[k] = c.Query(k)
}
return queryMap
}
// GetAccessToken 从 Authorization 请求头提取 access token。
func GetAccessToken(c *gin.Context) (string, error) {
if c == nil {
return "", errors.New("gin context is nil")
}
authorization := c.GetHeader("Authorization")
return token.GetAccessToken(authorization)
}
================================================
FILE: internal/pkg/request/request_test.go
================================================
package request
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestGetAccessTokenSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/test", nil)
ctx.Request.Header.Set("Authorization", "Bearer token-value")
tokenValue, err := GetAccessToken(ctx)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if tokenValue != "token-value" {
t.Fatalf("unexpected token value %q", tokenValue)
}
}
func TestGetAccessTokenNilContext(t *testing.T) {
_, err := GetAccessToken(nil)
if err == nil {
t.Fatal("expected nil context to return error")
}
}
func TestGetQueryParamsNilContext(t *testing.T) {
params := GetQueryParams(nil)
if len(params) != 0 {
t.Fatalf("expected empty map, got %#v", params)
}
}
================================================
FILE: internal/pkg/response/response.go
================================================
package response
import (
"net/http"
"reflect"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
)
// Result API响应结果结构
type Result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
Cost string `json:"cost"`
RequestId string `json:"request_id"`
}
// NewResult 创建新的响应结果
func NewResult() *Result {
return &Result{
Code: 0,
Msg: "",
Data: emptyObject(),
Cost: "",
RequestId: "",
}
}
// Response 响应处理器
type Response struct {
httpCode int
result *Result
msgKey string
msgArgs []any
}
// Resp 创建响应处理器实例
func Resp() *Response {
return &Response{
httpCode: http.StatusOK,
result: NewResult(),
}
}
// Fail 错误返回
func (r *Response) Fail(c *gin.Context, code int, msg string, data ...any) {
r.SetCode(code)
r.SetMessage(msg)
if len(data) > 0 && data[0] != nil {
r.WithData(data[0])
}
r.json(c)
}
// FailCode 自定义错误码返回
func (r *Response) FailCode(c *gin.Context, code int, msg ...string) {
r.SetCode(code)
if len(msg) > 0 && msg[0] != "" {
r.SetMessage(msg[0])
}
r.json(c)
}
// FailCodeByKey 自定义错误码返回(按文案 key 国际化)。
func (r *Response) FailCodeByKey(c *gin.Context, code int, key string, args ...any) {
r.SetCode(code)
r.SetMessageKey(key, args...)
r.json(c)
}
// Success 正确返回
func (r *Response) Success(c *gin.Context) {
r.SetCode(errors.SUCCESS)
r.json(c)
}
// WithDataSuccess 成功后需要返回值
func (r *Response) WithDataSuccess(c *gin.Context, data interface{}) {
r.SetCode(errors.SUCCESS)
r.WithData(data)
r.json(c)
}
// SetCode 设置返回code码
func (r *Response) SetCode(code int) *Response {
r.result.Code = code
return r
}
// SetHttpCode 设置http状态码
func (r *Response) SetHttpCode(code int) *Response {
r.httpCode = code
return r
}
// defaultRes 默认响应数据结构
type defaultRes struct {
Result any `json:"result"`
}
// WithData 设置返回data数据
func (r *Response) WithData(data any) *Response {
if isNilData(data) {
r.result.Data = emptyObject()
return r
}
if !isObjectData(data) {
r.result.Data = &defaultRes{Result: data}
return r
}
r.result.Data = data
return r
}
// SetMessage 设置返回自定义错误消息
func (r *Response) SetMessage(message string) *Response {
r.result.Msg = message
r.msgKey = ""
r.msgArgs = nil
return r
}
// SetMessageKey 设置返回错误文案 key(供国际化解析)。
func (r *Response) SetMessageKey(key string, args ...any) *Response {
r.msgKey = key
r.msgArgs = append([]any(nil), args...)
return r
}
// json 返回 gin 框架的 JSON 响应
func (r *Response) json(c *gin.Context) {
// 如果消息为空,使用错误码对应的默认消息
if r.result.Msg == "" {
language := config.GetConfig().Language
if c != nil {
if locale, exists := c.Get(global.ContextKeyLocale); exists {
if localeText, ok := locale.(string); ok {
language = i18n.ToErrorLanguage(localeText)
}
}
}
errorText := errors.NewErrorText(language)
if r.msgKey != "" {
if msg, ok := errorText.TextByKey(r.msgKey, r.msgArgs...); ok && msg != "" {
r.result.Msg = msg
}
}
if r.result.Msg == "" {
r.result.Msg = errorText.Text(r.result.Code)
}
}
// 计算请求耗时
r.result.Cost = time.Since(c.GetTime(global.ContextKeyRequestStartTime)).String()
r.result.RequestId = c.GetString(global.ContextKeyRequestID)
c.AbortWithStatusJSON(r.httpCode, r.result)
}
// Success 业务成功响应(便捷方法)
func Success(c *gin.Context, data ...any) {
if len(data) > 0 && data[0] != nil {
Resp().WithDataSuccess(c, data[0])
return
}
Resp().Success(c)
}
// FailCode 业务失败响应(便捷方法)
func FailCode(c *gin.Context, code int, data ...any) {
if len(data) > 0 && data[0] != nil {
Resp().WithData(data[0]).FailCode(c, code)
return
}
Resp().FailCode(c, code)
}
// FailCodeByKey 业务失败响应(按 key 解析多语言文案)。
func FailCodeByKey(c *gin.Context, code int, key string, args ...any) {
Resp().FailCodeByKey(c, code, key, args...)
}
// Fail 业务失败响应(便捷方法)
func Fail(c *gin.Context, code int, message string, data ...any) {
if len(data) > 0 && data[0] != nil {
Resp().WithData(data[0]).Fail(c, code, message)
return
}
Resp().Fail(c, code, message)
}
func emptyObject() map[string]any {
return map[string]any{}
}
func isNilData(data any) bool {
if data == nil {
return true
}
value := reflect.ValueOf(data)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func isObjectData(data any) bool {
value := reflect.ValueOf(data)
// 解引用接口和指针,判断底层真实类型是否为对象形态。
for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
if value.IsNil() {
return false
}
value = value.Elem()
}
switch value.Kind() {
case reflect.Struct, reflect.Map:
return true
default:
return false
}
}
================================================
FILE: internal/pkg/response/response_test.go
================================================
package response
import (
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestSuccessDefaultsDataToEmptyObject(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-1")
Resp().Success(ctx)
var result Result
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
data, ok := result.Data.(map[string]any)
if !ok {
t.Fatalf("expected object data, got %#v", result.Data)
}
if len(data) != 0 {
t.Fatalf("expected empty object, got %#v", data)
}
}
func TestWithNilDataReturnsEmptyObject(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-2")
Resp().WithDataSuccess(ctx, nil)
var result Result
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
data, ok := result.Data.(map[string]any)
if !ok {
t.Fatalf("expected object data, got %#v", result.Data)
}
if len(data) != 0 {
t.Fatalf("expected empty object, got %#v", data)
}
}
func TestScalarDataStillWrapped(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-3")
Resp().WithDataSuccess(ctx, true)
var result struct {
Data struct {
Result bool `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
if !result.Data.Result {
t.Fatalf("expected wrapped scalar result=true")
}
}
func TestInt64DataStillWrapped(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-4")
Resp().WithDataSuccess(ctx, int64(42))
var result struct {
Data struct {
Result int64 `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
if result.Data.Result != 42 {
t.Fatalf("expected wrapped scalar result=42, got %d", result.Data.Result)
}
}
func TestTypedNilPointerReturnsEmptyObject(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-5")
var nilPayload *payload
Resp().WithDataSuccess(ctx, nilPayload)
var result Result
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
data, ok := result.Data.(map[string]any)
if !ok {
t.Fatalf("expected object data, got %#v", result.Data)
}
if len(data) != 0 {
t.Fatalf("expected empty object, got %#v", data)
}
}
func TestNilSliceReturnsEmptyObject(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-6")
var list []int
Resp().WithDataSuccess(ctx, list)
var result Result
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
data, ok := result.Data.(map[string]any)
if !ok {
t.Fatalf("expected object data, got %#v", result.Data)
}
if len(data) != 0 {
t.Fatalf("expected empty object, got %#v", data)
}
}
func TestSliceDataWrappedAsObject(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-7")
Resp().WithDataSuccess(ctx, []int{1, 2, 3})
var result struct {
Data struct {
Result []int `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
if len(result.Data.Result) != 3 {
t.Fatalf("expected wrapped slice length=3, got %d", len(result.Data.Result))
}
}
func TestFailCodeByKeyResolvesMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "req-key")
ctx.Set(global.ContextKeyLocale, "zh-CN")
Resp().FailCodeByKey(ctx, errors.ServerErr, errors.MsgKeyAuthPermissionInitFailed)
var result Result
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
if result.Code != errors.ServerErr {
t.Fatalf("expected code %d, got %d", errors.ServerErr, result.Code)
}
if result.Msg != "权限验证初始化失败" {
t.Fatalf("expected translated key message, got %q", result.Msg)
}
}
================================================
FILE: internal/pkg/testkit/secret.go
================================================
package testkit
import "strings"
// SecretKey 返回测试专用密钥,统一管理避免散落硬编码。
func SecretKey(scope string) string {
scope = strings.TrimSpace(scope)
if scope == "" {
scope = "default"
}
// 保持长度充足以满足生产级最小长度校验场景。
return "unit-test-secret-key-" + scope + "-0123456789abcdef"
}
================================================
FILE: internal/pkg/testkit/secret_test.go
================================================
package testkit
import "testing"
func TestSecretKey(t *testing.T) {
secret := SecretKey("auth")
if secret == "" {
t.Fatal("expected non-empty secret")
}
if len(secret) < 16 {
t.Fatalf("expected secret length >=16, got %d", len(secret))
}
fallback := SecretKey("")
if fallback == "" {
t.Fatal("expected fallback secret to be non-empty")
}
if fallback == secret {
t.Fatal("expected scoped and fallback secrets to differ")
}
}
================================================
FILE: internal/pkg/utils/desensitize.go
================================================
package utils
import (
"strings"
"unicode/utf8"
)
// DesensitizeRule 描述字符串脱敏策略。
type DesensitizeRule struct {
KeepPrefixLen int // 保留前缀长度
KeepSuffixLen int // 保留后缀长度
MaskChar rune // 脱敏字符
Separator rune // 特殊分隔符(如邮箱的@)
FixedMaskLength int // 固定脱敏长度(0表示不固定)
}
// NewPhoneRule 构建手机号码脱敏规则
func NewPhoneRule() *DesensitizeRule {
return &DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*', FixedMaskLength: 4}
}
// NewEmailRule 构建邮箱脱敏规则
func NewEmailRule() *DesensitizeRule {
return &DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3}
}
// Apply 按当前规则对输入字符串做脱敏。
func (r *DesensitizeRule) Apply(s string) string {
if utf8.RuneCountInString(s) == 0 {
return s
}
// 处理带分隔符的情况(如邮箱)
if r.Separator != 0 {
parts := strings.Split(s, string(r.Separator))
if len(parts) == 2 {
localPart := r.applyToPart(parts[0])
return localPart + string(r.Separator) + parts[1]
}
}
return r.applyToPart(s)
}
func (r *DesensitizeRule) applyToPart(s string) string {
runes := []rune(s)
length := len(runes)
// 计算需要保留的前后部分
keepPrefix := r.min(r.KeepPrefixLen, length)
keepSuffix := r.min(r.KeepSuffixLen, length-keepPrefix)
// 计算脱敏部分长度
var maskLength int
if r.FixedMaskLength > 0 {
maskLength = r.FixedMaskLength // 使用固定长度
} else {
maskLength = length - keepPrefix - keepSuffix // 使用可变长度
}
// 构建结果
var result strings.Builder
if keepPrefix > 0 {
result.WriteString(string(runes[:keepPrefix]))
}
if maskLength > 0 {
result.WriteString(strings.Repeat(string(r.MaskChar), maskLength))
}
if keepSuffix > 0 {
result.WriteString(string(runes[length-keepSuffix:]))
}
return result.String()
}
func (r *DesensitizeRule) min(a, b int) int {
if a < b {
return a
}
return b
}
================================================
FILE: internal/pkg/utils/format_time.go
================================================
package utils
import (
"database/sql/driver"
"fmt"
"strings"
"time"
)
// FormatDate 为时间字段提供统一的 JSON/SQL 编解码行为。
type FormatDate struct {
time.Time
}
const (
timeFormat = "2006-01-02 15:04:05"
)
// MarshalJSON 以固定格式输出 JSON 时间字符串。
func (t FormatDate) MarshalJSON() ([]byte, error) {
if &t == nil || t.IsZero() {
return []byte("null"), nil
}
return []byte(fmt.Sprintf("\"%s\"", t.Format(timeFormat))), nil
}
// Value 实现 driver.Valuer 接口。
func (t FormatDate) Value() (driver.Value, error) {
var zeroTime time.Time
if t.Time.UnixNano() == zeroTime.UnixNano() {
return nil, nil
}
return t.Time, nil
}
// Scan 实现 sql.Scanner 接口。
func (t *FormatDate) Scan(v interface{}) error {
if value, ok := v.(time.Time); ok {
*t = FormatDate{value}
return nil
}
return fmt.Errorf("can not convert %v to timestamp", v)
}
// String 返回可读的时间字符串。
func (t *FormatDate) String() string {
if t == nil || t.IsZero() {
return ""
}
return fmt.Sprintf("%s", t.Time.Format(timeFormat))
}
// UnmarshalJSON 解析固定格式的 JSON 时间字符串。
func (t *FormatDate) UnmarshalJSON(data []byte) error {
str := string(data)
if str == "null" {
return nil
}
t1, err := time.ParseInLocation(timeFormat, strings.Trim(str, "\""), time.Local)
*t = FormatDate{t1}
return err
}
================================================
FILE: internal/pkg/utils/sensitive/fields.go
================================================
package sensitive
import (
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
)
const (
maskTokenPrefixLen = 6
maskTokenSuffixLen = 6
maskPhonePrefixLen = 3
maskPhoneSuffixLen = 4
maskEmailPrefixLen = 2
maskIdCardPrefixLen = 6
maskIdCardSuffixLen = 4
maskBankCardPrefixLen = 4
maskBankCardSuffixLen = 4
maskDefaultPrefixLen = 1
maskDefaultSuffixLen = 1
)
// SensitiveFieldsConfig 敏感字段配置结构(未来可通过配置文件加载)
type SensitiveFieldsConfig struct {
Common []string `json:"common"`
RequestHeader []string `json:"request_header"`
RequestBody []string `json:"request_body"`
ResponseHeader []string `json:"response_header"`
ResponseBody []string `json:"response_body"`
}
type sensitiveFieldsManager struct {
commonFields map[string]bool
requestHeaderFields map[string]bool
requestBodyFields map[string]bool
responseHeaderFields map[string]bool
responseBodyFields map[string]bool
mu sync.RWMutex
}
var (
defaultFieldsManagerOnce sync.Once
defaultFieldsManagerVal atomic.Pointer[sensitiveFieldsManager]
phoneRegex = regexp.MustCompile(`1[3-9]\d{9}`)
emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
idCardRegex = regexp.MustCompile(`\d{15}|\d{17}[\dXx]`)
bankCardRegex = regexp.MustCompile(`\d{16,19}`)
)
func currentFieldsManager() *sensitiveFieldsManager {
defaultFieldsManagerOnce.Do(func() {
defaultFieldsManagerVal.Store(newSensitiveFieldsManager(defaultSensitiveFieldsConfig()))
})
manager := defaultFieldsManagerVal.Load()
if manager != nil {
return manager
}
// 防御性兜底,确保任何情况下都返回可用 manager。
manager = newSensitiveFieldsManager(defaultSensitiveFieldsConfig())
defaultFieldsManagerVal.Store(manager)
return manager
}
func newSensitiveFieldsManager(config SensitiveFieldsConfig) *sensitiveFieldsManager {
manager := &sensitiveFieldsManager{
commonFields: make(map[string]bool),
requestHeaderFields: make(map[string]bool),
requestBodyFields: make(map[string]bool),
responseHeaderFields: make(map[string]bool),
responseBodyFields: make(map[string]bool),
}
manager.applyConfig(config)
return manager
}
func (m *sensitiveFieldsManager) applyConfig(config SensitiveFieldsConfig) {
m.commonFields = sliceToMap(config.Common)
m.requestHeaderFields = sliceToMap(config.RequestHeader)
m.requestBodyFields = sliceToMap(config.RequestBody)
m.responseHeaderFields = sliceToMap(config.ResponseHeader)
m.responseBodyFields = sliceToMap(config.ResponseBody)
}
func defaultSensitiveFieldsConfig() SensitiveFieldsConfig {
return SensitiveFieldsConfig{
Common: []string{
"password", "pwd", "passwd", "pass", "secret",
"token", "access_token", "refresh_token",
"api_key", "apikey", "apiKey",
"pin", "cvv", "cvc", "cvv2", "security_code",
},
RequestHeader: []string{
"authorization", "auth",
"cookie",
"x-api-key", "x-access-token", "x-auth-token", "x-token",
},
RequestBody: []string{
"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",
},
ResponseHeader: []string{
"set-cookie",
"authorization", "auth",
"x-api-key", "x-access-token", "x-auth-token", "x-token", "x-refresh-token",
"refresh-access-token", "refresh-exp",
"cookie",
},
ResponseBody: []string{
"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",
},
}
}
// DefaultSensitiveFieldsConfig 返回默认敏感字段配置副本。
func DefaultSensitiveFieldsConfig() SensitiveFieldsConfig {
return defaultSensitiveFieldsConfig()
}
// LoadSensitiveFieldsConfig 加载敏感字段配置(未来可从配置文件调用)
func LoadSensitiveFieldsConfig(config SensitiveFieldsConfig) {
manager := currentFieldsManager()
manager.mu.Lock()
defer manager.mu.Unlock()
manager.applyConfig(config)
}
// GetSensitiveFieldsConfig 返回当前生效的敏感字段配置快照。
func GetSensitiveFieldsConfig() SensitiveFieldsConfig {
manager := currentFieldsManager()
manager.mu.RLock()
defer manager.mu.RUnlock()
return SensitiveFieldsConfig{
Common: mapKeys(manager.commonFields),
RequestHeader: mapKeys(manager.requestHeaderFields),
RequestBody: mapKeys(manager.requestBodyFields),
ResponseHeader: mapKeys(manager.responseHeaderFields),
ResponseBody: mapKeys(manager.responseBodyFields),
}
}
func sliceToMap(slice []string) map[string]bool {
if len(slice) == 0 {
return make(map[string]bool)
}
result := make(map[string]bool, len(slice))
for _, s := range slice {
if s != "" {
result[strings.ToLower(s)] = true
}
}
return result
}
func getCommonFields() map[string]bool {
manager := currentFieldsManager()
return manager.cloneFieldSet(manager.commonFields)
}
func getRequestHeaderFields() map[string]bool {
manager := currentFieldsManager()
return manager.cloneFieldSet(manager.requestHeaderFields)
}
func getRequestBodyFields() map[string]bool {
manager := currentFieldsManager()
return manager.cloneFieldSet(manager.requestBodyFields)
}
func getResponseHeaderFields() map[string]bool {
manager := currentFieldsManager()
return manager.cloneFieldSet(manager.responseHeaderFields)
}
func getResponseBodyFields() map[string]bool {
manager := currentFieldsManager()
return manager.cloneFieldSet(manager.responseBodyFields)
}
func (m *sensitiveFieldsManager) cloneFieldSet(source map[string]bool) map[string]bool {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]bool, len(source))
for k, v := range source {
result[k] = v
}
return result
}
func mapKeys(source map[string]bool) []string {
if len(source) == 0 {
return []string{}
}
result := make([]string, 0, len(source))
for key := range source {
result = append(result, key)
}
sort.Strings(result)
return result
}
================================================
FILE: internal/pkg/utils/sensitive/http_mask.go
================================================
package sensitive
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"unicode/utf8"
)
func maskHeaders(headers http.Header, sensitiveFields map[string]bool) string {
if len(headers) == 0 {
return "{}"
}
maskedHeaders := make(http.Header, len(headers))
for k, v := range headers {
if isSensitiveField(strings.ToLower(k), sensitiveFields) {
maskedHeaders[k] = maskStringSlice(v, maskSensitiveString)
continue
}
maskedHeaders[k] = v
}
bytes, err := json.Marshal(maskedHeaders)
if err != nil {
return "{}"
}
return string(bytes)
}
// GetMaskedRequestHeaders 获取脱敏后的请求头
func GetMaskedRequestHeaders(headers http.Header) string {
return maskHeaders(headers, getRequestHeaderFields())
}
// GetMaskedResponseHeaders 获取脱敏后的响应头
func GetMaskedResponseHeaders(headers http.Header) string {
return maskHeaders(headers, getResponseHeaderFields())
}
// GetMaskedRequestBody 获取脱敏后的请求体
func GetMaskedRequestBody(bodyBytes []byte, contentType string) string {
if len(bodyBytes) == 0 {
return ""
}
contentTypeLower := strings.ToLower(contentType)
switch {
case strings.Contains(contentTypeLower, "multipart/form-data"):
return "[multipart/form-data: file upload, body not logged]"
case !isValidUTF8(bodyBytes):
return "[binary data: non-text content, body not logged]"
case !strings.Contains(contentTypeLower, "application/json"):
return maskString(string(bodyBytes))
default:
return maskJSONBytes(bodyBytes, getRequestBodyFields())
}
}
// GetMaskedResponseBody 获取脱敏后的响应体
func GetMaskedResponseBody(bodyBytes []byte) string {
if len(bodyBytes) == 0 {
return ""
}
if !isValidUTF8(bodyBytes) {
return "[binary data: non-text content, body not logged]"
}
return maskJSONBytes(bodyBytes, getResponseBodyFields())
}
// MaskQueryString 对查询字符串进行脱敏
func MaskQueryString(queryString string) string {
if queryString == "" {
return queryString
}
values, err := url.ParseQuery(queryString)
if err != nil {
return maskString(queryString)
}
requestBodyFields := getRequestBodyFields()
maskedValues := make(url.Values, len(values))
for key, values := range values {
maskValueFn := maskString
if isSensitiveField(strings.ToLower(key), requestBodyFields) {
maskValueFn = maskSensitiveString
}
maskedValues[key] = maskStringSlice(values, maskValueFn)
}
return maskedValues.Encode()
}
func maskJSONBytes(bodyBytes []byte, sensitiveFields map[string]bool) string {
var data interface{}
if err := json.Unmarshal(bodyBytes, &data); err != nil {
return maskString(string(bodyBytes))
}
maskedData := maskSensitiveDataWithFields(data, sensitiveFields)
maskedBytes, err := json.Marshal(maskedData)
if err != nil {
return maskString(string(bodyBytes))
}
return string(maskedBytes)
}
func maskStringSlice(values []string, fn func(string) string) []string {
masked := make([]string, len(values))
for i, value := range values {
masked[i] = fn(value)
}
return masked
}
func isValidUTF8(data []byte) bool {
return len(data) == 0 || len(string(data)) == len(data) && utf8.Valid(data)
}
================================================
FILE: internal/pkg/utils/sensitive/mask.go
================================================
package sensitive
import "strings"
// maskMap 对 map 进行递归脱敏(使用指定的敏感字段列表)
func maskMap(m map[string]interface{}, sensitiveFields map[string]bool) map[string]interface{} {
if len(m) == 0 {
return m
}
result := make(map[string]interface{}, len(m))
for k, v := range m {
keyLower := strings.ToLower(k)
if isSensitiveField(keyLower, sensitiveFields) {
result[k] = maskValue(v)
} else {
result[k] = maskSensitiveDataWithFields(v, sensitiveFields)
}
}
return result
}
// maskSensitiveDataWithFields 对敏感数据进行脱敏处理(使用指定的敏感字段列表)
func maskSensitiveDataWithFields(data interface{}, sensitiveFields map[string]bool) interface{} {
switch v := data.(type) {
case map[string]interface{}:
return maskMap(v, sensitiveFields)
case []interface{}:
return maskArrayWithFields(v, sensitiveFields)
case string:
return maskString(v)
default:
return data
}
}
// maskArrayWithFields 对数组进行递归脱敏(使用指定的敏感字段列表)
func maskArrayWithFields(arr []interface{}, sensitiveFields map[string]bool) []interface{} {
if len(arr) == 0 {
return arr
}
result := make([]interface{}, len(arr))
for i, v := range arr {
result[i] = maskSensitiveDataWithFields(v, sensitiveFields)
}
return result
}
================================================
FILE: internal/pkg/utils/sensitive/string_mask.go
================================================
package sensitive
import "strings"
func maskString(s string) string {
if s == "" {
return s
}
return applyMatchers(s, maskBankCard, maskIdCard, maskPhone, maskEmail)
}
func maskValue(v interface{}) interface{} {
switch val := v.(type) {
case string:
if val == "" {
return val
}
return maskSensitiveString(val)
case map[string]interface{}:
return maskMap(val, getCommonFields())
case []interface{}:
return maskArrayWithFields(val, getCommonFields())
default:
return v
}
}
func maskSensitiveString(s string) string {
if s == "" {
return s
}
if prefix, ok := authPrefix(s); ok {
return maskAuthToken(s, prefix)
}
masked := applyMatchers(s, maskBankCard, maskIdCard, maskPhone, maskEmail)
if masked != s {
return masked
}
return maskDefault(s)
}
func authPrefix(s string) (string, bool) {
switch sLower := strings.ToLower(s); {
case strings.HasPrefix(s, "eyJ"):
return "", false
case strings.HasPrefix(sLower, "bearer "):
return "bearer ", true
case strings.HasPrefix(sLower, "basic "):
return "basic ", true
default:
return "", false
}
}
func applyMatchers(s string, replacers ...func(string) string) string {
result := s
if bankCardRegex.MatchString(result) {
result = bankCardRegex.ReplaceAllStringFunc(result, replacers[0])
}
if idCardRegex.MatchString(result) {
result = idCardRegex.ReplaceAllStringFunc(result, replacers[1])
}
if phoneRegex.MatchString(result) {
result = phoneRegex.ReplaceAllStringFunc(result, replacers[2])
}
if emailRegex.MatchString(result) {
result = emailRegex.ReplaceAllStringFunc(result, replacers[3])
}
return result
}
func maskAuthToken(s, prefix string) string {
if strings.HasPrefix(strings.ToLower(s), prefix) {
tokenPart := s[len(prefix):]
return prefix + maskToken(tokenPart)
}
parts := strings.SplitN(s, " ", 2)
if len(parts) == 2 {
return parts[0] + " " + maskToken(parts[1])
}
return maskDefault(s)
}
func isSensitiveField(fieldName string, sensitiveFields map[string]bool) bool {
if sensitiveFields[fieldName] {
return true
}
if len(fieldName) < 3 {
return false
}
for keyword := range sensitiveFields {
if len(keyword) <= len(fieldName) && strings.Contains(fieldName, keyword) {
return true
}
}
return false
}
func maskToken(token string) string {
length := len(token)
if length <= maskTokenPrefixLen+maskTokenSuffixLen {
return strings.Repeat("*", length)
}
return token[:maskTokenPrefixLen] + "***" + token[length-maskTokenSuffixLen:]
}
func maskPhone(phone string) string {
if len(phone) != 11 {
return maskDefault(phone)
}
return phone[:maskPhonePrefixLen] + "****" + phone[11-maskPhoneSuffixLen:]
}
func maskEmail(email string) string {
idx := strings.IndexByte(email, '@')
if idx == -1 || idx == 0 {
return maskDefault(email)
}
localPart := email[:idx]
domain := email[idx:]
if len(localPart) <= maskEmailPrefixLen {
return strings.Repeat("*", len(localPart)) + domain
}
return localPart[:maskEmailPrefixLen] + "***" + domain
}
func maskIdCard(idCard string) string {
switch len(idCard) {
case 15:
return idCard[:maskIdCardPrefixLen] + "******" + idCard[15-maskIdCardSuffixLen:]
case 18:
return idCard[:maskIdCardPrefixLen] + "********" + idCard[18-maskIdCardSuffixLen:]
default:
return maskDefault(idCard)
}
}
func maskBankCard(cardNo string) string {
length := len(cardNo)
if length < maskBankCardPrefixLen+maskBankCardSuffixLen {
return maskDefault(cardNo)
}
maskLen := length - maskBankCardPrefixLen - maskBankCardSuffixLen
return cardNo[:maskBankCardPrefixLen] + strings.Repeat("*", maskLen) + cardNo[length-maskBankCardSuffixLen:]
}
func maskDefault(s string) string {
length := len(s)
if length <= maskDefaultPrefixLen+maskDefaultSuffixLen {
return strings.Repeat("*", length)
}
maskLen := length - maskDefaultPrefixLen - maskDefaultSuffixLen
return s[:maskDefaultPrefixLen] + strings.Repeat("*", maskLen) + s[length-maskDefaultSuffixLen:]
}
================================================
FILE: internal/pkg/utils/token/jwt.go
================================================
package token
import (
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
// AdminUserInfo 是写入 JWT 的管理员基础信息。
type AdminUserInfo struct {
// 可根据需要自行添加字段
UserID uint `json:"user_id"`
Username string `json:"username"`
FullPhoneNumber string `json:"full_phone_number"`
Email string `json:"email"`
Nickname string `json:"nickname"`
PhoneNumber string `json:"phone_number"`
CountryCode string `json:"country_code"`
IsSuperAdmin uint8 `json:"is_super_admin"`
}
// Generate 生成JWT Token
func Generate(claims jwt.Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
cfg := c.GetConfig()
// 生成签名字符串
tokenStr, err := token.SignedString([]byte(cfg.Jwt.SecretKey))
if err != nil {
return "", err
}
return tokenStr, nil
}
// Refresh 刷新JWT Token
func Refresh(claims jwt.Claims) (string, error) {
return Generate(claims)
}
// Parse 解析token
func Parse(accessToken string, claims jwt.Claims, options ...jwt.ParserOption) error {
cfg := c.GetConfig()
token, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (i interface{}, err error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(cfg.Jwt.SecretKey), nil
}, options...)
if err != nil {
return err
}
if !token.Valid {
return e.NewBusinessError(e.NotLogin)
}
return nil
}
// GetAccessToken 获取jwt的Token
func GetAccessToken(authorization string) (accessToken string, err error) {
if authorization == "" {
return "", errors.New("authorization header is missing")
}
// 检查 Authorization 头的格式
if !strings.HasPrefix(authorization, "Bearer ") {
return "", errors.New("invalid Authorization header format")
}
// 提取 Token 的值
accessToken = strings.TrimPrefix(authorization, "Bearer ")
return
}
// AdminCustomClaims 自定义格式内容
type AdminCustomClaims struct {
AdminUserInfo
jwt.RegisteredClaims // 内嵌标准的声明
}
// NewAdminCustomClaims 初始化AdminCustomClaims
func NewAdminCustomClaims(user *model.AdminUser) AdminCustomClaims {
cfg := c.GetConfig()
now := time.Now().UTC()
expiresAt := now.Add(time.Second * cfg.Jwt.TTL)
// phoneRule := &utils.DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*'}
// emailRule := &utils.DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3}
return AdminCustomClaims{
AdminUserInfo: AdminUserInfo{
UserID: user.ID,
Username: user.Username,
FullPhoneNumber: user.FullPhoneNumber, // phoneRule.Apply(user.Mobile),
PhoneNumber: user.PhoneNumber, // phoneRule.Apply(user.Mobile),
CountryCode: user.CountryCode, // phoneRule.Apply(user.Mobile),
Email: user.Email, // emailRule.Apply(user.Email),
Nickname: user.Nickname,
IsSuperAdmin: user.IsSuperAdmin,
},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt), // 定义过期时间
Issuer: global.Issuer, // 签发人
IssuedAt: jwt.NewNumericDate(now), // 签发时间
Subject: global.PcAdminSubject, // 签发主题
NotBefore: jwt.NewNumericDate(now), // 生效时间
ID: uuid.New().String(), // 唯一标识
},
}
}
================================================
FILE: internal/pkg/utils/token/jwt_test.go
================================================
package token
import (
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestGenerate(t *testing.T) {
claims := jwt.MapClaims{
"Id": 1,
}
_, err := Generate(claims)
if err != nil {
t.Error("生成Token失败")
}
}
func TestParse(t *testing.T) {
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MX0.JGVOAsonk7CoOaTS-b6dW86LLEOt8Z6kHhsFxIvqaCE"
claims := jwt.MapClaims{}
err := Parse(tokenString, claims)
if err != nil {
t.Error("解析Token失败")
}
}
func TestGetAccessToken(t *testing.T) {
authorization := "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MX0.JGVOAsonk7CoOaTS-b6dW86LLEOt8Z6kHhsFxIvqaCE"
_, err := GetAccessToken(authorization)
if err != nil {
t.Error("获取Token失败")
}
}
================================================
FILE: internal/pkg/utils/utils.go
================================================
package utils
import (
"math/rand"
"regexp"
"strings"
"time"
"github.com/samber/lo"
)
// CalculateChanges 计算差集 (一次性获取删除、新增和剩余列表)
// 计算交集
// 合并差集和交集
// 示例:
//
// existingIds := []int{1, 2, 3, 4, 5}
// ids := []int{2, 3, 6, 7}
// toDelete, toAdd, remainingList := CalculateChanges(existingIds, ids)
// fmt.Println("toDelete:", toDelete)
// fmt.Println("toAdd:", toAdd)
// fmt.Println("remainingList:", remainingList)
//
// 输出:
// toDelete: [1 4 5]
// toAdd: [6 7]
// remainingList: [2 3 6 7]
func CalculateChanges[T comparable](existingIds, ids []T) (toDelete, toAdd, remainingList []T) {
// 2. 计算差集(一次性获取删除和新增列表)
toDelete, toAdd = lo.Difference(existingIds, lo.Uniq(ids))
// 2. 计算交集
intersection := lo.Intersect(ids, existingIds)
// 3. 合并差集和交集
remainingList = lo.Union(intersection, toAdd)
return
}
// RandString 生成随机字符串
func RandString(n int) string {
letterBytes := []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
var src = rand.NewSource(time.Now().UnixNano())
const (
letterIdxBits = 6
letterIdxMask = 1<= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}
// TrimPrefixAndSuffixAND 去除字符串前后的 AND(不区分大小写,忽略多余空白)
func TrimPrefixAndSuffixAND(s string) string {
s = strings.TrimSpace(s)
// 正则匹配开头或结尾的 AND(忽略大小写和空白)
re := regexp.MustCompile(`(?i)^(AND\s+)|(\s+AND)$`)
for {
trimmed := re.ReplaceAllString(s, "")
if trimmed == s {
break
}
s = strings.TrimSpace(trimmed)
}
return s
}
================================================
FILE: internal/pkg/utils/utils_test.go
================================================
package utils
import (
"fmt"
"testing"
)
func TestCalculateChanges(t *testing.T) {
existingIds := []int{1, 2, 3, 4, 5}
ids := []int{2, 3, 6, 7}
toDelete, toAdd, remainingList := CalculateChanges(existingIds, ids)
fmt.Println("toDelete:", toDelete)
fmt.Println("toAdd:", toAdd)
fmt.Println("remainingList:", remainingList)
}
func TestRandString(t *testing.T) {
s := RandString(12)
if s == "" {
t.Error("获取运行路径失败")
}
}
func BenchmarkRandString(b *testing.B) {
// 基准函数会运行目标代码b.N次。
for i := 0; i < b.N; i++ {
RandString(12)
}
}
func TestDesensitizeRule(b *testing.T) {
// 手机号脱敏
phoneRule := &DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*'}
if phoneRule.Apply("13812345678") != "138****5678" {
b.Error("手机号码脱敏失败")
}
// 邮箱脱敏
emailRule := &DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3}
if emailRule.Apply("test@example.com") != "te***@example.com" {
b.Error("邮箱脱敏失败")
}
}
func BenchmarkTrimPrefixAndSuffixAND(b *testing.B) {
input := " AND AND name = 'Tom' AND age = 18 AND "
for i := 0; i < b.N; i++ {
_ = TrimPrefixAndSuffixAND(input)
}
}
================================================
FILE: internal/queue/asynqx/asynq.go
================================================
package asynqx
import (
"context"
"errors"
"fmt"
"strings"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue"
"github.com/wannanbigpig/gin-layout/internal/service/taskcenter"
)
func init() {
queue.RegisterPublisherFactory(NewPublisher)
queue.RegisterInspectorFactory(NewInspector)
}
type publisher struct {
client *asynq.Client
namespace string
}
type inspector struct {
raw *asynq.Inspector
namespace string
}
// NewPublisher 创建 Asynq publisher。
func NewPublisher(cfg *config.Conf) (queue.Publisher, error) {
if cfg == nil {
cfg = config.GetConfig()
}
if cfg == nil || !cfg.Queue.Enable {
return nil, nil
}
redisOpt, err := newRedisConnOpt(cfg)
if err != nil {
return nil, err
}
client := asynq.NewClient(redisOpt)
return &publisher{
client: client,
namespace: strings.TrimSpace(cfg.Queue.Namespace),
}, nil
}
// NewInspector 创建 Asynq inspector。
func NewInspector(cfg *config.Conf) (queue.Inspector, error) {
if cfg == nil {
cfg = config.GetConfig()
}
if cfg == nil || !cfg.Queue.Enable {
return nil, nil
}
redisOpt, err := newRedisConnOpt(cfg)
if err != nil {
return nil, err
}
return &inspector{
raw: asynq.NewInspector(redisOpt),
namespace: strings.TrimSpace(cfg.Queue.Namespace),
}, nil
}
func (p *publisher) Enqueue(ctx context.Context, job queue.Job) (queue.JobInfo, error) {
if job == nil {
return queue.JobInfo{}, errors.New("queue job is nil")
}
payload, err := job.Payload()
if err != nil {
return queue.JobInfo{}, err
}
task := asynq.NewTask(job.Type(), payload)
options := make([]asynq.Option, 0, len(job.Options())+1)
options = append(options, asynq.Queue(prefixedQueueName(p.namespace, job.Queue())))
options = append(options, mapOptions(p.namespace, job.Options())...)
info, err := p.client.EnqueueContext(ctx, task, options...)
if err != nil {
return queue.JobInfo{}, err
}
return queue.JobInfo{
ID: info.ID,
Queue: unprefixQueueName(p.namespace, info.Queue),
Type: info.Type,
}, nil
}
func (i *inspector) DeleteTask(ctx context.Context, queueName, taskID string) error {
_ = ctx
if i == nil || i.raw == nil {
return queue.ErrInspectorUnavailable
}
return normalizeInspectorError(i.raw.DeleteTask(prefixedQueueName(i.namespace, queueName), taskID))
}
func (i *inspector) CancelProcessing(ctx context.Context, taskID string) error {
_ = ctx
if i == nil || i.raw == nil {
return queue.ErrInspectorUnavailable
}
return normalizeInspectorError(i.raw.CancelProcessing(taskID))
}
// NewServer 创建 Asynq worker server 和 mux。
func NewServer(cfg *config.Conf, registry queue.Registry) (*asynq.Server, *asynq.ServeMux, error) {
if cfg == nil {
cfg = config.GetConfig()
}
if cfg == nil {
return nil, nil, errors.New("queue config is nil")
}
if registry == nil {
return nil, nil, errors.New("queue registry is nil")
}
redisOpt, err := newRedisConnOpt(cfg)
if err != nil {
return nil, nil, err
}
server := asynq.NewServer(redisOpt, asynq.Config{
Concurrency: cfg.Queue.Concurrency,
Queues: prefixedQueues(cfg.Queue.Namespace, cfg.Queue.Queues),
StrictPriority: cfg.Queue.StrictPriority,
ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {
log.Warn("Asynq task failed",
zap.String("task_type", task.Type()),
zap.Error(err))
}),
})
mux := asynq.NewServeMux()
for _, entry := range registry.Entries() {
entry := entry
mux.HandleFunc(entry.TaskType, func(ctx context.Context, task *asynq.Task) error {
run := recordAsynqTaskStart(ctx, task)
err := entry.Handler(ctx, task.Payload())
recordAsynqTaskFinish(ctx, run, err)
if err == nil {
return nil
}
if errors.Is(err, queue.ErrSkipRetry) {
return fmt.Errorf("%w: %w", err, asynq.SkipRetry)
}
return err
})
}
return server, mux, nil
}
func recordAsynqTaskStart(ctx context.Context, task *asynq.Task) *model.TaskRun {
if task == nil {
return nil
}
taskID, _ := asynq.GetTaskID(ctx)
retryCount, _ := asynq.GetRetryCount(ctx)
maxRetry, _ := asynq.GetMaxRetry(ctx)
queueName, _ := asynq.GetQueueName(ctx)
recorder := taskcenter.NewRunRecorder()
run, err := recorder.Start(ctx, taskcenter.RunStart{
TaskCode: task.Type(),
Kind: model.TaskKindAsync,
Source: model.TaskSourceQueue,
SourceID: taskID,
Queue: unprefixQueueName(currentQueueNamespace(), queueName),
Attempt: retryCount + 1,
MaxRetry: maxRetry,
Payload: task.Payload(),
})
if err != nil {
log.Warn("Record async task start failed",
zap.String("task_type", task.Type()),
zap.String("task_id", taskID),
zap.Error(err))
return nil
}
return run
}
func recordAsynqTaskFinish(ctx context.Context, run *model.TaskRun, taskErr error) {
if run == nil {
return
}
if err := taskcenter.NewRunRecorder().Finish(ctx, run, taskcenter.RunFinish{Error: taskErr}); err != nil {
log.Warn("Record async task finish failed",
zap.String("task_type", run.TaskCode),
zap.String("task_id", run.SourceID),
zap.Error(err))
}
}
func currentQueueNamespace() string {
cfg := config.GetConfig()
if cfg == nil {
return ""
}
return cfg.Queue.Namespace
}
func newRedisConnOpt(cfg *config.Conf) (asynq.RedisClientOpt, error) {
if cfg == nil {
return asynq.RedisClientOpt{}, errors.New("queue config is nil")
}
if cfg.Queue.UseDefaultRedis {
if !cfg.Redis.Enable {
return asynq.RedisClientOpt{}, errors.New("queue uses default redis, but redis.enable is false")
}
host := strings.TrimSpace(cfg.Redis.Host)
port := strings.TrimSpace(cfg.Redis.Port)
if host == "" || port == "" {
return asynq.RedisClientOpt{}, errors.New("queue uses default redis, but redis host/port is empty")
}
return asynq.RedisClientOpt{
Addr: host + ":" + port,
Password: cfg.Redis.Password,
DB: cfg.Redis.Database,
}, nil
}
host := strings.TrimSpace(cfg.Queue.Redis.Host)
port := strings.TrimSpace(cfg.Queue.Redis.Port)
if host == "" || port == "" {
return asynq.RedisClientOpt{}, errors.New("queue.redis host/port is required when queue.use_default_redis is false")
}
return asynq.RedisClientOpt{
Addr: host + ":" + port,
Password: cfg.Queue.Redis.Password,
DB: cfg.Queue.Redis.Database,
}, nil
}
func normalizeInspectorError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, asynq.ErrQueueNotFound) {
return queue.ErrQueueNotFound
}
if errors.Is(err, asynq.ErrTaskNotFound) {
return queue.ErrTaskNotFound
}
return err
}
func mapOptions(namespace string, options []queue.JobOption) []asynq.Option {
mapped := make([]asynq.Option, 0, len(options))
for _, option := range options {
switch option.Type {
case queue.JobOptionMaxRetry:
mapped = append(mapped, asynq.MaxRetry(option.IntValue))
case queue.JobOptionQueue:
mapped = append(mapped, asynq.Queue(prefixedQueueName(namespace, option.StringValue)))
case queue.JobOptionTimeout:
mapped = append(mapped, asynq.Timeout(option.DurationValue))
case queue.JobOptionRetention:
mapped = append(mapped, asynq.Retention(option.DurationValue))
case queue.JobOptionTaskID:
mapped = append(mapped, asynq.TaskID(option.StringValue))
}
}
return mapped
}
func prefixedQueues(namespace string, queues map[string]int) map[string]int {
if len(queues) == 0 {
return map[string]int{"default": 1}
}
prefixed := make(map[string]int, len(queues))
for name, priority := range queues {
prefixed[prefixedQueueName(namespace, name)] = priority
}
return prefixed
}
func prefixedQueueName(namespace, name string) string {
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
trimmedName = "default"
}
namespace = strings.TrimSpace(namespace)
if namespace == "" {
return trimmedName
}
return namespace + ":" + trimmedName
}
func unprefixQueueName(namespace, name string) string {
namespace = strings.TrimSpace(namespace)
if namespace == "" {
return name
}
prefix := namespace + ":"
if strings.HasPrefix(name, prefix) {
return strings.TrimPrefix(name, prefix)
}
return name
}
================================================
FILE: internal/queue/asynqx/asynq_test.go
================================================
package asynqx
import (
"testing"
"time"
"github.com/hibiken/asynq"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
"github.com/wannanbigpig/gin-layout/internal/queue"
)
func TestPrefixedQueueName(t *testing.T) {
if got := prefixedQueueName("go_layout", "audit"); got != "go_layout:audit" {
t.Fatalf("unexpected prefixed queue: %s", got)
}
if got := unprefixQueueName("go_layout", "go_layout:audit"); got != "audit" {
t.Fatalf("unexpected unprefixed queue: %s", got)
}
if got := prefixedQueueName("", "audit"); got != "audit" {
t.Fatalf("unexpected queue without namespace: %s", got)
}
}
func TestMapOptions(t *testing.T) {
options := mapOptions("go_layout", []queue.JobOption{
queue.WithMaxRetry(3),
queue.WithQueue("audit"),
queue.WithTimeout(10 * time.Second),
queue.WithTaskID("task-1"),
})
if len(options) != 4 {
t.Fatalf("expected 4 options, got %d", len(options))
}
assertOption := func(index int, wantType asynq.OptionType, wantValue any) {
if options[index].Type() != wantType {
t.Fatalf("option %d type mismatch: got %v want %v", index, options[index].Type(), wantType)
}
if options[index].Value() != wantValue {
t.Fatalf("option %d value mismatch: got %#v want %#v", index, options[index].Value(), wantValue)
}
}
assertOption(0, asynq.MaxRetryOpt, 3)
assertOption(1, asynq.QueueOpt, "go_layout:audit")
assertOption(2, asynq.TimeoutOpt, 10*time.Second)
assertOption(3, asynq.TaskIDOpt, "task-1")
}
func TestNewRedisConnOptUsesDefaultRedis(t *testing.T) {
cfg := &config.Conf{
Redis: autoload.RedisConfig{
Enable: true,
Host: "127.0.0.1",
Port: "6380",
Password: "default-pass",
Database: 3,
},
Queue: autoload.QueueConfig{
Enable: true,
UseDefaultRedis: true,
},
}
opt, err := newRedisConnOpt(cfg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if opt.Addr != "127.0.0.1:6380" {
t.Fatalf("unexpected addr: %s", opt.Addr)
}
if opt.Password != "default-pass" || opt.DB != 3 {
t.Fatalf("unexpected redis option: %+v", opt)
}
}
func TestNewRedisConnOptUsesQueueRedis(t *testing.T) {
cfg := &config.Conf{
Redis: autoload.RedisConfig{
Enable: false,
},
Queue: autoload.QueueConfig{
Enable: true,
UseDefaultRedis: false,
Redis: autoload.QueueRedisConfig{
Host: "10.0.0.8",
Port: "6381",
Password: "queue-pass",
Database: 6,
},
},
}
opt, err := newRedisConnOpt(cfg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if opt.Addr != "10.0.0.8:6381" {
t.Fatalf("unexpected addr: %s", opt.Addr)
}
if opt.Password != "queue-pass" || opt.DB != 6 {
t.Fatalf("unexpected redis option: %+v", opt)
}
}
func TestNewRedisConnOptReturnsErrorWhenDefaultRedisDisabled(t *testing.T) {
cfg := &config.Conf{
Redis: autoload.RedisConfig{
Enable: false,
Host: "127.0.0.1",
Port: "6379",
},
Queue: autoload.QueueConfig{
Enable: true,
UseDefaultRedis: true,
},
}
if _, err := newRedisConnOpt(cfg); err == nil {
t.Fatal("expected error when default redis is disabled")
}
}
================================================
FILE: internal/queue/asynqx/inspector_test.go
================================================
package asynqx
import (
"errors"
"testing"
"github.com/hibiken/asynq"
"github.com/wannanbigpig/gin-layout/internal/queue"
)
func TestNormalizeInspectorError(t *testing.T) {
cases := []struct {
name string
in error
want error
}{
{name: "queue not found", in: asynq.ErrQueueNotFound, want: queue.ErrQueueNotFound},
{name: "task not found", in: asynq.ErrTaskNotFound, want: queue.ErrTaskNotFound},
{name: "other error", in: errors.New("boom"), want: errors.New("boom")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalizeInspectorError(tc.in)
if tc.want == nil {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if tc.name == "other error" {
if got == nil || got.Error() != "boom" {
t.Fatalf("unexpected error: %v", got)
}
return
}
if !errors.Is(got, tc.want) {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
================================================
FILE: internal/queue/asynqx/task_record_test.go
================================================
package asynqx
import (
"context"
"errors"
"testing"
"github.com/hibiken/asynq"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/service/taskcenter"
)
func TestRecordAsynqTaskStartAndFinish(t *testing.T) {
fake := &fakeRunRecorder{}
restore := taskcenter.SetRecorderForTesting(fake)
defer restore()
task := asynq.NewTask("demo:send", []byte(`{"name":"codex"}`))
run := recordAsynqTaskStart(context.Background(), task)
if run == nil {
t.Fatal("expected run to be returned")
}
recordAsynqTaskFinish(context.Background(), run, errors.New("boom"))
if len(fake.starts) != 1 {
t.Fatalf("expected 1 start call, got %d", len(fake.starts))
}
start := fake.starts[0]
if start.TaskCode != "demo:send" || start.Kind != model.TaskKindAsync || start.Source != model.TaskSourceQueue {
t.Fatalf("unexpected start input: %#v", start)
}
if string(start.Payload) != `{"name":"codex"}` {
t.Fatalf("unexpected payload: %s", string(start.Payload))
}
if len(fake.finishes) != 1 {
t.Fatalf("expected 1 finish call, got %d", len(fake.finishes))
}
if fake.finishes[0].Error == nil || fake.finishes[0].Error.Error() != "boom" {
t.Fatalf("unexpected finish input: %#v", fake.finishes[0])
}
}
type fakeRunRecorder struct {
starts []taskcenter.RunStart
finishes []taskcenter.RunFinish
}
func (f *fakeRunRecorder) Enqueue(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {
_ = ctx
f.starts = append(f.starts, input)
return &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source, SourceID: input.SourceID}, nil
}
func (f *fakeRunRecorder) Start(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {
_ = ctx
f.starts = append(f.starts, input)
return &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source, SourceID: input.SourceID}, nil
}
func (f *fakeRunRecorder) Finish(ctx context.Context, run *model.TaskRun, input taskcenter.RunFinish) error {
_ = ctx
_ = run
f.finishes = append(f.finishes, input)
return nil
}
================================================
FILE: internal/queue/queue.go
================================================
package queue
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/wannanbigpig/gin-layout/config"
)
var (
ErrPublisherUnavailable = errors.New("queue publisher unavailable")
ErrInspectorUnavailable = errors.New("queue inspector unavailable")
ErrQueueNotFound = errors.New("queue not found")
ErrTaskNotFound = errors.New("queue task not found")
ErrSkipRetry = errors.New("queue skip retry")
)
const DefaultQueue = "default"
// Job 表示一个可发布的异步任务。
type Job interface {
Type() string
Queue() string
Payload() ([]byte, error)
Options() []JobOption
}
// JobInfo 表示任务发布后的基础元信息。
type JobInfo struct {
ID string
Queue string
Type string
}
// Publisher 负责发布任务。
type Publisher interface {
Enqueue(ctx context.Context, job Job) (JobInfo, error)
}
// Inspector 负责对已入队任务执行控制操作。
type Inspector interface {
DeleteTask(ctx context.Context, queueName, taskID string) error
CancelProcessing(ctx context.Context, taskID string) error
}
// Handler 负责消费任务 payload。
type Handler func(ctx context.Context, payload []byte) error
// Registry 保存任务类型到 handler 的映射。
type Registry interface {
Register(taskType string, handler Handler)
Entries() []Registration
}
// Registration 描述一个已注册的任务处理器。
type Registration struct {
TaskType string
Handler Handler
}
// Validatable 表示 payload 支持自校验。
type Validatable interface {
Validate() error
}
// JobOptionType 表示任务选项类型。
type JobOptionType string
const (
JobOptionMaxRetry JobOptionType = "max_retry"
JobOptionQueue JobOptionType = "queue"
JobOptionTimeout JobOptionType = "timeout"
JobOptionRetention JobOptionType = "retention"
JobOptionTaskID JobOptionType = "task_id"
)
// JobOption 表示项目内统一的任务选项。
type JobOption struct {
Type JobOptionType
IntValue int
StringValue string
DurationValue time.Duration
}
type jsonJob struct {
taskType string
queueName string
payload any
options []JobOption
}
type skipRetryError struct {
err error
}
func WithMaxRetry(n int) JobOption {
return JobOption{Type: JobOptionMaxRetry, IntValue: n}
}
func WithQueue(name string) JobOption {
return JobOption{Type: JobOptionQueue, StringValue: name}
}
func WithTimeout(timeout time.Duration) JobOption {
return JobOption{Type: JobOptionTimeout, DurationValue: timeout}
}
func WithRetention(retention time.Duration) JobOption {
return JobOption{Type: JobOptionRetention, DurationValue: retention}
}
func WithTaskID(taskID string) JobOption {
return JobOption{Type: JobOptionTaskID, StringValue: taskID}
}
// NewJSONJob 创建一个基于 JSON payload 的通用任务。
func NewJSONJob(taskType, queueName string, payload any, opts ...JobOption) Job {
if queueName == "" {
queueName = DefaultQueue
}
return &jsonJob{
taskType: taskType,
queueName: queueName,
payload: payload,
options: opts,
}
}
// Publish 使用全局 publisher 发布任务。
func Publish(ctx context.Context, job Job) (JobInfo, error) {
publisher := PublisherOrNil()
if publisher == nil {
return JobInfo{}, ErrPublisherUnavailable
}
return publisher.Enqueue(ctx, job)
}
// PublishJSON 发布一个 JSON 任务。
func PublishJSON(ctx context.Context, taskType, queueName string, payload any, opts ...JobOption) (JobInfo, error) {
return Publish(ctx, NewJSONJob(taskType, queueName, payload, opts...))
}
// RegisterJSON 注册一个基于 JSON payload 的处理器。
func RegisterJSON[T any](registry Registry, taskType string, handler func(ctx context.Context, payload T) error) {
if registry == nil || taskType == "" || handler == nil {
return
}
registry.Register(taskType, func(ctx context.Context, raw []byte) error {
var payload T
if err := json.Unmarshal(raw, &payload); err != nil {
return SkipRetry(fmt.Errorf("decode %s payload failed: %w", taskType, err))
}
if err := validatePayload(payload); err != nil {
return SkipRetry(fmt.Errorf("invalid %s payload: %w", taskType, err))
}
return handler(ctx, payload)
})
}
// SkipRetry 标记任务错误为不再重试。
func SkipRetry(err error) error {
return &skipRetryError{err: err}
}
func (j *jsonJob) Type() string {
return j.taskType
}
func (j *jsonJob) Queue() string {
if j.queueName == "" {
return DefaultQueue
}
return j.queueName
}
func (j *jsonJob) Payload() ([]byte, error) {
return json.Marshal(j.payload)
}
func (j *jsonJob) Options() []JobOption {
return append([]JobOption(nil), j.options...)
}
func (e *skipRetryError) Error() string {
if e == nil || e.err == nil {
return ErrSkipRetry.Error()
}
return e.err.Error()
}
func (e *skipRetryError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func (e *skipRetryError) Is(target error) bool {
return target == ErrSkipRetry
}
type publisherFactory func(cfg *config.Conf) (Publisher, error)
type inspectorFactory func(cfg *config.Conf) (Inspector, error)
var (
publisherMu sync.RWMutex
activePublisher Publisher
activePublisherF publisherFactory
activePublisherE error
inspectorMu sync.RWMutex
activeInspector Inspector
activeInspectorF inspectorFactory
activeInspectorE error
)
// RegisterPublisherFactory 注册默认的 publisher 构建器。
func RegisterPublisherFactory(factory func(cfg *config.Conf) (Publisher, error)) {
publisherMu.Lock()
defer publisherMu.Unlock()
activePublisherF = factory
}
// InitPublisher 根据当前配置初始化全局 publisher。
func InitPublisher(cfg *config.Conf) error {
publisherMu.Lock()
defer publisherMu.Unlock()
if cfg == nil || !cfg.Queue.Enable {
activePublisher = nil
activePublisherE = nil
return nil
}
if activePublisherF == nil {
activePublisher = nil
activePublisherE = errors.New("queue publisher factory not registered")
return activePublisherE
}
publisher, err := activePublisherF(cfg)
if err != nil {
activePublisher = nil
activePublisherE = err
return err
}
activePublisher = publisher
activePublisherE = nil
return nil
}
// RegisterInspectorFactory 注册默认的 inspector 构建器。
func RegisterInspectorFactory(factory func(cfg *config.Conf) (Inspector, error)) {
inspectorMu.Lock()
defer inspectorMu.Unlock()
activeInspectorF = factory
}
// InitInspector 根据当前配置初始化全局 inspector。
func InitInspector(cfg *config.Conf) error {
inspectorMu.Lock()
defer inspectorMu.Unlock()
if cfg == nil || !cfg.Queue.Enable {
activeInspector = nil
activeInspectorE = nil
return nil
}
if activeInspectorF == nil {
activeInspector = nil
activeInspectorE = errors.New("queue inspector factory not registered")
return activeInspectorE
}
inspector, err := activeInspectorF(cfg)
if err != nil {
activeInspector = nil
activeInspectorE = err
return err
}
activeInspector = inspector
activeInspectorE = nil
return nil
}
// PublisherOrNil 返回当前全局 publisher;未启用时返回 nil。
func PublisherOrNil() Publisher {
publisherMu.RLock()
defer publisherMu.RUnlock()
return activePublisher
}
// PublisherInitError 返回最近一次 publisher 初始化错误。
func PublisherInitError() error {
publisherMu.RLock()
defer publisherMu.RUnlock()
return activePublisherE
}
// InspectorOrNil 返回当前全局 inspector;未启用时返回 nil。
func InspectorOrNil() Inspector {
inspectorMu.RLock()
defer inspectorMu.RUnlock()
return activeInspector
}
// InspectorInitError 返回最近一次 inspector 初始化错误。
func InspectorInitError() error {
inspectorMu.RLock()
defer inspectorMu.RUnlock()
return activeInspectorE
}
// DeleteTask 删除队列中的任务(pending/scheduled/retry/archived)。
func DeleteTask(ctx context.Context, queueName, taskID string) error {
inspector := InspectorOrNil()
if inspector == nil {
return ErrInspectorUnavailable
}
return inspector.DeleteTask(ctx, queueName, taskID)
}
// CancelProcessing 发送取消正在执行任务的信号(best-effort)。
func CancelProcessing(ctx context.Context, taskID string) error {
inspector := InspectorOrNil()
if inspector == nil {
return ErrInspectorUnavailable
}
return inspector.CancelProcessing(ctx, taskID)
}
// SetPublisherForTesting 仅用于测试时替换全局 publisher。
func SetPublisherForTesting(publisher Publisher) func() {
publisherMu.Lock()
previous := activePublisher
previousErr := activePublisherE
activePublisher = publisher
activePublisherE = nil
publisherMu.Unlock()
return func() {
publisherMu.Lock()
activePublisher = previous
activePublisherE = previousErr
publisherMu.Unlock()
}
}
// SetInspectorForTesting 仅用于测试时替换全局 inspector。
func SetInspectorForTesting(inspector Inspector) func() {
inspectorMu.Lock()
previous := activeInspector
previousErr := activeInspectorE
activeInspector = inspector
activeInspectorE = nil
inspectorMu.Unlock()
return func() {
inspectorMu.Lock()
activeInspector = previous
activeInspectorE = previousErr
inspectorMu.Unlock()
}
}
func validatePayload(payload any) error {
if payload == nil {
return nil
}
if validatable, ok := payload.(Validatable); ok {
return validatable.Validate()
}
return nil
}
type memoryRegistry struct {
mu sync.RWMutex
entries map[string]Handler
}
// NewRegistry 创建一个内存 registry。
func NewRegistry() Registry {
return &memoryRegistry{
entries: make(map[string]Handler),
}
}
func (r *memoryRegistry) Register(taskType string, handler Handler) {
if taskType == "" || handler == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.entries[taskType] = handler
}
func (r *memoryRegistry) Entries() []Registration {
r.mu.RLock()
defer r.mu.RUnlock()
registrations := make([]Registration, 0, len(r.entries))
for taskType, handler := range r.entries {
registrations = append(registrations, Registration{
TaskType: taskType,
Handler: handler,
})
}
return registrations
}
================================================
FILE: internal/queue/queue_test.go
================================================
package queue
import (
"context"
"errors"
"testing"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
)
type testPayload struct {
Name string `json:"name"`
}
func (p testPayload) Validate() error {
if p.Name == "" {
return errors.New("name is required")
}
return nil
}
type stubPublisher struct {
lastJob Job
}
type stubInspector struct {
deleteCalled bool
cancelCalled bool
lastQueue string
lastTaskID string
lastCancelID string
}
func (s *stubPublisher) Enqueue(ctx context.Context, job Job) (JobInfo, error) {
_ = ctx
s.lastJob = job
return JobInfo{ID: "job-1", Queue: job.Queue(), Type: job.Type()}, nil
}
func (s *stubInspector) DeleteTask(ctx context.Context, queueName, taskID string) error {
_ = ctx
s.deleteCalled = true
s.lastQueue = queueName
s.lastTaskID = taskID
return nil
}
func (s *stubInspector) CancelProcessing(ctx context.Context, taskID string) error {
_ = ctx
s.cancelCalled = true
s.lastCancelID = taskID
return nil
}
func TestPublishJSONUsesGlobalPublisher(t *testing.T) {
publisher := &stubPublisher{}
restore := SetPublisherForTesting(publisher)
defer restore()
info, err := PublishJSON(context.Background(), "demo:send", "critical", testPayload{Name: "codex"}, WithMaxRetry(3))
if err != nil {
t.Fatalf("PublishJSON returned error: %v", err)
}
if info.Type != "demo:send" || info.Queue != "critical" {
t.Fatalf("unexpected job info: %#v", info)
}
if publisher.lastJob == nil {
t.Fatal("expected publisher to receive job")
}
}
func TestRegisterJSONDecodesAndValidatesPayload(t *testing.T) {
registry := NewRegistry()
called := false
RegisterJSON(registry, "demo:send", func(ctx context.Context, payload testPayload) error {
_ = ctx
called = true
if payload.Name != "codex" {
t.Fatalf("unexpected payload: %#v", payload)
}
return nil
})
entries := registry.Entries()
if len(entries) != 1 {
t.Fatalf("expected 1 registry entry, got %d", len(entries))
}
if err := entries[0].Handler(context.Background(), []byte(`{"name":"codex"}`)); err != nil {
t.Fatalf("handler returned error: %v", err)
}
if !called {
t.Fatal("expected handler to be called")
}
}
func TestRegisterJSONReturnsSkipRetryForInvalidPayload(t *testing.T) {
registry := NewRegistry()
RegisterJSON(registry, "demo:send", func(ctx context.Context, payload testPayload) error {
_ = ctx
_ = payload
return nil
})
entries := registry.Entries()
if len(entries) != 1 {
t.Fatalf("expected 1 registry entry, got %d", len(entries))
}
err := entries[0].Handler(context.Background(), []byte(`{}`))
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrSkipRetry) {
t.Fatalf("expected skip retry error, got %v", err)
}
}
func TestInitPublisherStoresLastError(t *testing.T) {
previousFactory := activePublisherF
previousPublisher := activePublisher
previousInitErr := activePublisherE
t.Cleanup(func() {
activePublisherF = previousFactory
activePublisher = previousPublisher
activePublisherE = previousInitErr
})
activePublisherF = func(cfg *config.Conf) (Publisher, error) {
_ = cfg
return nil, errTestPublisherInit
}
cfg := &config.Conf{
Queue: autoload.QueueConfig{Enable: true},
}
err := InitPublisher(cfg)
if !errors.Is(err, errTestPublisherInit) {
t.Fatalf("expected init error %v, got %v", errTestPublisherInit, err)
}
if !errors.Is(PublisherInitError(), errTestPublisherInit) {
t.Fatalf("expected stored init error %v, got %v", errTestPublisherInit, PublisherInitError())
}
if PublisherOrNil() != nil {
t.Fatal("expected publisher to remain nil on init failure")
}
}
func TestDeleteTaskUsesGlobalInspector(t *testing.T) {
inspector := &stubInspector{}
restore := SetInspectorForTesting(inspector)
defer restore()
if err := DeleteTask(context.Background(), "default", "task-1"); err != nil {
t.Fatalf("DeleteTask returned error: %v", err)
}
if !inspector.deleteCalled || inspector.lastQueue != "default" || inspector.lastTaskID != "task-1" {
t.Fatalf("unexpected inspector state: %#v", inspector)
}
}
func TestCancelProcessingUsesGlobalInspector(t *testing.T) {
inspector := &stubInspector{}
restore := SetInspectorForTesting(inspector)
defer restore()
if err := CancelProcessing(context.Background(), "task-1"); err != nil {
t.Fatalf("CancelProcessing returned error: %v", err)
}
if !inspector.cancelCalled || inspector.lastCancelID != "task-1" {
t.Fatalf("unexpected inspector state: %#v", inspector)
}
}
func TestDeleteTaskReturnsUnavailableWithoutInspector(t *testing.T) {
restore := SetInspectorForTesting(nil)
defer restore()
err := DeleteTask(context.Background(), "default", "task-1")
if !errors.Is(err, ErrInspectorUnavailable) {
t.Fatalf("expected ErrInspectorUnavailable, got %v", err)
}
}
var errTestPublisherInit = errors.New("publisher init failed")
================================================
FILE: internal/resources/admin_user.go
================================================
package resources
import (
"github.com/samber/lo"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// AdminUserResources 是后台管理员用户的响应资源结构体
// 用于对外暴露字段,避免直接返回数据库模型结构体
// 可配合脱敏规则处理敏感信息
type AdminUserResources struct {
ID uint `json:"id"` // 管理员ID
Nickname string `json:"nickname"` // 昵称
Username string `json:"username"` // 用户名
IsSuperAdmin uint8 `json:"is_super_admin"` // 是否为超级管理员
IsSuperAdminName string `json:"is_super_admin_name"` // 是否为超级管理员名称
PhoneNumber string `json:"phone_number"` // 手机号(可脱敏)
CountryCode string `json:"country_code"` // 国家区号
Email string `json:"email"` // 邮箱(可脱敏)
Avatar string `json:"avatar"` // 头像链接
CreatedAt utils.FormatDate `json:"created_at"` // 创建时间
UpdatedAt utils.FormatDate `json:"updated_at"` // 更新时间
Status uint8 `json:"status"` // 状态(1启用/0禁用)
StatusName string `json:"status_name"` // 状态名称
LastIp string `json:"last_ip"` // 上次登录 IP
LastLogin utils.FormatDate `json:"last_login"` // 上次登录时间
Departments []department `json:"departments"` // 部门信息D
RoleList []uint `json:"role_list"` // 角色信息
}
// AdminUserTransformer 是 AdminUser 的资源转换器,实现 Resources 接口
// 内部嵌入 BaseResources 实现结构复用
type AdminUserTransformer struct {
BaseResources[*model.AdminUser, *AdminUserResources]
}
// SetCustomFields 填充管理员资源的映射字段和关联字段。
func (r *AdminUserResources) SetCustomFields(data *model.AdminUser) {
// 初始化 RoleList 和 Departments 为空切片,确保字段总是存在
r.RoleList = []uint{}
r.Departments = []department{}
if data == nil {
return
}
// 设置映射字段
r.IsSuperAdminName = data.IsSuperAdminMap()
r.StatusName = data.StatusMap()
// 头像URL原样返回
r.Avatar = data.Avatar
// 如果 RoleList 有数据,则提取 RoleId
if len(data.RoleList) > 0 {
r.RoleList = lo.Map(data.RoleList, func(m model.AdminUserRoleMap, _ int) uint {
return m.RoleId
})
}
// 如果 Department 有数据,则转换为 department 结构
if len(data.Department) > 0 {
r.Departments = lo.Map(data.Department, func(d model.Department, _ int) department {
return department{
ID: d.ID,
Name: d.Name,
Pid: d.Pid,
}
})
}
}
// NewAdminUserTransformer 返回 AdminUserTransformer 实例,绑定资源创建函数
func NewAdminUserTransformer() AdminUserTransformer {
return AdminUserTransformer{
BaseResources: BaseResources[*model.AdminUser, *AdminUserResources]{
NewResource: func() *AdminUserResources {
return &AdminUserResources{}
},
},
}
}
// ToCollection 覆盖默认实现,支持手机号、邮箱等字段的自定义脱敏逻辑
// 若无特殊处理需求,可不实现该方法,默认继承 BaseResources 的逻辑
func (AdminUserTransformer) ToCollection(page, perPage int, total int64, data []*model.AdminUser) *Collection {
response := make([]any, 0, len(data))
phoneRule := utils.NewPhoneRule() // 手机号脱敏规则
emailRule := utils.NewEmailRule() // 邮箱脱敏规则
for _, v := range data {
deptSlice := make([]department, 0, len(v.Department))
for _, d := range v.Department {
deptSlice = append(deptSlice, department{
ID: d.ID,
Name: d.Name,
Pid: d.Pid,
})
}
response = append(response, &AdminUserResources{
ID: v.ID,
Nickname: v.Nickname,
Username: v.Username,
IsSuperAdmin: v.IsSuperAdmin,
IsSuperAdminName: v.IsSuperAdminMap(),
PhoneNumber: phoneRule.Apply(v.PhoneNumber),
CountryCode: v.CountryCode,
Email: emailRule.Apply(v.Email),
Avatar: v.Avatar,
Status: v.Status,
StatusName: v.StatusMap(),
LastIp: v.LastIp,
LastLogin: v.LastLogin,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
Departments: deptSlice,
RoleList: []uint{},
})
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(response)
}
================================================
FILE: internal/resources/api.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
)
// ApiResources 表示接口权限的响应结构。
type ApiResources struct {
ID uint `json:"id"`
Name string `json:"name"` // 权限名称
Code string `json:"code"` // 权限名称
Description string `json:"description"` // 描述
Method string `json:"method"` // 接口请求方法
Route string `json:"route"` // 接口路由
Func string `json:"func"` // 接口方法
FuncPath string `json:"func_path"` // 接口方法
IsAuth uint8 `json:"is_auth"` // 接口鉴权模式
IsEffective uint8 `json:"is_effective"` // 是否有效
IsAuthName *string `json:"is_auth_name"` // 接口鉴权模式名称
IsEffectiveName *string `json:"is_effective_name"` // 是否有效
Sort int `json:"sort"` // 排序
}
// ApiTransformer 权限资源转换
type ApiTransformer struct {
BaseResources[*model.Api, *ApiResources]
}
// NewApiTransformer 实例化权限资源转换器
func NewApiTransformer() ApiTransformer {
return ApiTransformer{
BaseResources: BaseResources[*model.Api, *ApiResources]{
NewResource: func() *ApiResources {
return &ApiResources{}
},
},
}
}
// ToStruct 将 API 模型转换为响应结构。
func (ApiTransformer) ToStruct(data *model.Api) *ApiResources {
isAuthName := data.IsAuthMap()
isEffectiveName := data.IsEffectiveMap()
return &ApiResources{
ID: data.ID,
Name: data.Name,
Description: data.Description,
Method: data.Method,
Route: data.Route,
Func: data.Func,
FuncPath: data.FuncPath,
IsAuth: data.IsAuth,
IsAuthName: &isAuthName,
Sort: data.Sort,
Code: data.Code,
IsEffective: data.IsEffective,
IsEffectiveName: &isEffectiveName,
}
}
// ToCollection 将 API 模型集合转换为分页响应。
func (ApiTransformer) ToCollection(page, perPage int, total int64, data []*model.Api) *Collection {
response := make([]any, 0, len(data))
for _, v := range data {
response = append(response, ApiTransformer{}.ToStruct(v))
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(response)
}
================================================
FILE: internal/resources/base.go
================================================
package resources
import (
"github.com/jinzhu/copier"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/internal/global"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
// Resources 定义模型到响应资源的转换接口。
type Resources[T any, R any] interface {
ToStruct(data T) R
ToCollection(page, perPage int, total int64, data []T) *Collection
}
// CustomFieldSetter 允许资源在复制基础字段后补充扩展信息。
type CustomFieldSetter[T any] interface {
SetCustomFields(T)
}
// BaseResources 提供通用的资源转换实现。
type BaseResources[T any, R any] struct {
NewResource func() R
}
// ToStruct 将单个模型复制为资源结构。
func (br BaseResources[T, R]) ToStruct(data T) R {
resource, _ := toGenericStruct(data, br.NewResource)
return resource
}
// ToCollection 将模型集合转换为统一分页响应。
func (br BaseResources[T, R]) ToCollection(page, perPage int, total int64, data []T) *Collection {
items := make([]any, 0, len(data))
for _, item := range data {
items = append(items, br.ToStruct(item))
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(items)
}
// ToAnySlice 将泛型切片转换为 []any。
func ToAnySlice[T any](data []T) []any {
items := make([]any, len(data))
for i, v := range data {
items[i] = v
}
return items
}
// TreeNode 表示可挂载子节点的树形资源。
type TreeNode[R any] interface {
SetChildren(children []R)
}
// Identifiable 表示可参与树构建的节点标识接口。
type Identifiable interface {
GetID() uint
GetPID() uint
}
// TreeResources 定义树形资源的转换接口。
type TreeResources[T any, R TreeNode[R]] interface {
ToStruct(data T) R
BuildTree(data []T, pidFn func(T) uint, idFn func(T) uint) []R
}
// TreeResource 提供通用的树形资源转换实现。
type TreeResource[T any, R TreeNode[R]] struct {
NewResource func() R
}
// ToStruct 将单个模型复制为树形资源节点。
func (tr TreeResource[T, R]) ToStruct(data T) R {
resource, _ := toGenericStruct(data, tr.NewResource)
return resource
}
// BuildTree 根据父子关系构建树形结果。
func (tr TreeResource[T, R]) BuildTree(data []T, pidFn func(T) uint, idFn func(T) uint, rootID uint) []R {
parentMap := make(map[uint][]T)
for _, item := range data {
pid := pidFn(item)
parentMap[pid] = append(parentMap[pid], item)
}
var build func(uint) []R
build = func(parentID uint) []R {
children, ok := parentMap[parentID]
if !ok {
return nil
}
var tree []R
for _, v := range children {
resource := tr.ToStruct(v)
resource.SetChildren(build(idFn(v)))
tree = append(tree, resource)
}
return tree
}
return build(rootID)
}
// BuildTreeByNode 使用资源节点自带的标识信息构建树。
func (tr TreeResource[T, R]) BuildTreeByNode(data []T, rootID uint) []R {
if len(data) == 0 {
return []R{}
}
parentMap := make(map[uint][]T)
for _, item := range data {
resource := tr.ToStruct(item)
if identifiable, ok := any(resource).(Identifiable); ok {
pid := identifiable.GetPID()
parentMap[pid] = append(parentMap[pid], item)
}
}
var build func(uint) []R
build = func(parentID uint) []R {
children := parentMap[parentID]
var tree []R
for _, v := range children {
resource := tr.ToStruct(v)
if identifiable, ok := any(resource).(Identifiable); ok {
resource.SetChildren(build(identifiable.GetID()))
}
tree = append(tree, resource)
}
return tree
}
return build(rootID)
}
// toGenericStruct 复制模型字段并补充自定义资源字段。
func toGenericStruct[T any, R any](data T, newFunc func() R) (R, error) {
var resource = newFunc()
err := copier.Copy(&resource, data)
if err != nil {
log.Logger.Error("Copy data to struct error", zap.Error(err))
return resource, err
}
if cfs, ok := any(resource).(CustomFieldSetter[T]); ok {
cfs.SetCustomFields(data)
}
return resource, nil
}
// Paginate 表示统一分页元数据。
type Paginate struct {
Total int64 `json:"total"`
PerPage int `json:"per_page"`
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
}
// calculateLastPage 归一化分页参数并计算最后一页。
func (p *Paginate) calculateLastPage() {
if p.CurrentPage < 1 {
p.CurrentPage = 1
}
if p.PerPage < 1 {
p.PerPage = global.PerPage
}
if p.PerPage < 0 {
p.PerPage = 10 // fallback 默认值
}
if p.Total == 0 {
p.LastPage = 1
return
}
p.LastPage = int((p.Total + int64(p.PerPage) - 1) / int64(p.PerPage))
}
// ResponseCollectionInterface 定义分页集合的基础能力。
type ResponseCollectionInterface interface {
GetPaginate() *Paginate
SetPaginate(page, perPage int, total int64) *Collection
ToCollection(data []any) *Collection
}
// Collection 表示带分页信息的列表响应。
type Collection struct {
Paginate
Data []any `json:"data"`
}
// GetPaginate 返回当前集合的分页信息。
func (p *Collection) GetPaginate() *Paginate {
return &p.Paginate
}
// SetPaginate 设置集合的分页元数据。
func (p *Collection) SetPaginate(page, perPage int, total int64) *Collection {
p.Paginate = Paginate{
Total: total,
CurrentPage: page,
PerPage: perPage,
}
p.Paginate.calculateLastPage()
return p
}
// ToCollection 设置集合数据项。
func (p *Collection) ToCollection(data []any) *Collection {
p.Data = data
return p
}
// NewCollection 创建空的分页集合。
func NewCollection() *Collection {
return &Collection{}
}
// ToRawCollection 直接将模型切片包装为分页响应。
func ToRawCollection[T any](page, perPage int, total int64, data []T) *Collection {
items := make([]any, len(data))
for i, v := range data {
items[i] = v
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(items)
}
================================================
FILE: internal/resources/base_test.go
================================================
package resources
import "testing"
type baseTestModel struct {
ID uint
}
type baseTestResource struct {
ID uint
Computed string
}
func (r *baseTestResource) SetCustomFields(data baseTestModel) {
r.Computed = "ok"
}
func TestBaseResourcesToCollectionTransformsItems(t *testing.T) {
transformer := BaseResources[baseTestModel, *baseTestResource]{
NewResource: func() *baseTestResource {
return &baseTestResource{}
},
}
collection := transformer.ToCollection(1, 10, 2, []baseTestModel{{ID: 1}, {ID: 2}})
if len(collection.Data) != 2 {
t.Fatalf("unexpected data len: %d", len(collection.Data))
}
item, ok := collection.Data[0].(*baseTestResource)
if !ok {
t.Fatalf("expected transformed resource, got %#v", collection.Data[0])
}
if item.Computed != "ok" {
t.Fatalf("expected custom field to be applied, got %#v", item)
}
}
func TestPaginateCalculateLastPageUsesIntegerCeil(t *testing.T) {
collection := NewCollection().SetPaginate(1, 10, 21)
if collection.LastPage != 3 {
t.Fatalf("expected last page 3, got %d", collection.LastPage)
}
}
================================================
FILE: internal/resources/common.go
================================================
package resources
type department struct {
ID uint `json:"id"`
Name string `json:"name"`
Pid uint `json:"pid"`
}
================================================
FILE: internal/resources/dept.go
================================================
package resources
import (
"github.com/samber/lo"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// DeptResources 表示部门树节点的响应结构。
type DeptResources struct {
ID uint `json:"id"`
Code string `json:"code"`
IsSystem uint8 `json:"is_system"`
Pid uint `json:"pid"`
Name string `json:"name"`
Description string `json:"description"`
Level uint8 `json:"level"`
Sort uint16 `json:"sort"`
ChildrenNum uint `json:"children_num"`
Children []*DeptResources `json:"children,omitempty"`
RoleList []uint `json:"role_list"`
UserNumber uint `json:"user_number"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// SetChildren 设置部门节点的子节点。
func (r *DeptResources) SetChildren(children []*DeptResources) {
r.Children = children
}
// GetID 返回当前部门节点 ID。
func (r *DeptResources) GetID() uint {
return r.ID
}
// GetPID 返回当前部门节点父级 ID。
func (r *DeptResources) GetPID() uint {
return r.Pid
}
// DeptTreeTransformer 负责把部门模型转换为树形响应结构。
type DeptTreeTransformer struct {
TreeResource[*model.Department, *DeptResources]
}
// SetCustomFields 填充部门资源的扩展字段。
func (r *DeptResources) SetCustomFields(data *model.Department) {
r.RoleList = []uint{}
if data == nil {
return
}
r.Code = data.Code
r.IsSystem = data.IsSystem
if len(data.RoleList) > 0 {
r.RoleList = lo.Map(data.RoleList, func(m model.DeptRoleMap, _ int) uint {
return m.RoleId
})
}
}
// NewDeptTreeTransformer 创建部门树资源转换器。
func NewDeptTreeTransformer() DeptTreeTransformer {
return DeptTreeTransformer{
TreeResource: TreeResource[*model.Department, *DeptResources]{
NewResource: func() *DeptResources {
return &DeptResources{}
},
},
}
}
================================================
FILE: internal/resources/file_resource.go
================================================
package resources
import (
"strings"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// FileResourceResources 文件资源响应结构。
type FileResourceResources struct {
ID uint `json:"id"`
FileObjectID uint `json:"file_object_id"`
UID uint `json:"uid"`
FolderID uint `json:"folder_id"`
LogicalPath string `json:"logical_path"`
DisplayName string `json:"display_name"`
OriginName string `json:"origin_name"`
Name string `json:"name"`
Path string `json:"path"`
Size uint `json:"size"`
Ext string `json:"ext"`
Hash string `json:"hash"`
UUID string `json:"uuid"`
MimeType string `json:"mime_type"`
FileType string `json:"file_type"`
IsPublic uint8 `json:"is_public"`
StorageDriver string `json:"storage_driver"`
StorageBase string `json:"storage_base"`
Bucket string `json:"bucket"`
StoragePath string `json:"storage_path"`
ObjectKey string `json:"object_key"`
ETag string `json:"etag"`
StorageStatus string `json:"storage_status"`
StorageStatusName string `json:"storage_status_name"`
UploadSource string `json:"upload_source"`
UploadSourceName string `json:"upload_source_name"`
UploadScene string `json:"upload_scene"`
UploadStatus string `json:"upload_status"`
UploadStatusName string `json:"upload_status_name"`
ReferenceCount int64 `json:"reference_count"`
ObjectReuseCount int64 `json:"object_reuse_count"`
ObjectStatus string `json:"object_status"`
ObjectStatusName string `json:"object_status_name"`
URL string `json:"url"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
LastAccessedAt utils.FormatDate `json:"last_accessed_at"`
DeletedBy uint `json:"deleted_by"`
DeletedReason string `json:"deleted_reason"`
DeletedAt uint `json:"deleted_at"`
References any `json:"references,omitempty"`
}
// FileResourceTransformer 文件资源转换器。
type FileResourceTransformer struct {
BaseResources[*model.UploadFiles, *FileResourceResources]
}
func NewFileResourceTransformer() FileResourceTransformer {
return FileResourceTransformer{
BaseResources: BaseResources[*model.UploadFiles, *FileResourceResources]{
NewResource: func() *FileResourceResources {
return &FileResourceResources{}
},
},
}
}
func (r *FileResourceResources) SetCustomFields(data *model.UploadFiles) {
r.URL = buildFileResourceURL(data.UUID)
r.DeletedAt = uint(data.DeletedAt)
if r.DisplayName == "" {
r.DisplayName = data.OriginName
}
r.StorageStatusName = fileStorageStatusName(data.StorageStatus)
if r.ObjectStatus == "" {
r.ObjectStatus = data.ObjectStatus
}
r.ObjectStatusName = fileStorageStatusName(r.ObjectStatus)
r.UploadSourceName = fileUploadSourceName(data.UploadSource)
r.UploadStatusName = fileUploadStatusName(data.UploadStatus)
}
func fileStorageStatusName(status string) string {
switch status {
case model.StorageStatusStored:
return "已存储"
case model.StorageStatusDeleteFailed:
return "删除失败"
case "uploading":
return "上传中"
case "missing":
return "对象缺失"
default:
return status
}
}
func fileUploadSourceName(source string) string {
switch source {
case model.UploadSourceBackend:
return "后端上传"
case model.UploadSourceDirect:
return "前端直传"
case model.UploadSourceSystem:
return "系统生成"
default:
return source
}
}
func fileUploadStatusName(status string) string {
switch status {
case model.UploadStatusPending:
return "待完成"
case model.UploadStatusUploaded:
return "已上传"
case model.UploadStatusFailed:
return "上传失败"
default:
return status
}
}
type FileFolderResources struct {
ID uint `json:"id"`
ParentID uint `json:"parent_id"`
Name string `json:"name"`
LogicalPath string `json:"logical_path"`
Sort int `json:"sort"`
FileCount int64 `json:"file_count"`
TotalSize int64 `json:"total_size"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
Children []*FileFolderResources `json:"children,omitempty"`
}
type FileFolderTransformer struct {
BaseResources[*model.UploadFileFolder, *FileFolderResources]
}
func NewFileFolderTransformer() FileFolderTransformer {
return FileFolderTransformer{
BaseResources: BaseResources[*model.UploadFileFolder, *FileFolderResources]{
NewResource: func() *FileFolderResources {
return &FileFolderResources{}
},
},
}
}
type FileMoveResult struct {
Total int64 `json:"total"`
Moved int64 `json:"moved"`
Skipped int64 `json:"skipped"`
}
type FileUploadCredentialResources struct {
StorageDriver string `json:"storage_driver"`
Driver string `json:"driver"`
Bucket string `json:"bucket"`
ObjectKey string `json:"object_key"`
UploadURL string `json:"upload_url"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
ExpireAt utils.FormatDate `json:"expire_at"`
Reuse bool `json:"reuse"`
FileObjectID uint `json:"file_object_id"`
Size uint `json:"size"`
Hash string `json:"hash"`
MimeType string `json:"mime_type"`
ETag string `json:"etag"`
ObjectStatus string `json:"object_status"`
CompletePayload map[string]any `json:"complete_payload"`
}
type FileReferenceResources struct {
ID uint `json:"id"`
FileID uint `json:"file_id"`
UUID string `json:"uuid"`
OwnerType string `json:"owner_type"`
OwnerID uint `json:"owner_id"`
OwnerField string `json:"owner_field"`
SourceName string `json:"source_name"`
FieldName string `json:"field_name"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
type FileReferenceTransformer struct {
BaseResources[*model.UploadFileReference, *FileReferenceResources]
}
func NewFileReferenceTransformer() FileReferenceTransformer {
return FileReferenceTransformer{
BaseResources: BaseResources[*model.UploadFileReference, *FileReferenceResources]{
NewResource: func() *FileReferenceResources {
return &FileReferenceResources{}
},
},
}
}
func (r *FileReferenceResources) SetCustomFields(data *model.UploadFileReference) {
r.SourceName = fileReferenceSourceName(data.OwnerType)
r.FieldName = fileReferenceFieldName(data.OwnerField)
}
func fileReferenceSourceName(ownerType string) string {
switch ownerType {
case "admin_user":
return "管理员"
default:
return ownerType
}
}
func fileReferenceFieldName(ownerField string) string {
switch ownerField {
case "avatar":
return "头像"
default:
return ownerField
}
}
func buildFileResourceURL(uuid string) string {
if uuid == "" {
return ""
}
baseURL := strings.TrimSuffix(c.GetConfig().BaseURL, "/")
if baseURL == "" {
return "/admin/v1/file/" + uuid
}
return baseURL + "/admin/v1/file/" + uuid
}
================================================
FILE: internal/resources/file_resource_test.go
================================================
package resources
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestFileReferenceTransformerAddsDisplayNames(t *testing.T) {
result := NewFileReferenceTransformer().ToStruct(&model.UploadFileReference{
FileID: 1,
OwnerType: "admin_user",
OwnerID: 2,
OwnerField: "avatar",
})
if result.SourceName != "管理员" {
t.Fatalf("expected source_name 管理员, got %q", result.SourceName)
}
if result.FieldName != "头像" {
t.Fatalf("expected field_name 头像, got %q", result.FieldName)
}
}
func TestFileReferenceTransformerFallsBackToRawNames(t *testing.T) {
result := NewFileReferenceTransformer().ToStruct(&model.UploadFileReference{
OwnerType: "custom_owner",
OwnerField: "custom_field",
})
if result.SourceName != "custom_owner" {
t.Fatalf("expected raw source_name, got %q", result.SourceName)
}
if result.FieldName != "custom_field" {
t.Fatalf("expected raw field_name, got %q", result.FieldName)
}
}
func TestFileResourceTransformerAddsStatusDisplayNames(t *testing.T) {
result := NewFileResourceTransformer().ToStruct(&model.UploadFiles{
OriginName: "avatar.png",
StorageStatus: model.StorageStatusStored,
UploadSource: model.UploadSourceBackend,
UploadStatus: model.UploadStatusUploaded,
})
if result.StorageStatusName != "已存储" {
t.Fatalf("expected storage_status_name 已存储, got %q", result.StorageStatusName)
}
if result.UploadSourceName != "后端上传" {
t.Fatalf("expected upload_source_name 后端上传, got %q", result.UploadSourceName)
}
if result.UploadStatusName != "已上传" {
t.Fatalf("expected upload_status_name 已上传, got %q", result.UploadStatusName)
}
}
================================================
FILE: internal/resources/login_log.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// AdminLoginLogBaseResources 表示登录日志的公共响应字段。
type AdminLoginLogBaseResources struct {
ID uint `json:"id"`
UID uint `json:"uid"` // 用户 ID(登录失败时为 0)
Username string `json:"username"` // 登录账号
IP string `json:"ip"` // 登录 IP(支持 IPv6)
OS string `json:"os"` // 操作系统
Browser string `json:"browser"` // 浏览器
ExecutionTime int `json:"execution_time"` // 登录耗时(毫秒)
LoginStatus uint8 `json:"login_status"` // 登录状态:1=成功,0=失败
LoginStatusName string `json:"login_status_name"` // 登录状态名称
LoginFailReason string `json:"login_fail_reason"` // 登录失败原因
Type uint8 `json:"type"` // 操作类型:1=登录操作,2=刷新 token
TypeName string `json:"type_name"` // 操作类型名称
IsRevoked uint8 `json:"is_revoked"` // 是否被撤销:0=否,1=是
IsRevokedName string `json:"is_revoked_name"` // 是否被撤销名称
RevokedCode uint8 `json:"revoked_code"` // 撤销原因码
RevokedCodeName string `json:"revoked_code_name"` // 撤销原因码名称
RevokedReason string `json:"revoked_reason"` // 撤销原因
RevokedAt *utils.FormatDate `json:"revoked_at"` // 撤销时间
CreatedAt utils.FormatDate `json:"created_at"` // 创建时间
}
// AdminLoginLogListResources 表示登录日志列表项。
type AdminLoginLogListResources struct {
AdminLoginLogBaseResources
}
// AdminLoginLogResources 表示登录日志详情响应。
type AdminLoginLogResources struct {
AdminLoginLogBaseResources
JwtID string `json:"jwt_id"` // JWT 唯一标识 (jti claim)
UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息)
TokenHash string `json:"token_hash"` // Token 的 SHA256 哈希值
RefreshTokenHash string `json:"refresh_token_hash"` // Refresh Token 的哈希值
TokenExpires *utils.FormatDate `json:"token_expires"` // Token 过期时间
RefreshExpires *utils.FormatDate `json:"refresh_expires"` // Refresh Token 过期时间
UpdatedAt utils.FormatDate `json:"updated_at"` // 更新时间
}
// AdminLoginLogTransformer 负责登录日志资源转换。
type AdminLoginLogTransformer struct {
BaseResources[*model.AdminLoginLogs, *AdminLoginLogResources]
}
// NewAdminLoginLogTransformer 创建登录日志资源转换器。
func NewAdminLoginLogTransformer() AdminLoginLogTransformer {
return AdminLoginLogTransformer{
BaseResources: BaseResources[*model.AdminLoginLogs, *AdminLoginLogResources]{
NewResource: func() *AdminLoginLogResources {
return &AdminLoginLogResources{}
},
},
}
}
// buildAdminLoginLogBaseResources 提取登录日志公共字段。
func buildAdminLoginLogBaseResources(data *model.AdminLoginLogs) AdminLoginLogBaseResources {
return AdminLoginLogBaseResources{
ID: data.ID,
UID: data.UID,
Username: data.Username,
IP: data.IP,
OS: data.OS,
Browser: data.Browser,
ExecutionTime: data.ExecutionTime,
LoginStatus: data.LoginStatus,
LoginStatusName: data.LoginStatusMap(),
LoginFailReason: data.LoginFailReason,
Type: data.Type,
TypeName: data.TypeMap(),
IsRevoked: data.IsRevoked,
IsRevokedName: data.IsRevokedMap(),
RevokedCode: data.RevokedCode,
RevokedCodeName: data.RevokedCodeMap(),
RevokedReason: data.RevokedReason,
RevokedAt: data.RevokedAt,
CreatedAt: data.CreatedAt,
}
}
// ToStruct 将登录日志模型转换为详情响应。
func (r AdminLoginLogTransformer) ToStruct(data *model.AdminLoginLogs) *AdminLoginLogResources {
base := buildAdminLoginLogBaseResources(data)
return &AdminLoginLogResources{
AdminLoginLogBaseResources: base,
JwtID: data.JwtID,
UserAgent: data.UserAgent,
TokenHash: data.TokenHash,
RefreshTokenHash: data.RefreshTokenHash,
TokenExpires: data.TokenExpires,
RefreshExpires: data.RefreshExpires,
UpdatedAt: data.UpdatedAt,
}
}
// ToCollection 将登录日志模型集合转换为分页响应。
func (r AdminLoginLogTransformer) ToCollection(page, perPage int, total int64, data []*model.AdminLoginLogs) *Collection {
response := make([]any, 0, len(data))
for _, v := range data {
base := buildAdminLoginLogBaseResources(v)
response = append(response, &AdminLoginLogListResources{
AdminLoginLogBaseResources: base,
})
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(response)
}
================================================
FILE: internal/resources/login_log_test.go
================================================
package resources
import (
"encoding/json"
"testing"
"time"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
func TestAdminLoginLogTransformerOnlyExposeTokenHashesInDetail(t *testing.T) {
now := utils.FormatDate{Time: time.Now()}
resource := NewAdminLoginLogTransformer().ToStruct(&model.AdminLoginLogs{
JwtID: "jwt-id",
UserAgent: "ua",
AccessToken: "plain-access-token",
RefreshToken: "plain-refresh-token",
TokenHash: "access-hash",
RefreshTokenHash: "refresh-hash",
TokenExpires: &now,
RefreshExpires: &now,
})
if resource.JwtID != "jwt-id" || resource.UserAgent != "ua" {
t.Fatal("expected detail basic fields to be preserved")
}
if resource.TokenHash != "access-hash" || resource.RefreshTokenHash != "refresh-hash" {
t.Fatal("expected token hashes to be preserved")
}
if resource.TokenExpires == nil || resource.RefreshExpires == nil {
t.Fatal("expected token expiry fields to be preserved")
}
if !resource.TokenExpires.Time.Equal(now.Time) || !resource.RefreshExpires.Time.Equal(now.Time) {
t.Fatal("expected token expiry values to be preserved")
}
payload, err := json.Marshal(resource)
if err != nil {
t.Fatalf("marshal resource failed: %v", err)
}
fields := map[string]any{}
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatalf("unmarshal resource payload failed: %v", err)
}
if _, ok := fields["access_token"]; ok {
t.Fatal("expected access_token to be hidden from detail response")
}
if _, ok := fields["refresh_token"]; ok {
t.Fatal("expected refresh_token to be hidden from detail response")
}
}
================================================
FILE: internal/resources/menu.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// MenuBaseResources 表示菜单响应的公共字段。
type MenuBaseResources struct {
ID uint `json:"id"`
Icon string `json:"icon"` // 图标
Title string `json:"title,omitempty"` // 当前请求语言标题
Name string `json:"name"` // 路由名称
Code string `json:"code"` // 前端权限标识
Path string `json:"path"` // 前端路由地址
IsExternalLinks uint8 `json:"is_external_links"` // 是否外链 0:否 1:是
IsAuth uint8 `json:"is_auth"` // 是否鉴权 0:否 1:是
Status uint8 `json:"status"` // 状态,0禁用 1启用
StatusName string `json:"status_name,omitempty"` // 状态名称
IsShow uint8 `json:"is_show"` // 是否显示,1是 0否
IsNewWindow uint8 `json:"is_new_window"` // 是否新窗口打开, 1是 0否
Sort uint `json:"sort"` // 排序,数字越大,排名越靠前
Type uint8 `json:"type"` // 菜单类型,1目录,2菜单,3按钮
TypeName string `json:"type_name,omitempty"`
Pid uint `json:"pid"` // 上级菜单id
ChildrenNum uint `json:"children_num"` // 子集数量
Description string `json:"description"` // 描述
Component string `json:"component"` // 前端组件路径
Redirect string `json:"redirect"` // 重定向地址
FullPath string `json:"full_path"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// MenuResources 表示菜单详情响应。
type MenuResources struct {
MenuBaseResources
TitleI18n map[string]string `json:"title_i18n,omitempty"` // 多语言标题(仅详情返回)
IsExternalLinksName string `json:"is_external_links_name,omitempty"`
IsAuthName string `json:"is_auth_name,omitempty"`
IsShowName string `json:"is_show_name,omitempty"`
ISNewWindowName string `json:"is_new_window_name,omitempty"`
Level uint8 `json:"level"` // 层级
AnimateEnter string `json:"animate_enter"` // 进入动画
AnimateLeave string `json:"animate_leave"` // 离开动画
AnimateDuration float32 `json:"animate_duration"` // 动画时长
Children []*MenuResources `json:"children,omitempty"`
ApiList []uint `json:"api_list"`
}
// MenuTransformer 负责菜单详情资源转换。
type MenuTransformer struct {
BaseResources[*model.Menu, *MenuResources]
}
// NewMenuTransformer 创建菜单资源转换器。
func NewMenuTransformer() MenuTransformer {
return MenuTransformer{
BaseResources: BaseResources[*model.Menu, *MenuResources]{
NewResource: func() *MenuResources {
return &MenuResources{}
},
},
}
}
// ToStruct 将菜单模型转换为详情响应。
func (m MenuTransformer) ToStruct(data *model.Menu) *MenuResources {
return m.ToStructWithTitles(data, "", nil)
}
// ToStructWithTitles 将菜单模型转换为详情响应,并显式注入标题信息。
func (m MenuTransformer) ToStructWithTitles(data *model.Menu, title string, titleI18n map[string]string) *MenuResources {
return buildMenuResource(data, title, titleI18n)
}
// ToCollection 将菜单模型集合转换为分页响应。
func (m MenuTransformer) ToCollection(page, perPage int, total int64, data []*model.Menu) *Collection {
return m.ToCollectionWithTitles(page, perPage, total, data, nil)
}
// ToCollectionWithTitles 将菜单模型集合转换为分页响应,并显式注入标题信息。
func (m MenuTransformer) ToCollectionWithTitles(page, perPage int, total int64, data []*model.Menu, titleByMenuID map[uint]string) *Collection {
response := make([]any, 0, len(data))
for _, v := range data {
response = append(response, buildListMenuResource(v, resolveTitleByMenuID(v.ID, titleByMenuID)))
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(response)
}
// buildMenuBaseResources 提取菜单响应的公共字段。
func buildMenuBaseResources(v *model.Menu, title string) MenuBaseResources {
return MenuBaseResources{
ID: v.ID,
Icon: v.Icon,
Title: title,
Name: v.Name,
Component: v.Component,
Code: v.Code,
Path: v.Path,
FullPath: v.FullPath,
Redirect: v.Redirect,
IsExternalLinks: v.IsExternalLinks,
IsAuth: v.IsAuth,
Status: v.Status,
StatusName: v.StatusMap(),
IsShow: v.IsShow,
IsNewWindow: v.IsNewWindow,
Sort: v.Sort,
Type: v.Type,
TypeName: v.MenuTypeMap(),
Pid: v.Pid,
Description: v.Description,
ChildrenNum: v.ChildrenNum,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
}
// buildMenuResource 构建菜单详情响应。
func buildMenuResource(v *model.Menu, title string, titleI18n map[string]string) *MenuResources {
base := buildMenuBaseResources(v, title)
return &MenuResources{
MenuBaseResources: base,
TitleI18n: titleI18n,
IsExternalLinksName: v.IsExternalLinksMap(),
IsAuthName: v.IsAuthMap(),
IsShowName: v.IsShowMap(),
ISNewWindowName: v.IsNewWindowMap(),
Level: v.Level,
AnimateEnter: v.AnimateEnter,
AnimateLeave: v.AnimateLeave,
AnimateDuration: v.AnimateDuration,
ApiList: v.GetApiIds(),
}
}
// MenuCollectionResources 表示菜单树节点响应。
type MenuCollectionResources struct {
MenuBaseResources
Children []*MenuCollectionResources `json:"children,omitempty"`
}
// SetChildren 设置菜单树节点的子节点。
func (r *MenuCollectionResources) SetChildren(children []*MenuCollectionResources) {
r.Children = children
}
// GetID 返回当前菜单节点 ID。
func (r *MenuCollectionResources) GetID() uint {
return r.ID
}
// GetPID 返回当前菜单节点父级 ID。
func (r *MenuCollectionResources) GetPID() uint {
return r.Pid
}
// SetCustomFields 填充菜单树节点的扩展字段。
func (r *MenuCollectionResources) SetCustomFields(data *model.Menu) {
r.TypeName = data.MenuTypeMap()
}
// buildListMenuResource 构建菜单树节点响应。
func buildListMenuResource(v *model.Menu, title string) *MenuCollectionResources {
base := buildMenuBaseResources(v, title)
return &MenuCollectionResources{
MenuBaseResources: base,
Children: []*MenuCollectionResources{},
}
}
// BuildMenuTree 构建菜单树,并显式注入本地化标题。
func BuildMenuTree(data []*model.Menu, rootID uint, titleByMenuID map[uint]string) []*MenuCollectionResources {
if len(data) == 0 {
return []*MenuCollectionResources{}
}
parentMap := make(map[uint][]*model.Menu)
for _, item := range data {
if item == nil {
continue
}
parentMap[item.Pid] = append(parentMap[item.Pid], item)
}
var build func(parentID uint) []*MenuCollectionResources
build = func(parentID uint) []*MenuCollectionResources {
children := parentMap[parentID]
tree := make([]*MenuCollectionResources, 0, len(children))
for _, menu := range children {
node := buildListMenuResource(menu, resolveTitleByMenuID(menu.ID, titleByMenuID))
node.TypeName = menu.MenuTypeMap()
node.Children = build(menu.ID)
tree = append(tree, node)
}
return tree
}
return build(rootID)
}
func resolveTitleByMenuID(menuID uint, titleByMenuID map[uint]string) string {
if len(titleByMenuID) == 0 {
return ""
}
return titleByMenuID[menuID]
}
================================================
FILE: internal/resources/request_log.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// RequestLogBaseResources 表示请求日志的公共响应字段。
type RequestLogBaseResources struct {
ID uint `json:"id"`
RequestID string `json:"request_id"` // 请求唯一标识
OperatorID uint `json:"operator_id"` // 操作ID(用户ID)
IP string `json:"ip"` // 客户端IP地址
Method string `json:"method"` // HTTP请求方法(GET/POST等)
BaseURL string `json:"base_url"` // 请求基础URL
OperationName string `json:"operation_name"` // 操作名称
OperationStatus int `json:"operation_status"` // 操作状态码(响应返回的code,0=成功,其他=失败)
OperationStatusName string `json:"operation_status_name"` // 操作状态名称
IsHighRisk uint8 `json:"is_high_risk"` // 是否高危操作
OperatorAccount string `json:"operator_account"` // 操作账号
OperatorName string `json:"operator_name"` // 操作人员
ResponseStatus int `json:"response_status"` // 响应状态码
ExecutionTime float64 `json:"execution_time"` // 执行时间(毫秒,支持小数,最多4位)
CreatedAt utils.FormatDate `json:"created_at"` // 创建时间
}
// RequestLogListResources 表示请求日志列表项。
type RequestLogListResources struct {
RequestLogBaseResources
}
// RequestLogResources 表示请求日志详情响应。
type RequestLogResources struct {
RequestLogBaseResources
JwtID string `json:"jwt_id"` // 请求授权的jwtId
UserAgent string `json:"user_agent"` // 用户代理(浏览器/设备信息)
OS string `json:"os"` // 操作系统
Browser string `json:"browser"` // 浏览器
RequestHeaders string `json:"request_headers"` // 请求头(JSON格式)
RequestQuery string `json:"request_query"` // 请求参数
RequestBody string `json:"request_body"` // 请求体
ChangeDiff string `json:"change_diff"` // 关键变更前后差异(JSON)
ResponseBody string `json:"response_body"` // 响应体
ResponseHeader string `json:"response_header"` // 响应头
UpdatedAt utils.FormatDate `json:"updated_at"` // 更新时间
}
// RequestLogTransformer 负责请求日志资源转换。
type RequestLogTransformer struct {
BaseResources[*model.RequestLogs, *RequestLogResources]
}
// NewRequestLogTransformer 创建请求日志资源转换器。
func NewRequestLogTransformer() RequestLogTransformer {
return RequestLogTransformer{
BaseResources: BaseResources[*model.RequestLogs, *RequestLogResources]{
NewResource: func() *RequestLogResources {
return &RequestLogResources{}
},
},
}
}
// buildRequestLogBaseResources 提取请求日志公共字段。
func buildRequestLogBaseResources(data *model.RequestLogs) RequestLogBaseResources {
return RequestLogBaseResources{
ID: data.ID,
RequestID: data.RequestID,
OperatorID: data.OperatorID,
IP: data.IP,
Method: data.Method,
BaseURL: data.BaseURL,
OperationName: data.OperationName,
OperationStatus: data.OperationStatus,
OperationStatusName: getOperationStatusName(data.OperationStatus),
IsHighRisk: data.IsHighRisk,
OperatorAccount: data.OperatorAccount,
OperatorName: data.OperatorName,
ResponseStatus: data.ResponseStatus,
ExecutionTime: data.ExecutionTime,
CreatedAt: data.CreatedAt,
}
}
// ToStruct 将请求日志模型转换为详情响应。
func (r RequestLogTransformer) ToStruct(data *model.RequestLogs) *RequestLogResources {
base := buildRequestLogBaseResources(data)
return &RequestLogResources{
RequestLogBaseResources: base,
JwtID: data.JwtID,
UserAgent: data.UserAgent,
OS: data.OS,
Browser: data.Browser,
RequestHeaders: data.RequestHeaders,
RequestQuery: data.RequestQuery,
RequestBody: data.RequestBody,
ChangeDiff: data.ChangeDiff,
ResponseBody: data.ResponseBody,
ResponseHeader: data.ResponseHeader,
UpdatedAt: data.UpdatedAt,
}
}
// ToCollection 将请求日志模型集合转换为分页响应。
func (r RequestLogTransformer) ToCollection(page, perPage int, total int64, data []*model.RequestLogs) *Collection {
response := make([]any, 0, len(data))
for _, v := range data {
base := buildRequestLogBaseResources(v)
response = append(response, &RequestLogListResources{
RequestLogBaseResources: base,
})
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(response)
}
// getOperationStatusName 将业务码映射为结果名称。
func getOperationStatusName(code int) string {
if code == 0 {
return "成功"
}
return "失败"
}
================================================
FILE: internal/resources/role.go
================================================
package resources
import (
"github.com/samber/lo"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// RoleResources 表示角色详情和树节点的响应结构。
type RoleResources struct {
ID uint `json:"id"`
Code string `json:"code"`
IsSystem uint8 `json:"is_system"`
Pid uint `json:"pid"`
Name string `json:"name"`
Description string `json:"description"`
Level uint8 `json:"level"`
Sort uint16 `json:"sort"`
ChildrenNum uint `json:"children_num"`
Status uint8 `json:"status"`
StatusName string `json:"status_name"` // 状态名称
MenuList []uint `json:"menu_list"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// GetID 返回当前角色节点 ID。
func (r *RoleResources) GetID() uint {
return r.ID
}
// GetPID 返回当前角色节点父级 ID。
func (r *RoleResources) GetPID() uint {
return r.Pid
}
// SetCustomFields 填充角色资源的扩展字段。
func (r *RoleResources) SetCustomFields(data *model.Role) {
r.MenuList = []uint{}
if data == nil {
return
}
r.Code = data.Code
r.IsSystem = data.IsSystem
// 设置映射字段
r.StatusName = data.StatusMap()
r.MenuList = lo.Map(data.MenuList, func(m model.RoleMenuMap, _ int) uint {
return m.MenuId
})
}
// RoleTransformer 负责角色资源转换。
type RoleTransformer struct {
BaseResources[*model.Role, *RoleResources]
}
// NewRoleTransformer 创建角色资源转换器。
func NewRoleTransformer() RoleTransformer {
return RoleTransformer{
BaseResources: BaseResources[*model.Role, *RoleResources]{
NewResource: func() *RoleResources {
return &RoleResources{}
},
},
}
}
================================================
FILE: internal/resources/session.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// SessionResources 在线会话响应结构。
type SessionResources struct {
ID uint `json:"id"`
UID uint `json:"uid"`
Username string `json:"username"`
JwtID string `json:"jwt_id"`
IP string `json:"ip"`
OS string `json:"os"`
Browser string `json:"browser"`
IsRevoked uint8 `json:"is_revoked"`
RevokedReason string `json:"revoked_reason"`
RevokedAt *utils.FormatDate `json:"revoked_at"`
TokenExpires *utils.FormatDate `json:"token_expires"`
CreatedAt utils.FormatDate `json:"created_at"`
}
// SessionTransformer 在线会话转换器。
type SessionTransformer struct {
BaseResources[*model.AdminLoginLogs, *SessionResources]
}
func NewSessionTransformer() SessionTransformer {
return SessionTransformer{
BaseResources: BaseResources[*model.AdminLoginLogs, *SessionResources]{
NewResource: func() *SessionResources {
return &SessionResources{}
},
},
}
}
================================================
FILE: internal/resources/sys_config.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// SysConfigResources 系统参数响应结构。
type SysConfigResources struct {
ID uint `json:"id"`
ConfigKey string `json:"config_key"`
ConfigName string `json:"config_name"`
ConfigNameI18n map[string]string `json:"config_name_i18n,omitempty"`
ConfigValue string `json:"config_value"`
ValueType string `json:"value_type"`
GroupCode string `json:"group_code"`
IsSystem uint8 `json:"is_system"`
IsSensitive uint8 `json:"is_sensitive"`
IsVisible uint8 `json:"is_visible"`
ManageTab string `json:"manage_tab"`
Status uint8 `json:"status"`
Sort uint `json:"sort"`
Remark string `json:"remark"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// SysConfigTransformer 负责系统参数资源转换。
type SysConfigTransformer struct {
BaseResources[*model.SysConfig, *SysConfigResources]
}
func NewSysConfigTransformer() SysConfigTransformer {
return SysConfigTransformer{
BaseResources: BaseResources[*model.SysConfig, *SysConfigResources]{
NewResource: func() *SysConfigResources {
return &SysConfigResources{}
},
},
}
}
================================================
FILE: internal/resources/sys_dict.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// SysDictTypeResources 系统字典类型响应结构。
type SysDictTypeResources struct {
ID uint `json:"id"`
TypeCode string `json:"type_code"`
TypeName string `json:"type_name"`
TypeNameI18n map[string]string `json:"type_name_i18n,omitempty"`
IsSystem uint8 `json:"is_system"`
Status uint8 `json:"status"`
Sort uint `json:"sort"`
Remark string `json:"remark"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// SysDictItemResources 系统字典项响应结构。
type SysDictItemResources struct {
ID uint `json:"id"`
TypeCode string `json:"type_code"`
Label string `json:"label"`
LabelI18n map[string]string `json:"label_i18n,omitempty"`
Value string `json:"value"`
Color string `json:"color"`
TagType string `json:"tag_type"`
IsDefault uint8 `json:"is_default"`
IsSystem uint8 `json:"is_system"`
Status uint8 `json:"status"`
Sort uint `json:"sort"`
Remark string `json:"remark"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// SysDictOptionResources 前端下拉选项响应结构。
type SysDictOptionResources struct {
Label string `json:"label"`
Value string `json:"value"`
Color string `json:"color"`
TagType string `json:"tag_type"`
IsDefault uint8 `json:"is_default"`
}
type SysDictTypeTransformer struct {
BaseResources[*model.SysDictType, *SysDictTypeResources]
}
type SysDictItemTransformer struct {
BaseResources[*model.SysDictItem, *SysDictItemResources]
}
func NewSysDictTypeTransformer() SysDictTypeTransformer {
return SysDictTypeTransformer{
BaseResources: BaseResources[*model.SysDictType, *SysDictTypeResources]{
NewResource: func() *SysDictTypeResources {
return &SysDictTypeResources{}
},
},
}
}
func NewSysDictItemTransformer() SysDictItemTransformer {
return SysDictItemTransformer{
BaseResources: BaseResources[*model.SysDictItem, *SysDictItemResources]{
NewResource: func() *SysDictItemResources {
return &SysDictItemResources{}
},
},
}
}
func ToSysDictOptions(items []model.SysDictItem) []SysDictOptionResources {
options := make([]SysDictOptionResources, 0, len(items))
for _, item := range items {
options = append(options, SysDictOptionResources{
Label: item.Label,
Value: item.Value,
Color: item.Color,
TagType: item.TagType,
IsDefault: item.IsDefault,
})
}
return options
}
================================================
FILE: internal/resources/task_center.go
================================================
package resources
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// TaskDefinitionResources 任务定义响应结构。
type TaskDefinitionResources struct {
ID uint `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Queue string `json:"queue"`
CronSpec string `json:"cron_spec"`
Handler string `json:"handler"`
Status uint8 `json:"status"`
AllowManual uint8 `json:"allow_manual"`
AllowRetry uint8 `json:"allow_retry"`
IsHighRisk uint8 `json:"is_high_risk"`
Remark string `json:"remark"`
CreatedAt utils.FormatDate `json:"created_at"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// TaskDefinitionTransformer 任务定义资源转换器。
type TaskDefinitionTransformer struct {
BaseResources[*model.TaskDefinition, *TaskDefinitionResources]
}
func NewTaskDefinitionTransformer() TaskDefinitionTransformer {
return TaskDefinitionTransformer{
BaseResources: BaseResources[*model.TaskDefinition, *TaskDefinitionResources]{
NewResource: func() *TaskDefinitionResources {
return &TaskDefinitionResources{}
},
},
}
}
// TaskRunBaseResources 任务执行记录公共字段。
type TaskRunBaseResources struct {
ID uint `json:"id"`
TaskCode string `json:"task_code"`
Kind string `json:"kind"`
Source string `json:"source"`
SourceID string `json:"source_id"`
Queue string `json:"queue"`
Status string `json:"status"`
Attempt int `json:"attempt"`
MaxRetry int `json:"max_retry"`
ErrorMessage string `json:"error_message"`
DurationMS float64 `json:"duration_ms"`
StartedAt *utils.FormatDate `json:"started_at"`
FinishedAt *utils.FormatDate `json:"finished_at"`
CreatedAt utils.FormatDate `json:"created_at"`
TriggerUserID uint `json:"trigger_user_id"`
TriggerAccount string `json:"trigger_account"`
}
// TaskRunListResources 任务执行记录列表响应。
type TaskRunListResources struct {
TaskRunBaseResources
}
// TaskRunResources 任务执行记录详情响应。
type TaskRunResources struct {
TaskRunBaseResources
Payload string `json:"payload"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// TaskRunEventResources 任务执行事件响应。
type TaskRunEventResources struct {
ID uint `json:"id"`
RunID uint `json:"run_id"`
EventType string `json:"event_type"`
Message string `json:"message"`
Meta string `json:"meta"`
CreatedAt utils.FormatDate `json:"created_at"`
}
// TaskRunTransformer 任务执行记录资源转换器。
type TaskRunTransformer struct {
BaseResources[*model.TaskRun, *TaskRunResources]
}
func NewTaskRunTransformer() TaskRunTransformer {
return TaskRunTransformer{
BaseResources: BaseResources[*model.TaskRun, *TaskRunResources]{
NewResource: func() *TaskRunResources {
return &TaskRunResources{}
},
},
}
}
func buildTaskRunBaseResources(data *model.TaskRun) TaskRunBaseResources {
return TaskRunBaseResources{
ID: data.ID,
TaskCode: data.TaskCode,
Kind: data.Kind,
Source: data.Source,
SourceID: data.SourceID,
Queue: data.Queue,
Status: data.Status,
Attempt: data.Attempt,
MaxRetry: data.MaxRetry,
ErrorMessage: data.ErrorMessage,
DurationMS: data.DurationMS,
StartedAt: data.StartedAt,
FinishedAt: data.FinishedAt,
CreatedAt: data.CreatedAt,
TriggerUserID: data.TriggerUserID,
TriggerAccount: data.TriggerAccount,
}
}
func (r TaskRunTransformer) ToStruct(data *model.TaskRun) *TaskRunResources {
base := buildTaskRunBaseResources(data)
return &TaskRunResources{
TaskRunBaseResources: base,
Payload: data.Payload,
UpdatedAt: data.UpdatedAt,
}
}
func (r TaskRunTransformer) ToCollection(page, perPage int, total int64, data []*model.TaskRun) *Collection {
response := make([]any, 0, len(data))
for _, v := range data {
base := buildTaskRunBaseResources(v)
response = append(response, &TaskRunListResources{
TaskRunBaseResources: base,
})
}
return NewCollection().SetPaginate(page, perPage, total).ToCollection(response)
}
// TaskRunEventTransformer 任务执行事件转换器。
type TaskRunEventTransformer struct {
BaseResources[*model.TaskRunEvent, *TaskRunEventResources]
}
func NewTaskRunEventTransformer() TaskRunEventTransformer {
return TaskRunEventTransformer{
BaseResources: BaseResources[*model.TaskRunEvent, *TaskRunEventResources]{
NewResource: func() *TaskRunEventResources {
return &TaskRunEventResources{}
},
},
}
}
// CronTaskStateResources 定时任务最近状态响应结构。
type CronTaskStateResources struct {
ID uint `json:"id"`
TaskCode string `json:"task_code"`
CronSpec string `json:"cron_spec"`
LastRunID uint `json:"last_run_id"`
LastStatus string `json:"last_status"`
LastStartedAt *utils.FormatDate `json:"last_started_at"`
LastFinishedAt *utils.FormatDate `json:"last_finished_at"`
NextRunAt *utils.FormatDate `json:"next_run_at"`
LastError string `json:"last_error"`
UpdatedAt utils.FormatDate `json:"updated_at"`
}
// CronTaskStateTransformer 定时任务状态资源转换器。
type CronTaskStateTransformer struct {
BaseResources[*model.CronTaskState, *CronTaskStateResources]
}
func NewCronTaskStateTransformer() CronTaskStateTransformer {
return CronTaskStateTransformer{
BaseResources: BaseResources[*model.CronTaskState, *CronTaskStateResources]{
NewResource: func() *CronTaskStateResources {
return &CronTaskStateResources{}
},
},
}
}
================================================
FILE: internal/routers/admin_router.go
================================================
package routers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/middleware"
)
// 路由构建辅助函数(减少重复代码)
func GET(path, title string, auth AuthMode, handlers ...gin.HandlerFunc) RouteDef {
return RouteDef{Method: http.MethodGet, Path: path, Title: title, Auth: auth, Handlers: handlers}
}
func POST(path, title string, auth AuthMode, handlers ...gin.HandlerFunc) RouteDef {
return RouteDef{Method: http.MethodPost, Path: path, Title: title, Auth: auth, Handlers: handlers}
}
// AdminRouteTree 返回管理员后台路由声明树。
// deps: 控制器依赖容器,传入 nil 则使用默认实现
func AdminRouteTree(deps *ControllerDeps) RouteGroupDef {
deps = normalizeControllerDeps(deps)
return RouteGroupDef{
Prefix: "admin/v1",
Children: []RouteGroupDef{
adminOtherGroup(deps),
adminAuthGroup(deps),
},
}
}
// adminOtherGroup 其他路由组(公开接口、登录等)
func adminOtherGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
GroupCode: "other",
Routes: []RouteDef{
GET("demo", "Demo 示例", AuthModeNone, deps.Demo.HelloWorld).WithDesc("Demo 示例备注"),
GET("file/:uuid", "获取文件", AuthModeNone, middleware.DatabaseReadyGuard(), deps.Common.GetFile),
},
Children: []RouteGroupDef{
{
GroupCode: "login",
Routes: []RouteDef{
POST("login", "登录", AuthModeNone, middleware.DatabaseReadyGuard(), deps.Login.Login).WithDesc("用户登录接口"),
GET("login-captcha", "验证码", AuthModeNone, deps.Login.LoginCaptcha).WithDesc("获取登录验证码"),
},
},
},
}
}
// adminAuthGroup 需要认证的路由组
func adminAuthGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Middleware: []gin.HandlerFunc{middleware.OptionalDatabaseReadyGuard(), middleware.AdminAuthHandler()},
Children: []RouteGroupDef{
commonGroup(deps),
dashboardGroup(deps),
authGroup(deps),
adminUserGroup(deps),
permissionGroup(deps),
menuGroup(deps),
roleGroup(deps),
deptGroup(deps),
systemGroup(deps),
logGroup(deps),
taskGroup(deps),
},
}
}
func dashboardGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "dashboard",
GroupCode: "dashboard",
Routes: []RouteDef{
GET("overview", "仪表盘概览", AuthModeLogin, deps.Dashboard.Overview),
},
}
}
// commonGroup 通用接口组
func commonGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "common",
GroupCode: "common",
Routes: []RouteDef{
POST("upload", "上传文件", AuthModeLogin, deps.Common.Upload),
},
}
}
// authGroup 认证相关接口组
func authGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "auth",
GroupCode: "auth",
Routes: []RouteDef{
POST("logout", "退出登录", AuthModeLogin, deps.Login.Logout),
GET("check-token", "检查 Token", AuthModeLogin, deps.Login.CheckToken).WithDesc("验证 Token 有效性"),
},
Children: []RouteGroupDef{
{
Prefix: "session",
GroupCode: "session",
Routes: []RouteDef{
GET("list", "在线会话列表", AuthModeAuth, deps.Session.List),
POST("revoke", "撤销在线会话", AuthModeAuth, deps.Session.Revoke),
},
},
},
}
}
// adminUserGroup 管理员用户管理组
func adminUserGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "admin-user",
GroupCode: "adminUser",
Routes: []RouteDef{
// 个人资料(AuthModeLogin:只需登录)
GET("get", "获取当前用户信息", AuthModeLogin, deps.AdminUser.GetUserInfo),
GET("user-menu-info", "获取用户权限信息", AuthModeLogin, deps.AdminUser.GetUserMenuInfo),
POST("update-profile", "更新个人资料", AuthModeLogin, deps.AdminUser.UpdateProfile),
// 用户管理(AuthModeAuth:需要权限)
GET("list", "管理员列表", AuthModeAuth, deps.AdminUser.List),
GET("detail", "管理员详情", AuthModeAuth, deps.AdminUser.Detail),
GET("get-full-phone", "获取完整手机号", AuthModeAuth, deps.AdminUser.GetFullPhone).WithDesc("脱敏前完整手机号"),
GET("get-full-email", "获取完整邮箱", AuthModeAuth, deps.AdminUser.GetFullEmail).WithDesc("脱敏前完整邮箱"),
POST("create", "新增管理员", AuthModeAuth, deps.AdminUser.Create),
POST("update", "更新管理员", AuthModeAuth, deps.AdminUser.Update),
POST("delete", "删除管理员", AuthModeAuth, deps.AdminUser.Delete),
POST("bind-role", "绑定角色", AuthModeAuth, deps.AdminUser.BindRole),
},
}
}
// permissionGroup 接口权限管理组
func permissionGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "permission",
GroupCode: "api",
Routes: []RouteDef{
POST("update", "更新接口", AuthModeAuth, deps.Api.Update),
GET("list", "接口列表", AuthModeAuth, deps.Api.List),
},
}
}
// menuGroup 菜单管理组
func menuGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "menu",
GroupCode: "menu",
Routes: []RouteDef{
GET("list", "菜单列表", AuthModeAuth, deps.Menu.List),
POST("delete", "删除菜单", AuthModeAuth, deps.Menu.Delete),
POST("create", "新增菜单", AuthModeAuth, deps.Menu.Create),
POST("update", "更新菜单", AuthModeAuth, deps.Menu.Update),
POST("update-all-menu-permissions", "刷新菜单权限缓存", AuthModeAuth, deps.Menu.UpdateAllMenuPermissions),
GET("detail", "菜单详情", AuthModeAuth, deps.Menu.Detail),
},
}
}
// roleGroup 角色管理组
func roleGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "role",
GroupCode: "role",
Routes: []RouteDef{
GET("list", "角色列表", AuthModeAuth, deps.Role.List),
POST("create", "新增角色", AuthModeAuth, deps.Role.Create),
POST("update", "更新角色", AuthModeAuth, deps.Role.Update),
GET("detail", "角色详情", AuthModeAuth, deps.Role.Detail),
POST("delete", "删除角色", AuthModeAuth, deps.Role.Delete),
},
}
}
// deptGroup 部门管理组
func deptGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "department",
GroupCode: "department",
Routes: []RouteDef{
GET("list", "部门列表", AuthModeAuth, deps.Dept.List),
POST("create", "新增部门", AuthModeAuth, deps.Dept.Create),
POST("update", "更新部门", AuthModeAuth, deps.Dept.Update),
GET("detail", "部门详情", AuthModeAuth, deps.Dept.Detail),
POST("delete", "删除部门", AuthModeAuth, deps.Dept.Delete),
POST("bind-role", "部门绑定角色", AuthModeAuth, deps.Dept.BindRole),
},
}
}
// systemGroup 系统管理组
func systemGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "system",
GroupCode: "system",
Children: []RouteGroupDef{
{
Prefix: "config",
GroupCode: "sysConfig",
Routes: []RouteDef{
GET("list", "系统参数列表", AuthModeAuth, deps.SysConfig.List),
GET("detail", "系统参数详情", AuthModeAuth, deps.SysConfig.Detail),
GET("value", "获取系统参数值", AuthModeAuth, deps.SysConfig.Value),
POST("create", "新增系统参数", AuthModeAuth, deps.SysConfig.Create),
POST("update", "更新系统参数", AuthModeAuth, deps.SysConfig.Update),
POST("delete", "删除系统参数", AuthModeAuth, deps.SysConfig.Delete),
POST("refresh", "刷新系统参数缓存", AuthModeAuth, deps.SysConfig.Refresh),
},
},
{
Prefix: "dict",
GroupCode: "sysDict",
Routes: []RouteDef{
GET("type/list", "字典类型列表", AuthModeAuth, deps.SysDict.TypeList),
GET("type/detail", "字典类型详情", AuthModeAuth, deps.SysDict.TypeDetail),
POST("type/create", "新增字典类型", AuthModeAuth, deps.SysDict.TypeCreate),
POST("type/update", "更新字典类型", AuthModeAuth, deps.SysDict.TypeUpdate),
POST("type/delete", "删除字典类型", AuthModeAuth, deps.SysDict.TypeDelete),
GET("item/list", "字典项列表", AuthModeAuth, deps.SysDict.ItemList),
POST("item/create", "新增字典项", AuthModeAuth, deps.SysDict.ItemCreate),
POST("item/update", "更新字典项", AuthModeAuth, deps.SysDict.ItemUpdate),
POST("item/delete", "删除字典项", AuthModeAuth, deps.SysDict.ItemDelete),
GET("options", "字典选项", AuthModeAuth, deps.SysDict.Options),
},
},
{
Prefix: "file",
GroupCode: "file",
Routes: []RouteDef{
GET("list", "文件资源列表", AuthModeAuth, deps.File.List),
GET("detail", "文件资源详情", AuthModeAuth, deps.File.Detail),
GET("folder/tree", "文件目录树", AuthModeAuth, deps.File.FolderTree),
POST("folder/create", "创建文件目录", AuthModeAuth, deps.File.FolderCreate),
POST("folder/update", "更新文件目录", AuthModeAuth, deps.File.FolderUpdate),
POST("folder/delete", "删除文件目录", AuthModeAuth, deps.File.FolderDelete),
POST("folder/move", "移动文件目录", AuthModeAuth, deps.File.FolderMove),
POST("move", "移动文件资源", AuthModeAuth, deps.File.Move),
POST("upload/local", "本地上传文件资源", AuthModeAuth, deps.File.UploadLocal),
POST("upload/credential", "获取文件直传凭证", AuthModeAuth, deps.File.UploadCredential),
POST("upload/complete", "完成文件直传登记", AuthModeAuth, deps.File.UploadComplete),
POST("delete", "删除文件资源", AuthModeAuth, deps.File.Delete),
GET("trash/list", "文件回收站列表", AuthModeAuth, deps.File.TrashList),
POST("trash/restore", "恢复文件资源", AuthModeAuth, deps.File.Restore),
POST("trash/destroy", "硬删除文件资源", AuthModeAuth, deps.File.Destroy),
GET("references", "文件引用列表", AuthModeAuth, deps.File.References),
},
},
{
Prefix: "storage",
GroupCode: "storage",
Routes: []RouteDef{
GET("config", "存储配置", AuthModeAuth, deps.Storage.Config),
POST("config", "保存存储配置", AuthModeAuth, deps.Storage.Save),
POST("test", "测试存储配置", AuthModeAuth, deps.Storage.Test),
},
},
},
}
}
// logGroup 日志管理组
func logGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "log",
GroupCode: "log",
Children: []RouteGroupDef{
{
Prefix: "request",
Routes: []RouteDef{
GET("list", "请求日志列表", AuthModeAuth, deps.RequestLog.List),
GET("detail", "请求日志详情", AuthModeAuth, deps.RequestLog.Detail),
GET("export", "导出请求日志", AuthModeAuth, deps.RequestLog.Export),
GET("mask-config", "获取请求日志脱敏配置", AuthModeAuth, deps.RequestLog.MaskConfig),
POST("mask-config", "更新请求日志脱敏配置", AuthModeAuth, deps.RequestLog.UpdateMaskConfig),
},
},
{
Prefix: "login",
Routes: []RouteDef{
GET("list", "登录日志列表", AuthModeAuth, deps.LoginLog.List),
GET("detail", "登录日志详情", AuthModeAuth, deps.LoginLog.Detail),
},
},
},
}
}
// taskGroup 任务中心组
func taskGroup(deps *ControllerDeps) RouteGroupDef {
return RouteGroupDef{
Prefix: "task",
GroupCode: "task",
Routes: []RouteDef{
GET("list", "任务定义列表", AuthModeAuth, deps.TaskCenter.TaskList),
POST("trigger", "手动触发任务", AuthModeAuth, deps.TaskCenter.Trigger),
},
Children: []RouteGroupDef{
{
Prefix: "run",
Routes: []RouteDef{
GET("list", "任务执行记录列表", AuthModeAuth, deps.TaskCenter.RunList),
GET("detail", "任务执行记录详情", AuthModeAuth, deps.TaskCenter.RunDetail),
GET("events", "任务执行事件列表", AuthModeAuth, deps.TaskCenter.RunEvents),
POST("retry", "重试失败任务", AuthModeAuth, deps.TaskCenter.Retry),
POST("cancel", "取消任务", AuthModeAuth, deps.TaskCenter.Cancel),
},
},
{
Prefix: "cron",
Routes: []RouteDef{
GET("state", "定时任务最近状态", AuthModeAuth, deps.TaskCenter.CronStateList),
},
},
},
}
}
================================================
FILE: internal/routers/defs.go
================================================
package routers
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
)
// AuthMode 定义路由认证授权模式。
type AuthMode = global.ApiAuthMode
const (
// AuthModeNone 无需登录,无需权限校验(如:登录、验证码、公开 API)
AuthModeNone = global.ApiAuthModeNone
// AuthModeLogin 需要登录,但无需菜单权限校验(如:获取当前用户信息、退出登录)
AuthModeLogin = global.ApiAuthModeLogin
// AuthModeAuth 需要登录且需要api权限校验(如:增删改查业务数据)
AuthModeAuth = global.ApiAuthModeAuth
)
// RouteDef 定义单条路由。
// 建议使用辅助函数 GET()/POST() 创建,避免手写冗长结构。
type RouteDef struct {
Method string // HTTP 方法:GET, POST, PUT, DELETE 等
Path string // 相对路径,如 "list", ":id"
Title string // 路由标题,用于 API 文档
Desc string // 路由描述,补充 Title 未涵盖的信息
Auth AuthMode // 认证授权模式,使用 AuthModeNone/Login/Auth
Handlers []gin.HandlerFunc // Gin 处理器链
}
// WithDesc 设置路由描述,便于在路由声明时按需补充说明。
func (r RouteDef) WithDesc(desc string) RouteDef {
r.Desc = desc
return r
}
// RouteGroupDef 定义一组共享前缀、中间件和分组编码的路由。
// 用于组织路由树结构,支持嵌套分组。
type RouteGroupDef struct {
Prefix string // 路由前缀,如 "admin/v1", "user"
GroupCode string // 分组编码,用于权限分组和 API 文档归类
Middleware []gin.HandlerFunc // 组内路由共享的中间件(按顺序执行)
Routes []RouteDef // 直接子路由列表
Children []RouteGroupDef // 嵌套子分组(支持无限层级)
}
// RouteMeta 表示写入 API 权限表所需的路由元数据。
// 由 RouteDef 派生,不包含 Handlers(避免序列化)。
type RouteMeta struct {
Method string // HTTP 方法
Path string // 完整路径(含前缀)
Title string // 路由标题
Desc string // 路由描述
Auth AuthMode // 认证授权模式
GroupCode string // 所属分组编码
}
// RouteMetaMap 按 method+path 的哈希值保存路由元数据。
// 用于快速查找和权限校验。
type RouteMetaMap map[string]*RouteMeta
================================================
FILE: internal/routers/deps.go
================================================
package routers
import (
"sync"
"github.com/wannanbigpig/gin-layout/internal/controller"
admin_v1 "github.com/wannanbigpig/gin-layout/internal/controller/admin_v1"
)
// ControllerDeps 控制器依赖容器。
// 所有控制器在此集中注册,便于单元测试替换和灰度切换。
type ControllerDeps struct {
Demo *controller.DemoController
Login *admin_v1.LoginController
Common *admin_v1.CommonController
Dashboard *admin_v1.DashboardController
AdminUser *admin_v1.AdminUserController
Api *admin_v1.ApiController
Menu *admin_v1.MenuController
Role *admin_v1.RoleController
Dept *admin_v1.DeptController
SysConfig *admin_v1.SysConfigController
SysDict *admin_v1.SysDictController
Storage *admin_v1.StorageConfigController
File *admin_v1.FileResourceController
Session *admin_v1.SessionController
RequestLog *admin_v1.RequestLogController
LoginLog *admin_v1.AdminLoginLogController
TaskCenter *admin_v1.TaskCenterController
}
var defaultDepsOnce sync.Once
var defaultDeps *ControllerDeps
// DefaultControllerDeps 返回默认控制器依赖(生产环境使用)。
// 使用 sync.Once 确保只初始化一次,提升性能。
func DefaultControllerDeps() *ControllerDeps {
defaultDepsOnce.Do(func() {
defaultDeps = &ControllerDeps{
Demo: controller.NewDemoController(),
Login: admin_v1.NewLoginController(),
Common: admin_v1.NewCommonController(),
Dashboard: admin_v1.NewDashboardController(),
AdminUser: admin_v1.NewAdminUserController(),
Api: admin_v1.NewApiController(),
Menu: admin_v1.NewMenuController(),
Role: admin_v1.NewRoleController(),
Dept: admin_v1.NewDeptController(),
SysConfig: admin_v1.NewSysConfigController(),
SysDict: admin_v1.NewSysDictController(),
Storage: admin_v1.NewStorageConfigController(),
File: admin_v1.NewFileResourceController(),
Session: admin_v1.NewSessionController(),
RequestLog: admin_v1.NewRequestLogController(),
LoginLog: admin_v1.NewAdminLoginLogController(),
TaskCenter: admin_v1.NewTaskCenterController(),
}
})
return defaultDeps
}
func normalizeControllerDeps(deps *ControllerDeps) *ControllerDeps {
defaultDeps := DefaultControllerDeps()
if deps == nil {
return defaultDeps
}
if deps.Demo == nil {
deps.Demo = defaultDeps.Demo
}
if deps.Login == nil {
deps.Login = defaultDeps.Login
}
if deps.Common == nil {
deps.Common = defaultDeps.Common
}
if deps.Dashboard == nil {
deps.Dashboard = defaultDeps.Dashboard
}
if deps.AdminUser == nil {
deps.AdminUser = defaultDeps.AdminUser
}
if deps.Api == nil {
deps.Api = defaultDeps.Api
}
if deps.Menu == nil {
deps.Menu = defaultDeps.Menu
}
if deps.Role == nil {
deps.Role = defaultDeps.Role
}
if deps.Dept == nil {
deps.Dept = defaultDeps.Dept
}
if deps.SysConfig == nil {
deps.SysConfig = defaultDeps.SysConfig
}
if deps.SysDict == nil {
deps.SysDict = defaultDeps.SysDict
}
if deps.Storage == nil {
deps.Storage = defaultDeps.Storage
}
if deps.File == nil {
deps.File = defaultDeps.File
}
if deps.Session == nil {
deps.Session = defaultDeps.Session
}
if deps.RequestLog == nil {
deps.RequestLog = defaultDeps.RequestLog
}
if deps.LoginLog == nil {
deps.LoginLog = defaultDeps.LoginLog
}
if deps.TaskCenter == nil {
deps.TaskCenter = defaultDeps.TaskCenter
}
return deps
}
// MockControllerDeps 返回测试用控制器依赖(可传入 mock 实现)。
func MockControllerDeps(deps *ControllerDeps) *ControllerDeps {
if deps == nil {
return DefaultControllerDeps()
}
return normalizeControllerDeps(deps)
}
================================================
FILE: internal/routers/meta.go
================================================
package routers
import "github.com/wannanbigpig/gin-layout/pkg/utils"
// CollectRouteMeta 根据路由树递归收集路由元数据。
func CollectRouteMeta(root RouteGroupDef) RouteMetaMap {
metaMap := make(RouteMetaMap)
collectRouteMeta(metaMap, root, "", "")
return metaMap
}
func collectRouteMeta(metaMap RouteMetaMap, group RouteGroupDef, basePath, inheritedGroupCode string) {
fullPrefix := joinFullPath(basePath, group.Prefix)
groupCode := inheritedGroupCode
if group.GroupCode != "" {
groupCode = group.GroupCode
}
for _, route := range group.Routes {
fullPath := joinFullPath(fullPrefix, route.Path)
meta := &RouteMeta{
Method: route.Method,
Path: fullPath,
Title: route.Title,
Desc: route.Desc,
Auth: route.Auth,
GroupCode: groupCode,
}
metaMap[utils.MD5(meta.Method+"_"+meta.Path)] = meta
}
for _, child := range group.Children {
collectRouteMeta(metaMap, child, fullPrefix, groupCode)
}
}
================================================
FILE: internal/routers/register.go
================================================
package routers
import (
"strings"
"github.com/gin-gonic/gin"
)
// RegisterRoutes 根据路由树递归注册 Gin 路由。
func RegisterRoutes(engine *gin.Engine, root RouteGroupDef) {
registerGroup(&engine.RouterGroup, root)
}
func registerGroup(routes *gin.RouterGroup, group RouteGroupDef) {
current := routes
if group.Prefix != "" || len(group.Middleware) > 0 {
current = routes.Group(normalizeRelativePath(group.Prefix), group.Middleware...)
}
for _, route := range group.Routes {
current.Handle(route.Method, normalizeRelativePath(route.Path), route.Handlers...)
}
for _, child := range group.Children {
registerGroup(current, child)
}
}
func normalizeRelativePath(path string) string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return ""
}
return "/" + trimmed
}
func joinFullPath(parts ...string) string {
segments := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.Trim(part, "/")
if trimmed != "" {
segments = append(segments, trimmed)
}
}
if len(segments) == 0 {
return "/"
}
return "/" + strings.Join(segments, "/")
}
================================================
FILE: internal/routers/router.go
================================================
package routers
import (
"fmt"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/middleware"
"github.com/wannanbigpig/gin-layout/internal/pkg/errors"
response2 "github.com/wannanbigpig/gin-layout/internal/pkg/response"
"github.com/wannanbigpig/gin-layout/internal/queue"
)
// SetRouters 创建 Gin 引擎并注册全部应用路由。
func SetRouters() (*gin.Engine, error) {
return SetRoutersWithTree(AppRouteTree())
}
// SetRoutersWithTree 使用指定路由树创建 Gin 引擎并注册路由。
func SetRoutersWithTree(routeTree RouteGroupDef) (*gin.Engine, error) {
// 启动时校验路由树
if err := ValidateRouteTree(routeTree); err != nil {
return nil, fmt.Errorf("route tree validation failed: %w", err)
}
engine, err := createEngine()
if err != nil {
return nil, err
}
RegisterRoutes(engine, routeTree)
// 统一处理 404
engine.NoRoute(func(c *gin.Context) {
response2.Resp().SetHttpCode(http.StatusNotFound).FailCode(c, errors.NotFound)
})
return engine, nil
}
// createEngine 创建 gin 引擎并设置相关中间件
func createEngine() (*gin.Engine, error) {
var engine *gin.Engine
cfg := config.GetConfig()
if cfg != nil && cfg.Debug {
// 开发调试模式
engine = gin.New()
engine.Use(
middleware.CorsHandler(),
middleware.RequestCostHandler(), // 请求耗时统计
middleware.RequestLocaleHandler(),
middleware.ParseTokenHandler(), // 全局token解析(所有路由都走)
gin.Logger(),
middleware.CustomRecovery(),
middleware.CustomLogger(),
)
} else {
// 生产模式
engine = ReleaseRouter()
engine.Use(
middleware.CorsHandler(),
middleware.RequestCostHandler(), // 请求耗时统计
middleware.RequestLocaleHandler(),
middleware.ParseTokenHandler(), // 全局token解析(所有路由都走)
middleware.CustomRecovery(),
middleware.CustomLogger(),
)
}
// 配置受信任代理,决定是否信任 X-Forwarded-For / X-Real-IP 等代理头。
trustedProxies := []string(nil)
if cfg != nil {
trustedProxies = cfg.TrustedProxies
}
if err := engine.SetTrustedProxies(trustedProxies); err != nil {
return nil, fmt.Errorf("set trusted proxies failed: %w", err)
}
return engine, nil
}
// ReleaseRouter 生产模式使用官方建议设置为 release 模式
func ReleaseRouter() *gin.Engine {
// 切换到生产模式
gin.SetMode(gin.ReleaseMode)
// 禁用 gin 输出接口访问日志
gin.DefaultWriter = io.Discard
engine := gin.New()
return engine
}
// AppRouteTree 返回应用完整路由树。
func AppRouteTree() RouteGroupDef {
return RouteGroupDef{
Routes: []RouteDef{
{
Method: http.MethodGet,
Path: "ping",
Title: "ping",
Desc: "服务心跳检测接口",
Auth: AuthModeNone,
Handlers: []gin.HandlerFunc{func(c *gin.Context) {
c.String(http.StatusOK, "pong")
}},
},
{
Method: http.MethodGet,
Path: "health/readiness",
Title: "readiness",
Desc: "服务依赖就绪状态",
Auth: AuthModeNone,
Handlers: []gin.HandlerFunc{func(c *gin.Context) {
status := buildReadinessStatus()
httpCode := http.StatusOK
if !status.Ready {
httpCode = http.StatusServiceUnavailable
}
c.JSON(httpCode, status)
}},
},
},
Children: []RouteGroupDef{AdminRouteTree(nil)},
}
}
type readinessStatus struct {
Ready bool `json:"ready"`
Timestamp string `json:"timestamp"`
Dependencies readinessComponent `json:"dependencies"`
}
type readinessComponent struct {
Mysql dependencyStatus `json:"mysql"`
Redis dependencyStatus `json:"redis"`
Queue dependencyStatus `json:"queue"`
}
type dependencyStatus struct {
Enabled bool `json:"enabled"`
Required bool `json:"required"`
Ready bool `json:"ready"`
Message string `json:"message,omitempty"`
}
func buildReadinessStatus() readinessStatus {
cfg := config.GetConfig()
mysqlStatus := buildMySQLReadiness(cfg)
redisStatus := buildRedisReadiness(cfg)
queueStatus := buildQueueReadiness(cfg)
ready := mysqlStatus.Ready
if redisStatus.Enabled && !redisStatus.Ready {
ready = false
}
if queueStatus.Enabled && !queueStatus.Ready {
ready = false
}
return readinessStatus{
Ready: ready,
Timestamp: time.Now().Format(time.RFC3339Nano),
Dependencies: readinessComponent{
Mysql: mysqlStatus,
Redis: redisStatus,
Queue: queueStatus,
},
}
}
func buildMySQLReadiness(cfg *config.Conf) dependencyStatus {
if cfg == nil || !cfg.Mysql.Enable {
return dependencyStatus{
Enabled: false,
Required: true,
Ready: false,
Message: "mysql is disabled",
}
}
mysqlStatus := data.MysqlRuntimeStatus()
if mysqlStatus.Ready {
return dependencyStatus{
Enabled: true,
Required: true,
Ready: true,
}
}
message := "mysql connection is unavailable"
if mysqlStatus.Error != nil {
message = mysqlStatus.Error.Error()
}
return dependencyStatus{
Enabled: true,
Required: true,
Ready: false,
Message: message,
}
}
func buildRedisReadiness(cfg *config.Conf) dependencyStatus {
if cfg == nil || !cfg.Redis.Enable {
return dependencyStatus{
Enabled: false,
Required: false,
Ready: false,
Message: "redis is disabled",
}
}
redisStatus := data.RedisRuntimeStatus()
if redisStatus.Ready {
return dependencyStatus{
Enabled: true,
Required: false,
Ready: true,
}
}
message := "redis client is unavailable"
if redisStatus.Error != nil {
message = redisStatus.Error.Error()
}
return dependencyStatus{
Enabled: true,
Required: false,
Ready: false,
Message: message,
}
}
func buildQueueReadiness(cfg *config.Conf) dependencyStatus {
if cfg == nil || !cfg.Queue.Enable {
return dependencyStatus{
Enabled: false,
Required: false,
Ready: false,
Message: "queue is disabled",
}
}
if queue.PublisherOrNil() != nil {
return dependencyStatus{
Enabled: true,
Required: false,
Ready: true,
}
}
message := "queue publisher is unavailable"
if err := queue.PublisherInitError(); err != nil {
message = err.Error()
}
return dependencyStatus{
Enabled: true,
Required: false,
Ready: false,
Message: message,
}
}
================================================
FILE: internal/routers/router_deps_test.go
================================================
package routers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/wannanbigpig/gin-layout/internal/controller"
admin_v1 "github.com/wannanbigpig/gin-layout/internal/controller/admin_v1"
)
// TestAdminRouteTree_WithCustomDeps 测试使用自定义依赖的路由树
func TestAdminRouteTree_WithCustomDeps(t *testing.T) {
gin.SetMode(gin.TestMode)
// 构建依赖容器(全部使用默认实现)
deps := &ControllerDeps{
Demo: controller.NewDemoController(),
Login: admin_v1.NewLoginController(),
Common: admin_v1.NewCommonController(),
AdminUser: admin_v1.NewAdminUserController(),
Api: admin_v1.NewApiController(),
Menu: admin_v1.NewMenuController(),
Role: admin_v1.NewRoleController(),
Dept: admin_v1.NewDeptController(),
RequestLog: admin_v1.NewRequestLogController(),
LoginLog: admin_v1.NewAdminLoginLogController(),
}
// 构建路由树
routeTree := AdminRouteTree(deps)
// 验证路由树非空
assert.NotNil(t, routeTree)
assert.Equal(t, "admin/v1", routeTree.Prefix)
}
// TestValidateRouteTree 测试路由树校验
func TestValidateRouteTree(t *testing.T) {
gin.SetMode(gin.TestMode)
// 测试正常路由树
routeTree := AppRouteTree()
err := ValidateRouteTree(routeTree)
assert.NoError(t, err)
// 测试空 Handler 的路由
invalidTree := RouteGroupDef{
Prefix: "test",
Routes: []RouteDef{
{
Method: http.MethodGet,
Path: "invalid",
Handlers: []gin.HandlerFunc{}, // 空 handler
},
},
}
err = ValidateRouteTree(invalidTree)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no handlers registered")
}
// TestAdminRouteTree_DefaultDeps 测试使用默认依赖
func TestAdminRouteTree_DefaultDeps(t *testing.T) {
gin.SetMode(gin.TestMode)
// 传入 nil 应该使用默认依赖
routeTree := AdminRouteTree(nil)
assert.NotNil(t, routeTree)
assert.Equal(t, "admin/v1", routeTree.Prefix)
// 验证路由树可以正常遍历
err := ValidateRouteTree(routeTree)
assert.NoError(t, err)
}
// TestCollectRouteMeta 测试路由元数据收集
func TestCollectRouteMeta(t *testing.T) {
gin.SetMode(gin.TestMode)
metaMap := CollectRouteMeta(AppRouteTree())
assert.NotEmpty(t, metaMap)
}
// BenchmarkAdminRouteTree 性能测试
func BenchmarkAdminRouteTree(b *testing.B) {
gin.SetMode(gin.TestMode)
b.ResetTimer()
for i := 0; i < b.N; i++ {
routeTree := AdminRouteTree(nil)
_ = ValidateRouteTree(routeTree)
}
}
// TestRouterIntegration 集成测试示例
func TestRouterIntegration(t *testing.T) {
gin.SetMode(gin.TestMode)
// 创建测试引擎
engine := gin.New()
// 使用简化路由树进行测试
testTree := RouteGroupDef{
Prefix: "test",
Routes: []RouteDef{
{
Method: http.MethodGet,
Path: "ping",
Auth: AuthModeNone,
Handlers: []gin.HandlerFunc{
func(c *gin.Context) {
c.String(http.StatusOK, "pong")
},
},
},
},
}
RegisterRoutes(engine, testTree)
// 发起测试请求
req, _ := http.NewRequest(http.MethodGet, "/test/ping", nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "pong", w.Body.String())
}
================================================
FILE: internal/routers/router_test.go
================================================
package routers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/pkg/utils"
)
func TestSetRoutersRegistersApiMetadata(t *testing.T) {
routes := CollectRouteMeta(AppRouteTree())
menuCode := utils.MD5(http.MethodPost + "_/admin/v1/menu/update")
menuRoute, ok := routes[menuCode]
if !ok {
t.Fatalf("missing route metadata for menu update")
}
if menuRoute.GroupCode != "menu" {
t.Fatalf("unexpected group code: %s", menuRoute.GroupCode)
}
if menuRoute.Auth != AuthModeAuth {
t.Fatalf("unexpected auth flag: %d", menuRoute.Auth)
}
}
func TestSetRoutersRegistersPermissionCriticalRoutes(t *testing.T) {
routes := CollectRouteMeta(AppRouteTree())
checkTokenCode := utils.MD5(http.MethodGet + "_/admin/v1/auth/check-token")
if route, ok := routes[checkTokenCode]; !ok || route.Auth != AuthModeLogin {
t.Fatalf("missing or invalid auth check-token route: %#v", route)
}
updateProfileCode := utils.MD5(http.MethodPost + "_/admin/v1/admin-user/update-profile")
if route, ok := routes[updateProfileCode]; !ok || route.Auth != AuthModeLogin {
t.Fatalf("missing or invalid update-profile route: %#v", route)
}
fileCode := utils.MD5(http.MethodGet + "_/admin/v1/file/:uuid")
if route, ok := routes[fileCode]; !ok || route.Auth != AuthModeNone {
t.Fatalf("missing or invalid file route: %#v", route)
}
storageConfigCode := utils.MD5(http.MethodGet + "_/admin/v1/system/storage/config")
if route, ok := routes[storageConfigCode]; !ok || route.Auth != AuthModeAuth {
t.Fatalf("missing or invalid storage config route: %#v", route)
}
fileDestroyCode := utils.MD5(http.MethodPost + "_/admin/v1/system/file/trash/destroy")
if route, ok := routes[fileDestroyCode]; !ok || route.Auth != AuthModeAuth {
t.Fatalf("missing or invalid file destroy route: %#v", route)
}
}
func TestSetRoutersRegistersCriticalRoutes(t *testing.T) {
engine, err := SetRouters()
if err != nil {
t.Fatalf("SetRouters returned error: %v", err)
}
routeMap := make(map[string]bool)
for _, route := range engine.Routes() {
routeMap[route.Method+" "+route.Path] = true
}
required := []string{
http.MethodGet + " /ping",
http.MethodGet + " /admin/v1/admin-user/list",
http.MethodPost + " /admin/v1/permission/update",
http.MethodGet + " /admin/v1/menu/list",
http.MethodGet + " /admin/v1/role/list",
http.MethodGet + " /admin/v1/department/list",
http.MethodGet + " /admin/v1/log/request/list",
http.MethodGet + " /admin/v1/log/login/list",
}
for _, route := range required {
if !routeMap[route] {
t.Fatalf("missing registered route: %s", route)
}
}
}
func TestLoginRouteReturnsDependencyNotReadyWhenMysqlUnavailable(t *testing.T) {
restoreMysql := disableMysqlForRouterTest(t)
defer restoreMysql()
engine, err := SetRouters()
if err != nil {
t.Fatalf("SetRouters returned error: %v", err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/v1/login", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var result routeResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
if result.Code != e.ServiceDependencyNotReady {
t.Fatalf("expected code %d, got %d", e.ServiceDependencyNotReady, result.Code)
}
}
func TestLoginCaptchaRouteRemainsAvailableWithoutMysql(t *testing.T) {
restoreMysql := disableMysqlForRouterTest(t)
defer restoreMysql()
engine, err := SetRouters()
if err != nil {
t.Fatalf("SetRouters returned error: %v", err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/admin/v1/login-captcha", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var result routeResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal login captcha response: %v", err)
}
if result.Code != e.SUCCESS {
t.Fatalf("expected code %d, got %d", e.SUCCESS, result.Code)
}
}
func TestReadinessRouteReportsMysqlUnavailable(t *testing.T) {
restoreState := disableDependenciesForReadinessTest(t)
defer restoreState()
engine, err := SetRouters()
if err != nil {
t.Fatalf("SetRouters returned error: %v", err)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health/readiness", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code)
}
var status readinessStatus
if err := json.Unmarshal(recorder.Body.Bytes(), &status); err != nil {
t.Fatalf("unmarshal readiness response: %v", err)
}
if status.Ready {
t.Fatal("expected readiness to be false when mysql is unavailable")
}
if status.Dependencies.Mysql.Ready {
t.Fatal("expected mysql readiness to be false")
}
if !status.Dependencies.Mysql.Required {
t.Fatal("expected mysql to be marked as required")
}
}
type routeResult struct {
Code int `json:"code"`
}
func disableMysqlForRouterTest(t *testing.T) func() {
t.Helper()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Mysql.Enable = false
})
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
return func() {
restoreConfig()
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
}
}
func disableDependenciesForReadinessTest(t *testing.T) func() {
t.Helper()
restoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {
cfg.Mysql.Enable = false
cfg.Redis.Enable = false
cfg.Queue.Enable = false
})
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
if err := data.CloseRedis(); err != nil {
t.Fatalf("close redis: %v", err)
}
return func() {
restoreConfig()
if err := data.CloseRedis(); err != nil {
t.Fatalf("close redis: %v", err)
}
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
}
}
================================================
FILE: internal/routers/validate.go
================================================
package routers
import (
"fmt"
"net/http"
"strings"
)
// RouteTreeError 路由树校验错误。
type RouteTreeError struct {
Path string
Message string
}
func (e *RouteTreeError) Error() string {
return fmt.Sprintf("route tree error at %s: %s", e.Path, e.Message)
}
// ValidateRouteTree 校验路由树的完整性(启动时调用)。
// 检查项:
// 1. 路由路径非空且合法
// 2. HTTP 方法合法
// 3. Handler 非空
// 4. 重复路由检测
func ValidateRouteTree(root RouteGroupDef) error {
return validateRouteTree(root, "", make(map[string]bool))
}
func validateRouteTree(group RouteGroupDef, basePath string, seen map[string]bool) error {
fullPrefix := joinFullPath(basePath, group.Prefix)
for _, route := range group.Routes {
// 检查路径非空
if strings.TrimSpace(route.Path) == "" {
return &RouteTreeError{Path: fullPrefix, Message: "route path is empty"}
}
// 检查 HTTP 方法合法
if !isValidHTTPMethod(route.Method) {
return &RouteTreeError{
Path: joinFullPath(fullPrefix, route.Path),
Message: fmt.Sprintf("invalid HTTP method: %s", route.Method),
}
}
// 检查 Handler 非空
if len(route.Handlers) == 0 {
return &RouteTreeError{
Path: joinFullPath(fullPrefix, route.Path),
Message: "no handlers registered",
}
}
// 检查重复路由
routeKey := route.Method + ":" + joinFullPath(fullPrefix, route.Path)
if seen[routeKey] {
return &RouteTreeError{
Path: routeKey,
Message: "duplicate route definition",
}
}
seen[routeKey] = true
}
for _, child := range group.Children {
if err := validateRouteTree(child, fullPrefix, seen); err != nil {
return err
}
}
return nil
}
func isValidHTTPMethod(method string) bool {
validMethods := map[string]bool{
http.MethodGet: true,
http.MethodPost: true,
http.MethodPut: true,
http.MethodDelete: true,
http.MethodPatch: true,
http.MethodHead: true,
http.MethodOptions: true,
}
return validMethods[method]
}
================================================
FILE: internal/runtime/config_reload.go
================================================
package runtime
import (
"fmt"
"sync"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
casbinx "github.com/wannanbigpig/gin-layout/internal/access/casbin"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
var registerOnce sync.Once
// RegisterConfigReloadHandlers 注册配置热更新处理器。
func RegisterConfigReloadHandlers() {
registerOnce.Do(func() {
config.RegisterConfigReloadHandler(config.ConfigReloadHandler{
Name: "logger",
Priority: 10,
Handle: reloadLogger,
})
config.RegisterConfigReloadHandler(config.ConfigReloadHandler{
Name: "data",
Priority: 20,
Handle: reloadData,
})
config.RegisterConfigReloadHandler(config.ConfigReloadHandler{
Name: "casbin",
Priority: 30,
Handle: reloadCasbin,
})
config.RegisterConfigReloadHandler(config.ConfigReloadHandler{
Name: "warnings",
Priority: 100,
Handle: logWarnings,
})
})
}
func reloadLogger(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {
if !diff.LoggerChanged {
return nil
}
return log.ReloadLogger(newConfig)
}
func reloadData(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {
if diff.MysqlChanged {
if err := data.ReloadMysql(newConfig); err != nil {
return fmt.Errorf("mysql reload failed: %w", err)
}
log.Logger.Info("MySQL runtime reloaded")
}
if diff.RedisChanged {
if err := data.ReloadRedis(newConfig); err != nil {
return fmt.Errorf("redis reload failed: %w", err)
}
log.Logger.Info("Redis runtime reloaded")
}
return nil
}
func reloadCasbin(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {
if !diff.MysqlChanged {
return nil
}
if !newConfig.Mysql.Enable {
return nil
}
if err := casbinx.ReloadEnforcer(); err != nil {
return fmt.Errorf("casbin reload failed: %w", err)
}
log.Logger.Info("Casbin runtime reloaded")
return nil
}
func logWarnings(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {
if len(diff.ChangedFields) > 0 {
log.Logger.Info("Detected config changes",
zap.Strings("fields", diff.ChangedFields),
)
}
if len(diff.RestartRequiredFields) > 0 {
log.Logger.Warn("Detected config changes that require process restart",
zap.Strings("fields", diff.RestartRequiredFields),
)
}
return nil
}
================================================
FILE: internal/service/access/api_cache.go
================================================
package access
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
const (
apiRedisKey = "api_info_map"
apiCacheRedisTimeout = 3 * time.Second
apiCacheRefreshTotalTimeout = 15 * time.Second
apiCacheWriteBatch = 500
)
// ApiRouteInfo 描述接口路由的鉴权模式和展示名称。
type ApiRouteInfo struct {
IsAuth uint8 `json:"is_auth"`
Name string `json:"name"`
}
type apiRouteCacheMetrics struct {
requestTotal atomic.Uint64
cacheHitTotal atomic.Uint64
cacheMissTotal atomic.Uint64
sourceLoadTotal atomic.Uint64
singleflightShared atomic.Uint64
refreshBatchTotal atomic.Uint64
refreshWriteTotal atomic.Uint64
}
type apiRouteCacheEntry struct {
field string
value string
}
// ApiRouteCacheMetricsSnapshot 用于观测 API 路由缓存命中与回源情况。
type ApiRouteCacheMetricsSnapshot struct {
RequestTotal uint64 `json:"request_total"`
CacheHitTotal uint64 `json:"cache_hit_total"`
CacheMissTotal uint64 `json:"cache_miss_total"`
HitRate float64 `json:"hit_rate"`
SourceLoadTotal uint64 `json:"source_load_total"`
SingleflightShared uint64 `json:"singleflight_shared_total"`
RefreshBatchTotal uint64 `json:"refresh_batch_total"`
RefreshWriteTotal uint64 `json:"refresh_write_total"`
}
// ApiRouteCacheService 负责缓存 API 路由元数据。
type ApiRouteCacheService struct {
loadRouteInfo func(route string, method string) (*ApiRouteInfo, error)
singleflightGroup *singleflight.Group
metrics *apiRouteCacheMetrics
configProvider func() *config.Conf
}
// NewApiRouteCacheService 创建 API 路由缓存服务实例。
func NewApiRouteCacheService() *ApiRouteCacheService {
return &ApiRouteCacheService{
singleflightGroup: &singleflight.Group{},
metrics: &apiRouteCacheMetrics{},
configProvider: config.GetConfig,
}
}
func (s *ApiRouteCacheService) ensureRuntimeDeps() {
if s.singleflightGroup == nil {
s.singleflightGroup = &singleflight.Group{}
}
if s.metrics == nil {
s.metrics = &apiRouteCacheMetrics{}
}
if s.configProvider == nil {
s.configProvider = config.GetConfig
}
}
func (s *ApiRouteCacheService) currentConfig() *config.Conf {
s.ensureRuntimeDeps()
return config.GetConfigFrom(s.configProvider)
}
// MetricsSnapshot 返回当前 API 路由缓存指标快照。
func (s *ApiRouteCacheService) MetricsSnapshot() ApiRouteCacheMetricsSnapshot {
s.ensureRuntimeDeps()
requestTotal := s.metrics.requestTotal.Load()
cacheHitTotal := s.metrics.cacheHitTotal.Load()
cacheMissTotal := s.metrics.cacheMissTotal.Load()
hitRate := 0.0
if requestTotal > 0 {
hitRate = float64(cacheHitTotal) / float64(requestTotal)
}
return ApiRouteCacheMetricsSnapshot{
RequestTotal: requestTotal,
CacheHitTotal: cacheHitTotal,
CacheMissTotal: cacheMissTotal,
HitRate: hitRate,
SourceLoadTotal: s.metrics.sourceLoadTotal.Load(),
SingleflightShared: s.metrics.singleflightShared.Load(),
RefreshBatchTotal: s.metrics.refreshBatchTotal.Load(),
RefreshWriteTotal: s.metrics.refreshWriteTotal.Load(),
}
}
// ResetMetrics 清空 API 路由缓存指标。
func (s *ApiRouteCacheService) ResetMetrics() {
s.ensureRuntimeDeps()
s.metrics.requestTotal.Store(0)
s.metrics.cacheHitTotal.Store(0)
s.metrics.cacheMissTotal.Store(0)
s.metrics.sourceLoadTotal.Store(0)
s.metrics.singleflightShared.Store(0)
s.metrics.refreshBatchTotal.Store(0)
s.metrics.refreshWriteTotal.Store(0)
}
func (s *ApiRouteCacheService) cacheKey(route string, method string) string {
return fmt.Sprintf("%s:%s", method, route)
}
func redisContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), apiCacheRedisTimeout)
}
func redisContextWithTimeout(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if parent == nil {
return context.WithTimeout(context.Background(), timeout)
}
if deadline, ok := parent.Deadline(); ok {
remaining := time.Until(deadline)
if remaining <= 0 {
return context.WithCancel(parent)
}
if remaining < timeout {
return context.WithTimeout(parent, remaining)
}
}
return context.WithTimeout(parent, timeout)
}
func (s *ApiRouteCacheService) refreshTempKey() string {
return fmt.Sprintf("%s:refresh:%d", apiRedisKey, time.Now().UnixNano())
}
func (s *ApiRouteCacheService) writeRouteCacheBatch(parent context.Context, client *redis.Client, redisKey string, batch []apiRouteCacheEntry) error {
if len(batch) == 0 {
return nil
}
ctx, cancel := redisContextWithTimeout(parent, apiCacheRedisTimeout)
defer cancel()
pipe := client.Pipeline()
for _, entry := range batch {
pipe.HSet(ctx, redisKey, entry.field, entry.value)
}
if _, err := pipe.Exec(ctx); err != nil {
return err
}
return nil
}
// RefreshCache 重建 Redis 中的 API 路由缓存。
func (s *ApiRouteCacheService) RefreshCache() error {
s.ensureRuntimeDeps()
cfg := s.currentConfig()
if !cfg.Redis.Enable {
return nil
}
apis, err := model.ListE(model.NewApi(), "", nil)
if err != nil {
return err
}
client := data.RedisClient()
if client == nil {
return nil
}
if len(apis) == 0 {
ctx, cancel := redisContext()
defer cancel()
if err := client.Del(ctx, apiRedisKey).Err(); err != nil {
return fmt.Errorf("clear empty api route cache failed: %w", err)
}
return nil
}
totalCtx, totalCancel := context.WithTimeout(context.Background(), apiCacheRefreshTotalTimeout)
defer totalCancel()
tempKey := s.refreshTempKey()
shouldCleanupTempKey := true
defer func() {
if !shouldCleanupTempKey {
return
}
ctx, cancel := redisContextWithTimeout(context.Background(), apiCacheRedisTimeout)
defer cancel()
if err := client.Del(ctx, tempKey).Err(); err != nil && !errors.Is(err, redis.Nil) {
log.Logger.Warn("清理 API 路由缓存临时 key 失败",
zap.String("key", tempKey),
zap.Error(err))
}
}()
batch := make([]apiRouteCacheEntry, 0, apiCacheWriteBatch)
batchCount := 0
writeCount := 0
flush := func() error {
if len(batch) == 0 {
return nil
}
if err := s.writeRouteCacheBatch(totalCtx, client, tempKey, batch); err != nil {
return fmt.Errorf("write api route cache batch failed: %w", err)
}
batchCount++
writeCount += len(batch)
batch = batch[:0]
return nil
}
for _, api := range apis {
cacheInfo := ApiRouteInfo{IsAuth: api.IsAuth, Name: api.Name}
cacheInfoBytes, err := json.Marshal(cacheInfo)
if err != nil {
return err
}
batch = append(batch, apiRouteCacheEntry{
field: s.cacheKey(api.Route, api.Method),
value: string(cacheInfoBytes),
})
if len(batch) >= apiCacheWriteBatch {
if err := flush(); err != nil {
return err
}
}
}
if err := flush(); err != nil {
return err
}
renameCtx, renameCancel := redisContextWithTimeout(totalCtx, apiCacheRedisTimeout)
defer renameCancel()
if err := client.Rename(renameCtx, tempKey, apiRedisKey).Err(); err != nil {
return fmt.Errorf("swap api route cache key failed: %w", err)
}
shouldCleanupTempKey = false
s.metrics.refreshBatchTotal.Add(uint64(batchCount))
s.metrics.refreshWriteTotal.Add(uint64(writeCount))
return nil
}
// GetRouteInfo 返回指定路由的方法元数据。
func (s *ApiRouteCacheService) GetRouteInfo(route string, method string) (*ApiRouteInfo, error) {
s.ensureRuntimeDeps()
s.metrics.requestTotal.Add(1)
cfg := s.currentConfig()
cacheKey := s.cacheKey(route, method)
client := data.RedisClient()
if cfg.Redis.Enable && client != nil {
ctx, cancel := redisContext()
defer cancel()
val, err := client.HGet(ctx, apiRedisKey, cacheKey).Result()
if err == nil {
var cacheInfo ApiRouteInfo
unmarshalErr := json.Unmarshal([]byte(val), &cacheInfo)
if unmarshalErr == nil {
s.metrics.cacheHitTotal.Add(1)
return &cacheInfo, nil
}
logError("api 路由缓存反序列化失败", unmarshalErr, route, method)
if delErr := client.HDel(ctx, apiRedisKey, cacheKey).Err(); delErr != nil {
logError("api 路由缓存删除损坏值失败", delErr, route, method)
}
} else if !errors.Is(err, redis.Nil) {
logError("api表Redis查询出错", err, route, method)
}
}
s.metrics.cacheMissTotal.Add(1)
value, err, shared := s.singleflightGroup.Do(cacheKey, func() (interface{}, error) {
return s.loadRouteInfoFromSource(route, method)
})
if shared {
s.metrics.singleflightShared.Add(1)
}
if err != nil {
return nil, err
}
cacheInfo, ok := value.(*ApiRouteInfo)
if !ok || cacheInfo == nil {
return nil, fmt.Errorf("invalid api route info type")
}
return cacheInfo, nil
}
func (s *ApiRouteCacheService) loadRouteInfoFromSource(route string, method string) (*ApiRouteInfo, error) {
s.ensureRuntimeDeps()
s.metrics.sourceLoadTotal.Add(1)
if s.loadRouteInfo != nil {
return s.loadRouteInfo(route, method)
}
api := model.NewApi()
if err := api.GetDetail("route = ? AND method = ? AND deleted_at = 0", route, method); err != nil {
return nil, err
}
cacheInfo := &ApiRouteInfo{IsAuth: api.IsAuth, Name: api.Name}
cfg := s.currentConfig()
client := data.RedisClient()
if cfg.Redis.Enable && client != nil {
if cacheInfoBytes, err := json.Marshal(cacheInfo); err == nil {
ctx, cancel := redisContext()
defer cancel()
cacheKey := s.cacheKey(route, method)
if err := client.HSet(ctx, apiRedisKey, cacheKey, string(cacheInfoBytes)).Err(); err != nil {
logError("api 路由缓存写入 Redis 失败", err, route, method)
}
}
}
return cacheInfo, nil
}
// CheckoutRouteIsAuth 判断指定路由是否要求 API 权限校验。
func (s *ApiRouteCacheService) CheckoutRouteIsAuth(route string, method string) bool {
cacheInfo, err := s.GetRouteInfo(route, method)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logError("api表数据库查询出错", err, route, method)
}
return true
}
return global.ApiAuthMode(cacheInfo.IsAuth).RequiresAPIPermission()
}
// GetApiName 返回指定路由的人类可读名称。
func (s *ApiRouteCacheService) GetApiName(route string, method string) string {
cacheInfo, err := s.GetRouteInfo(route, method)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logError("api名称数据库查询出错", err, route, method)
}
return ""
}
return cacheInfo.Name
}
func logError(message string, err error, route string, method string) {
if log.Logger == nil {
return
}
log.Logger.Error(message, zap.Error(err), zap.String("route", route), zap.String("method", method))
}
================================================
FILE: internal/service/access/api_cache_test.go
================================================
package access
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
)
func TestApiRouteCacheServiceDefaultsWithoutDatabase(t *testing.T) {
service := NewApiRouteCacheService()
service.ResetMetrics()
if got := service.GetApiName("/missing", "GET"); got != "" {
t.Fatalf("expected empty api name, got %q", got)
}
if got := service.CheckoutRouteIsAuth("/missing", "GET"); !got {
t.Fatal("expected route to default to auth-required when lookup fails")
}
}
func TestApiRouteCacheServiceCacheKey(t *testing.T) {
service := NewApiRouteCacheService()
service.ResetMetrics()
if got := service.cacheKey("/admin/v1/users", "GET"); got != "GET:/admin/v1/users" {
t.Fatalf("unexpected cache key: %s", got)
}
}
func TestApiRouteCacheServiceGetRouteInfoSingleflightDeduplicates(t *testing.T) {
service := NewApiRouteCacheService()
service.ResetMetrics()
service.configProvider = func() *config.Conf {
return &config.Conf{
Redis: autoload.RedisConfig{Enable: false},
}
}
var loadCalls int32
service.loadRouteInfo = func(route string, method string) (*ApiRouteInfo, error) {
atomic.AddInt32(&loadCalls, 1)
time.Sleep(30 * time.Millisecond)
return &ApiRouteInfo{IsAuth: 1, Name: "demo"}, nil
}
start := make(chan struct{})
var wg sync.WaitGroup
errCh := make(chan error, 16)
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
info, err := service.GetRouteInfo("/admin/v1/demo", "GET")
if err != nil {
errCh <- err
return
}
if info == nil || info.Name != "demo" || info.IsAuth != 1 {
errCh <- fmt.Errorf("unexpected route info: %#v", info)
}
}()
}
close(start)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
if got := atomic.LoadInt32(&loadCalls); got != 1 {
t.Fatalf("expected loadRouteInfo to be called once, got %d", got)
}
snapshot := service.MetricsSnapshot()
if snapshot.RequestTotal != 16 {
t.Fatalf("expected request_total=16, got %d", snapshot.RequestTotal)
}
if snapshot.CacheMissTotal != 16 {
t.Fatalf("expected cache_miss_total=16, got %d", snapshot.CacheMissTotal)
}
if snapshot.SourceLoadTotal != 1 {
t.Fatalf("expected source_load_total=1, got %d", snapshot.SourceLoadTotal)
}
if snapshot.SingleflightShared == 0 {
t.Fatal("expected singleflight_shared_total > 0")
}
if snapshot.CacheHitTotal != 0 {
t.Fatalf("expected cache_hit_total=0, got %d", snapshot.CacheHitTotal)
}
if snapshot.HitRate != 0 {
t.Fatalf("expected hit_rate=0, got %f", snapshot.HitRate)
}
}
func TestApiRouteCacheServiceResetMetrics(t *testing.T) {
service := NewApiRouteCacheService()
service.ResetMetrics()
service.metrics.requestTotal.Store(3)
service.metrics.cacheHitTotal.Store(2)
service.metrics.cacheMissTotal.Store(1)
service.metrics.sourceLoadTotal.Store(1)
service.metrics.singleflightShared.Store(1)
service.metrics.refreshBatchTotal.Store(2)
service.metrics.refreshWriteTotal.Store(9)
service.ResetMetrics()
snapshot := service.MetricsSnapshot()
if snapshot.RequestTotal != 0 ||
snapshot.CacheHitTotal != 0 ||
snapshot.CacheMissTotal != 0 ||
snapshot.SourceLoadTotal != 0 ||
snapshot.SingleflightShared != 0 ||
snapshot.RefreshBatchTotal != 0 ||
snapshot.RefreshWriteTotal != 0 ||
snapshot.HitRate != 0 {
t.Fatalf("expected metrics reset to zero, got %#v", snapshot)
}
}
func TestApiRouteCacheServiceCheckoutRouteIsAuthUsesThreeStateMode(t *testing.T) {
service := NewApiRouteCacheService()
service.configProvider = func() *config.Conf {
return &config.Conf{
Redis: autoload.RedisConfig{Enable: false},
}
}
service.loadRouteInfo = func(route string, method string) (*ApiRouteInfo, error) {
switch route {
case "/public":
return &ApiRouteInfo{IsAuth: 0, Name: "public"}, nil
case "/login-only":
return &ApiRouteInfo{IsAuth: 1, Name: "login"}, nil
case "/authz":
return &ApiRouteInfo{IsAuth: 2, Name: "authz"}, nil
default:
return nil, fmt.Errorf("unexpected route: %s", route)
}
}
if service.CheckoutRouteIsAuth("/public", "GET") {
t.Fatal("expected public route to not require api permission")
}
if service.CheckoutRouteIsAuth("/login-only", "GET") {
t.Fatal("expected login-only route to not require api permission")
}
if !service.CheckoutRouteIsAuth("/authz", "GET") {
t.Fatal("expected authz route to require api permission")
}
}
func TestRedisContextWithTimeoutHonorsParentDeadline(t *testing.T) {
parent, cancelParent := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancelParent()
ctx, cancel := redisContextWithTimeout(parent, time.Second)
defer cancel()
parentDeadline, ok := parent.Deadline()
if !ok {
t.Fatal("expected parent deadline")
}
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected derived deadline")
}
if deadline.After(parentDeadline) {
t.Fatalf("expected derived deadline %v to not exceed parent deadline %v", deadline, parentDeadline)
}
}
func TestApiRouteCacheServiceRefreshTempKeyUsesShadowKey(t *testing.T) {
service := NewApiRouteCacheService()
tempKey := service.refreshTempKey()
if tempKey == apiRedisKey {
t.Fatal("expected temp key to differ from live cache key")
}
expectedPrefix := apiRedisKey + ":refresh:"
if len(tempKey) <= len(expectedPrefix) || tempKey[:len(expectedPrefix)] != expectedPrefix {
t.Fatalf("expected temp key prefix %q, got %q", expectedPrefix, tempKey)
}
}
================================================
FILE: internal/service/access/common.go
================================================
package access
import (
casbinx "github.com/wannanbigpig/gin-layout/internal/access/casbin"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"gorm.io/gorm"
)
func defaultReloadPolicy() error {
return casbinx.ReloadPolicy()
}
// getPolicyEnforcer 返回已初始化的 Casbin 封装实例。
func getPolicyEnforcer() (*casbinx.CasbinEnforcer, error) {
enforcer, err := casbinx.GetEnforcer()
if err != nil {
return nil, e.NewBusinessError(e.CasbinInitFailed)
}
return enforcer, nil
}
// FirstTx 返回可选事务切片中的第一个事务。
func FirstTx(tx []*gorm.DB) *gorm.DB {
if len(tx) == 0 {
return nil
}
return tx[0]
}
================================================
FILE: internal/service/access/coordinator.go
================================================
package access
import (
"gorm.io/gorm"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
// PermissionSyncCoordinator 统一协调权限重建触发逻辑。
type PermissionSyncCoordinator struct {
// syncer 执行用户权限重建与清理。
syncer *UserPermissionSyncService
// resolver 解析资源变更对应的受影响用户集合。
resolver *AffectedUsersResolver
}
// PermissionSyncCoordinatorDeps 描述 PermissionSyncCoordinator 可注入依赖。
type PermissionSyncCoordinatorDeps struct {
// Syncer 自定义权限同步服务实现。
Syncer *UserPermissionSyncService
// Resolver 自定义受影响用户解析实现。
Resolver *AffectedUsersResolver
}
// NewPermissionSyncCoordinator 创建权限同步协调器。
func NewPermissionSyncCoordinator() *PermissionSyncCoordinator {
return NewPermissionSyncCoordinatorWithDeps(PermissionSyncCoordinatorDeps{})
}
// NewPermissionSyncCoordinatorWithDeps 创建带依赖注入的权限同步协调器。
func NewPermissionSyncCoordinatorWithDeps(deps PermissionSyncCoordinatorDeps) *PermissionSyncCoordinator {
coordinator := &PermissionSyncCoordinator{
syncer: deps.Syncer,
resolver: deps.Resolver,
}
if coordinator.syncer == nil {
coordinator.syncer = NewUserPermissionSyncService()
}
if coordinator.resolver == nil {
coordinator.resolver = NewAffectedUsersResolver()
}
return coordinator
}
// SyncAll 重建全部用户最终 API 权限。
func (c *PermissionSyncCoordinator) SyncAll() error {
if err := NewSystemDefaultsService().Ensure(); err != nil {
return err
}
return c.syncer.SyncAllUsers()
}
// SyncAllInTx 在事务内重建全部用户最终 API 权限。
func (c *PermissionSyncCoordinator) SyncAllInTx(tx *gorm.DB) error {
if err := NewSystemDefaultsService().Ensure(tx); err != nil {
return err
}
return c.syncer.SyncAllUsers(tx)
}
// SyncUser 重建单个用户最终 API 权限。
func (c *PermissionSyncCoordinator) SyncUser(userID uint, tx ...*gorm.DB) error {
return c.syncer.SyncUser(userID, tx...)
}
// SyncUsers 重建多个用户最终 API 权限。
func (c *PermissionSyncCoordinator) SyncUsers(userIDs []uint, tx ...*gorm.DB) error {
return c.syncer.SyncUsers(userIDs, tx...)
}
// SyncUsersAffectedByScope 根据资源变更范围重建受影响用户权限。
func (c *PermissionSyncCoordinator) SyncUsersAffectedByScope(scope PermissionChangeScope, tx ...*gorm.DB) error {
userIDs, err := c.resolver.Resolve(scope, tx...)
if err != nil {
return err
}
return c.syncer.SyncUsers(userIDs, tx...)
}
// SyncUsersAffectedByAPIs 重建受指定 API 变更影响的用户权限。
func (c *PermissionSyncCoordinator) SyncUsersAffectedByAPIs(apiIDs []uint, tx ...*gorm.DB) error {
return c.SyncUsersAffectedByScope(PermissionChangeScope{APIIDs: apiIDs}, tx...)
}
// SyncUsersAffectedByMenus 重建受指定菜单变更影响的用户权限。
func (c *PermissionSyncCoordinator) SyncUsersAffectedByMenus(menuIDs []uint, tx ...*gorm.DB) error {
return c.SyncUsersAffectedByScope(PermissionChangeScope{MenuIDs: menuIDs}, tx...)
}
// SyncUsersAffectedByRoles 重建受指定角色变更影响的用户权限。
func (c *PermissionSyncCoordinator) SyncUsersAffectedByRoles(roleIDs []uint, tx ...*gorm.DB) error {
return c.SyncUsersAffectedByScope(PermissionChangeScope{RoleIDs: roleIDs}, tx...)
}
// SyncUsersAffectedByDepartments 重建受指定部门变更影响的用户权限。
func (c *PermissionSyncCoordinator) SyncUsersAffectedByDepartments(deptIDs []uint, tx ...*gorm.DB) error {
return c.SyncUsersAffectedByScope(PermissionChangeScope{DepartmentIDs: deptIDs}, tx...)
}
// ClearUser 清理单个用户最终 API 权限。
func (c *PermissionSyncCoordinator) ClearUser(userID uint, tx ...*gorm.DB) error {
return c.syncer.ClearUser(userID, tx...)
}
// AccessibleMenuIDs 返回用户可访问菜单 ID。
func (c *PermissionSyncCoordinator) AccessibleMenuIDs(userID uint, includeParents bool, tx ...*gorm.DB) ([]uint, error) {
return c.syncer.AccessibleMenuIDs(userID, includeParents, tx...)
}
// ReloadPolicyCache 在事务提交后刷新共享 Casbin Enforcer 的内存策略。
func (c *PermissionSyncCoordinator) ReloadPolicyCache() error {
return c.syncer.ReloadPolicyCache()
}
// ReloadPolicyCacheWithMessage 在事务提交后刷新共享策略,并统一包装业务错误。
func (c *PermissionSyncCoordinator) ReloadPolicyCacheWithMessage(_ string) error {
return c.ReloadPolicyCacheWithCode(e.FAILURE)
}
// ReloadPolicyCacheWithCode 在事务提交后刷新共享策略,并按错误码包装业务错误。
func (c *PermissionSyncCoordinator) ReloadPolicyCacheWithCode(code int) error {
if err := c.ReloadPolicyCache(); err != nil {
return e.NewBusinessError(code)
}
return nil
}
// RunAfterCommit 执行事务逻辑并在成功提交后刷新共享策略缓存。
func (c *PermissionSyncCoordinator) RunAfterCommit(db *gorm.DB, _ string, fn func(tx *gorm.DB) error) error {
return c.RunAfterCommitWithCode(db, e.FAILURE, fn)
}
// RunAfterCommitWithCode 执行事务逻辑并在成功提交后刷新共享策略缓存,失败时返回指定错误码。
func (c *PermissionSyncCoordinator) RunAfterCommitWithCode(db *gorm.DB, code int, fn func(tx *gorm.DB) error) error {
if err := RunInTransaction(db, fn); err != nil {
return err
}
return c.ReloadPolicyCacheWithCode(code)
}
================================================
FILE: internal/service/access/graph_loader.go
================================================
package access
import (
"fmt"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
)
// -------------------- 单用户权限展开链路 --------------------
// collectUserPolicies 根据数据库关系展开单个用户的最终接口权限。
func (s *UserPermissionSyncService) collectUserPolicies(userID uint, tx ...*gorm.DB) ([][]string, error) {
userInfo, err := s.userInfo(userID, tx...)
if err != nil {
return nil, err
}
if !isSyncableUser(userInfo) {
return nil, nil
}
roleIDs, err := s.userRoleIDs(userID, tx...)
if err != nil {
return nil, err
}
menuIDs, err := s.RoleMenuIDs(roleIDs, tx...)
if err != nil {
return nil, err
}
return s.menuAPIPolicies(menuIDs, tx...)
}
// -------------------- 批量用户权限同步链路 --------------------
func (s *UserPermissionSyncService) collectPoliciesForUsers(userIDs []uint, tx *gorm.DB) (map[uint][][]string, error) {
uniqueIDs := UniqueUintSlice(userIDs)
result := make(map[uint][][]string, len(uniqueIDs))
if len(uniqueIDs) == 0 {
return result, nil
}
activeUserIDs, err := s.collectActiveUserIDs(uniqueIDs, result, tx)
if err != nil || len(activeUserIDs) == 0 {
return result, err
}
userRoleMap, err := s.userBaseRoleMap(activeUserIDs, tx)
if err != nil || len(userRoleMap) == 0 {
return result, err
}
roleStatusMap, err := s.loadRoleStatusMap(tx)
if err != nil {
return nil, err
}
userExpandedRoles, allRoleIDs := expandUserRoles(userRoleMap, roleStatusMap)
if len(allRoleIDs) == 0 {
return result, nil
}
roleMenuMap, err := s.roleMenuMap(allRoleIDs, tx)
if err != nil || len(roleMenuMap) == 0 {
return result, err
}
enabledMenus, menuPolicies, err := s.collectMenuPermissionData(roleMenuMap.AllMenuIDs(), tx)
if err != nil {
return nil, err
}
for userID, roleIDs := range userExpandedRoles {
result[userID] = buildUserPolicies(roleIDs, roleMenuMap, enabledMenus, menuPolicies)
}
return result, nil
}
func (s *UserPermissionSyncService) collectActiveUserIDs(userIDs []uint, result map[uint][][]string, tx *gorm.DB) ([]uint, error) {
userModel := model.NewAdminUsers()
userModel.SetDB(tx)
users, err := userModel.SyncUserRows(userIDs)
if err != nil {
return nil, err
}
activeUserIDs := make([]uint, 0, len(users))
for _, user := range users {
if user.Status != model.AdminUserStatusEnabled || user.ID == global.SuperAdminId || user.IsSuperAdmin == global.Yes {
result[user.ID] = nil
continue
}
activeUserIDs = append(activeUserIDs, user.ID)
}
return activeUserIDs, nil
}
func (s *UserPermissionSyncService) collectMenuPermissionData(menuIDs []uint, tx *gorm.DB) (map[uint]struct{}, map[uint][][]string, error) {
enabledMenus, err := s.enabledMenuSet(menuIDs, tx)
if err != nil {
return nil, nil, err
}
menuPolicies, err := s.menuPolicyMap(menuIDs, tx)
if err != nil {
return nil, nil, err
}
return enabledMenus, menuPolicies, nil
}
func (s *UserPermissionSyncService) userRoleIDs(userID uint, tx ...*gorm.DB) ([]uint, error) {
roleMapModel := model.NewAdminUserRoleMap()
deptMapModel := model.NewAdminUserDeptMap()
deptRoleMapModel := model.NewDeptRoleMap()
if t := FirstTx(tx); t != nil {
roleMapModel.SetDB(t)
deptMapModel.SetDB(t)
deptRoleMapModel.SetDB(t)
}
directRoleIDs, err := roleMapModel.RoleIdsByUid(userID)
if err != nil {
return nil, err
}
deptIDs, err := deptMapModel.DeptIdsByUid(userID)
if err != nil {
return nil, err
}
deptRoleIDs, err := deptRoleMapModel.RoleIdsByDeptIds(deptIDs)
if err != nil {
return nil, err
}
roleIDs := UniqueUintSlice(append(directRoleIDs, deptRoleIDs...))
return s.expandRoleIDs(roleIDs, tx...)
}
func (s *UserPermissionSyncService) expandRoleIDs(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {
if len(roleIDs) == 0 {
return nil, nil
}
roleModel := model.NewRole()
if t := FirstTx(tx); t != nil {
roleModel.SetDB(t)
}
roles, err := roleModel.FindPidsByIds(roleIDs)
if err != nil {
return nil, err
}
roleSet := buildAncestorSet(roleIDs, func(add func(uint)) {
for _, role := range roles {
addAncestorIDs(role.Pids, add)
}
})
return roleModel.EnabledIdsByIds(roleSet)
}
// RoleMenuIDs 根据角色列表解析出启用状态的菜单 ID。
func (s *UserPermissionSyncService) RoleMenuIDs(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {
if len(roleIDs) == 0 {
return nil, nil
}
roleMenuMapModel := model.NewRoleMenuMap()
menuModel := model.NewMenu()
if t := FirstTx(tx); t != nil {
roleMenuMapModel.SetDB(t)
menuModel.SetDB(t)
}
menuIDs, err := roleMenuMapModel.MenuIdsByRoleIds(roleIDs)
if err != nil || len(menuIDs) == 0 {
return nil, err
}
return menuModel.EnabledIdsByIds(UniqueUintSlice(menuIDs))
}
func (s *UserPermissionSyncService) expandMenuIDsWithParents(menuIDs []uint, tx ...*gorm.DB) ([]uint, error) {
if len(menuIDs) == 0 {
return nil, nil
}
menuModel := model.NewMenu()
if t := FirstTx(tx); t != nil {
menuModel.SetDB(t)
}
menus, err := menuModel.FindPidsByIds(menuIDs)
if err != nil {
return nil, err
}
menuSet := buildAncestorSet(menuIDs, func(add func(uint)) {
for _, menu := range menus {
addAncestorIDs(menu.Pids, add)
}
})
return menuModel.EnabledIdsByIds(menuSet)
}
func (s *UserPermissionSyncService) menuAPIPolicies(menuIDs []uint, tx ...*gorm.DB) ([][]string, error) {
if len(menuIDs) == 0 {
return nil, nil
}
menuApiMapModel := model.NewMenuApiMap()
if t := FirstTx(tx); t != nil {
menuApiMapModel.SetDB(t)
}
permissions, err := menuApiMapModel.ApiPermissionsByMenuIds(menuIDs)
if err != nil {
return nil, err
}
policies := make([][]string, 0, len(permissions))
for _, permission := range permissions {
if permission.Route == "" || permission.Method == "" {
continue
}
policies = append(policies, []string{permission.Route, permission.Method})
}
return policies, nil
}
func (s *UserPermissionSyncService) allUserIDs(tx ...*gorm.DB) ([]uint, error) {
userModel := model.NewAdminUsers()
if t := FirstTx(tx); t != nil {
userModel.SetDB(t)
}
return userModel.AllIds()
}
func (s *UserPermissionSyncService) userInfo(userID uint, tx ...*gorm.DB) (*model.AdminUser, error) {
user := model.NewAdminUsers()
if FirstTx(tx) != nil {
user.SetDB(FirstTx(tx))
}
if err := user.GetById(userID); err != nil {
return nil, err
}
return user, nil
}
// UserKey 生成管理员用户对应的 Casbin subject。
func (s *UserPermissionSyncService) UserKey(userID uint) string {
return fmt.Sprintf("%s%s%d", global.CasbinAdminUserPrefix, global.CasbinSeparator, userID)
}
// -------------------- 角色 / 菜单聚合上下文 --------------------
type roleStatusInfo struct {
ID uint
Pids string
Status uint8
}
type roleMenuIDMap map[uint][]uint
func (m roleMenuIDMap) AllMenuIDs() []uint {
menuIDs := make([]uint, 0)
for _, values := range m {
menuIDs = append(menuIDs, values...)
}
return UniqueUintSlice(menuIDs)
}
func (s *UserPermissionSyncService) userBaseRoleMap(userIDs []uint, tx *gorm.DB) (map[uint][]uint, error) {
userRoleMap := make(map[uint][]uint, len(userIDs))
if len(userIDs) == 0 {
return userRoleMap, nil
}
if err := s.appendDirectRoles(userRoleMap, userIDs, tx); err != nil {
return nil, err
}
userDepts, deptIDs, err := s.userDepartmentMap(userIDs, tx)
if err != nil || len(deptIDs) == 0 {
return userRoleMap, err
}
deptRoleMap, err := s.departmentRoleMap(deptIDs, tx)
if err != nil {
return nil, err
}
for userID, deptIDs := range userDepts {
for _, deptID := range deptIDs {
userRoleMap[userID] = append(userRoleMap[userID], deptRoleMap[deptID]...)
}
userRoleMap[userID] = UniqueUintSlice(userRoleMap[userID])
}
return userRoleMap, nil
}
func (s *UserPermissionSyncService) appendDirectRoles(userRoleMap map[uint][]uint, userIDs []uint, tx *gorm.DB) error {
roleMapModel := model.NewAdminUserRoleMap()
roleMapModel.SetDB(tx)
directMap, err := roleMapModel.UserRoleMapByUids(userIDs)
if err != nil {
return err
}
for uid, roleIDs := range directMap {
userRoleMap[uid] = append(userRoleMap[uid], roleIDs...)
}
return nil
}
func (s *UserPermissionSyncService) userDepartmentMap(userIDs []uint, tx *gorm.DB) (map[uint][]uint, []uint, error) {
deptMapModel := model.NewAdminUserDeptMap()
deptMapModel.SetDB(tx)
return deptMapModel.UserDeptMapByUids(userIDs)
}
func (s *UserPermissionSyncService) departmentRoleMap(deptIDs []uint, tx *gorm.DB) (map[uint][]uint, error) {
deptRoleMapModel := model.NewDeptRoleMap()
deptRoleMapModel.SetDB(tx)
return deptRoleMapModel.DeptRoleMapByDeptIds(deptIDs)
}
func (s *UserPermissionSyncService) loadRoleStatusMap(tx *gorm.DB) (map[uint]roleStatusInfo, error) {
roleModel := model.NewRole()
roleModel.SetDB(tx)
rows, err := roleModel.AllRoleStatusInfos()
if err != nil {
return nil, err
}
roleMap := make(map[uint]roleStatusInfo, len(rows))
for _, row := range rows {
roleMap[row.ID] = roleStatusInfo{
ID: row.ID,
Pids: row.Pids,
Status: row.Status,
}
}
return roleMap, nil
}
func (s *UserPermissionSyncService) roleMenuMap(roleIDs []uint, tx *gorm.DB) (roleMenuIDMap, error) {
roleMenuMapModel := model.NewRoleMenuMap()
roleMenuMapModel.SetDB(tx)
m, err := roleMenuMapModel.RoleMenuMapByRoleIds(roleIDs)
if err != nil {
return nil, err
}
return roleMenuIDMap(m), nil
}
func (s *UserPermissionSyncService) enabledMenuSet(menuIDs []uint, tx *gorm.DB) (map[uint]struct{}, error) {
menuIDs = UniqueUintSlice(menuIDs)
result := make(map[uint]struct{}, len(menuIDs))
if len(menuIDs) == 0 {
return result, nil
}
menuModel := model.NewMenu()
menuModel.SetDB(tx)
enabledMenuIDs, err := menuModel.EnabledIdsByIds(menuIDs)
if err != nil {
return nil, err
}
for _, menuID := range enabledMenuIDs {
result[menuID] = struct{}{}
}
return result, nil
}
func (s *UserPermissionSyncService) menuPolicyMap(menuIDs []uint, tx *gorm.DB) (map[uint][][]string, error) {
menuIDs = UniqueUintSlice(menuIDs)
result := make(map[uint][][]string, len(menuIDs))
if len(menuIDs) == 0 {
return result, nil
}
menuApiMapModel := model.NewMenuApiMap()
menuApiMapModel.SetDB(tx)
rows, err := menuApiMapModel.MenuApiPermissionsByMenuIds(menuIDs)
if err != nil {
return nil, err
}
for _, row := range rows {
if row.Route == "" || row.Method == "" {
continue
}
result[row.MenuId] = append(result[row.MenuId], []string{row.Route, row.Method})
}
return result, nil
}
// -------------------- 树关系与去重工具 --------------------
func buildAncestorSet(baseIDs []uint, collect func(add func(uint))) []uint {
idSet := make(map[uint]struct{}, len(baseIDs))
for _, id := range baseIDs {
idSet[id] = struct{}{}
}
collect(func(id uint) {
idSet[id] = struct{}{}
})
result := make([]uint, 0, len(idSet))
for id := range idSet {
result = append(result, id)
}
return result
}
func addAncestorIDs(pids string, add func(uint)) {
if pids == "" || pids == "0" {
return
}
for _, pid := range strings.Split(pids, ",") {
pid = strings.TrimSpace(pid)
if pid == "" || pid == "0" {
continue
}
if parsed, err := strconv.ParseUint(pid, 10, 64); err == nil {
add(uint(parsed))
}
}
}
func isSyncableUser(userInfo *model.AdminUser) bool {
return userInfo != nil &&
userInfo.ID != 0 &&
userInfo.Status == model.AdminUserStatusEnabled &&
userInfo.ID != global.SuperAdminId
}
func expandUserRoles(userRoleMap map[uint][]uint, roleStatusMap map[uint]roleStatusInfo) (map[uint][]uint, []uint) {
userExpandedRoles := make(map[uint][]uint, len(userRoleMap))
allRoleIDs := make([]uint, 0, len(userRoleMap)*2)
for userID, roleIDs := range userRoleMap {
expanded := expandRoleAncestors(roleIDs, roleStatusMap)
userExpandedRoles[userID] = expanded
allRoleIDs = append(allRoleIDs, expanded...)
}
return userExpandedRoles, UniqueUintSlice(allRoleIDs)
}
func buildUserPolicies(roleIDs []uint, roleMenuMap roleMenuIDMap, enabledMenus map[uint]struct{}, menuPolicies map[uint][][]string) [][]string {
menuSet := collectEnabledMenuSet(roleIDs, roleMenuMap, enabledMenus)
return dedupePolicies(menuSet, menuPolicies)
}
func collectEnabledMenuSet(roleIDs []uint, roleMenuMap roleMenuIDMap, enabledMenus map[uint]struct{}) map[uint]struct{} {
menuSet := make(map[uint]struct{})
for _, roleID := range roleIDs {
for _, menuID := range roleMenuMap[roleID] {
if _, ok := enabledMenus[menuID]; ok {
menuSet[menuID] = struct{}{}
}
}
}
return menuSet
}
func dedupePolicies(menuSet map[uint]struct{}, menuPolicies map[uint][][]string) [][]string {
policies := make([][]string, 0, len(menuSet)*5)
seenPolicy := make(map[string]struct{})
for menuID := range menuSet {
for _, policy := range menuPolicies[menuID] {
if len(policy) < 2 {
continue
}
key := policy[0] + "::" + policy[1]
if _, exists := seenPolicy[key]; exists {
continue
}
seenPolicy[key] = struct{}{}
policies = append(policies, policy)
}
}
return policies
}
func expandRoleAncestors(roleIDs []uint, roleStatusMap map[uint]roleStatusInfo) []uint {
roleSet := make(map[uint]struct{})
for _, roleID := range UniqueUintSlice(roleIDs) {
role, ok := roleStatusMap[roleID]
if !ok || role.Status != global.Yes {
continue
}
roleSet[roleID] = struct{}{}
addAncestorIDs(role.Pids, func(ancestorID uint) {
if ancestor, ok := roleStatusMap[ancestorID]; ok && ancestor.Status == global.Yes {
roleSet[ancestorID] = struct{}{}
}
})
}
result := make([]uint, 0, len(roleSet))
for roleID := range roleSet {
result = append(result, roleID)
}
return UniqueUintSlice(result)
}
================================================
FILE: internal/service/access/menu_api_defaults.go
================================================
package access
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/model"
)
type defaultMenuAPIBinding struct {
// MenuCode 菜单编码。
MenuCode string
// Route 绑定的接口路由。
Route string
// Method 绑定的 HTTP 方法。
Method string
}
var builtInDefaultMenuAPIBindings = [...]defaultMenuAPIBinding{
{MenuCode: "adminUser:update", Route: "/admin/v1/admin-user/update", Method: "POST"},
{MenuCode: "adminUser:add", Route: "/admin/v1/admin-user/create", Method: "POST"},
{MenuCode: "adminUser:bindRole", Route: "/admin/v1/admin-user/bind-role", Method: "POST"},
{MenuCode: "adminUser:bindRole", Route: "/admin/v1/admin-user/detail", Method: "GET"},
{MenuCode: "adminUser:bindRole", Route: "/admin/v1/role/list", Method: "GET"},
{MenuCode: "adminUser:delete", Route: "/admin/v1/admin-user/delete", Method: "POST"},
{MenuCode: "menu:add", Route: "/admin/v1/menu/create", Method: "POST"},
{MenuCode: "menu:add", Route: "/admin/v1/permission/list", Method: "GET"},
{MenuCode: "menu:addChild", Route: "/admin/v1/menu/create", Method: "POST"},
{MenuCode: "menu:addChild", Route: "/admin/v1/permission/list", Method: "GET"},
{MenuCode: "menu:update", Route: "/admin/v1/menu/detail", Method: "GET"},
{MenuCode: "menu:update", Route: "/admin/v1/menu/update", Method: "POST"},
{MenuCode: "menu:update", Route: "/admin/v1/permission/list", Method: "GET"},
{MenuCode: "menu:delete", Route: "/admin/v1/menu/delete", Method: "POST"},
{MenuCode: "role:add", Route: "/admin/v1/menu/list", Method: "GET"},
{MenuCode: "role:add", Route: "/admin/v1/role/create", Method: "POST"},
{MenuCode: "role:update", Route: "/admin/v1/menu/list", Method: "GET"},
{MenuCode: "role:update", Route: "/admin/v1/role/detail", Method: "GET"},
{MenuCode: "role:update", Route: "/admin/v1/role/update", Method: "POST"},
{MenuCode: "role:delete", Route: "/admin/v1/role/delete", Method: "POST"},
{MenuCode: "department:add", Route: "/admin/v1/department/create", Method: "POST"},
{MenuCode: "department:addChild", Route: "/admin/v1/department/create", Method: "POST"},
{MenuCode: "department:update", Route: "/admin/v1/department/update", Method: "POST"},
{MenuCode: "department:bindRole", Route: "/admin/v1/department/bind-role", Method: "POST"},
{MenuCode: "department:bindRole", Route: "/admin/v1/department/detail", Method: "GET"},
{MenuCode: "department:bindRole", Route: "/admin/v1/role/list", Method: "GET"},
{MenuCode: "department:delete", Route: "/admin/v1/department/delete", Method: "POST"},
{MenuCode: "api:update", Route: "/admin/v1/permission/update", Method: "POST"},
{MenuCode: "role:addChild", Route: "/admin/v1/role/create", Method: "POST"},
{MenuCode: "adminLoginLog:detail", Route: "/admin/v1/log/login/detail", Method: "GET"},
{MenuCode: "requestLog:detail", Route: "/admin/v1/log/request/detail", Method: "GET"},
{MenuCode: "adminUser:list", Route: "/admin/v1/department/list", Method: "GET"},
{MenuCode: "adminUser:list", Route: "/admin/v1/admin-user/list", Method: "GET"},
{MenuCode: "department:list", Route: "/admin/v1/department/list", Method: "GET"},
{MenuCode: "role:list", Route: "/admin/v1/role/list", Method: "GET"},
{MenuCode: "menu:list", Route: "/admin/v1/menu/list", Method: "GET"},
{MenuCode: "api:list", Route: "/admin/v1/permission/list", Method: "GET"},
{MenuCode: "adminLoginLog:list", Route: "/admin/v1/log/login/list", Method: "GET"},
{MenuCode: "requestLog:list", Route: "/admin/v1/log/request/list", Method: "GET"},
{MenuCode: "requestLog:export", Route: "/admin/v1/log/request/export", Method: "GET"},
{MenuCode: "requestLog:maskConfig", Route: "/admin/v1/log/request/mask-config", Method: "GET"},
{MenuCode: "requestLog:maskConfig", Route: "/admin/v1/log/request/mask-config", Method: "POST"},
{MenuCode: "sysConfig:list", Route: "/admin/v1/system/config/list", Method: "GET"},
{MenuCode: "sysConfig:list", Route: "/admin/v1/system/config/detail", Method: "GET"},
{MenuCode: "sysConfig:list", Route: "/admin/v1/system/config/value", Method: "GET"},
{MenuCode: "sysConfig:add", Route: "/admin/v1/system/config/create", Method: "POST"},
{MenuCode: "sysConfig:update", Route: "/admin/v1/system/config/update", Method: "POST"},
{MenuCode: "sysConfig:delete", Route: "/admin/v1/system/config/delete", Method: "POST"},
{MenuCode: "sysConfig:refresh", Route: "/admin/v1/system/config/refresh", Method: "POST"},
{MenuCode: "sysDict:list", Route: "/admin/v1/system/dict/type/list", Method: "GET"},
{MenuCode: "sysDict:list", Route: "/admin/v1/system/dict/type/detail", Method: "GET"},
{MenuCode: "sysDict:list", Route: "/admin/v1/system/dict/item/list", Method: "GET"},
{MenuCode: "sysDict:list", Route: "/admin/v1/system/dict/options", Method: "GET"},
{MenuCode: "sysDict:add", Route: "/admin/v1/system/dict/type/create", Method: "POST"},
{MenuCode: "sysDict:add", Route: "/admin/v1/system/dict/item/create", Method: "POST"},
{MenuCode: "sysDict:update", Route: "/admin/v1/system/dict/type/update", Method: "POST"},
{MenuCode: "sysDict:update", Route: "/admin/v1/system/dict/item/update", Method: "POST"},
{MenuCode: "sysDict:delete", Route: "/admin/v1/system/dict/type/delete", Method: "POST"},
{MenuCode: "sysDict:delete", Route: "/admin/v1/system/dict/item/delete", Method: "POST"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/list", Method: "GET"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/detail", Method: "GET"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/folder/tree", Method: "GET"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/trash/list", Method: "GET"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/references", Method: "GET"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/upload/credential", Method: "POST"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/upload/complete", Method: "POST"},
{MenuCode: "file:list", Route: "/admin/v1/system/file/upload/local", Method: "POST"},
{MenuCode: "file:update", Route: "/admin/v1/system/file/folder/create", Method: "POST"},
{MenuCode: "file:update", Route: "/admin/v1/system/file/folder/update", Method: "POST"},
{MenuCode: "file:update", Route: "/admin/v1/system/file/folder/delete", Method: "POST"},
{MenuCode: "file:update", Route: "/admin/v1/system/file/folder/move", Method: "POST"},
{MenuCode: "file:update", Route: "/admin/v1/system/file/move", Method: "POST"},
{MenuCode: "file:delete", Route: "/admin/v1/system/file/delete", Method: "POST"},
{MenuCode: "file:restore", Route: "/admin/v1/system/file/trash/restore", Method: "POST"},
{MenuCode: "file:destroy", Route: "/admin/v1/system/file/trash/destroy", Method: "POST"},
{MenuCode: "storage:config", Route: "/admin/v1/system/storage/config", Method: "GET"},
{MenuCode: "storage:update", Route: "/admin/v1/system/storage/config", Method: "POST"},
{MenuCode: "storage:test", Route: "/admin/v1/system/storage/test", Method: "POST"},
{MenuCode: "session:list", Route: "/admin/v1/auth/session/list", Method: "GET"},
{MenuCode: "session:revoke", Route: "/admin/v1/auth/session/revoke", Method: "POST"},
{MenuCode: "task:list", Route: "/admin/v1/task/list", Method: "GET"},
{MenuCode: "task:list", Route: "/admin/v1/task/run/list", Method: "GET"},
{MenuCode: "task:detail", Route: "/admin/v1/task/run/detail", Method: "GET"},
{MenuCode: "task:detail", Route: "/admin/v1/task/run/events", Method: "GET"},
{MenuCode: "task:list", Route: "/admin/v1/task/cron/state", Method: "GET"},
{MenuCode: "task:trigger", Route: "/admin/v1/task/trigger", Method: "POST"},
{MenuCode: "task:retry", Route: "/admin/v1/task/run/retry", Method: "POST"},
{MenuCode: "task:cancel", Route: "/admin/v1/task/run/cancel", Method: "POST"},
}
// MenuAPIDefaultsService 负责初始化默认菜单与接口映射关系。
type MenuAPIDefaultsService struct {
// bindings 默认菜单与接口绑定配置。
bindings []defaultMenuAPIBinding
}
// MenuAPIDefaultsServiceDeps 描述 MenuAPIDefaultsService 可注入依赖。
type MenuAPIDefaultsServiceDeps struct {
// Bindings 自定义默认菜单接口绑定。
Bindings []defaultMenuAPIBinding
}
// NewMenuAPIDefaultsService 创建默认菜单接口映射服务实例。
func NewMenuAPIDefaultsService() *MenuAPIDefaultsService {
return NewMenuAPIDefaultsServiceWithDeps(MenuAPIDefaultsServiceDeps{})
}
// NewMenuAPIDefaultsServiceWithDeps 创建带依赖注入的默认菜单接口映射服务实例。
func NewMenuAPIDefaultsServiceWithDeps(deps MenuAPIDefaultsServiceDeps) *MenuAPIDefaultsService {
s := &MenuAPIDefaultsService{}
if deps.Bindings != nil {
s.bindings = cloneMenuAPIBindings(deps.Bindings)
} else {
s.bindings = defaultMenuAPIBindings()
}
return s
}
func defaultMenuAPIBindings() []defaultMenuAPIBinding {
return cloneMenuAPIBindings(builtInDefaultMenuAPIBindings[:])
}
func cloneMenuAPIBindings(source []defaultMenuAPIBinding) []defaultMenuAPIBinding {
if len(source) == 0 {
return nil
}
cloned := make([]defaultMenuAPIBinding, len(source))
copy(cloned, source)
return cloned
}
// Sync 将默认菜单接口映射写入数据库。
func (s *MenuAPIDefaultsService) Sync(tx ...*gorm.DB) error {
bindings := s.bindings
if len(bindings) == 0 {
return nil
}
db, err := defaultMenuAPIDB(FirstTx(tx))
if err != nil {
return err
}
menuCodes, routes, methods := collectMenuAPIBindingKeys(bindings)
targets, err := loadDefaultMenuAPITargets(db, menuCodes, routes, methods)
if err != nil {
return err
}
mappings := buildDefaultMenuAPIMappings(bindings, targets)
if len(mappings) == 0 {
return nil
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "menu_id"}, {Name: "api_id"}},
DoNothing: true,
}).Create(&mappings).Error
}
func defaultMenuAPIDB(tx *gorm.DB) (*gorm.DB, error) {
if tx != nil {
return tx, nil
}
return model.NewMenuApiMap().GetDB()
}
func collectMenuAPIBindingKeys(bindings []defaultMenuAPIBinding) (menuCodes []string, routes []string, methods []string) {
menuCodeSet := make(map[string]struct{}, len(bindings))
routeSet := make(map[string]struct{}, len(bindings))
methodSet := make(map[string]struct{}, len(bindings))
for _, item := range bindings {
if _, ok := menuCodeSet[item.MenuCode]; !ok {
menuCodeSet[item.MenuCode] = struct{}{}
menuCodes = append(menuCodes, item.MenuCode)
}
if _, ok := routeSet[item.Route]; !ok {
routeSet[item.Route] = struct{}{}
routes = append(routes, item.Route)
}
if _, ok := methodSet[item.Method]; !ok {
methodSet[item.Method] = struct{}{}
methods = append(methods, item.Method)
}
}
return menuCodes, routes, methods
}
type defaultMenuAPITargets struct {
menuIDByCode map[string]uint
apiIDByRouteMethod map[string]uint
}
func loadDefaultMenuAPITargets(db *gorm.DB, menuCodes []string, routes []string, methods []string) (defaultMenuAPITargets, error) {
menuModel := model.NewMenu()
menuModel.SetDB(db)
menus, err := menuModel.FindIdsByCodes(menuCodes)
if err != nil {
return defaultMenuAPITargets{}, err
}
menuIDByCode := make(map[string]uint, len(menus))
for _, menu := range menus {
menuIDByCode[menu.Code] = menu.ID
}
apiModel := model.NewApi()
apiModel.SetDB(db)
apis, err := apiModel.FindIdsByRouteAndMethod(routes, methods)
if err != nil {
return defaultMenuAPITargets{}, err
}
apiIDByRouteMethod := make(map[string]uint, len(apis))
for _, api := range apis {
apiIDByRouteMethod[api.Method+":"+api.Route] = api.ID
}
return defaultMenuAPITargets{
menuIDByCode: menuIDByCode,
apiIDByRouteMethod: apiIDByRouteMethod,
}, nil
}
func buildDefaultMenuAPIMappings(bindings []defaultMenuAPIBinding, targets defaultMenuAPITargets) []*model.MenuApiMap {
mappings := make([]*model.MenuApiMap, 0, len(bindings))
for _, item := range bindings {
menuID, ok := targets.menuIDByCode[item.MenuCode]
if !ok {
continue
}
apiID, ok := targets.apiIDByRouteMethod[item.Method+":"+item.Route]
if !ok {
continue
}
mappings = append(mappings, &model.MenuApiMap{
MenuId: menuID,
ApiId: apiID,
})
}
return mappings
}
================================================
FILE: internal/service/access/menu_api_defaults_test.go
================================================
package access
import (
"net/http"
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestNewMenuAPIDefaultsServiceUsesIsolatedDefaultBindings(t *testing.T) {
first := NewMenuAPIDefaultsService()
second := NewMenuAPIDefaultsService()
if len(first.bindings) == 0 || len(second.bindings) == 0 {
t.Fatal("expected default bindings to be initialized")
}
originalRoute := second.bindings[0].Route
first.bindings[0].Route = "/mutated-by-test"
if second.bindings[0].Route != originalRoute {
t.Fatal("expected default bindings to be isolated per service instance")
}
}
func TestNewMenuAPIDefaultsServiceWithDepsClonesBindings(t *testing.T) {
customBindings := []defaultMenuAPIBinding{
{MenuCode: "menu:test", Route: "/custom", Method: "GET"},
}
service := NewMenuAPIDefaultsServiceWithDeps(MenuAPIDefaultsServiceDeps{
Bindings: customBindings,
})
customBindings[0].Route = "/changed"
if service.bindings[0].Route != "/custom" {
t.Fatal("expected custom bindings to be cloned")
}
}
func TestNewMenuAPIDefaultsServiceWithDepsAllowsEmptyBindings(t *testing.T) {
service := NewMenuAPIDefaultsServiceWithDeps(MenuAPIDefaultsServiceDeps{
Bindings: []defaultMenuAPIBinding{},
})
if len(service.bindings) != 0 {
t.Fatalf("expected empty bindings, got %d", len(service.bindings))
}
}
func TestDefaultMenuAPIBindingsCoverManagementRoutes(t *testing.T) {
service := NewMenuAPIDefaultsService()
bindingSet := make(map[string]struct{}, len(service.bindings))
for _, binding := range service.bindings {
bindingSet[binding.Method+" "+binding.Route] = struct{}{}
}
required := []string{
http.MethodGet + " /admin/v1/system/config/list",
http.MethodGet + " /admin/v1/system/config/detail",
http.MethodPost + " /admin/v1/system/config/create",
http.MethodPost + " /admin/v1/system/config/update",
http.MethodGet + " /admin/v1/system/dict/type/list",
http.MethodGet + " /admin/v1/system/dict/options",
http.MethodGet + " /admin/v1/task/list",
http.MethodPost + " /admin/v1/task/trigger",
http.MethodPost + " /admin/v1/task/run/retry",
http.MethodPost + " /admin/v1/task/run/cancel",
http.MethodGet + " /admin/v1/log/request/list",
http.MethodGet + " /admin/v1/log/request/detail",
http.MethodPost + " /admin/v1/log/request/mask-config",
}
for _, route := range required {
if _, ok := bindingSet[route]; !ok {
t.Fatalf("missing default menu API binding for %s", route)
}
}
}
func TestCollectMenuAPIBindingKeysKeepsUniqueValues(t *testing.T) {
bindings := []defaultMenuAPIBinding{
{MenuCode: "menu:list", Route: "/admin/v1/menu/list", Method: http.MethodGet},
{MenuCode: "menu:list", Route: "/admin/v1/menu/list", Method: http.MethodGet},
{MenuCode: "menu:update", Route: "/admin/v1/menu/update", Method: http.MethodPost},
}
menuCodes, routes, methods := collectMenuAPIBindingKeys(bindings)
if len(menuCodes) != 2 || menuCodes[0] != "menu:list" || menuCodes[1] != "menu:update" {
t.Fatalf("unexpected menu codes: %#v", menuCodes)
}
if len(routes) != 2 || routes[0] != "/admin/v1/menu/list" || routes[1] != "/admin/v1/menu/update" {
t.Fatalf("unexpected routes: %#v", routes)
}
if len(methods) != 2 || methods[0] != http.MethodGet || methods[1] != http.MethodPost {
t.Fatalf("unexpected methods: %#v", methods)
}
}
func TestBuildDefaultMenuAPIMappingsSkipsMissingTargets(t *testing.T) {
bindings := []defaultMenuAPIBinding{
{MenuCode: "menu:list", Route: "/admin/v1/menu/list", Method: http.MethodGet},
{MenuCode: "menu:missing", Route: "/admin/v1/menu/list", Method: http.MethodGet},
{MenuCode: "menu:list", Route: "/admin/v1/menu/missing", Method: http.MethodGet},
}
targets := defaultMenuAPITargets{
menuIDByCode: map[string]uint{
"menu:list": 10,
},
apiIDByRouteMethod: map[string]uint{
http.MethodGet + ":/admin/v1/menu/list": 20,
},
}
mappings := buildDefaultMenuAPIMappings(bindings, targets)
if len(mappings) != 1 {
t.Fatalf("expected one mapping, got %d", len(mappings))
}
want := &model.MenuApiMap{MenuId: 10, ApiId: 20}
if mappings[0].MenuId != want.MenuId || mappings[0].ApiId != want.ApiId {
t.Fatalf("unexpected mapping: got=%+v want=%+v", mappings[0], want)
}
}
================================================
FILE: internal/service/access/scope_resolver.go
================================================
package access
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
)
// PermissionChangeScope 描述一次业务变更影响的权限对象范围。
type PermissionChangeScope struct {
APIIDs []uint
MenuIDs []uint
RoleIDs []uint
DepartmentIDs []uint
UserIDs []uint
}
// AffectedUsersResolver 负责将资源变更转换成受影响用户集合。
type AffectedUsersResolver struct{}
// NewAffectedUsersResolver 创建受影响用户解析器。
func NewAffectedUsersResolver() *AffectedUsersResolver {
return &AffectedUsersResolver{}
}
// Resolve 返回指定作用域下的受影响用户集合。
func (r *AffectedUsersResolver) Resolve(scope PermissionChangeScope, tx ...*gorm.DB) ([]uint, error) {
userSet := make([]uint, 0, len(scope.UserIDs))
userSet = append(userSet, scope.UserIDs...)
menuIDs := UniqueUintSlice(scope.MenuIDs)
if len(scope.APIIDs) > 0 {
menuApiMapModel := model.NewMenuApiMap()
if t := FirstTx(tx); t != nil {
menuApiMapModel.SetDB(t)
}
apiMenuIDs, err := menuApiMapModel.MenuIdsByApiIds(scope.APIIDs)
if err != nil {
return nil, err
}
menuIDs = UniqueUintSlice(append(menuIDs, apiMenuIDs...))
}
roleIDs := UniqueUintSlice(scope.RoleIDs)
if len(menuIDs) > 0 {
roleMenuMapModel := model.NewRoleMenuMap()
if t := FirstTx(tx); t != nil {
roleMenuMapModel.SetDB(t)
}
menuRoleIDs, err := roleMenuMapModel.RoleIdsByMenuIds(menuIDs)
if err != nil {
return nil, err
}
roleIDs = UniqueUintSlice(append(roleIDs, menuRoleIDs...))
}
if len(roleIDs) > 0 {
roleUserIDs, err := r.userIDsByRoles(roleIDs, tx...)
if err != nil {
return nil, err
}
userSet = append(userSet, roleUserIDs...)
}
if len(scope.DepartmentIDs) > 0 {
deptMapModel := model.NewAdminUserDeptMap()
if t := FirstTx(tx); t != nil {
deptMapModel.SetDB(t)
}
deptUserIDs, err := deptMapModel.UidsByDeptIds(scope.DepartmentIDs)
if err != nil {
return nil, err
}
userSet = append(userSet, deptUserIDs...)
}
return UniqueUintSlice(userSet), nil
}
func (r *AffectedUsersResolver) userIDsByRoles(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {
roleIDs = UniqueUintSlice(roleIDs)
if len(roleIDs) == 0 {
return nil, nil
}
expandedRoleIDs, err := r.expandRoleSubtree(roleIDs, tx...)
if err != nil {
return nil, err
}
roleMapModel := model.NewAdminUserRoleMap()
deptRoleMapModel := model.NewDeptRoleMap()
deptMapModel := model.NewAdminUserDeptMap()
if t := FirstTx(tx); t != nil {
roleMapModel.SetDB(t)
deptRoleMapModel.SetDB(t)
deptMapModel.SetDB(t)
}
userIDs, err := roleMapModel.UidsByRoleIds(expandedRoleIDs)
if err != nil {
return nil, err
}
deptIDs, err := deptRoleMapModel.DeptIdsByRoleIds(expandedRoleIDs)
if err != nil {
return nil, err
}
if len(deptIDs) == 0 {
return userIDs, nil
}
deptUserIDs, err := deptMapModel.UidsByDeptIds(deptIDs)
if err != nil {
return nil, err
}
return UniqueUintSlice(append(userIDs, deptUserIDs...)), nil
}
func (r *AffectedUsersResolver) expandRoleSubtree(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {
roleIDs = UniqueUintSlice(roleIDs)
if len(roleIDs) == 0 {
return nil, nil
}
roleModel := model.NewRole()
if t := FirstTx(tx); t != nil {
roleModel.SetDB(t)
}
subtreeIDs, err := roleModel.SubtreeIdsByRootIds(roleIDs)
if err != nil {
return nil, err
}
return UniqueUintSlice(append(subtreeIDs, roleIDs...)), nil
}
================================================
FILE: internal/service/access/system_defaults.go
================================================
package access
import (
"errors"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
// SystemDefaultsService 负责校验和补齐系统默认角色、部门与关联关系。
type SystemDefaultsService struct{}
// NewSystemDefaultsService 创建系统默认数据服务实例。
func NewSystemDefaultsService() *SystemDefaultsService {
return &SystemDefaultsService{}
}
// Ensure 确保系统默认数据和关联关系存在。
func (s *SystemDefaultsService) Ensure(tx ...*gorm.DB) error {
existingTx := FirstTx(tx)
if existingTx != nil {
return s.ensureWithTx(existingTx)
}
db, err := model.NewAdminUsers().GetDB()
if err != nil {
return err
}
return db.Transaction(func(execTx *gorm.DB) error {
return s.ensureWithTx(execTx)
})
}
// IsProtectedRole 判断角色是否为系统保护角色。
func (s *SystemDefaultsService) IsProtectedRole(role *model.Role) bool {
return role != nil && role.IsSystemRole() && role.Code == global.SuperAdminRoleCode
}
// IsProtectedDepartment 判断部门是否为系统保护部门。
func (s *SystemDefaultsService) IsProtectedDepartment(dept *model.Department) bool {
return dept != nil && dept.IsSystemDepartment() && dept.Code == global.DefaultDepartmentCode
}
// EnsureSuperAdminRoleMenus 兼容旧入口,确保超级管理员角色菜单完整。
func (s *SystemDefaultsService) EnsureSuperAdminRoleMenus(tx ...*gorm.DB) error {
return s.Ensure(tx...)
}
func (s *SystemDefaultsService) ensureWithTx(tx *gorm.DB) error {
dept, err := s.ensureDefaultDepartment(tx)
if err != nil {
return err
}
role, err := s.ensureSuperAdminRole(tx)
if err != nil {
return err
}
if err := s.ensureSuperAdminUser(tx); err != nil {
return err
}
if err := s.ensureSuperAdminUserDept(tx, dept.ID); err != nil {
return err
}
if err := s.ensureSuperAdminUserRole(tx, role.ID); err != nil {
return err
}
return s.ensureSuperAdminRoleMenusWithTx(tx, role.ID)
}
func (s *SystemDefaultsService) ensureDefaultDepartment(tx *gorm.DB) (*model.Department, error) {
dept := model.NewDepartment()
dept.SetDB(tx)
if err := dept.FindByCode(global.DefaultDepartmentCode); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
dept.Code = global.DefaultDepartmentCode
dept.IsSystem = global.Yes
dept.Pid = 0
dept.Pids = "0"
dept.Level = 1
dept.Name = "默认部门"
dept.Description = "系统默认部门"
dept.Sort = 100
if err := dept.Save(); err != nil {
return nil, err
}
return dept, nil
}
updates := map[string]any{}
if dept.IsSystem != global.Yes {
updates["is_system"] = global.Yes
}
if dept.Pid != 0 {
updates["pid"] = 0
}
if dept.Pids != "0" {
updates["pids"] = "0"
}
if dept.Level != 1 {
updates["level"] = 1
}
if dept.Code != global.DefaultDepartmentCode {
updates["code"] = global.DefaultDepartmentCode
}
if len(updates) > 0 {
if err := dept.UpdateById(dept.ID, updates); err != nil {
return nil, err
}
}
return dept, nil
}
func (s *SystemDefaultsService) ensureSuperAdminRole(tx *gorm.DB) (*model.Role, error) {
role := model.NewRole()
role.SetDB(tx)
if err := role.FindByCode(global.SuperAdminRoleCode); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
role.Code = global.SuperAdminRoleCode
role.IsSystem = global.Yes
role.Pid = 0
role.Pids = "0"
role.Level = 1
role.Name = "超级管理员"
role.Description = "系统默认超级管理员角色"
role.Sort = 100
role.Status = global.Yes
if err := role.Save(); err != nil {
return nil, err
}
return role, nil
}
updates := map[string]any{}
if role.IsSystem != global.Yes {
updates["is_system"] = global.Yes
}
if role.Code != global.SuperAdminRoleCode {
updates["code"] = global.SuperAdminRoleCode
}
if role.Pid != 0 {
updates["pid"] = 0
}
if role.Pids != "0" {
updates["pids"] = "0"
}
if role.Level != 1 {
updates["level"] = 1
}
if role.Name != "超级管理员" {
updates["name"] = "超级管理员"
}
if role.Description != "系统默认超级管理员角色" {
updates["description"] = "系统默认超级管理员角色"
}
if role.Sort != 100 {
updates["sort"] = 100
}
if role.Status != 1 {
updates["status"] = 1
}
if len(updates) > 0 {
if err := role.UpdateById(role.ID, updates); err != nil {
return nil, err
}
}
return role, nil
}
func (s *SystemDefaultsService) ensureSuperAdminUser(tx *gorm.DB) error {
adminUser := model.NewAdminUsers()
adminUser.SetDB(tx)
if err := adminUser.GetById(global.SuperAdminId); err != nil {
return err
}
updates := map[string]any{}
if adminUser.IsSuperAdmin != global.Yes {
updates["is_super_admin"] = global.Yes
}
if adminUser.Status != model.AdminUserStatusEnabled {
updates["status"] = model.AdminUserStatusEnabled
}
if adminUser.Username != global.SuperAdminRoleCode {
updates["username"] = global.SuperAdminRoleCode
}
if len(updates) == 0 {
return nil
}
return adminUser.UpdateById(adminUser.ID, updates)
}
func (s *SystemDefaultsService) ensureSuperAdminUserDept(tx *gorm.DB, deptID uint) error {
rel := model.NewAdminUserDeptMap()
rel.SetDB(tx)
count, err := rel.CountByCondition("uid = ? AND dept_id = ?", global.SuperAdminId, deptID)
if err != nil {
return err
}
if count > 0 {
return nil
}
rel.Uid = global.SuperAdminId
rel.DeptId = deptID
return rel.CreateOne()
}
func (s *SystemDefaultsService) ensureSuperAdminUserRole(tx *gorm.DB, roleID uint) error {
rel := model.NewAdminUserRoleMap()
rel.SetDB(tx)
count, err := rel.CountByCondition("uid = ? AND role_id = ?", global.SuperAdminId, roleID)
if err != nil {
return err
}
if count > 0 {
return nil
}
rel.Uid = global.SuperAdminId
rel.RoleId = roleID
return rel.CreateOne()
}
func (s *SystemDefaultsService) ensureSuperAdminRoleMenusWithTx(tx *gorm.DB, roleID uint) error {
menuModel := model.NewMenu()
menuModel.SetDB(tx)
allMenuIDs, err := menuModel.AllIds()
if err != nil {
return err
}
allMenuIDs = lo.Uniq(allMenuIDs)
roleMenuMap := model.NewRoleMenuMap()
roleMenuMap.SetDB(tx)
existingIDs, err := model.ExtractColumnsByCondition[model.RoleMenuMap, *model.RoleMenuMap, uint](roleMenuMap, "menu_id", "role_id = ?", roleID)
if err != nil {
return err
}
toDelete, toAdd, _ := utils.CalculateChanges(existingIDs, allMenuIDs)
if len(toDelete) > 0 {
if err := roleMenuMap.DeleteWhere("role_id = ? AND menu_id IN (?)", roleID, toDelete); err != nil {
return err
}
}
if len(toAdd) == 0 {
return nil
}
newMappings := lo.Map(toAdd, func(menuID uint, _ int) *model.RoleMenuMap {
return &model.RoleMenuMap{RoleId: roleID, MenuId: menuID}
})
return roleMenuMap.CreateBatch(newMappings)
}
// RequireSuperAdminRoleForUser 确保超级管理员用户始终保留超级管理员角色。
func (s *SystemDefaultsService) RequireSuperAdminRoleForUser(uid uint, roleIDs []uint) error {
if uid != global.SuperAdminId {
return nil
}
role := model.NewRole()
if err := role.FindByCode(global.SuperAdminRoleCode); err != nil {
return err
}
if lo.Contains(roleIDs, role.ID) {
return nil
}
return e.NewBusinessError(e.SuperAdminMustKeepRole)
}
================================================
FILE: internal/service/access/system_defaults_test.go
================================================
package access
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestSystemDefaultsServiceProtectedPredicates(t *testing.T) {
service := NewSystemDefaultsService()
protectedRole := model.NewRole()
protectedRole.Code = global.SuperAdminRoleCode
protectedRole.IsSystem = global.Yes
if !service.IsProtectedRole(protectedRole) {
t.Fatal("expected super admin role to be protected")
}
normalRole := model.NewRole()
normalRole.Code = "editor"
normalRole.IsSystem = global.No
if service.IsProtectedRole(normalRole) {
t.Fatal("expected normal role to be mutable")
}
protectedDept := model.NewDepartment()
protectedDept.Code = global.DefaultDepartmentCode
protectedDept.IsSystem = global.Yes
if !service.IsProtectedDepartment(protectedDept) {
t.Fatal("expected default department to be protected")
}
normalDept := model.NewDepartment()
normalDept.Code = "sales"
normalDept.IsSystem = global.No
if service.IsProtectedDepartment(normalDept) {
t.Fatal("expected normal department to be mutable")
}
}
func TestRequireSuperAdminRoleForNonSuperAdminUserSkipsLookup(t *testing.T) {
service := NewSystemDefaultsService()
if err := service.RequireSuperAdminRoleForUser(2, nil); err != nil {
t.Fatalf("expected non-super-admin user to skip protected role validation, got %v", err)
}
}
================================================
FILE: internal/service/access/transaction.go
================================================
package access
import "gorm.io/gorm"
// RunInTransaction 统一执行事务。
func RunInTransaction(db *gorm.DB, fn func(tx *gorm.DB) error) error {
return db.Transaction(fn)
}
================================================
FILE: internal/service/access/user_permission_sync.go
================================================
package access
import (
"gorm.io/gorm"
casbinx "github.com/wannanbigpig/gin-layout/internal/access/casbin"
"github.com/wannanbigpig/gin-layout/internal/model"
)
// UserPermissionSyncService 负责把数据库关系展开为用户最终接口权限。
type UserPermissionSyncService struct {
// reloadPolicyFn 刷新 Casbin 内存策略缓存。
reloadPolicyFn func() error
}
// UserPermissionSyncServiceDeps 描述 UserPermissionSyncService 可注入依赖。
type UserPermissionSyncServiceDeps struct {
// ReloadPolicy 自定义策略刷新实现(测试场景常用)。
ReloadPolicy func() error
}
// NewUserPermissionSyncService 创建用户权限同步服务实例。
func NewUserPermissionSyncService() *UserPermissionSyncService {
return NewUserPermissionSyncServiceWithDeps(UserPermissionSyncServiceDeps{})
}
// NewUserPermissionSyncServiceWithDeps 创建带依赖注入的用户权限同步服务实例。
func NewUserPermissionSyncServiceWithDeps(deps UserPermissionSyncServiceDeps) *UserPermissionSyncService {
s := &UserPermissionSyncService{
reloadPolicyFn: deps.ReloadPolicy,
}
if s.reloadPolicyFn == nil {
s.reloadPolicyFn = defaultReloadPolicy
}
return s
}
// ReloadPolicyCache 刷新共享 Casbin Enforcer 策略缓存。
func (s *UserPermissionSyncService) ReloadPolicyCache() error {
return s.reloadPolicyFn()
}
// SyncUser 重建单个用户的最终接口权限并同步到 Casbin。
func (s *UserPermissionSyncService) SyncUser(userID uint, tx ...*gorm.DB) error {
return s.withSyncTransaction(tx, func(execTx *gorm.DB) error { return s.syncUserWithTx(userID, execTx) })
}
// SyncUsers 重建多个用户的最终接口权限并同步到 Casbin。
func (s *UserPermissionSyncService) SyncUsers(userIDs []uint, tx ...*gorm.DB) error {
return s.withSyncTransaction(tx, func(execTx *gorm.DB) error {
enforcer, err := getPolicyEnforcer()
if err != nil {
return err
}
return s.batchSyncUsersWithEnforcer(userIDs, enforcer, execTx)
})
}
// SyncAllUsers 重建全部用户的最终接口权限并同步到 Casbin。
func (s *UserPermissionSyncService) SyncAllUsers(tx ...*gorm.DB) error {
return s.withSyncTransaction(tx, func(execTx *gorm.DB) error {
userIDs, err := s.allUserIDs(execTx)
if err != nil {
return err
}
enforcer, err := getPolicyEnforcer()
if err != nil {
return err
}
return s.batchSyncUsersWithEnforcer(userIDs, enforcer, execTx)
})
}
// ClearUser 清理单个用户在 Casbin 中的最终接口权限。
func (s *UserPermissionSyncService) ClearUser(userID uint, tx ...*gorm.DB) error {
return s.withSyncTransaction(tx, func(execTx *gorm.DB) error {
enforcer, err := getPolicyEnforcer()
if err != nil {
return err
}
return enforcer.SetDB(execTx).EditPolicyPermissions(s.UserKey(userID), nil)
})
}
// AccessibleMenuIDs 返回用户可访问的菜单 ID 列表。
// 当 includeParents 为 true 时,会补齐菜单树展示所需的父级目录。
func (s *UserPermissionSyncService) AccessibleMenuIDs(userID uint, includeParents bool, tx ...*gorm.DB) ([]uint, error) {
roleIDs, err := s.userRoleIDs(userID, tx...)
if err != nil {
return nil, err
}
menuIDs, err := s.RoleMenuIDs(roleIDs, tx...)
if err != nil {
return nil, err
}
if includeParents {
return s.expandMenuIDsWithParents(menuIDs, tx...)
}
return menuIDs, nil
}
// withSyncTransaction 使用现有事务或新事务执行权限同步,确保写入原子性。
// 如果有现有事务则复用,否则创建新事务并在完成后刷新 Casbin 策略。
func (s *UserPermissionSyncService) withSyncTransaction(tx []*gorm.DB, fn func(execTx *gorm.DB) error) error {
if existingTx := FirstTx(tx); existingTx != nil {
return fn(existingTx)
}
db, err := model.NewAdminUsers().GetDB()
if err != nil {
return err
}
if err := db.Transaction(fn); err != nil {
return err
}
return s.ReloadPolicyCache()
}
// forEachUser 遍历用户 ID 列表并执行回调函数,遇到错误立即返回。
func (s *UserPermissionSyncService) forEachUser(userIDs []uint, fn func(userID uint) error) error {
uniqueIDs := UniqueUintSlice(userIDs)
if len(uniqueIDs) == 0 {
return nil
}
for _, userID := range uniqueIDs {
if err := fn(userID); err != nil {
return err
}
}
return nil
}
// batchSyncUsersWithEnforcer 批量同步多个用户的权限,使用同一个enforcer减少重复获取
// batchSyncUsersWithEnforcer 批量同步多个用户的权限到 Casbin,使用同一个 enforcer 减少重复开销。
// 参数:
// - userIDs: 用户 ID 列表
// - enforcer: Casbin Enforcer 实例
// - tx: 事务实例
func (s *UserPermissionSyncService) batchSyncUsersWithEnforcer(userIDs []uint, enforcer *casbinx.CasbinEnforcer, tx *gorm.DB) error {
uniqueIDs := UniqueUintSlice(userIDs)
if len(uniqueIDs) == 0 {
return nil
}
// 收集所有用户的权限策略
policiesByUser, err := s.collectPoliciesForUsers(uniqueIDs, tx)
if err != nil {
return err
}
// 构建 subject -> policies 映射
subjectPolicies := make(map[string][][]string, len(uniqueIDs))
for _, userID := range uniqueIDs {
subjectPolicies[s.UserKey(userID)] = policiesByUser[userID]
}
return enforcer.EditPolicyPermissionsBatch(subjectPolicies, tx)
}
// syncUserWithTx 在指定事务内同步单个用户的最终接口权限到 Casbin。
func (s *UserPermissionSyncService) syncUserWithTx(userID uint, tx *gorm.DB) error {
enforcer, err := getPolicyEnforcer()
if err != nil {
return err
}
// 收集用户的所有权限策略
policies, err := s.collectUserPolicies(userID, tx)
if err != nil {
return err
}
return enforcer.SetDB(tx).EditPolicyPermissions(s.UserKey(userID), policies)
}
// UniqueUintSlice 对 uint 切片去重并保留首次出现顺序。
func UniqueUintSlice(values []uint) []uint {
if len(values) == 0 {
return nil
}
set := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
result = append(result, value)
}
return result
}
================================================
FILE: internal/service/access/user_permission_sync_bench_test.go
================================================
package access
import "testing"
func BenchmarkBatchPermissionSync(b *testing.B) {
roleIDs := []uint{11, 12, 13, 14}
roleMenuMap := roleMenuIDMap{
11: {101, 102, 103},
12: {102, 104, 105},
13: {106, 107, 108},
14: {101, 108, 109},
}
enabledMenus := map[uint]struct{}{
101: {}, 102: {}, 103: {}, 104: {}, 105: {}, 106: {}, 107: {}, 108: {}, 109: {},
}
menuPolicies := map[uint][][]string{
101: {{"/admin/v1/user/list", "GET"}},
102: {{"/admin/v1/user/create", "POST"}},
103: {{"/admin/v1/user/update", "PUT"}},
104: {{"/admin/v1/role/list", "GET"}},
105: {{"/admin/v1/role/bind", "POST"}},
106: {{"/admin/v1/dept/list", "GET"}},
107: {{"/admin/v1/dept/update", "PUT"}},
108: {{"/admin/v1/menu/list", "GET"}},
109: {{"/admin/v1/menu/update", "PUT"}},
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
policies := buildUserPolicies(roleIDs, roleMenuMap, enabledMenus, menuPolicies)
if len(policies) == 0 {
b.Fatal("expected policies")
}
}
}
================================================
FILE: internal/service/access/user_permission_sync_test.go
================================================
package access
import (
"errors"
"reflect"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestUserPermissionSyncForEachUserDeduplicatesInOrder(t *testing.T) {
service := NewUserPermissionSyncService()
var visited []uint
err := service.forEachUser([]uint{2, 5, 2, 0, 5}, func(userID uint) error {
visited = append(visited, userID)
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []uint{2, 5, 0}
if !reflect.DeepEqual(visited, want) {
t.Fatalf("unexpected visit order: got %v want %v", visited, want)
}
}
func TestUserPermissionSyncForEachUserStopsOnError(t *testing.T) {
service := NewUserPermissionSyncService()
wantErr := errors.New("stop")
err := service.forEachUser([]uint{1, 2, 3}, func(userID uint) error {
if userID == 2 {
return wantErr
}
return nil
})
if !errors.Is(err, wantErr) {
t.Fatalf("expected %v, got %v", wantErr, err)
}
}
func TestExpandRoleAncestorsSkipsDisabledRoles(t *testing.T) {
roleMap := map[uint]roleStatusInfo{
1: {ID: 1, Status: 1},
2: {ID: 2, Pids: "1", Status: 1},
3: {ID: 3, Pids: "1,2", Status: 0},
}
got := expandRoleAncestors([]uint{2, 3}, roleMap)
want := []uint{2, 1}
if !reflect.DeepEqual(got, want) && !reflect.DeepEqual(got, []uint{1, 2}) {
t.Fatalf("unexpected expanded roles: got %v want %v", got, want)
}
}
func TestPermissionSyncCoordinatorRunAfterCommitReloadsOnce(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
reloadCount := 0
coordinator := NewPermissionSyncCoordinatorWithDeps(PermissionSyncCoordinatorDeps{
Syncer: NewUserPermissionSyncServiceWithDeps(UserPermissionSyncServiceDeps{
ReloadPolicy: func() error {
reloadCount++
return nil
},
}),
})
err = coordinator.RunAfterCommit(db, "reload failed", func(tx *gorm.DB) error {
if tx == nil {
t.Fatal("expected transaction")
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reloadCount != 1 {
t.Fatalf("expected reload once, got %d", reloadCount)
}
}
================================================
FILE: internal/service/admin/admin_user.go
================================================
package admin
import (
"errors"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/service/auth"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// AdminUserService 授权服务。
type AdminUserService struct {
service.Base
}
const (
menuQuerySuperAdmin = "status = ?"
menuQueryNoAuth = "status = ? AND is_auth = ?"
menuQueryWithAuth = "status = ? AND (is_auth = ? OR (is_auth = ? AND id IN (?)))"
)
// NewAdminUserService 创建管理员用户服务实例。
func NewAdminUserService() *AdminUserService {
return &AdminUserService{}
}
func (s *AdminUserService) handleMutationError(err error, fallbackCode int) error {
if err == nil {
return nil
}
var businessErr *e.BusinessError
if errors.As(err, &businessErr) {
return businessErr
}
return e.NewBusinessError(fallbackCode)
}
func (s *AdminUserService) revokeUserTokens(tx *gorm.DB, userID uint, revokedCode uint8, revokedReason string) {
loginService := auth.NewLoginService()
if err := loginService.RevokeUserTokens(userID, revokedCode, revokedReason, tx); err != nil {
log.Logger.Error("撤销用户token失败", zap.Error(err), zap.Uint("user_id", userID))
}
}
type userTokenRevocation struct {
userID uint
revokedCode uint8
revokedReason string
}
func (s *AdminUserService) revokeUserTokensAfterCommit(items []userTokenRevocation) {
for _, item := range items {
s.revokeUserTokens(nil, item.userID, item.revokedCode, item.revokedReason)
}
}
// GetUserInfo 获取用户信息。
func (s *AdminUserService) GetUserInfo(id uint) (*resources.AdminUserResources, error) {
adminUsersModel := model.NewAdminUsers()
err := adminUsersModel.GetByIdWithPreload(id, "RoleList", "Department")
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, e.NewBusinessError(e.UserDoesNotExist)
}
return nil, err
}
return resources.NewAdminUserTransformer().ToStruct(adminUsersModel), nil
}
// GetUserMenuInfo 获取用户权限信息。
func (s *AdminUserService) GetUserMenuInfo(id uint, locale string) (any, error) {
adminUsersModel := model.NewAdminUsers()
err := adminUsersModel.GetById(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, e.NewBusinessError(e.UserDoesNotExist)
}
return nil, err
}
condition, args := s.userMenuQuery(id == global.SuperAdminId, nil)
if id != global.SuperAdminId {
menuIDs, err := access.NewPermissionSyncCoordinator().AccessibleMenuIDs(id, true)
if err != nil {
return nil, err
}
condition, args = s.userMenuQuery(false, menuIDs)
}
menus, err := model.ListE(model.NewMenu(), condition, args, model.ListOptionalParams{
OrderBy: "sort desc, id desc",
})
if err != nil {
return nil, err
}
menuIDs := make([]uint, 0, len(menus))
for _, menu := range menus {
if menu == nil {
continue
}
menuIDs = append(menuIDs, menu.ID)
}
titleMap, err := model.NewMenuI18n().LocalizedTitleMapByMenuIDs(menuIDs, []string{
i18n.NormalizeLocale(locale),
i18n.LocaleZhCN,
i18n.LocaleEnUS,
})
if err != nil {
return nil, err
}
return resources.BuildMenuTree(menus, 0, titleMap), nil
}
// List 返回管理员分页列表。
func (s *AdminUserService) List(params *form.AdminUserList) *resources.Collection {
conditionStr, args := s.buildListCondition(params)
adminUserModel := model.NewAdminUsers()
total, collection, err := model.ListPageE(adminUserModel, params.Page, params.PerPage, conditionStr, args, s.adminUserListOptions())
if err != nil {
log.Logger.Error("查询管理员列表失败", zap.Error(err))
return resources.NewAdminUserTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
return resources.NewAdminUserTransformer().ToCollection(params.Page, params.PerPage, total, collection)
}
// adminUserListOptions 返回管理员列表查询选项。
// 列表页仅使用部门 id/name/pid,避免 Preload 全字段带来额外 IO。
func (s *AdminUserService) adminUserListOptions() model.ListOptionalParams {
return model.ListOptionalParams{
OrderBy: "created_at desc, id desc",
Preload: map[string]func(db *gorm.DB) *gorm.DB{
"Department": func(db *gorm.DB) *gorm.DB {
return db.Select("id", "name", "pid")
},
},
}
}
// buildListCondition 构建管理员列表查询条件。
func (s *AdminUserService) buildListCondition(params *form.AdminUserList) (string, []any) {
qb := query_builder.New().
AddLike("username", params.UserName).
AddEq("id", zeroToNil(params.ID)).
AddLike("nickname", params.NickName).
AddLike("email", params.Email).
AddLike("full_phone_number", params.PhoneNumber).
AddEq("status", params.Status)
// 部门筛选:使用 EXISTS 子查询关联用户 - 部门映射表
if params.DeptId > 0 {
qb.AddCondition(
"EXISTS (SELECT 1 FROM admin_user_department_map WHERE admin_user_department_map.uid = admin_user.id AND admin_user_department_map.dept_id = ?)",
params.DeptId,
)
}
return qb.Build()
}
// zeroToNil 将 0 转换为 nil,用于查询条件构建时排除空值筛选。
func zeroToNil(value uint) any {
if value == 0 {
return nil
}
return value
}
// userMenuQuery 构建用户菜单查询条件。
// 参数:
// - isSuperAdmin: 是否为超级管理员
// - menuIDs: 用户可访问的菜单 ID 列表
//
// 返回:查询条件和参数
func (s *AdminUserService) userMenuQuery(isSuperAdmin bool, menuIDs []uint) (string, []any) {
if isSuperAdmin {
return menuQuerySuperAdmin, []any{1}
}
if len(menuIDs) == 0 {
return menuQueryNoAuth, []any{1, 0}
}
return menuQueryWithAuth, []any{1, 0, 1, menuIDs}
}
// adminUserEditParams 管理员用户编辑参数,字段使用指针支持部分更新。
type adminUserEditParams struct {
Id uint // 用户 ID,0 表示新增
Username *string // 用户名
Nickname *string // 昵称
Password *string // 密码
PhoneNumber *string // 手机号
CountryCode *string // 国家代码
Email *string // 邮箱
Status *uint8 // 状态
Avatar *string // 头像
DeptIds *[]uint // 关联的部门 ID 列表
}
================================================
FILE: internal/service/admin/admin_user_bind.go
================================================
package admin
import (
"fmt"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// BindDept 绑定部门。
func (s *AdminUserService) BindDept(uid uint, deptId []uint, tx ...*gorm.DB) (err error) {
var dbTx *gorm.DB
if len(tx) > 0 {
dbTx = tx[0]
} else {
dbTx, err = model.NewAdminUserDeptMap().GetDB()
if err != nil {
return err
}
}
adminUserDeptMap := model.NewAdminUserDeptMap()
adminUserDeptMap.SetDB(dbTx)
existingIds, err := model.ExtractColumnsByCondition[model.AdminUserDeptMap, *model.AdminUserDeptMap, uint](adminUserDeptMap, "dept_id", "uid = ?", uid)
if err != nil {
return err
}
toDelete, toAdd, _ := utils.CalculateChanges(existingIds, deptId)
if len(toDelete) > 0 {
if err := adminUserDeptMap.DeleteWhere("uid = ? AND dept_id IN (?)", []any{uid, toDelete}...); err != nil {
return err
}
if err := s.updateDeptUserNumber(toDelete, -1, dbTx); err != nil {
return err
}
}
if len(toAdd) > 0 {
newMappings := lo.Map(toAdd, func(deptId uint, _ int) *model.AdminUserDeptMap {
return &model.AdminUserDeptMap{DeptId: deptId, Uid: uid}
})
if err := adminUserDeptMap.CreateBatch(newMappings); err != nil {
return err
}
if err := s.updateDeptUserNumber(toAdd, 1, dbTx); err != nil {
return err
}
}
return access.NewPermissionSyncCoordinator().SyncUser(uid, tx...)
}
func (s *AdminUserService) updateDeptUserNumber(deptIds []uint, delta int, tx *gorm.DB) error {
if len(deptIds) == 0 {
return nil
}
deptModel := model.NewDepartment()
deptModel.SetDB(tx)
var updateExpr string
if delta < 0 {
updateExpr = fmt.Sprintf("GREATEST(user_number + %d, 0)", delta)
} else {
updateExpr = fmt.Sprintf("user_number + %d", delta)
}
return deptModel.UpdateUserNumberByIds(deptIds, updateExpr)
}
// BindRole 绑定角色。
func (s *AdminUserService) BindRole(params *form.BindRole) error {
adminUserModel := model.NewAdminUsers()
err := adminUserModel.GetById(params.UserId)
if err != nil {
return e.NewBusinessError(e.UserDoesNotExist)
}
ids, err := model.VerifyExistingIDs(model.NewRole(), params.RoleIds)
if err != nil {
return e.NewBusinessError(e.RoleNotFound)
}
if err := access.NewSystemDefaultsService().RequireSuperAdminRoleForUser(adminUserModel.ID, ids); err != nil {
return err
}
db, err := model.NewAdminUserRoleMap().GetDB()
if err != nil {
return e.NewBusinessError(e.UpdateUserFailed)
}
err = access.NewPermissionSyncCoordinator().RunAfterCommitWithCode(db, e.UpdateUserFailed, func(tx *gorm.DB) error {
return s.updateAdminUserRole(adminUserModel.ID, ids, tx)
})
if err != nil {
return e.NewBusinessError(e.UpdateUserFailed)
}
return nil
}
func (s *AdminUserService) updateAdminUserRole(uid uint, roleIds []uint, tx ...*gorm.DB) error {
if err := access.NewSystemDefaultsService().RequireSuperAdminRoleForUser(uid, roleIds); err != nil {
return err
}
adminUserRoleMap := model.NewAdminUserRoleMap()
if len(tx) > 0 {
adminUserRoleMap.SetDB(tx[0])
}
existingIds, err := model.ExtractColumnsByCondition[model.AdminUserRoleMap, *model.AdminUserRoleMap, uint](adminUserRoleMap, "role_id", "uid = ?", uid)
if err != nil {
return err
}
toDelete, toAdd, _ := utils.CalculateChanges(existingIds, roleIds)
if len(toDelete) > 0 {
if err := adminUserRoleMap.DeleteWhere("uid = ? AND role_id IN (?)", []any{uid, toDelete}...); err != nil {
return err
}
}
if len(toAdd) > 0 {
newMappings := lo.Map(toAdd, func(roleId uint, _ int) *model.AdminUserRoleMap {
return &model.AdminUserRoleMap{RoleId: roleId, Uid: uid}
})
if err := adminUserRoleMap.CreateBatch(newMappings); err != nil {
return err
}
}
return access.NewPermissionSyncCoordinator().SyncUser(uid, tx...)
}
================================================
FILE: internal/service/admin/admin_user_create_test.go
================================================
package admin
import (
"errors"
"testing"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestAdminUserCreateRequiresUsername(t *testing.T) {
service := NewAdminUserService()
nickname := "nick"
params := form.NewCreateAdminUser()
params.Nickname = &nickname
err := service.Create(params)
assertBusinessErrorMessage(t, err, e.UsernameRequired, "用户名必填")
}
func TestAdminUserCreateRequiresNickname(t *testing.T) {
service := NewAdminUserService()
username := "admin"
password := "123456"
params := form.NewCreateAdminUser()
params.Username = &username
params.Password = &password
err := service.Create(params)
assertBusinessErrorMessage(t, err, e.NicknameRequired, "昵称必填")
}
func TestAdminUserCreateRequiresPassword(t *testing.T) {
service := NewAdminUserService()
username := "admin"
nickname := "nick"
params := form.NewCreateAdminUser()
params.Username = &username
params.Nickname = &nickname
err := service.Create(params)
assertBusinessErrorMessage(t, err, e.PasswordRequired, "密码必填")
}
func assertBusinessErrorMessage(t *testing.T, err error, code int, message string) {
t.Helper()
var businessErr *e.BusinessError
if !errors.As(err, &businessErr) {
t.Fatalf("expected business error, got %v", err)
}
if businessErr.GetCode() != code {
t.Fatalf("expected code %d, got %d", code, businessErr.GetCode())
}
if businessErr.GetMessage() != message {
t.Fatalf("expected message %q, got %q", message, businessErr.GetMessage())
}
}
================================================
FILE: internal/service/admin/admin_user_mutation.go
================================================
package admin
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
utils2 "github.com/wannanbigpig/gin-layout/pkg/utils"
)
// Create 新增管理员用户。
func (s *AdminUserService) Create(params *form.CreateAdminUser) error {
return s.saveAdminUserMutation(&adminUserEditParams{
Username: params.Username,
Nickname: params.Nickname,
Password: params.Password,
PhoneNumber: params.PhoneNumber,
CountryCode: params.CountryCode,
Email: params.Email,
Status: params.Status,
Avatar: params.Avatar,
DeptIds: params.DeptIds,
})
}
// Update 更新管理员用户。
func (s *AdminUserService) Update(params *form.UpdateAdminUser) error {
return s.saveAdminUserMutation(&adminUserEditParams{
Id: params.Id,
Username: params.Username,
Nickname: params.Nickname,
Password: params.Password,
PhoneNumber: params.PhoneNumber,
CountryCode: params.CountryCode,
Email: params.Email,
Status: params.Status,
Avatar: params.Avatar,
DeptIds: params.DeptIds,
})
}
// saveAdminUserMutation 执行管理员用户变更操作(新增/更新)。
// 处理逻辑:
// 1. 验证用户是否存在(更新时)
// 2. 应用字段变更(创建/更新场景分别处理)
// 3. 验证唯一字段(用户名、手机号、邮箱)
// 4. 事务保存:用户数据、Token 撤销(密码变更/禁用时)、部门绑定
// 5. 同步用户权限缓存
func (s *AdminUserService) saveAdminUserMutation(params *adminUserEditParams) error {
mutationFailedCode := e.CreateUserFailed
excludeID := uint(0)
oldStatus := uint8(0)
adminUserModel := model.NewAdminUsers()
if params.Id > 0 {
mutationFailedCode = e.UpdateUserFailed
if err := adminUserModel.GetById(params.Id); err != nil {
return e.NewBusinessError(e.UserDoesNotExist)
}
excludeID = params.Id
oldStatus = adminUserModel.Status
}
oldAvatar := adminUserModel.Avatar
var err error
if params.Id > 0 {
err = s.applyUpdateFields(adminUserModel, params)
} else {
err = s.applyCreateFields(adminUserModel, params)
}
if err != nil {
return err
}
db, err := adminUserModel.GetDB()
if err != nil {
return e.NewBusinessError(mutationFailedCode)
}
pendingRevocations := make([]userTokenRevocation, 0, 2)
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
adminUserModel.SetDB(tx)
validateParams := map[string]interface{}{
"username": params.Username,
"email": params.Email,
}
if (params.PhoneNumber != nil || params.CountryCode != nil) && adminUserModel.PhoneNumber != "" {
validateParams["full_phone_number"] = adminUserModel.FullPhoneNumber
}
if err := s.validateUniqueFieldsWithLock(tx, validateParams, excludeID); err != nil {
return err
}
if err := adminUserModel.Save(); err != nil {
return err
}
if params.Avatar != nil {
refService := service.NewFileReferenceService(tx)
if oldAvatar != "" && oldAvatar != adminUserModel.Avatar {
if err := refService.ReleaseReference(oldAvatar, "admin_user", adminUserModel.ID, "avatar"); err != nil {
return err
}
}
if adminUserModel.Avatar != "" {
if err := refService.BindReference(adminUserModel.Avatar, "admin_user", adminUserModel.ID, "avatar"); err != nil {
return err
}
}
}
if params.Id > 0 && params.Status != nil &&
oldStatus == model.AdminUserStatusEnabled &&
*params.Status == model.AdminUserStatusDisabled {
pendingRevocations = append(pendingRevocations, userTokenRevocation{
userID: adminUserModel.ID,
revokedCode: model.RevokedCodeSystemForce,
revokedReason: "系统强制登出(账号被封)",
})
}
if params.Id > 0 && params.Password != nil && *params.Password != "" {
pendingRevocations = append(pendingRevocations, userTokenRevocation{
userID: adminUserModel.ID,
revokedCode: model.RevokedCodePasswordChangeAdmin,
revokedReason: "管理员修改密码",
})
}
if params.DeptIds != nil {
deptIDs := access.UniqueUintSlice(*params.DeptIds)
return s.BindDept(adminUserModel.ID, deptIDs, tx)
}
return access.NewPermissionSyncCoordinator().SyncUser(adminUserModel.ID, tx)
})
if err := s.handleMutationError(err, mutationFailedCode); err != nil {
return err
}
s.revokeUserTokensAfterCommit(pendingRevocations)
return access.NewPermissionSyncCoordinator().ReloadPolicyCacheWithCode(mutationFailedCode)
}
// applyUpdateFields 应用更新场景的字段变更。
// 只更新非 nil 指针字段,支持部分更新语义。
// 特殊处理:
// - 超级管理员不可修改密码
// - 密码相同时拒绝更新
// - 超级管理员不可禁用
func (s *AdminUserService) applyUpdateFields(adminUserModel *model.AdminUser, params *adminUserEditParams) error {
if params.Username != nil {
adminUserModel.Username = *params.Username
}
if params.Nickname != nil {
adminUserModel.Nickname = *params.Nickname
}
if params.Password != nil && *params.Password != "" {
if adminUserModel.ID == global.SuperAdminId {
return e.NewBusinessError(e.SuperAdminCannotModify)
}
if utils2.ComparePasswords(adminUserModel.Password, *params.Password) {
return e.NewBusinessError(e.SamePassword)
}
if err := setHashedPassword(adminUserModel, *params.Password); err != nil {
return err
}
}
if params.PhoneNumber != nil {
adminUserModel.PhoneNumber = *params.PhoneNumber
}
if params.CountryCode != nil {
adminUserModel.CountryCode = *params.CountryCode
} else if params.PhoneNumber != nil {
adminUserModel.CountryCode = global.ChinaCountryCode
}
if params.Email != nil {
adminUserModel.Email = *params.Email
}
if params.Status != nil {
if *params.Status == model.AdminUserStatusDisabled && adminUserModel.ID == global.SuperAdminId {
return e.NewBusinessError(e.SuperAdminCannotDisable)
}
adminUserModel.Status = *params.Status
}
if params.Avatar != nil {
adminUserModel.Avatar = *params.Avatar
}
if params.PhoneNumber != nil || params.CountryCode != nil {
if adminUserModel.PhoneNumber == "" {
adminUserModel.FullPhoneNumber = ""
} else {
adminUserModel.FullPhoneNumber = adminUserModel.CountryCode + adminUserModel.PhoneNumber
}
}
return nil
}
// applyCreateFields 应用新增场景的字段填充。
// 必填字段:用户名、昵称、密码
// 默认值:国家代码默认为中国
func (s *AdminUserService) applyCreateFields(adminUserModel *model.AdminUser, params *adminUserEditParams) error {
if params.Username == nil || *params.Username == "" {
return e.NewBusinessError(e.UsernameRequired)
}
if params.Nickname == nil || *params.Nickname == "" {
return e.NewBusinessError(e.NicknameRequired)
}
if params.Password == nil || *params.Password == "" {
return e.NewBusinessError(e.PasswordRequired)
}
adminUserModel.Username = *params.Username
adminUserModel.Nickname = *params.Nickname
if params.PhoneNumber != nil {
adminUserModel.PhoneNumber = *params.PhoneNumber
}
if params.CountryCode != nil {
adminUserModel.CountryCode = *params.CountryCode
} else {
adminUserModel.CountryCode = global.ChinaCountryCode
}
if params.Email != nil {
adminUserModel.Email = *params.Email
}
if err := setHashedPassword(adminUserModel, *params.Password); err != nil {
return err
}
if params.Avatar != nil {
adminUserModel.Avatar = *params.Avatar
}
if params.Status != nil {
adminUserModel.Status = *params.Status
}
if adminUserModel.PhoneNumber != "" {
adminUserModel.FullPhoneNumber = adminUserModel.CountryCode + adminUserModel.PhoneNumber
}
return nil
}
// setHashedPassword 对用户密码进行哈希处理后设置到模型。
func setHashedPassword(adminUserModel *model.AdminUser, plainPassword string) error {
passwordHash, err := utils2.PasswordHash(plainPassword)
if err != nil {
return e.NewBusinessError(e.PasswordProcessFailed)
}
adminUserModel.Password = passwordHash
return nil
}
// UpdateProfile 更新个人资料。
func (s *AdminUserService) UpdateProfile(uid uint, params *form.UpdateProfile) error {
adminUserModel := model.NewAdminUsers()
err := adminUserModel.GetById(uid)
if err != nil {
if err == gorm.ErrRecordNotFound {
return e.NewBusinessError(e.UserDoesNotExist)
}
return err
}
oldAvatar := adminUserModel.Avatar
passwordChanged := params.Password != nil && *params.Password != ""
hasUpdate := params.Nickname != nil ||
passwordChanged ||
params.PhoneNumber != nil ||
params.CountryCode != nil ||
params.Email != nil ||
params.Avatar != nil
if !hasUpdate {
return nil
}
editParams := &adminUserEditParams{
Id: uid,
Nickname: params.Nickname,
Password: params.Password,
PhoneNumber: params.PhoneNumber,
CountryCode: params.CountryCode,
Email: params.Email,
Avatar: params.Avatar,
}
if err := s.applyUpdateFields(adminUserModel, editParams); err != nil {
return err
}
db, err := adminUserModel.GetDB()
if err != nil {
return e.NewBusinessError(e.UpdateUserFailed)
}
pendingRevocations := make([]userTokenRevocation, 0, 1)
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
adminUserModel.SetDB(tx)
validateParams := map[string]interface{}{
"email": params.Email,
}
if (params.PhoneNumber != nil || params.CountryCode != nil) && adminUserModel.PhoneNumber != "" {
validateParams["full_phone_number"] = adminUserModel.FullPhoneNumber
}
if err := s.validateUniqueFieldsWithLock(tx, validateParams, uid); err != nil {
return err
}
if err := adminUserModel.Save(); err != nil {
return err
}
if params.Avatar != nil {
refService := service.NewFileReferenceService(tx)
if oldAvatar != "" && oldAvatar != adminUserModel.Avatar {
if err := refService.ReleaseReference(oldAvatar, "admin_user", uid, "avatar"); err != nil {
return err
}
}
if adminUserModel.Avatar != "" {
if err := refService.BindReference(adminUserModel.Avatar, "admin_user", uid, "avatar"); err != nil {
return err
}
}
}
if passwordChanged {
pendingRevocations = append(pendingRevocations, userTokenRevocation{
userID: uid,
revokedCode: model.RevokedCodePasswordChangeSelf,
revokedReason: "用户自己修改密码",
})
}
return nil
})
if err := s.handleMutationError(err, e.UpdateUserFailed); err != nil {
return err
}
s.revokeUserTokensAfterCommit(pendingRevocations)
return nil
}
// validateUniqueFieldsWithLock 验证唯一字段(用户名、手机号、邮箱),使用数据库锁防止并发冲突。
// 参数:
// - tx: 事务实例
// - params: 待验证的字段值 map
// - excludeId: 排除的当前用户 ID(更新场景)
func (s *AdminUserService) validateUniqueFieldsWithLock(tx *gorm.DB, params map[string]interface{}, excludeId uint) error {
checkModel := model.NewAdminUsers()
checkModel.SetDB(tx)
// 验证用户名唯一性
if usernameVal, ok := params["username"]; ok {
if username, ok := usernameVal.(*string); ok && username != nil && *username != "" {
exists, err := checkModel.ExistsWithLockExcludeId("username", *username, excludeId)
if err != nil {
return err
}
if exists {
return e.NewBusinessError(e.UserExists)
}
}
}
// 验证手机号唯一性(使用模型最终的完整手机号,覆盖仅修改国家代码的场景)。
if fullPhoneNumberVal, ok := params["full_phone_number"]; ok {
if fullPhoneNumber, ok := fullPhoneNumberVal.(string); ok && fullPhoneNumber != "" {
exists, err := checkModel.ExistsWithLockExcludeId("full_phone_number", fullPhoneNumber, excludeId)
if err != nil {
return err
}
if exists {
return e.NewBusinessError(e.PhoneNumberExists)
}
}
}
// 验证邮箱唯一性
if emailVal, ok := params["email"]; ok {
if email, ok := emailVal.(*string); ok && email != nil && *email != "" {
exists, err := checkModel.ExistsWithLockExcludeId("email", *email, excludeId)
if err != nil {
return err
}
if exists {
return e.NewBusinessError(e.EmailExists)
}
}
}
return nil
}
// Delete 删除用户。
func (s *AdminUserService) Delete(id uint) error {
if id == global.SuperAdminId {
return e.NewBusinessError(e.SuperAdminCannotDelete)
}
adminUserModel := model.NewAdminUsers()
adminUserDeptMap := model.NewAdminUserDeptMap()
deptIds, err := model.ExtractColumnsByCondition[model.AdminUserDeptMap, *model.AdminUserDeptMap, uint](adminUserDeptMap, "dept_id", "uid = ?", id)
if err != nil {
return e.NewBusinessError(e.QueryUserDeptFailed)
}
db, err := adminUserModel.GetDB()
if err != nil {
return e.NewBusinessError(e.DeleteUserFailed)
}
pendingRevocations := []userTokenRevocation{{
userID: id,
revokedCode: model.RevokedCodeOther,
revokedReason: "管理员删除用户",
}}
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
adminUserModel.SetDB(tx)
if _, err := adminUserModel.DeleteByID(id); err != nil {
return err
}
if err := service.NewFileReferenceService(tx).ReleaseReferencesByOwner("admin_user", id, "avatar"); err != nil {
return err
}
adminUserDeptMap.SetDB(tx)
if err := adminUserDeptMap.DeleteWhere("uid = ?", id); err != nil {
return err
}
adminUserRoleMap := model.NewAdminUserRoleMap()
adminUserRoleMap.SetDB(tx)
if err := adminUserRoleMap.DeleteWhere("uid = ?", id); err != nil {
return err
}
if len(deptIds) > 0 {
if err := s.updateDeptUserNumber(deptIds, -1, tx); err != nil {
return err
}
}
return access.NewPermissionSyncCoordinator().ClearUser(id, tx)
})
if err != nil {
return e.NewBusinessError(e.DeleteUserFailed)
}
s.revokeUserTokensAfterCommit(pendingRevocations)
return access.NewPermissionSyncCoordinator().ReloadPolicyCacheWithCode(e.DeleteUserFailed)
}
================================================
FILE: internal/service/admin/admin_user_test.go
================================================
package admin
import (
"errors"
"strings"
"testing"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAdminUserBuildListCondition(t *testing.T) {
status := uint8(1)
params := &form.AdminUserList{
UserName: "root",
ID: 7,
NickName: "admin",
Email: "a@example.com",
PhoneNumber: "138",
Status: &status,
DeptId: 3,
}
condition, args := NewAdminUserService().buildListCondition(params)
expected := "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 = ?)"
if condition != expected {
t.Fatalf("unexpected condition: %s", condition)
}
if len(args) != 7 {
t.Fatalf("unexpected args len: %d", len(args))
}
}
func TestUniqueUintSlice(t *testing.T) {
menuIDs := access.UniqueUintSlice([]uint{2, 5, 2, 0, 5})
if len(menuIDs) != 3 {
t.Fatalf("unexpected menu id count: %#v", menuIDs)
}
if menuIDs[0] != 2 || menuIDs[1] != 5 || menuIDs[2] != 0 {
t.Fatalf("unexpected menu ids: %#v", menuIDs)
}
}
func TestUserPermissionSyncUserKey(t *testing.T) {
key := access.NewUserPermissionSyncService().UserKey(12)
if key != "adminUser:12" {
t.Fatalf("unexpected user key: %s", key)
}
}
func TestAdminUserMenuQuery(t *testing.T) {
service := NewAdminUserService()
condition, args := service.userMenuQuery(true, nil)
if condition != "status = ?" || len(args) != 1 {
t.Fatalf("unexpected super admin query: %s %#v", condition, args)
}
condition, args = service.userMenuQuery(false, nil)
if condition != "status = ? AND is_auth = ?" || len(args) != 2 {
t.Fatalf("unexpected anonymous menu query: %s %#v", condition, args)
}
condition, args = service.userMenuQuery(false, []uint{1, 2})
if condition != "status = ? AND (is_auth = ? OR (is_auth = ? AND id IN (?)))" || len(args) != 4 {
t.Fatalf("unexpected scoped menu query: %s %#v", condition, args)
}
}
func TestAdminUserHandleMutationErrorKeepsBusinessError(t *testing.T) {
service := NewAdminUserService()
businessErr := e.NewBusinessError(e.FAILURE, "business")
got := service.handleMutationError(businessErr, e.FAILURE)
if got != businessErr {
t.Fatalf("expected original business error, got %#v", got)
}
}
func TestAdminUserHandleMutationErrorWrapsPlainError(t *testing.T) {
service := NewAdminUserService()
err := service.handleMutationError(errors.New("plain"), e.CreateUserFailed)
assertBusinessErrorMessage(t, err, e.CreateUserFailed, "创建用户失败,请重试")
}
func TestAdminUserListOptionsDepartmentSelectFields(t *testing.T) {
service := NewAdminUserService()
options := service.adminUserListOptions()
scope, ok := options.Preload["Department"]
if !ok || scope == nil {
t.Fatal("expected Department preload scope")
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("failed to create dry-run db: %v", err)
}
scopedDB := scope(db)
selects := strings.Join(scopedDB.Statement.Selects, ",")
for _, field := range []string{"id", "name", "pid"} {
if !strings.Contains(selects, field) {
t.Fatalf("expected preload select to include %q, got %q", field, selects)
}
}
}
func TestApplyUpdateFieldsDefaultsCountryCodeWhenPhoneChanges(t *testing.T) {
service := NewAdminUserService()
phoneNumber := "13800138000"
adminUserModel := &model.AdminUser{
CountryCode: "+1",
}
err := service.applyUpdateFields(adminUserModel, &adminUserEditParams{
Id: 1,
PhoneNumber: &phoneNumber,
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if adminUserModel.CountryCode != global.ChinaCountryCode {
t.Fatalf("expected default country code %q, got %q", global.ChinaCountryCode, adminUserModel.CountryCode)
}
if adminUserModel.FullPhoneNumber != global.ChinaCountryCode+phoneNumber {
t.Fatalf("unexpected full phone number: %q", adminUserModel.FullPhoneNumber)
}
}
func TestApplyUpdateFieldsClearsFullPhoneWhenPhoneCleared(t *testing.T) {
service := NewAdminUserService()
phoneNumber := ""
adminUserModel := &model.AdminUser{
CountryCode: "+86",
PhoneNumber: "13800138000",
FullPhoneNumber: "+8613800138000",
}
err := service.applyUpdateFields(adminUserModel, &adminUserEditParams{
Id: 1,
PhoneNumber: &phoneNumber,
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if adminUserModel.PhoneNumber != "" {
t.Fatalf("expected phone number to be cleared, got %q", adminUserModel.PhoneNumber)
}
if adminUserModel.FullPhoneNumber != "" {
t.Fatalf("expected full phone number to be cleared, got %q", adminUserModel.FullPhoneNumber)
}
}
func TestApplyUpdateFieldsRejectsSuperAdminPasswordChange(t *testing.T) {
service := NewAdminUserService()
password := "new-password"
adminUserModel := &model.AdminUser{
ContainsDeleteBaseModel: model.ContainsDeleteBaseModel{
BaseModel: model.BaseModel{ID: global.SuperAdminId},
},
Password: "hashed-password",
}
err := service.applyUpdateFields(adminUserModel, &adminUserEditParams{
Id: global.SuperAdminId,
Password: &password,
})
assertBusinessErrorMessage(t, err, e.SuperAdminCannotModify, "系统默认超级管理员不允许修改密码")
}
================================================
FILE: internal/service/admin/audit_diff.go
================================================
package admin
import (
"sort"
"strings"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var adminUserDiffRules = []auditdiff.FieldRule{
{Field: "id", Label: "用户ID"},
{Field: "username", Label: "用户名"},
{Field: "nickname", Label: "昵称"},
{Field: "phone_number", Label: "手机号"},
{Field: "country_code", Label: "国家区号"},
{Field: "email", Label: "邮箱"},
{Field: "avatar", Label: "头像"},
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{
Field: "is_super_admin",
Label: "超级管理员",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{Field: "dept_ids", Label: "部门ID列表"},
{Field: "role_ids", Label: "角色ID列表"},
}
var adminUserRoleBindingDiffRules = []auditdiff.FieldRule{
{Field: "user_id", Label: "用户ID"},
{Field: "role_ids", Label: "角色ID列表"},
}
// CreateWithAuditDiff 新增管理员并返回精确 change_diff。
func (s *AdminUserService) CreateWithAuditDiff(params *form.CreateAdminUser) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
if err := s.Create(params); err != nil {
return "", err
}
username := ""
if params.Username != nil {
username = strings.TrimSpace(*params.Username)
}
if username == "" {
return auditdiff.Marshal(nil), nil
}
after, err := s.snapshotAdminUserByUsername(username)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildAdminUserDiff(nil, after), nil
}
// UpdateWithAuditDiff 更新管理员并返回精确 change_diff。
func (s *AdminUserService) UpdateWithAuditDiff(params *form.UpdateAdminUser) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotAdminUserByID(params.Id)
if err != nil {
return "", err
}
if err := s.Update(params); err != nil {
return "", err
}
after, err := s.snapshotAdminUserByID(params.Id)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildAdminUserDiff(before, after), nil
}
// DeleteWithAuditDiff 删除管理员并返回精确 change_diff。
func (s *AdminUserService) DeleteWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotAdminUserByID(id)
if err != nil {
return "", err
}
if err := s.Delete(id); err != nil {
return "", err
}
return buildAdminUserDiff(before, nil), nil
}
// UpdateProfileWithAuditDiff 更新个人资料并返回精确 change_diff。
func (s *AdminUserService) UpdateProfileWithAuditDiff(uid uint, params *form.UpdateProfile) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotAdminUserByID(uid)
if err != nil {
return "", err
}
if err := s.UpdateProfile(uid, params); err != nil {
return "", err
}
after, err := s.snapshotAdminUserByID(uid)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildAdminUserDiff(before, after), nil
}
// BindRoleWithAuditDiff 绑定角色并返回精确 change_diff。
func (s *AdminUserService) BindRoleWithAuditDiff(params *form.BindRole) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotAdminUserRoleBinding(params.UserId)
if err != nil {
return "", err
}
if err := s.BindRole(params); err != nil {
return "", err
}
after, err := s.snapshotAdminUserRoleBinding(params.UserId)
if err != nil {
return auditdiff.Marshal(nil), nil
}
items := auditdiff.BuildFieldDiff(before, after, adminUserRoleBindingDiffRules)
return auditdiff.Marshal(items), nil
}
func (s *AdminUserService) snapshotAdminUserByUsername(username string) (map[string]any, error) {
username = strings.TrimSpace(username)
if username == "" {
return nil, e.NewBusinessError(e.InvalidParameter)
}
user := model.NewAdminUsers()
if err := user.GetDetail("username = ? AND deleted_at = 0", username); err != nil || user.ID == 0 {
return nil, e.NewBusinessError(e.UserDoesNotExist)
}
return s.snapshotAdminUserByID(user.ID)
}
func (s *AdminUserService) snapshotAdminUserByID(id uint) (map[string]any, error) {
user := model.NewAdminUsers()
if err := user.GetById(id); err != nil || user.ID == 0 {
return nil, e.NewBusinessError(e.UserDoesNotExist)
}
deptIDs, err := model.NewAdminUserDeptMap().DeptIdsByUid(user.ID)
if err != nil {
return nil, err
}
roleIDs, err := model.NewAdminUserRoleMap().RoleIdsByUid(user.ID)
if err != nil {
return nil, err
}
sortUintSlice(deptIDs)
sortUintSlice(roleIDs)
return map[string]any{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"phone_number": user.PhoneNumber,
"country_code": user.CountryCode,
"email": user.Email,
"avatar": user.Avatar,
"status": user.Status,
"is_super_admin": user.IsSuperAdmin,
"dept_ids": deptIDs,
"role_ids": roleIDs,
}, nil
}
func (s *AdminUserService) snapshotAdminUserRoleBinding(userID uint) (map[string]any, error) {
user := model.NewAdminUsers()
if err := user.GetById(userID); err != nil || user.ID == 0 {
return nil, e.NewBusinessError(e.UserDoesNotExist)
}
roleIDs, err := model.NewAdminUserRoleMap().RoleIdsByUid(userID)
if err != nil {
return nil, err
}
sortUintSlice(roleIDs)
return map[string]any{
"user_id": userID,
"role_ids": roleIDs,
}, nil
}
func buildAdminUserDiff(before, after map[string]any) string {
items := auditdiff.BuildFieldDiff(before, after, adminUserDiffRules)
return auditdiff.Marshal(items)
}
func sortUintSlice(values []uint) {
if len(values) == 0 {
return
}
sort.Slice(values, func(i, j int) bool {
return values[i] < values[j]
})
}
================================================
FILE: internal/service/admin/audit_diff_test.go
================================================
package admin
import (
"encoding/json"
"testing"
)
func TestBuildAdminUserDiffIncludesStatusDisplay(t *testing.T) {
raw := buildAdminUserDiff(
map[string]any{
"id": uint(1),
"status": uint8(0),
"role_ids": []uint{1},
},
map[string]any{
"id": uint(1),
"status": uint8(1),
"role_ids": []uint{1, 2},
},
)
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
if len(items) != 2 {
t.Fatalf("expected 2 diff items, got %d", len(items))
}
for _, item := range items {
if item["field"] != "status" {
continue
}
if item["before_display"] != "禁用" || item["after_display"] != "启用" {
t.Fatalf("unexpected status display mapping: %#v", item)
}
return
}
t.Fatalf("expected status diff item, got %#v", items)
}
================================================
FILE: internal/service/api_permission/api.go
================================================
package api_permission
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// ApiService 处理 API 权限的维护与查询。
type ApiService struct {
service.Base
}
// NewApiService 创建 API 服务实例。
func NewApiService() *ApiService {
return &ApiService{}
}
// Update 更新 API 权限。
func (s *ApiService) Update(params *form.UpdatePermission) error {
apiModel := model.NewApi()
exists, err := apiModel.ExistsById(params.Id)
if err != nil {
return err
}
if !exists {
return e.NewBusinessError(e.NotFound)
}
data := map[string]any{
"name": params.Name,
"description": params.Description,
"is_auth": params.IsAuth,
"sort": params.Sort,
}
db, err := apiModel.GetDB()
if err != nil {
return err
}
if err := access.RunInTransaction(db, func(tx *gorm.DB) error {
apiModel.SetDB(tx)
return apiModel.UpdateById(params.Id, data)
}); err != nil {
return err
}
if err := access.NewApiRouteCacheService().RefreshCache(); err != nil {
return err
}
return access.NewPermissionSyncCoordinator().SyncUsersAffectedByAPIs([]uint{params.Id})
}
// ListPage 分页查询 API 权限列表。
func (s *ApiService) ListPage(params *form.ListPermission) *resources.Collection {
condition, args := s.buildListCondition(params)
apiModel := model.NewApi()
total, collection, err := model.ListPageE(
apiModel,
params.Page,
params.PerPage,
condition,
args,
model.ListOptionalParams{
OrderBy: "sort desc, id desc",
},
)
if err != nil {
return resources.NewApiTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
return resources.NewApiTransformer().ToCollection(params.Page, params.PerPage, total, collection)
}
// buildListCondition 构建 API 权限列表查询条件。
func (s *ApiService) buildListCondition(params *form.ListPermission) (string, []any) {
qb := query_builder.New()
if params.Keyword != "" {
qb.AddCondition("(name like ? OR route like ? OR code = ?)", "%"+params.Keyword+"%", "%"+params.Keyword+"%", params.Keyword)
}
qb.AddLike("name", params.Name).
AddEq("method", emptyToNil(params.Method)).
AddLike("route", params.Route).
AddEq("is_auth", params.IsAuth).
AddEq("is_effective", params.IsEffective)
return qb.Build()
}
func emptyToNil(value string) any {
if value == "" {
return nil
}
return value
}
================================================
FILE: internal/service/api_permission/api_test.go
================================================
package api_permission
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestApiBuildListCondition(t *testing.T) {
isAuth := int8(1)
isEffective := int8(0)
params := &form.ListPermission{
Name: "ping",
Method: "GET",
Route: "/ping",
Keyword: "svc",
IsAuth: &isAuth,
IsEffective: &isEffective,
}
condition, args := NewApiService().buildListCondition(params)
expected := "(name like ? OR route like ? OR code = ?) AND name like ? AND method = ? AND route like ? AND is_auth = ? AND is_effective = ?"
if condition != expected {
t.Fatalf("unexpected condition: %s", condition)
}
if len(args) != 8 {
t.Fatalf("unexpected args len: %d", len(args))
}
}
================================================
FILE: internal/service/api_permission/audit_diff.go
================================================
package api_permission
import (
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var apiPermissionDiffRules = []auditdiff.FieldRule{
{Field: "id", Label: "接口ID"},
{Field: "name", Label: "接口名称"},
{Field: "description", Label: "描述"},
{
Field: "is_auth",
Label: "鉴权模式",
ValueLabels: map[string]string{
"0": "无需登录",
"1": "需要登录",
"2": "需要登录且鉴权",
},
},
{Field: "sort", Label: "排序"},
}
// UpdateWithAuditDiff 更新 API 权限并返回精确 change_diff。
func (s *ApiService) UpdateWithAuditDiff(params *form.UpdatePermission) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotAPIPermissionByID(params.Id)
if err != nil {
return "", err
}
if err := s.Update(params); err != nil {
return "", err
}
after, err := s.snapshotAPIPermissionByID(params.Id)
if err != nil {
return auditdiff.Marshal(nil), nil
}
items := auditdiff.BuildFieldDiff(before, after, apiPermissionDiffRules)
return auditdiff.Marshal(items), nil
}
func (s *ApiService) snapshotAPIPermissionByID(id uint) (map[string]any, error) {
apiModel := model.NewApi()
if err := apiModel.GetById(id); err != nil || apiModel.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return map[string]any{
"id": apiModel.ID,
"name": apiModel.Name,
"description": apiModel.Description,
"is_auth": apiModel.IsAuth,
"sort": apiModel.Sort,
}, nil
}
================================================
FILE: internal/service/api_permission/audit_diff_test.go
================================================
package api_permission
import (
"encoding/json"
"testing"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
)
func TestAPIPermissionDiffIncludesAuthModeDisplay(t *testing.T) {
items := auditdiff.BuildFieldDiff(
map[string]any{"is_auth": uint8(0)},
map[string]any{"is_auth": uint8(2)},
apiPermissionDiffRules,
)
raw := auditdiff.Marshal(items)
var decoded []map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
if len(decoded) != 1 {
t.Fatalf("expected 1 diff item, got %d", len(decoded))
}
if decoded[0]["before_display"] != "无需登录" || decoded[0]["after_display"] != "需要登录且鉴权" {
t.Fatalf("unexpected auth display mapping: %#v", decoded[0])
}
}
================================================
FILE: internal/service/audit/list_helpers.go
================================================
package audit
import "github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
type logListQuery struct {
*query_builder.QueryBuilder
}
func newLogListQuery() *logListQuery {
return &logListQuery{QueryBuilder: query_builder.New()}
}
func (q *logListQuery) addEq(field string, value any) *logListQuery {
q.QueryBuilder.AddEq(field, value)
return q
}
func (q *logListQuery) addLike(field, value string) *logListQuery {
q.QueryBuilder.AddLike(field, value)
return q
}
func (q *logListQuery) addCondition(condition string, args ...any) *logListQuery {
q.QueryBuilder.AddCondition(condition, args...)
return q
}
func (q *logListQuery) addCreatedAtRange(startTime, endTime string) *logListQuery {
if startTime != "" {
q.QueryBuilder.AddCondition("created_at >= ?", startTime)
}
if endTime != "" {
q.QueryBuilder.AddCondition("created_at <= ?", endTime)
}
return q
}
func uintFilterValue(value uint) any {
if value == 0 {
return nil
}
return value
}
================================================
FILE: internal/service/audit/login_log.go
================================================
package audit
import (
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"github.com/wannanbigpig/gin-layout/pkg/utils/crypto"
"go.uber.org/zap"
)
// LoginLogService 登录日志服务
type AdminLoginLogService struct {
service.Base
// configProvider 提供运行时配置读取入口。
configProvider func() *config.Conf
}
// AdminLoginLogServiceDeps 描述 AdminLoginLogService 可注入依赖。
type AdminLoginLogServiceDeps struct {
// ConfigProvider 自定义配置读取函数。
ConfigProvider func() *config.Conf
}
// NewAdminLoginLogService 创建登录日志服务实例
func NewAdminLoginLogService() *AdminLoginLogService {
return NewAdminLoginLogServiceWithDeps(AdminLoginLogServiceDeps{})
}
// NewAdminLoginLogServiceWithDeps 创建带依赖注入的登录日志服务实例。
func NewAdminLoginLogServiceWithDeps(deps AdminLoginLogServiceDeps) *AdminLoginLogService {
s := &AdminLoginLogService{
configProvider: deps.ConfigProvider,
}
s.ensureRuntimeDeps()
return s
}
func (s *AdminLoginLogService) ensureRuntimeDeps() {
if s.configProvider == nil {
s.configProvider = config.GetConfig
}
}
func (s *AdminLoginLogService) currentConfig() *config.Conf {
s.ensureRuntimeDeps()
return config.GetConfigFrom(s.configProvider)
}
// List 分页查询登录日志列表
func (s *AdminLoginLogService) List(params *form.AdminLoginLogList) *resources.Collection {
query := newLogListQuery().
addLike("username", params.Username).
addEq("login_status", params.LoginStatus).
addLike("ip", params.IP).
addCreatedAtRange(params.StartTime, params.EndTime)
conditionStr, args := query.Build()
loginLogModel := model.NewAdminLoginLogs()
// 构建查询参数,只查询列表需要的字段,排除大字段
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"uid",
"username",
"ip",
"os",
"browser",
"execution_time",
"login_status",
"login_fail_reason",
"type",
"is_revoked",
"revoked_code",
"revoked_reason",
"revoked_at",
"created_at",
},
OrderBy: "created_at DESC, id DESC",
}
// 分页查询(只查询列表需要的字段)
total, collection, err := model.ListPageE(loginLogModel, params.Page, params.PerPage, conditionStr, args, listOptionalParams)
if err != nil {
log.Logger.Error("查询登录日志列表失败", zap.Error(err))
return resources.NewAdminLoginLogTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
// 使用资源类转换,列表不包含大字段
transformer := resources.NewAdminLoginLogTransformer()
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
// Detail 获取登录日志详情
func (s *AdminLoginLogService) Detail(id uint) (any, error) {
loginLog := model.NewAdminLoginLogs()
if err := loginLog.GetById(id); err != nil || loginLog.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
decryptKey := s.currentConfig().Jwt.SecretKey
loginLog.AccessToken = decryptLoginTokenIfNeeded(loginLog.AccessToken, decryptKey)
loginLog.RefreshToken = decryptLoginTokenIfNeeded(loginLog.RefreshToken, decryptKey)
transformer := resources.NewAdminLoginLogTransformer()
return transformer.ToStruct(loginLog), nil
}
func decryptLoginTokenIfNeeded(token, decryptKey string) string {
if token == "" || decryptKey == "" {
return token
}
decrypted, err := crypto.Decrypt(decryptKey, token)
if err != nil {
return token
}
return decrypted
}
================================================
FILE: internal/service/audit/login_log_test.go
================================================
package audit
import (
"testing"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
"github.com/wannanbigpig/gin-layout/internal/pkg/testkit"
"github.com/wannanbigpig/gin-layout/pkg/utils/crypto"
)
func TestDecryptLoginTokenIfNeeded(t *testing.T) {
key := testkit.SecretKey("audit-login-log")
const plain = "header.payload.signature"
encrypted, err := crypto.Encrypt(key, plain)
if err != nil {
t.Fatalf("encrypt token failed: %v", err)
}
if got := decryptLoginTokenIfNeeded(encrypted, key); got != plain {
t.Fatalf("expected decrypted token %q, got %q", plain, got)
}
}
func TestDecryptLoginTokenIfNeededFallbackOnDecryptError(t *testing.T) {
key := testkit.SecretKey("audit-login-log")
const raw = "not-encrypted-token"
if got := decryptLoginTokenIfNeeded(raw, key); got != raw {
t.Fatalf("expected fallback raw token %q, got %q", raw, got)
}
}
func TestCurrentAuditConfigUsesInjectedProvider(t *testing.T) {
service := NewAdminLoginLogServiceWithDeps(AdminLoginLogServiceDeps{
ConfigProvider: func() *config.Conf {
return &config.Conf{
Jwt: autoload.JwtConfig{
SecretKey: "audit-key",
},
}
},
})
if got := service.currentConfig().Jwt.SecretKey; got != "audit-key" {
t.Fatalf("expected injected key %q, got %q", "audit-key", got)
}
}
================================================
FILE: internal/service/audit/request_log.go
================================================
package audit
import (
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"go.uber.org/zap"
)
// RequestLogService 请求日志服务
type RequestLogService struct {
service.Base
}
// NewRequestLogService 创建请求日志服务实例
func NewRequestLogService() *RequestLogService {
return &RequestLogService{}
}
// List 分页查询请求日志列表
func (s *RequestLogService) List(params *form.RequestLogList) *resources.Collection {
query := buildRequestLogQuery(requestLogQueryInput{
OperatorID: params.OperatorID,
OperatorAccount: params.OperatorAccount,
OperationStatus: params.OperationStatus,
IsHighRisk: params.IsHighRisk,
Method: params.Method,
BaseURL: params.BaseURL,
OperationName: params.OperationName,
IP: params.IP,
StartTime: params.StartTime,
EndTime: params.EndTime,
})
conditionStr, args := query.Build()
requestLogModel := model.NewRequestLogs()
// 构建查询参数,只查询列表需要的字段,排除大字段
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"request_id",
"operator_id",
"ip",
"method",
"base_url",
"operation_name",
"operation_status",
"is_high_risk",
"operator_account",
"operator_name",
"response_status",
"execution_time",
"created_at",
},
OrderBy: "created_at DESC, id DESC",
}
transformer := resources.NewRequestLogTransformer()
// 分页查询(只查询列表需要的字段)
total, collection, err := model.ListPageE(requestLogModel, params.Page, params.PerPage, conditionStr, args, listOptionalParams)
if err != nil {
log.Logger.Error("查询请求日志列表失败", zap.Error(err))
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
// 使用资源类转换,列表不包含大字段
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
// Detail 获取请求日志详情
func (s *RequestLogService) Detail(id uint) (any, error) {
requestLog := model.NewRequestLogs()
if err := requestLog.GetById(id); err != nil || requestLog.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
// 使用资源类转换,详情包含所有字段
transformer := resources.NewRequestLogTransformer()
return transformer.ToStruct(requestLog), nil
}
type requestLogQueryInput struct {
OperatorID uint
OperatorAccount string
OperationStatus *int
IsHighRisk *uint8
Method string
BaseURL string
OperationName string
IP string
StartTime string
EndTime string
}
func buildRequestLogQuery(input requestLogQueryInput) *logListQuery {
query := newLogListQuery().
addEq("operator_id", uintFilterValue(input.OperatorID)).
addLike("operator_account", input.OperatorAccount).
addEq("base_url", input.BaseURL).
addEq("method", input.Method).
addLike("operation_name", input.OperationName).
addLike("ip", input.IP).
addEq("is_high_risk", input.IsHighRisk).
addCreatedAtRange(input.StartTime, input.EndTime)
if input.OperationStatus != nil {
switch *input.OperationStatus {
case 0:
query.addCondition("operation_status = ?", 0)
case 1:
query.addCondition("operation_status != ?", 0)
}
}
return query
}
================================================
FILE: internal/service/audit/request_log_manage.go
================================================
package audit
import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive"
"github.com/wannanbigpig/gin-layout/internal/service/sys_config"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
const defaultRequestLogExportLimit = 1000
const (
requestLogMaskConfigGroupCode = "audit"
requestLogMaskConfigSort = 95
requestLogMaskConfigRemark = "请求日志脱敏字段配置"
requestLogMaskConfigManageTab = "audit_mask"
)
var requestLogMaskConfigNameI18n = map[string]string{
"zh-CN": "请求日志脱敏配置",
"en-US": "Request Log Mask Config",
}
var requestLogMaskConfigDiffRules = []auditdiff.FieldRule{
{Field: "common", Label: "通用脱敏字段"},
{Field: "request_header", Label: "请求头脱敏字段"},
{Field: "request_body", Label: "请求体脱敏字段"},
{Field: "response_header", Label: "响应头脱敏字段"},
{Field: "response_body", Label: "响应体脱敏字段"},
}
// ExportCSV 导出请求日志 CSV。
func (s *RequestLogService) ExportCSV(params *form.RequestLogExport) ([]byte, string, error) {
query := buildRequestLogQuery(requestLogQueryInput{
OperatorID: params.OperatorID,
OperatorAccount: params.OperatorAccount,
OperationStatus: params.OperationStatus,
IsHighRisk: params.IsHighRisk,
Method: params.Method,
BaseURL: params.BaseURL,
OperationName: params.OperationName,
IP: params.IP,
StartTime: params.StartTime,
EndTime: params.EndTime,
})
condition, args := query.Build()
limit := params.Limit
if limit <= 0 {
limit = defaultRequestLogExportLimit
}
logModel := model.NewRequestLogs()
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"request_id",
"operator_account",
"operator_name",
"ip",
"method",
"base_url",
"operation_name",
"operation_status",
"is_high_risk",
"response_status",
"execution_time",
"change_diff",
"created_at",
},
OrderBy: "created_at DESC, id DESC",
}
_, records, err := model.ListPageE(logModel, 1, limit, condition, args, listOptionalParams)
if err != nil {
return nil, "", err
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
if err := writer.Write([]string{
"id",
"request_id",
"operator_account",
"operator_name",
"ip",
"method",
"base_url",
"operation_name",
"operation_status",
"is_high_risk",
"response_status",
"execution_time_ms",
"change_diff",
"created_at",
}); err != nil {
return nil, "", err
}
for _, record := range records {
if err := writer.Write([]string{
strconv.FormatUint(uint64(record.ID), 10),
record.RequestID,
record.OperatorAccount,
record.OperatorName,
record.IP,
record.Method,
record.BaseURL,
record.OperationName,
strconv.Itoa(record.OperationStatus),
strconv.Itoa(int(record.IsHighRisk)),
strconv.Itoa(record.ResponseStatus),
strconv.FormatFloat(record.ExecutionTime, 'f', 4, 64),
record.ChangeDiff,
record.CreatedAt.String(),
}); err != nil {
return nil, "", err
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, "", err
}
fileName := "request_logs_" + time.Now().Format("20060102150405") + ".csv"
return buffer.Bytes(), fileName, nil
}
// GetMaskConfig 获取当前敏感字段脱敏配置。
func (s *RequestLogService) GetMaskConfig() map[string]any {
cfg, err := loadMaskConfigFromSysConfig()
if err != nil {
cfg = sensitive.GetSensitiveFieldsConfig()
}
return toMaskConfigMap(cfg)
}
func toMaskConfigMap(cfg sensitive.SensitiveFieldsConfig) map[string]any {
return map[string]any{
"common": cfg.Common,
"request_header": cfg.RequestHeader,
"request_body": cfg.RequestBody,
"response_header": cfg.ResponseHeader,
"response_body": cfg.ResponseBody,
}
}
// UpdateMaskConfig 更新敏感字段脱敏配置(运行时生效)。
func (s *RequestLogService) UpdateMaskConfig(params *form.RequestLogMaskConfigForm) (map[string]any, error) {
if params == nil {
return nil, e.NewBusinessError(e.InvalidParameter)
}
nextConfig := sensitive.SensitiveFieldsConfig{
Common: normalizeSensitiveFieldList(params.Common),
RequestHeader: normalizeSensitiveFieldList(params.RequestHeader),
RequestBody: normalizeSensitiveFieldList(params.RequestBody),
ResponseHeader: normalizeSensitiveFieldList(params.ResponseHeader),
ResponseBody: normalizeSensitiveFieldList(params.ResponseBody),
}
persisted, err := saveMaskConfigToSysConfig(nextConfig)
if err != nil {
return nil, err
}
if persisted {
if err := sys_config.NewSysConfigService().RefreshCache(); err != nil {
return nil, err
}
return s.GetMaskConfig(), nil
}
// 兼容 sys_config 表尚未初始化的场景,保持运行时即时生效。
sensitive.LoadSensitiveFieldsConfig(nextConfig)
return toMaskConfigMap(nextConfig), nil
}
// UpdateMaskConfigWithAuditDiff 更新脱敏配置并返回精确 change_diff JSON。
func (s *RequestLogService) UpdateMaskConfigWithAuditDiff(params *form.RequestLogMaskConfigForm) (map[string]any, string, error) {
before := s.GetMaskConfig()
after, err := s.UpdateMaskConfig(params)
if err != nil {
return nil, "", err
}
changeDiff := buildMaskConfigAuditDiff(before, after)
return after, changeDiff, nil
}
func buildMaskConfigAuditDiff(before, after map[string]any) string {
items := auditdiff.BuildFieldDiff(before, after, requestLogMaskConfigDiffRules)
return auditdiff.Marshal(items)
}
func normalizeSensitiveFieldList(input []string) []string {
if len(input) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(input))
result := make([]string, 0, len(input))
for _, item := range input {
trimmed := strings.ToLower(strings.TrimSpace(item))
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
func loadMaskConfigFromSysConfig() (sensitive.SensitiveFieldsConfig, error) {
item, err := sys_config.NewSysConfigService().Value(sys_config.AuditSensitiveFieldsConfigKey)
if err != nil {
return sensitive.SensitiveFieldsConfig{}, err
}
if model.NormalizeValueType(item.ValueType) != model.SysConfigValueTypeJSON {
return sensitive.SensitiveFieldsConfig{}, fmt.Errorf("%s value_type must be json", sys_config.AuditSensitiveFieldsConfigKey)
}
return decodeMaskConfig(item.ConfigValue)
}
func decodeMaskConfig(raw string) (sensitive.SensitiveFieldsConfig, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return sensitive.DefaultSensitiveFieldsConfig(), nil
}
var config sensitive.SensitiveFieldsConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return sensitive.SensitiveFieldsConfig{}, err
}
return sensitive.SensitiveFieldsConfig{
Common: normalizeSensitiveFieldList(config.Common),
RequestHeader: normalizeSensitiveFieldList(config.RequestHeader),
RequestBody: normalizeSensitiveFieldList(config.RequestBody),
ResponseHeader: normalizeSensitiveFieldList(config.ResponseHeader),
ResponseBody: normalizeSensitiveFieldList(config.ResponseBody),
}, nil
}
func saveMaskConfigToSysConfig(config sensitive.SensitiveFieldsConfig) (bool, error) {
db, err := model.GetDB()
if err != nil {
return false, err
}
configModel := model.NewSysConfig()
if !db.Migrator().HasTable(configModel.TableName()) {
return false, nil
}
payload, err := json.Marshal(config)
if err != nil {
return false, err
}
err = configModel.FindByKey(sys_config.AuditSensitiveFieldsConfigKey)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
configModel = model.NewSysConfig()
configModel.ConfigKey = sys_config.AuditSensitiveFieldsConfigKey
configModel.Sort = requestLogMaskConfigSort
}
configModel.ConfigValue = string(payload)
configModel.ValueType = model.SysConfigValueTypeJSON
configModel.GroupCode = requestLogMaskConfigGroupCode
configModel.IsSystem = 1
configModel.IsSensitive = 1
configModel.IsVisible = 0
configModel.ManageTab = requestLogMaskConfigManageTab
configModel.Status = 1
configModel.Remark = requestLogMaskConfigRemark
if configModel.Sort == 0 {
configModel.Sort = requestLogMaskConfigSort
}
if err := db.Transaction(func(tx *gorm.DB) error {
configModel.SetDB(tx)
if err := configModel.Save(); err != nil {
return err
}
return model.NewSysConfigI18n().UpsertConfigNames(configModel.ID, requestLogMaskConfigNameI18n, tx)
}); err != nil {
return false, err
}
return true, nil
}
================================================
FILE: internal/service/audit/request_log_manage_test.go
================================================
package audit
import (
"encoding/json"
"testing"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive"
)
func TestDecodeMaskConfigNormalizesFields(t *testing.T) {
config, err := decodeMaskConfig(`{
"common": [" Password ", "password", "Token"],
"request_header": ["authorization", "Authorization", ""],
"request_body": ["mobile", "MOBILE"],
"response_header": [],
"response_body": ["IDCard", "idcard"]
}`)
if err != nil {
t.Fatalf("decodeMaskConfig returned error: %v", err)
}
if got, want := len(config.Common), 2; got != want {
t.Fatalf("unexpected common length: got=%d want=%d", got, want)
}
if config.Common[0] != "password" || config.Common[1] != "token" {
t.Fatalf("unexpected normalized common fields: %#v", config.Common)
}
if got, want := len(config.RequestHeader), 1; got != want {
t.Fatalf("unexpected request_header length: got=%d want=%d", got, want)
}
if config.RequestHeader[0] != "authorization" {
t.Fatalf("unexpected request_header fields: %#v", config.RequestHeader)
}
if got, want := len(config.RequestBody), 1; got != want {
t.Fatalf("unexpected request_body length: got=%d want=%d", got, want)
}
if config.RequestBody[0] != "mobile" {
t.Fatalf("unexpected request_body fields: %#v", config.RequestBody)
}
if got, want := len(config.ResponseBody), 1; got != want {
t.Fatalf("unexpected response_body length: got=%d want=%d", got, want)
}
if config.ResponseBody[0] != "idcard" {
t.Fatalf("unexpected response_body fields: %#v", config.ResponseBody)
}
}
func TestDecodeMaskConfigEmptyUsesDefault(t *testing.T) {
config, err := decodeMaskConfig(" ")
if err != nil {
t.Fatalf("decodeMaskConfig returned error: %v", err)
}
defaultConfig := sensitive.DefaultSensitiveFieldsConfig()
if len(config.Common) != len(defaultConfig.Common) {
t.Fatalf("unexpected default common length: got=%d want=%d", len(config.Common), len(defaultConfig.Common))
}
if len(config.RequestHeader) != len(defaultConfig.RequestHeader) {
t.Fatalf("unexpected default request_header length: got=%d want=%d", len(config.RequestHeader), len(defaultConfig.RequestHeader))
}
}
func TestBuildMaskConfigAuditDiff(t *testing.T) {
before := map[string]any{
"common": []string{"password"},
"request_header": []string{"authorization"},
"request_body": []string{"mobile"},
"response_header": []string{},
"response_body": []string{"idcard"},
}
after := map[string]any{
"common": []string{"password", "token"},
"request_header": []string{"authorization"},
"request_body": []string{"mobile"},
"response_header": []string{},
"response_body": []string{"idcard"},
}
raw := buildMaskConfigAuditDiff(before, after)
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
if len(items) != 1 {
t.Fatalf("expected 1 diff item, got %d", len(items))
}
if items[0]["field"] != "common" {
t.Fatalf("expected diff field common, got %#v", items[0]["field"])
}
}
================================================
FILE: internal/service/audit/request_log_write.go
================================================
package audit
import (
"github.com/wannanbigpig/gin-layout/internal/model"
)
// AuditLogSnapshot 表示请求结束时提取出的审计日志快照。
type AuditLogSnapshot struct {
RequestID string `json:"request_id"`
JwtID string `json:"jwt_id"`
OperatorID uint `json:"operator_id"`
OperatorAccount string `json:"operator_account"`
OperatorName string `json:"operator_name"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
OS string `json:"os"`
Browser string `json:"browser"`
Method string `json:"method"`
BaseURL string `json:"base_url"`
OperationName string `json:"operation_name"`
OperationStatus int `json:"operation_status"`
IsHighRisk uint8 `json:"is_high_risk"`
RequestHeaders string `json:"request_headers"`
RequestQuery string `json:"request_query"`
RequestBody string `json:"request_body"`
ChangeDiff string `json:"change_diff"`
ResponseStatus int `json:"response_status"`
ResponseBody string `json:"response_body"`
ResponseHeader string `json:"response_header"`
ExecutionTime float64 `json:"execution_time"`
}
// PersistAuditLog 将请求审计日志快照写入数据库。
func PersistAuditLog(snapshot *AuditLogSnapshot) error {
if snapshot == nil || snapshot.RequestID == "" {
return nil
}
requestLog := model.NewRequestLogs()
requestLog.RequestID = snapshot.RequestID
requestLog.JwtID = snapshot.JwtID
requestLog.OperatorID = snapshot.OperatorID
requestLog.IP = snapshot.IP
requestLog.UserAgent = snapshot.UserAgent
requestLog.OS = snapshot.OS
requestLog.Browser = snapshot.Browser
requestLog.Method = snapshot.Method
requestLog.BaseURL = snapshot.BaseURL
requestLog.OperationName = snapshot.OperationName
requestLog.OperationStatus = snapshot.OperationStatus
requestLog.IsHighRisk = snapshot.IsHighRisk
requestLog.OperatorAccount = snapshot.OperatorAccount
requestLog.OperatorName = snapshot.OperatorName
requestLog.RequestHeaders = snapshot.RequestHeaders
requestLog.RequestQuery = snapshot.RequestQuery
requestLog.RequestBody = snapshot.RequestBody
requestLog.ChangeDiff = snapshot.ChangeDiff
requestLog.ResponseStatus = snapshot.ResponseStatus
requestLog.ResponseBody = snapshot.ResponseBody
requestLog.ResponseHeader = snapshot.ResponseHeader
requestLog.ExecutionTime = snapshot.ExecutionTime
return requestLog.Create()
}
================================================
FILE: internal/service/auth/login.go
================================================
package auth
import (
"context"
stderrors "errors"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
"github.com/wannanbigpig/gin-layout/internal/service"
utils2 "github.com/wannanbigpig/gin-layout/pkg/utils"
)
// LoginService 登录授权服务。
type LoginService struct {
service.Base
// configProvider 提供运行时配置读取入口。
configProvider func() *config.Conf
// blacklistLookupFn 查询 token 是否在 Redis 黑名单中。
blacklistLookupFn func(jwtID string) (bool, error)
// tokenRevokedLookupFn 查询 token 是否在登录日志中已标记撤销。
tokenRevokedLookupFn func(jwtID string) bool
// mysqlReadyFn 判断 MySQL 依赖是否可用。
mysqlReadyFn func() bool
// refreshLockStore 在 Redis 不可用时提供进程内刷新锁。
refreshLockStore *refreshTokenLock
// tryRefreshPrincipalFn 执行 principal 自动刷新逻辑。
tryRefreshPrincipalFn func(principal *AuthPrincipal)
// markTokensRevokedFn 批量标记 token 为撤销状态。
markTokensRevokedFn func(ctx context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error
// writeTokenToBlacklistFn 将 token 写入 Redis 黑名单。
writeTokenToBlacklistFn func(jwtID string, remainingTime time.Duration) error
// loginLogDB 允许测试或事务场景指定登录日志查询连接。
loginLogDB *gorm.DB
}
// LoginServiceDeps 描述 LoginService 可注入依赖。
type LoginServiceDeps struct {
// ConfigProvider 自定义配置读取函数。
ConfigProvider func() *config.Conf
// BlacklistLookup 自定义 Redis 黑名单查询实现。
BlacklistLookup func(jwtID string) (bool, error)
// TokenRevokedLookup 自定义登录日志撤销状态查询实现。
TokenRevokedLookup func(jwtID string) bool
// MySQLReady 自定义 MySQL 可用性判断逻辑。
MySQLReady func() bool
// RefreshLockStore 自定义内存刷新锁存储实现。
RefreshLockStore *refreshTokenLock
// TryRefreshPrincipal 自定义自动刷新 principal 的执行入口。
TryRefreshPrincipal func(principal *AuthPrincipal)
// MarkTokensRevoked 自定义 token 撤销标记逻辑。
MarkTokensRevoked func(ctx context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error
// WriteTokenToBlacklist 自定义写 Redis 黑名单逻辑。
WriteTokenToBlacklist func(jwtID string, remainingTime time.Duration) error
// LoginLogDB 自定义登录日志查询连接。
LoginLogDB *gorm.DB
}
// NewLoginService 创建登录服务实例。
func NewLoginService() *LoginService {
return NewLoginServiceWithDeps(LoginServiceDeps{})
}
// NewLoginServiceWithDeps 创建带依赖注入的登录服务实例。
func NewLoginServiceWithDeps(deps LoginServiceDeps) *LoginService {
s := &LoginService{
configProvider: deps.ConfigProvider,
blacklistLookupFn: deps.BlacklistLookup,
tokenRevokedLookupFn: deps.TokenRevokedLookup,
mysqlReadyFn: deps.MySQLReady,
refreshLockStore: deps.RefreshLockStore,
tryRefreshPrincipalFn: deps.TryRefreshPrincipal,
markTokensRevokedFn: deps.MarkTokensRevoked,
writeTokenToBlacklistFn: deps.WriteTokenToBlacklist,
loginLogDB: deps.LoginLogDB,
}
s.ensureRuntimeDeps()
return s
}
func (s *LoginService) ensureRuntimeDeps() {
if s.configProvider == nil {
s.configProvider = config.GetConfig
}
if s.blacklistLookupFn == nil {
s.blacklistLookupFn = s.IsInBlacklist
}
if s.tokenRevokedLookupFn == nil {
s.tokenRevokedLookupFn = s.isTokenRevokedInLog
}
if s.mysqlReadyFn == nil {
s.mysqlReadyFn = data.MysqlReady
}
if s.refreshLockStore == nil {
s.refreshLockStore = defaultRefreshLockStore()
}
if s.tryRefreshPrincipalFn == nil {
s.tryRefreshPrincipalFn = s.tryRefreshToken
}
if s.markTokensRevokedFn == nil {
s.markTokensRevokedFn = func(ctx context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error {
return s.markTokensRevoked(ctx, jwtIDs, revokedCode, revokedReason)
}
}
if s.writeTokenToBlacklistFn == nil {
s.writeTokenToBlacklistFn = func(jwtID string, remainingTime time.Duration) error {
return s.writeTokenToBlacklist(jwtID, remainingTime)
}
}
}
func (s *LoginService) currentConfig() *config.Conf {
s.ensureRuntimeDeps()
return config.GetConfigFrom(s.configProvider)
}
// Login 用户登录。
func (s *LoginService) Login(username, password string, logInfo LoginLogInfo) (*TokenResponse, error) {
startTime := time.Now()
if err := s.ensureLoginAllowed(username); err != nil {
logInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())
s.HandleLoginFailure(username, s.extractErrorMessage(err), logInfo, false)
return nil, err
}
adminUser, err := s.validateUser(username, password)
if err != nil {
logInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())
s.HandleLoginFailure(username, s.extractErrorMessage(err), logInfo, s.shouldCountLockFailure(err))
return nil, err
}
claims := s.newAdminCustomClaims(adminUser)
accessToken, err := token.Generate(claims)
if err != nil {
logInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())
s.HandleLoginFailure(username, "生成Token失败", logInfo, false)
return nil, e.NewBusinessError(e.TokenGenerateFailed)
}
logInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())
if err := s.recordLoginLog(adminUser, claims, accessToken, "", logInfo, model.LoginTypeLogin); err != nil {
return nil, e.NewBusinessError(e.LoginFailed)
}
if err := s.clearLoginFailState(username); err != nil && !isTableNotFoundErr(err) {
log.Logger.Warn("清理登录失败计数失败", zap.String("username", username), zap.Error(err))
}
return &TokenResponse{
AccessToken: accessToken,
RefreshToken: "",
TokenType: tokenTypeBearer,
ExpiresAt: claims.ExpiresAt.Unix(),
}, nil
}
// validateUser 验证用户信息。
func (s *LoginService) validateUser(username, password string) (*model.AdminUser, error) {
adminUser := model.NewAdminUsers()
if err := adminUser.GetUserInfo(username); err != nil {
switch {
case e.IsDependencyNotReady(err):
return nil, e.NewDependencyNotReadyError()
case stderrors.Is(err, gorm.ErrRecordNotFound):
return nil, e.NewBusinessError(e.UserDoesNotExist)
default:
return nil, err
}
}
if adminUser.Status != model.AdminUserStatusEnabled {
return nil, e.NewBusinessError(e.UserDisable)
}
if !utils2.ComparePasswords(adminUser.Password, password) {
return nil, e.NewBusinessError(e.UserPasswordWrong)
}
return adminUser, nil
}
// recordLoginLog 记录登录日志并更新用户信息。
func (s *LoginService) recordLoginLog(adminUser *model.AdminUser, claims token.AdminCustomClaims, accessToken, refreshToken string, logInfo LoginLogInfo, logType uint8) error {
db, err := model.NewAdminLoginLogs().GetDB()
if err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
loginLog := s.buildLoginLog(adminUser.ID, adminUser.Username, claims.ID, accessToken, refreshToken, claims.ExpiresAt.Time, logInfo, model.LoginStatusSuccess, "", logType)
loginLog.SetDB(tx)
if err := loginLog.Create(); err != nil {
log.Logger.Error("记录登录日志失败", zap.Error(err), zap.Uint("user_id", adminUser.ID), zap.String("username", adminUser.Username))
return err
}
if logType == model.LoginTypeLogin {
adminUser.LastIp = logInfo.IP
adminUser.LastLogin = utils.FormatDate{Time: time.Now()}
adminUser.SetDB(tx)
if err := adminUser.Save(); err != nil {
log.Logger.Error("更新用户最后登录信息失败", zap.Error(err), zap.Uint("user_id", adminUser.ID))
return err
}
}
return nil
})
}
// Refresh 刷新 Token。
func (s *LoginService) Refresh(id uint, logInfo LoginLogInfo) (*TokenResponse, error) {
startTime := time.Now()
adminUserModel := model.NewAdminUsers()
if err := adminUserModel.GetById(id); err != nil {
return nil, e.NewBusinessError(e.UpdateUserFailed)
}
if adminUserModel.Status != model.AdminUserStatusEnabled {
return nil, e.NewBusinessError(e.UserDisable)
}
claims := s.newAdminCustomClaims(adminUserModel)
accessToken, err := token.Refresh(claims)
if err != nil {
return nil, e.NewBusinessError(e.TokenGenerateFailed)
}
logInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())
if err := s.recordLoginLog(adminUserModel, claims, accessToken, "", logInfo, model.LoginTypeRefresh); err != nil {
log.Logger.Error("记录刷新token日志失败", zap.Error(err), zap.Uint("user_id", id))
}
return &TokenResponse{
AccessToken: accessToken,
RefreshToken: "",
TokenType: tokenTypeBearer,
ExpiresAt: claims.ExpiresAt.Unix(),
}, nil
}
// newAdminCustomClaims 创建管理员自定义 Claims。
func (s *LoginService) newAdminCustomClaims(user *model.AdminUser) token.AdminCustomClaims {
return token.NewAdminCustomClaims(user)
}
================================================
FILE: internal/service/auth/login_bench_test.go
================================================
package auth
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
)
func BenchmarkResolvePrincipal(b *testing.B) {
service := NewLoginService()
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{
UserID: 12,
Username: "tester",
Nickname: "Tester",
Email: "tester@example.com",
FullPhoneNumber: "+8613800000000",
PhoneNumber: "13800000000",
CountryCode: "+86",
IsSuperAdmin: 0,
},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-bench",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
service.blacklistLookupFn = func(_ string) (bool, error) {
return false, nil
}
service.tokenRevokedLookupFn = func(_ string) bool {
return false
}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
principal, ok := service.resolvePrincipalFromClaims(claims)
if !ok || principal == nil {
b.Fatal("expected principal")
}
}
}
================================================
FILE: internal/service/auth/login_blacklist.go
================================================
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"go.uber.org/zap"
)
const redisOpTimeout = 3 * time.Second
var errRedisUnavailable = errors.New("redis client is not available")
// Logout 退出登录。
func (s *LoginService) Logout(accessToken string) error {
s.ensureRuntimeDeps()
claims, err := s.parseToken(accessToken)
if err != nil {
return err
}
exp, err := claims.GetExpirationTime()
if err != nil || exp == nil {
return err
}
if err := s.markTokensRevokedFn(context.Background(), []string{claims.ID}, model.RevokedCodeUserLogout, "用户主动登出(退出登录)"); err != nil {
return err
}
remainingTime := time.Until(exp.Time)
if err := s.writeTokenToBlacklistFn(claims.ID, remainingTime); err != nil {
log.Logger.Warn("Redis blacklist write failed after database revocation, treat logout as success",
zap.String("jwt_id", claims.ID),
zap.Bool("redis_unavailable", errors.Is(err, errRedisUnavailable)),
zap.Error(err))
return nil
}
return nil
}
// parseToken 解析 Token。
func (s *LoginService) parseToken(accessToken string) (*token.AdminCustomClaims, error) {
claims := new(token.AdminCustomClaims)
secret := []byte(s.currentConfig().Jwt.SecretKey)
parsedToken, err := jwt.ParseWithClaims(accessToken, claims, func(jwtToken *jwt.Token) (interface{}, error) {
if _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", jwtToken.Header["alg"])
}
return secret, nil
}, jwt.WithSubject(global.PcAdminSubject), jwt.WithIssuer(global.Issuer))
if err != nil {
return nil, err
}
if !parsedToken.Valid {
return nil, e.NewBusinessError(e.NotLogin)
}
return claims, nil
}
// IsInBlacklist 判断 Token 是否在黑名单中。
func (s *LoginService) IsInBlacklist(jwtId string) (bool, error) {
redisClient := data.RedisClient()
if redisClient == nil {
if err := data.GetRedisInitError(); err != nil {
return false, fmt.Errorf("%w: %v", errRedisUnavailable, err)
}
return false, errRedisUnavailable
}
ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)
defer cancel()
result, err := redisClient.Exists(ctx, s.getBlacklistKey(jwtId)).Result()
if err != nil {
return false, err
}
return result > 0, nil
}
func (s *LoginService) writeTokenToBlacklist(jwtID string, remainingTime time.Duration) error {
if jwtID == "" || remainingTime <= 0 {
return nil
}
redisClient := data.RedisClient()
if redisClient == nil {
if err := data.GetRedisInitError(); err != nil {
return fmt.Errorf("%w: %v", errRedisUnavailable, err)
}
return errRedisUnavailable
}
ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)
defer cancel()
return redisClient.Set(ctx, s.getBlacklistKey(jwtID), "1", remainingTime).Err()
}
// getBlacklistKey 获取 Redis 黑名单 key。
func (s *LoginService) getBlacklistKey(jwtId string) string {
return blacklistPrefix + jwtId
}
// addTokensToBlacklist 批量将 token 加入 Redis 黑名单。
func (s *LoginService) addTokensToBlacklist(loginLogs []model.AdminLoginLogs) error {
if len(loginLogs) == 0 {
return nil
}
redisClient := data.RedisClient()
if redisClient == nil {
if err := data.GetRedisInitError(); err != nil {
return fmt.Errorf("%w: %v", errRedisUnavailable, err)
}
return errRedisUnavailable
}
ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)
defer cancel()
pipe := redisClient.Pipeline()
now := time.Now()
queued := 0
for _, item := range loginLogs {
if item.JwtID == "" {
continue
}
remainingTime := s.calculateRemainingTime(item.TokenExpires, now)
if remainingTime <= 0 {
continue
}
blacklistKey := s.getBlacklistKey(item.JwtID)
pipe.Set(ctx, blacklistKey, "1", remainingTime)
queued++
}
if queued == 0 {
return nil
}
if _, err := pipe.Exec(ctx); err != nil {
log.Logger.Error("批量将 token 加入 Redis 黑名单失败", zap.Error(err), zap.Int("count", queued))
return err
}
return nil
}
// calculateRemainingTime 计算 token 剩余过期时间。
func (s *LoginService) calculateRemainingTime(tokenExpires *utils.FormatDate, now time.Time) time.Duration {
if tokenExpires != nil {
remainingTime := tokenExpires.Time.Sub(now)
if remainingTime > 0 {
return remainingTime
}
return 0
}
return defaultTokenTTL
}
// RevokeUserTokens 撤销用户所有未过期的 token。
func (s *LoginService) RevokeUserTokens(userId uint, revokedCode uint8, revokedReason string, tx ...*gorm.DB) error {
if userId == 0 {
return nil
}
loginLog := model.NewAdminLoginLogs()
if existingTx := access.FirstTx(tx); existingTx != nil {
loginLog.SetDB(existingTx)
} else {
if _, err := loginLog.GetDB(); err != nil {
return err
}
}
loginLogs, err := loginLog.FindActiveTokensByUserId(userId, time.Now())
if err != nil || len(loginLogs) == 0 {
return err
}
jwtIds := collectJWTIDs(loginLogs)
if len(jwtIds) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), revokeLogAsyncTimeout)
defer cancel()
if err := s.markTokensRevokedFn(ctx, jwtIds, revokedCode, revokedReason); err != nil {
return err
}
return s.addTokensToBlacklist(loginLogs)
}
func collectJWTIDs(loginLogs []model.AdminLoginLogs) []string {
jwtIds := make([]string, 0, len(loginLogs))
for _, item := range loginLogs {
if item.JwtID != "" {
jwtIds = append(jwtIds, item.JwtID)
}
}
return jwtIds
}
================================================
FILE: internal/service/auth/login_helpers_test.go
================================================
package auth
import (
"context"
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/testkit"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
)
// TestExtractErrorMessage 验证错误消息提取逻辑。
func TestExtractErrorMessage(t *testing.T) {
service := NewLoginService()
businessErr := e.NewBusinessError(e.CaptchaErr)
if got := service.extractErrorMessage(businessErr); got != businessErr.GetMessage() {
t.Fatalf("expected business message %q, got %q", businessErr.GetMessage(), got)
}
plainErr := errors.New("plain error")
if got := service.extractErrorMessage(plainErr); got != plainErr.Error() {
t.Fatalf("expected plain error message, got %q", got)
}
}
// TestCalculateTokenHash 验证 token 哈希值计算结果稳定。
func TestCalculateTokenHash(t *testing.T) {
service := NewLoginService()
const tokenValue = "token-value"
const expected = "e6c02a5742ea9d4de588eb9b9de7bed43dc17011552186bed3e98b2c5958ff4a"
if got := service.calculateTokenHash(tokenValue); got != expected {
t.Fatalf("expected %s, got %s", expected, got)
}
}
// TestGetBlacklistKey 验证黑名单 key 前缀拼接。
func TestGetBlacklistKey(t *testing.T) {
service := NewLoginService()
if got := service.getBlacklistKey("jwt-id"); got != "blacklist:jwt-id" {
t.Fatalf("unexpected blacklist key: %s", got)
}
}
// TestCalculateRemainingTime 验证剩余时间计算逻辑。
func TestCalculateRemainingTime(t *testing.T) {
service := NewLoginService()
now := time.Now()
expires := &utils.FormatDate{Time: now.Add(2 * time.Minute)}
if got := service.calculateRemainingTime(expires, now); got < time.Minute || got > 2*time.Minute {
t.Fatalf("unexpected remaining time: %v", got)
}
expired := &utils.FormatDate{Time: now.Add(-time.Minute)}
if got := service.calculateRemainingTime(expired, now); got != 0 {
t.Fatalf("expected 0 for expired token, got %v", got)
}
if got := service.calculateRemainingTime(nil, now); got != defaultTokenTTL {
t.Fatalf("expected default ttl %v, got %v", defaultTokenTTL, got)
}
}
// TestBuildRefreshLockKey 验证刷新锁 key 拼接。
func TestBuildRefreshLockKey(t *testing.T) {
service := NewLoginService()
claims := &token.AdminCustomClaims{AdminUserInfo: token.AdminUserInfo{UserID: 12}, RegisteredClaims: jwt.RegisteredClaims{ID: "jwt-id"}}
if got := service.buildRefreshLockKey(claims); got != "refresh_token_lock:12:jwt-id" {
t.Fatalf("unexpected refresh lock key: %s", got)
}
}
// TestShouldRefreshToken 验证刷新条件判断。
func TestShouldRefreshToken(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Conf{
Jwt: autoload.JwtConfig{
RefreshTTL: 30,
},
}
service := NewLoginServiceWithDeps(LoginServiceDeps{
ConfigProvider: func() *config.Conf { return cfg },
})
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
service.SetCtx(ctx)
claims := &token.AdminCustomClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Second)),
},
}
principal := &AuthPrincipal{Claims: claims}
if !service.shouldRefreshToken(principal) {
t.Fatal("expected token to require refresh")
}
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(2 * time.Minute))
if service.shouldRefreshToken(principal) {
t.Fatal("expected token with long remaining ttl to skip refresh")
}
}
// TestIsPrincipalValidSkipsFallbackWhenMysqlUnavailable 验证降级模式下不会继续回表。
func TestIsPrincipalValidSkipsFallbackWhenMysqlUnavailable(t *testing.T) {
service := NewLoginService()
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 12},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-id",
},
}
tokenRevokedCalled := false
service.blacklistLookupFn = func(_ string) (bool, error) {
return false, errRedisUnavailable
}
service.tokenRevokedLookupFn = func(_ string) bool {
tokenRevokedCalled = true
return false
}
service.mysqlReadyFn = func() bool { return false }
if service.isPrincipalValid(claims) {
t.Fatal("expected principal to be rejected when redis and mysql are unavailable")
}
if tokenRevokedCalled {
t.Fatal("expected database revoke lookup to be skipped")
}
}
func TestIsPrincipalValidFallsBackToDatabaseWhenMysqlReady(t *testing.T) {
service := NewLoginService()
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 12},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-id",
},
}
tokenRevokedCalled := false
service.blacklistLookupFn = func(_ string) (bool, error) {
return false, errRedisUnavailable
}
service.tokenRevokedLookupFn = func(jwtID string) bool {
tokenRevokedCalled = true
return jwtID == "revoked"
}
service.mysqlReadyFn = func() bool { return true }
if !service.isPrincipalValid(claims) {
t.Fatal("expected principal to stay valid when mysql fallback is available")
}
if !tokenRevokedCalled {
t.Fatal("expected database revoke lookup to be used")
}
}
func TestIsPrincipalValidChecksDatabaseWhenRedisMisses(t *testing.T) {
service := NewLoginService()
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 12},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-id",
},
}
tokenRevokedCalled := false
service.blacklistLookupFn = func(_ string) (bool, error) {
return false, nil
}
service.tokenRevokedLookupFn = func(jwtID string) bool {
tokenRevokedCalled = true
return jwtID == "jwt-id"
}
service.mysqlReadyFn = func() bool { return true }
if service.isPrincipalValid(claims) {
t.Fatal("expected redis miss to fall back to revoked token lookup")
}
if !tokenRevokedCalled {
t.Fatal("expected database revoke lookup to be used on redis miss")
}
}
func TestIsPrincipalValidRejectsRevokedToken(t *testing.T) {
service := NewLoginService()
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 12},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-id",
},
}
service.blacklistLookupFn = func(_ string) (bool, error) {
return false, errors.New("redis unavailable")
}
service.tokenRevokedLookupFn = func(jwtID string) bool {
return jwtID == "jwt-id"
}
if service.isPrincipalValid(claims) {
t.Fatal("expected revoked token to be rejected")
}
}
func TestValidateUserReturnsDependencyErrorWhenDBUnavailable(t *testing.T) {
service := NewLoginService()
user, err := service.validateUser("missing-user", "password")
if user != nil {
t.Fatalf("expected nil user, got %#v", user)
}
var businessErr *e.BusinessError
if !errors.As(err, &businessErr) {
t.Fatalf("expected business error, got %v", err)
}
if businessErr.GetCode() != e.ServiceDependencyNotReady {
t.Fatalf("expected code %d, got %d", e.ServiceDependencyNotReady, businessErr.GetCode())
}
}
func TestLogoutFallsBackToDatabaseRevocationWhenRedisUnavailable(t *testing.T) {
secretKey := testkit.SecretKey("auth-logout")
service := NewLoginServiceWithDeps(LoginServiceDeps{
ConfigProvider: func() *config.Conf {
return &config.Conf{
Jwt: autoload.JwtConfig{
SecretKey: secretKey,
},
}
},
})
revoked := false
service.markTokensRevokedFn = func(_ context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error {
revoked = true
if len(jwtIDs) != 1 || jwtIDs[0] == "" {
t.Fatalf("unexpected jwt ids: %#v", jwtIDs)
}
if revokedCode != model.RevokedCodeUserLogout || revokedReason == "" {
t.Fatalf("unexpected revoke payload: code=%d reason=%s", revokedCode, revokedReason)
}
return nil
}
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 1, Username: "tester"},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
Issuer: global.Issuer,
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: global.PcAdminSubject,
NotBefore: jwt.NewNumericDate(time.Now()),
ID: "jwt-logout-test",
},
}
accessToken, err := signTokenForTest(claims, secretKey)
if err != nil {
t.Fatalf("generate token failed: %v", err)
}
if err := service.Logout(accessToken); err != nil {
t.Fatalf("expected logout to succeed without redis, got %v", err)
}
if !revoked {
t.Fatal("expected database revocation to be invoked")
}
}
func TestLogoutTreatsRedisWriteFailureAsSuccessAfterDatabaseRevocation(t *testing.T) {
secretKey := testkit.SecretKey("auth-logout")
service := NewLoginServiceWithDeps(LoginServiceDeps{
ConfigProvider: func() *config.Conf {
return &config.Conf{
Jwt: autoload.JwtConfig{
SecretKey: secretKey,
},
}
},
})
revoked := false
blacklistWriteCalled := false
service.markTokensRevokedFn = func(_ context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error {
revoked = true
if len(jwtIDs) != 1 || jwtIDs[0] == "" {
t.Fatalf("unexpected jwt ids: %#v", jwtIDs)
}
if revokedCode != model.RevokedCodeUserLogout || revokedReason == "" {
t.Fatalf("unexpected revoke payload: code=%d reason=%s", revokedCode, revokedReason)
}
return nil
}
service.writeTokenToBlacklistFn = func(jwtID string, remainingTime time.Duration) error {
blacklistWriteCalled = true
if jwtID == "" || remainingTime <= 0 {
t.Fatalf("unexpected blacklist write args: jwtID=%q remaining=%v", jwtID, remainingTime)
}
return errors.New("redis write timeout")
}
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 1, Username: "tester"},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
Issuer: global.Issuer,
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: global.PcAdminSubject,
NotBefore: jwt.NewNumericDate(time.Now()),
ID: "jwt-logout-blacklist-fail",
},
}
accessToken, err := signTokenForTest(claims, secretKey)
if err != nil {
t.Fatalf("generate token failed: %v", err)
}
if err := service.Logout(accessToken); err != nil {
t.Fatalf("expected logout to degrade to success, got %v", err)
}
if !revoked {
t.Fatal("expected database revocation to be invoked")
}
if !blacklistWriteCalled {
t.Fatal("expected redis blacklist write to be attempted")
}
}
// TestAcquireMemoryLock 验证内存锁可以正常获取并释放。
func TestAcquireMemoryLock(t *testing.T) {
service := NewLoginService()
unlock := service.acquireMemoryLock("lock-key")
if unlock == nil {
t.Fatal("expected unlock function")
}
unlock()
}
func TestNewLoginServiceSharesDefaultRefreshLockStore(t *testing.T) {
first := NewLoginService()
second := NewLoginService()
if first.refreshLockStore == nil || second.refreshLockStore == nil {
t.Fatal("expected refresh lock store to be initialized")
}
if first.refreshLockStore != second.refreshLockStore {
t.Fatal("expected default refresh lock store to be shared across service instances")
}
}
func TestNewLoginServiceWithDepsUsesCustomRefreshLockStore(t *testing.T) {
customStore := newRefreshTokenLock(100*time.Millisecond, 20*time.Millisecond)
service := NewLoginServiceWithDeps(LoginServiceDeps{
RefreshLockStore: customStore,
})
if service.refreshLockStore != customStore {
t.Fatal("expected custom refresh lock store to be used")
}
}
func TestAcquireRefreshLockFallsBackToMemoryWhenRedisDisabled(t *testing.T) {
cfg := &config.Conf{
Redis: autoload.RedisConfig{
Enable: false,
},
}
service := NewLoginServiceWithDeps(LoginServiceDeps{
ConfigProvider: func() *config.Conf { return cfg },
})
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 42},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-id",
},
}
unlock := service.acquireRefreshLock("refresh-lock:test", claims)
if unlock == nil {
t.Fatal("expected memory lock fallback unlock function")
}
unlock()
}
func TestResolvePrincipalSkipsAutoRefreshWhenMysqlUnavailable(t *testing.T) {
service := NewLoginService()
claims := &token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{UserID: 12, Username: "tester"},
RegisteredClaims: jwt.RegisteredClaims{
ID: "jwt-id",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
},
}
refreshCalled := false
service.blacklistLookupFn = func(_ string) (bool, error) { return false, nil }
service.mysqlReadyFn = func() bool { return false }
service.tryRefreshPrincipalFn = func(_ *AuthPrincipal) {
refreshCalled = true
}
principal, ok := service.resolvePrincipalFromClaims(claims)
if !ok || principal == nil {
t.Fatal("expected principal to be resolved")
}
if refreshCalled {
t.Fatal("expected auto refresh to be skipped when mysql is unavailable")
}
}
func signTokenForTest(claims jwt.Claims, secret string) (string, error) {
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return jwtToken.SignedString([]byte(secret))
}
================================================
FILE: internal/service/auth/login_log_helpers.go
================================================
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/pkg/utils/crypto"
)
// buildLoginLog 构建登录日志记录。
func (s *LoginService) buildLoginLog(uid uint, username, jwtId, accessToken, refreshToken string, expiresAt time.Time, logInfo LoginLogInfo, loginStatus uint8, failReason string, logType uint8) *model.AdminLoginLogs {
loginLog := model.NewAdminLoginLogs()
loginLog.UID = uid
loginLog.Username = username
loginLog.JwtID = jwtId
s.encryptAndSetToken(loginLog, accessToken, refreshToken, uid)
loginLog.IP = logInfo.IP
loginLog.UserAgent = logInfo.UserAgent
loginLog.OS = logInfo.OS
loginLog.Browser = logInfo.Browser
loginLog.ExecutionTime = logInfo.ExecutionTime
loginLog.LoginStatus = loginStatus
loginLog.LoginFailReason = failReason
loginLog.Type = logType
if !expiresAt.IsZero() {
loginLog.TokenExpires = &(utils.FormatDate{Time: expiresAt})
}
return loginLog
}
// encryptAndSetToken 加密并设置 token 到登录日志。
func (s *LoginService) encryptAndSetToken(loginLog *model.AdminLoginLogs, accessToken, refreshToken string, uid uint) {
encryptKey := s.currentConfig().Jwt.SecretKey
if accessToken != "" {
loginLog.AccessToken = s.encryptToken(encryptKey, accessToken, "access_token", uid)
loginLog.TokenHash = s.calculateTokenHash(accessToken)
}
if refreshToken != "" {
loginLog.RefreshToken = s.encryptToken(encryptKey, refreshToken, "refresh_token", uid)
loginLog.RefreshTokenHash = s.calculateTokenHash(refreshToken)
}
}
// encryptToken 加密 token。
func (s *LoginService) encryptToken(key, token, tokenType string, uid uint) string {
encrypted, err := crypto.Encrypt(key, token)
if err != nil {
log.Logger.Error("加密 token 失败", zap.Error(err), zap.String("token_type", tokenType), zap.Uint("user_id", uid))
return ""
}
return encrypted
}
// extractErrorMessage 提取简洁的错误消息。
func (s *LoginService) extractErrorMessage(err error) string {
var businessErr *e.BusinessError
if errors.As(err, &businessErr) {
return businessErr.GetMessage()
}
return err.Error()
}
// ExtractErrorMessage 提供给 controller 的错误消息提取入口。
func (s *LoginService) ExtractErrorMessage(err error) string {
return s.extractErrorMessage(err)
}
// RecordLoginFailLog 记录登录失败日志。
func (s *LoginService) RecordLoginFailLog(username, failReason string, logInfo LoginLogInfo) {
if !s.currentConfig().Mysql.Enable {
return
}
loginLog := s.buildLoginLog(0, username, "", "", "", time.Time{}, logInfo, model.LoginStatusFail, failReason, model.LoginTypeLogin)
go func() {
defer func() {
if r := recover(); r != nil {
logLoginAsyncError("记录登录失败日志 panic",
zap.String("operation", "record_login_fail_log"),
zap.String("username", username),
zap.String("fail_reason", failReason),
zap.Any("recover", r))
}
}()
if err := loginLog.Create(); err != nil {
logLoginAsyncError("记录登录失败日志出错",
zap.String("operation", "record_login_fail_log"),
zap.String("username", username),
zap.String("fail_reason", failReason),
zap.String("ip", logInfo.IP),
zap.Error(err))
}
}()
}
// calculateTokenHash 计算 Token 的 SHA256 哈希值。
func (s *LoginService) calculateTokenHash(accessToken string) string {
hashBytes := sha256.Sum256([]byte(accessToken))
return hex.EncodeToString(hashBytes[:])
}
func logLoginAsyncError(message string, fields ...zap.Field) {
if log.Logger == nil {
return
}
log.Logger.Error(message, fields...)
}
================================================
FILE: internal/service/auth/login_refresh.go
================================================
package auth
import (
"context"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mssola/useragent"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/data"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
)
const refreshLockRedisTimeout = 2 * time.Second
const unknownUserAgentPart = "Unknown"
// BuildLoginLogInfo 从请求上下文构建登录日志信息。
func (s *LoginService) BuildLoginLogInfo(c *gin.Context) LoginLogInfo {
userAgentStr := c.Request.UserAgent()
ua := useragent.New(userAgentStr)
os := normalizeUserAgentPart(ua.OS())
browser, _ := ua.Browser()
browser = normalizeUserAgentPart(browser)
return LoginLogInfo{
IP: c.ClientIP(),
UserAgent: userAgentStr,
OS: os,
Browser: browser,
}
}
func normalizeUserAgentPart(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return unknownUserAgentPart
}
return value
}
// tryRefreshToken 尝试自动刷新 Token。
func (s *LoginService) tryRefreshToken(principal *AuthPrincipal) {
s.ensureRuntimeDeps()
if !s.mysqlReadyFn() {
return
}
if !s.shouldRefreshToken(principal) {
return
}
lockKey := s.buildRefreshLockKey(principal.Claims)
unlock := s.acquireRefreshLock(lockKey, principal.Claims)
if unlock == nil {
return
}
defer unlock()
s.doRefreshToken(principal)
}
// shouldRefreshToken 判断是否需要刷新 token。
func (s *LoginService) shouldRefreshToken(principal *AuthPrincipal) bool {
cfg := s.currentConfig()
if cfg.Jwt.RefreshTTL <= 0 || s.GetCtx() == nil || principal == nil || principal.Claims == nil {
return false
}
exp, err := principal.Claims.GetExpirationTime()
if err != nil || exp == nil {
return false
}
refreshTTL := cfg.Jwt.RefreshTTL * time.Second
return exp.Time.Sub(time.Now()) < refreshTTL
}
// buildRefreshLockKey 构建刷新锁 key。
func (s *LoginService) buildRefreshLockKey(claims *token.AdminCustomClaims) string {
return refreshLockPrefix + strconv.FormatUint(uint64(claims.UserID), 10) + ":" + claims.ID
}
// acquireRefreshLock 获取刷新锁。
func (s *LoginService) acquireRefreshLock(lockKey string, claims *token.AdminCustomClaims) func() {
cfg := s.currentConfig()
if !(cfg.Redis.Enable && data.RedisClient() != nil) {
return s.acquireMemoryLock(lockKey)
}
unlock, locked, err := s.acquireRedisLock(lockKey)
if err != nil {
log.Logger.Warn("获取刷新token Redis锁失败,降级到内存锁", zap.Error(err), zap.Uint("user_id", claims.UserID), zap.String("jwt_id", claims.ID))
return s.acquireMemoryLock(lockKey)
}
if !locked {
return nil
}
return unlock
}
// acquireRedisLock 获取 Redis 分布式锁。
func (s *LoginService) acquireRedisLock(lockKey string) (func(), bool, error) {
redisClient := data.RedisClient()
lockCtx, lockCancel := context.WithTimeout(context.Background(), refreshLockRedisTimeout)
defer lockCancel()
locked, err := redisClient.SetNX(lockCtx, lockKey, "1", refreshLockTTL).Result()
if err != nil {
return nil, false, err
}
if !locked {
return nil, false, nil
}
return func() {
unlockCtx, unlockCancel := context.WithTimeout(context.Background(), refreshLockRedisTimeout)
defer unlockCancel()
if err := redisClient.Del(unlockCtx, lockKey).Err(); err != nil {
log.Logger.Warn("释放刷新 token Redis 锁失败", zap.Error(err), zap.String("lock_key", lockKey))
}
}, true, nil
}
// acquireMemoryLock 获取内存锁。
func (s *LoginService) acquireMemoryLock(lockKey string) func() {
s.ensureRuntimeDeps()
memLock := s.refreshLockStore.getLock(lockKey)
memLock.Lock()
return memLock.Unlock
}
// doRefreshToken 执行刷新 token。
func (s *LoginService) doRefreshToken(principal *AuthPrincipal) {
if principal == nil || principal.Claims == nil {
return
}
logInfo := s.BuildLoginLogInfo(s.GetCtx())
tokenResponse, err := s.Refresh(principal.UserID, logInfo)
if err != nil {
log.Logger.Warn("自动刷新token失败", zap.Error(err), zap.Uint("user_id", principal.UserID), zap.String("jwt_id", principal.JWTID))
return
}
if tokenResponse == nil {
return
}
ctx := s.GetCtx()
ctx.Writer.Header().Set("refresh-access-token", tokenResponse.AccessToken)
ctx.Writer.Header().Set("refresh-exp", strconv.FormatInt(tokenResponse.ExpiresAt, 10))
}
================================================
FILE: internal/service/auth/login_revoke.go
================================================
package auth
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"go.uber.org/zap"
)
const revokeLogAsyncTimeout = 5 * time.Second
// isTokenRevokedInLog 检查登录日志表中 token 是否被撤销。
func (s *LoginService) isTokenRevokedInLog(jwtId string) bool {
if jwtId == "" {
return false
}
loginLog := model.NewAdminLoginLogs()
err := loginLog.FindByJwtId(jwtId)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logRevokeError("检查token撤销状态失败", zap.Error(err), zap.String("jwt_id", jwtId))
}
return false
}
return loginLog.IsRevoked == model.IsRevokedYes
}
func (s *LoginService) markTokensRevoked(ctx context.Context, jwtIds []string, revokedCode uint8, revokedReason string) error {
now := time.Now()
revokedAt := utils.FormatDate{Time: now}
loginLog := model.NewAdminLoginLogs()
db := s.loginLogDB
if db == nil {
var err error
db, err = loginLog.GetDB()
if err != nil {
return err
}
}
if ctx != nil {
db = db.WithContext(ctx)
}
loginLog.SetDB(db)
return loginLog.UpdateRevokedStatusByJwtIds(jwtIds, revokedCode, revokedReason, revokedAt)
}
func logRevokeError(message string, fields ...zap.Field) {
if log.Logger == nil {
return
}
log.Logger.Error(message, fields...)
}
================================================
FILE: internal/service/auth/login_security.go
================================================
package auth
import (
stderrors "errors"
"math"
"strings"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/service/sys_config"
"go.uber.org/zap"
)
const (
defaultLoginMaxFailures = 5
defaultLoginLockMinutes = 15
)
type loginLockPolicy struct {
Enabled bool
MaxFailures int
LockDuration time.Duration
}
// CheckLoginAllowed 校验账号当前是否允许继续登录。
func (s *LoginService) CheckLoginAllowed(username string) error {
return s.ensureLoginAllowed(username)
}
// HandleLoginFailure 统一处理登录失败:记录失败日志,并按策略累加失败计数。
func (s *LoginService) HandleLoginFailure(username, failReason string, logInfo LoginLogInfo, countTowardLock bool) {
s.RecordLoginFailLog(username, failReason, logInfo)
if !countTowardLock {
return
}
if err := s.incrementLoginFailState(username); err != nil {
log.Logger.Warn("更新登录失败计数失败", zap.String("username", username), zap.Error(err))
}
}
func (s *LoginService) ensureLoginAllowed(username string) error {
policy := s.loginLockPolicy()
if !policy.Enabled {
return nil
}
username = strings.TrimSpace(username)
if username == "" {
return nil
}
state := model.NewLoginSecurityState()
err := state.FindByUsername(username)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) || isTableNotFoundErr(err) || e.IsDependencyNotReady(err) {
return nil
}
return err
}
now := time.Now()
if state.LockUntil == nil || !state.LockUntil.Time.After(now) {
// 锁已过期后清空状态,避免残留失败计数影响后续判断。
if state.FailCount > 0 || state.LockUntil != nil {
_ = s.clearLoginFailState(username)
}
return nil
}
remainingMinutes := int(math.Ceil(state.LockUntil.Time.Sub(now).Minutes()))
if remainingMinutes < 1 {
remainingMinutes = 1
}
return e.NewBusinessErrorWithKey(e.LoginAccountLocked, e.MsgKeyAuthAccountLocked, remainingMinutes)
}
func (s *LoginService) incrementLoginFailState(username string) error {
policy := s.loginLockPolicy()
if !policy.Enabled {
return nil
}
username = strings.TrimSpace(username)
if username == "" {
return nil
}
state := model.NewLoginSecurityState()
db, err := state.GetDB()
if err != nil {
if e.IsDependencyNotReady(err) {
return nil
}
return err
}
now := time.Now()
return db.Transaction(func(tx *gorm.DB) error {
current := model.NewLoginSecurityState()
current.SetDB(tx)
findErr := current.FindByUsername(username)
if findErr != nil {
if isTableNotFoundErr(findErr) {
return nil
}
if !stderrors.Is(findErr, gorm.ErrRecordNotFound) {
return findErr
}
next := model.NewLoginSecurityState()
next.SetDB(tx)
next.Username = username
applyLoginFailState(next, now, policy)
return next.Save()
}
applyLoginFailState(current, now, policy)
return current.Save()
})
}
func (s *LoginService) clearLoginFailState(username string) error {
username = strings.TrimSpace(username)
if username == "" {
return nil
}
state := model.NewLoginSecurityState()
db, err := state.GetDB()
if err != nil {
if e.IsDependencyNotReady(err) {
return nil
}
return err
}
return db.Model(state).Where("username = ?", username).Updates(map[string]any{
"fail_count": 0,
"lock_until": nil,
"last_failed_at": nil,
}).Error
}
func (s *LoginService) loginLockPolicy() loginLockPolicy {
policy := loginLockPolicy{
Enabled: sys_config.BoolValue(sys_config.AuthLoginLockEnabledConfigKey, true),
MaxFailures: sys_config.IntValue(sys_config.AuthLoginMaxFailuresConfigKey, defaultLoginMaxFailures),
LockDuration: time.Duration(sys_config.IntValue(sys_config.AuthLoginLockMinutesConfigKey, defaultLoginLockMinutes)) * time.Minute,
}
if policy.MaxFailures < 1 {
policy.MaxFailures = defaultLoginMaxFailures
}
if policy.LockDuration < time.Minute {
policy.LockDuration = defaultLoginLockMinutes * time.Minute
}
return policy
}
func (s *LoginService) shouldCountLockFailure(err error) bool {
var businessErr *e.BusinessError
if !stderrors.As(err, &businessErr) {
return false
}
switch businessErr.GetCode() {
case e.UserDoesNotExist, e.UserDisable, e.UserPasswordWrong, e.CaptchaErr:
return true
default:
return false
}
}
func applyLoginFailState(state *model.LoginSecurityState, now time.Time, policy loginLockPolicy) {
if state == nil {
return
}
if state.LockUntil != nil && !state.LockUntil.Time.After(now) {
state.FailCount = 0
state.LockUntil = nil
}
state.FailCount++
state.LastFailedAt = &utils.FormatDate{Time: now}
if int(state.FailCount) >= policy.MaxFailures {
state.LockUntil = &utils.FormatDate{Time: now.Add(policy.LockDuration)}
}
}
func isTableNotFoundErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "doesn't exist") || strings.Contains(msg, "does not exist") || strings.Contains(msg, "no such table")
}
================================================
FILE: internal/service/auth/login_security_test.go
================================================
package auth
import (
"testing"
"time"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
func TestShouldCountLockFailure(t *testing.T) {
service := NewLoginService()
cases := []struct {
name string
err error
want bool
}{
{name: "wrong password", err: e.NewBusinessError(e.UserPasswordWrong), want: true},
{name: "captcha", err: e.NewBusinessError(e.CaptchaErr), want: true},
{name: "dependency not ready", err: e.NewBusinessError(e.ServiceDependencyNotReady), want: false},
{name: "login failed", err: e.NewBusinessError(e.LoginFailed), want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := service.shouldCountLockFailure(tc.err); got != tc.want {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
func TestApplyLoginFailStateLocksWhenThresholdReached(t *testing.T) {
state := &model.LoginSecurityState{
FailCount: 4,
}
now := time.Now()
policy := loginLockPolicy{
Enabled: true,
MaxFailures: 5,
LockDuration: 15 * time.Minute,
}
applyLoginFailState(state, now, policy)
if state.FailCount != 5 {
t.Fatalf("expected fail count 5, got %d", state.FailCount)
}
if state.LockUntil == nil || !state.LockUntil.Time.After(now) {
t.Fatal("expected lock_until to be set")
}
if state.LastFailedAt == nil || state.LastFailedAt.Time.IsZero() {
t.Fatal("expected last_failed_at to be set")
}
}
func TestApplyLoginFailStateResetsExpiredLock(t *testing.T) {
now := time.Now()
state := &model.LoginSecurityState{
FailCount: 9,
LockUntil: &utils.FormatDate{Time: now.Add(-time.Minute)},
}
policy := loginLockPolicy{
Enabled: true,
MaxFailures: 5,
LockDuration: 15 * time.Minute,
}
applyLoginFailState(state, now, policy)
if state.FailCount != 1 {
t.Fatalf("expected expired state to reset then increment to 1, got %d", state.FailCount)
}
if state.LockUntil != nil {
t.Fatal("expected no lock while fail count below threshold")
}
}
================================================
FILE: internal/service/auth/login_token_ops.go
================================================
package auth
import (
"errors"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
"go.uber.org/zap"
)
// CheckToken 检查 Token 是否有效。
func (s *LoginService) CheckToken(accessToken string) (*model.AdminUser, bool) {
principal, ok := s.ResolvePrincipal(accessToken)
if !ok || principal == nil {
return nil, false
}
return principal.AdminUser(), true
}
// ResolvePrincipal 解析并验证当前访问令牌对应的认证主体。
func (s *LoginService) ResolvePrincipal(accessToken string) (*AuthPrincipal, bool) {
claims, err := s.parseToken(accessToken)
if err != nil {
return nil, false
}
return s.resolvePrincipalFromClaims(claims)
}
func (s *LoginService) resolvePrincipalFromClaims(claims *token.AdminCustomClaims) (*AuthPrincipal, bool) {
s.ensureRuntimeDeps()
if claims == nil {
return nil, false
}
exp, err := claims.GetExpirationTime()
if err != nil || exp == nil {
return nil, false
}
if !s.isPrincipalValid(claims) {
return nil, false
}
principal := newAuthPrincipalFromClaims(claims)
if s.mysqlReadyFn() {
s.tryRefreshPrincipalFn(principal)
}
return principal, true
}
// isPrincipalValid 检查 token 对应主体是否仍然有效。
func (s *LoginService) isPrincipalValid(claims *token.AdminCustomClaims) bool {
s.ensureRuntimeDeps()
if claims == nil {
return false
}
inBlacklist, err := s.blacklistLookupFn(claims.ID)
if err == nil {
if inBlacklist {
return false
}
if s.mysqlReadyFn() {
return !s.tokenRevokedLookupFn(claims.ID)
}
return true
}
if !s.mysqlReadyFn() {
return false
}
if log.Logger != nil && s.shouldLogRedisFallback(err) {
log.Logger.Warn("Redis 黑名单查询失败,回退到数据库撤销状态校验", zap.Error(err), zap.String("jwt_id", claims.ID))
}
return !s.tokenRevokedLookupFn(claims.ID)
}
func (s *LoginService) shouldLogRedisFallback(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errRedisUnavailable) {
cfg := s.currentConfig()
return cfg != nil && cfg.Redis.Enable
}
return true
}
================================================
FILE: internal/service/auth/login_types.go
================================================
package auth
import (
"sync"
"time"
gocache "github.com/patrickmn/go-cache"
)
const (
tokenTypeBearer = "Bearer"
blacklistPrefix = "blacklist:"
refreshLockPrefix = "refresh_token_lock:"
refreshLockTTL = 5 * time.Second // 锁的过期时间,防止死锁
defaultTokenTTL = 24 * time.Hour // 默认 token 过期时间(当 token_expires 为 NULL 时使用)
)
// refreshTokenLock 内存锁存储(当Redis未启用时使用)。
type refreshTokenLock struct {
mu sync.RWMutex
ttl time.Duration
locks *gocache.Cache
}
func newRefreshTokenLock(ttl, cleanupInterval time.Duration) *refreshTokenLock {
return &refreshTokenLock{
ttl: ttl,
locks: gocache.New(ttl, cleanupInterval),
}
}
// getLock 获取或创建指定 key 的锁。
func (r *refreshTokenLock) getLock(key string) *sync.Mutex {
r.mu.RLock()
if lock, ok := r.locks.Get(key); ok {
r.mu.RUnlock()
return lock.(*sync.Mutex)
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
if lock, ok := r.locks.Get(key); ok {
return lock.(*sync.Mutex)
}
newLock := &sync.Mutex{}
// 让缓存接管过期回收,避免为每个 key 启一个 sleep goroutine 模拟 TTL。
r.locks.Set(key, newLock, r.ttl)
return newLock
}
var (
defaultRefreshLockStoreOnce sync.Once
defaultRefreshLockStoreInst *refreshTokenLock
)
func defaultRefreshLockStore() *refreshTokenLock {
defaultRefreshLockStoreOnce.Do(func() {
defaultRefreshLockStoreInst = newRefreshTokenLock(refreshLockTTL, refreshLockTTL)
})
return defaultRefreshLockStoreInst
}
// TokenResponse Token响应体。
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
}
// LoginLogInfo 登录日志信息。
type LoginLogInfo struct {
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
OS string `json:"os"`
Browser string `json:"browser"`
ExecutionTime int `json:"execution_time"`
}
================================================
FILE: internal/service/auth/login_types_test.go
================================================
package auth
import (
"sync"
"testing"
"time"
)
func TestRefreshTokenLockReusesMutexBeforeExpiry(t *testing.T) {
locker := newRefreshTokenLock(50*time.Millisecond, 10*time.Millisecond)
first := locker.getLock("same-key")
second := locker.getLock("same-key")
if first != second {
t.Fatal("expected the same mutex instance before expiry")
}
}
func TestRefreshTokenLockCreatesNewMutexAfterExpiry(t *testing.T) {
locker := newRefreshTokenLock(20*time.Millisecond, 5*time.Millisecond)
first := locker.getLock("expired-key")
time.Sleep(60 * time.Millisecond)
second := locker.getLock("expired-key")
if first == second {
t.Fatal("expected a new mutex instance after expiry")
}
}
func TestRefreshTokenLockIsSafeUnderConcurrentAccess(t *testing.T) {
locker := newRefreshTokenLock(100*time.Millisecond, 20*time.Millisecond)
const workers = 16
results := make(chan *sync.Mutex, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
results <- locker.getLock("concurrent-key")
}()
}
wg.Wait()
close(results)
var first *sync.Mutex
for lock := range results {
if first == nil {
first = lock
continue
}
if first != lock {
t.Fatal("expected all goroutines to receive the same mutex instance")
}
}
}
================================================
FILE: internal/service/auth/principal.go
================================================
package auth
import (
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
)
// AuthPrincipal 表示一次请求中已验证的认证主体。
//
// 这里固定采用 claims-first 语义:中间件只保存 JWT claims 中已有的字段快照,
// 不在请求上下文里缓存完整的 AdminUser 模型,避免每个请求默认回表。
type AuthPrincipal struct {
Claims *token.AdminCustomClaims
JWTID string
UserID uint
Username string
Nickname string
Email string
FullPhoneNumber string
PhoneNumber string
CountryCode string
IsSuperAdmin uint8
}
func newAuthPrincipalFromClaims(claims *token.AdminCustomClaims) *AuthPrincipal {
if claims == nil {
return nil
}
principal := &AuthPrincipal{
UserID: claims.UserID,
Username: claims.Username,
Nickname: claims.Nickname,
Email: claims.Email,
FullPhoneNumber: claims.FullPhoneNumber,
PhoneNumber: claims.PhoneNumber,
CountryCode: claims.CountryCode,
IsSuperAdmin: claims.IsSuperAdmin,
Claims: claims,
JWTID: claims.ID,
}
return principal
}
// AdminUser 将认证主体转换为兼容旧逻辑的轻量用户对象。
// 返回值只包含 claims 中已有字段,不保证数据库实时状态。
func (p *AuthPrincipal) AdminUser() *model.AdminUser {
if p == nil {
return nil
}
return &model.AdminUser{
ContainsDeleteBaseModel: model.ContainsDeleteBaseModel{
BaseModel: model.BaseModel{ID: p.UserID},
},
Username: p.Username,
Nickname: p.Nickname,
Email: p.Email,
FullPhoneNumber: p.FullPhoneNumber,
PhoneNumber: p.PhoneNumber,
CountryCode: p.CountryCode,
IsSuperAdmin: p.IsSuperAdmin,
}
}
// StoreAuthPrincipal 将认证主体写入上下文。
func StoreAuthPrincipal(c *gin.Context, principal *AuthPrincipal) {
if c == nil || principal == nil {
return
}
c.Set(global.ContextKeyAuthPrincipal, principal)
c.Set(global.ContextKeyUID, principal.UserID)
}
// GetAuthPrincipal 从上下文中读取认证主体。
func GetAuthPrincipal(c *gin.Context) *AuthPrincipal {
if c == nil {
return nil
}
if value, exists := c.Get(global.ContextKeyAuthPrincipal); exists {
if principal, ok := value.(*AuthPrincipal); ok {
return principal
}
}
return nil
}
================================================
FILE: internal/service/auth/session.go
================================================
package auth
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"go.uber.org/zap"
)
const defaultSessionRevokeReason = "管理员强制下线"
// ListSessions 分页查询在线会话列表。
func (s *LoginService) ListSessions(params *form.SessionList) *resources.Collection {
query := query_builder.New().
AddEq("login_status", model.LoginStatusSuccess).
AddLike("username", params.Username).
AddLike("ip", params.IP).
AddEq("is_revoked", params.IsRevoked)
if params.UID > 0 {
query.AddEq("uid", params.UID)
}
if params.StartTime != "" {
query.AddCondition("created_at >= ?", params.StartTime)
}
if params.EndTime != "" {
query.AddCondition("created_at <= ?", params.EndTime)
}
condition, args := query.Build()
loginLog := model.NewAdminLoginLogs()
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"uid",
"username",
"jwt_id",
"ip",
"os",
"browser",
"is_revoked",
"revoked_reason",
"revoked_at",
"token_expires",
"created_at",
},
OrderBy: "created_at DESC, id DESC",
}
transformer := resources.NewSessionTransformer()
total, collection, err := model.ListPageE(loginLog, params.Page, params.PerPage, condition, args, listOptionalParams)
if err != nil {
log.Logger.Error("查询在线会话列表失败", zap.Error(err))
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
// RevokeSession 撤销指定在线会话。
func (s *LoginService) RevokeSession(ctx context.Context, id uint, reason string) error {
s.ensureRuntimeDeps()
loginLog, err := s.loadRevocableSession(id)
if err != nil {
return err
}
revokeReason := strings.TrimSpace(reason)
if revokeReason == "" {
revokeReason = defaultSessionRevokeReason
}
if err := s.markTokensRevokedFn(ctx, []string{loginLog.JwtID}, model.RevokedCodeSystemForce, revokeReason); err != nil {
log.Logger.Error("撤销在线会话数据库状态失败", zap.Error(err), zap.Uint("id", id), zap.String("jwt_id", loginLog.JwtID))
return err
}
remainingTime := time.Until(loginLog.TokenExpires.Time)
if err := s.writeTokenToBlacklistFn(loginLog.JwtID, remainingTime); err != nil {
log.Logger.Warn("Redis 黑名单写入失败,保留数据库撤销状态作为兜底",
zap.Error(err),
zap.Bool("redis_unavailable", errors.Is(err, errRedisUnavailable)),
zap.Uint("id", id),
zap.String("jwt_id", loginLog.JwtID))
return nil
}
return nil
}
func (s *LoginService) loadRevocableSession(id uint) (*model.AdminLoginLogs, error) {
loginLog := model.NewAdminLoginLogs()
if s.loginLogDB != nil {
loginLog.SetDB(s.loginLogDB)
}
if err := loginLog.GetById(id); err != nil || loginLog.ID == 0 {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Logger.Error("查询在线会话失败", zap.Error(err), zap.Uint("id", id))
}
return nil, e.NewBusinessError(e.NotFound)
}
if loginLog.LoginStatus != model.LoginStatusSuccess {
return nil, e.NewBusinessError(e.InvalidParameter, "仅成功登录会话允许撤销")
}
if loginLog.IsRevoked != model.IsRevokedNo {
return nil, e.NewBusinessError(e.InvalidParameter, "会话已撤销")
}
if loginLog.TokenExpires == nil || !loginLog.TokenExpires.Time.After(time.Now()) {
return nil, e.NewBusinessError(e.InvalidParameter, "会话已过期")
}
if loginLog.JwtID == "" {
return nil, e.NewBusinessError(e.InvalidParameter, "会话缺少 jwt_id")
}
return loginLog, nil
}
================================================
FILE: internal/service/auth/session_test.go
================================================
package auth
import (
"context"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
func TestRevokeSessionUpdatesDatabaseWhenRedisUnavailable(t *testing.T) {
db := newSessionTestDB(t)
expires := utils.FormatDate{Time: time.Now().Add(time.Hour)}
loginLog := model.AdminLoginLogs{
UID: 1,
Username: "admin",
JwtID: "jwt-id",
LoginStatus: model.LoginStatusSuccess,
IsRevoked: model.IsRevokedNo,
TokenExpires: &expires,
}
if err := db.Create(&loginLog).Error; err != nil {
t.Fatalf("create login log failed: %v", err)
}
service := NewLoginServiceWithDeps(LoginServiceDeps{
LoginLogDB: db,
WriteTokenToBlacklist: func(_ string, _ time.Duration) error {
return errRedisUnavailable
},
})
if err := service.RevokeSession(context.Background(), loginLog.ID, "force offline"); err != nil {
t.Fatalf("RevokeSession returned error: %v", err)
}
var stored model.AdminLoginLogs
if err := db.First(&stored, loginLog.ID).Error; err != nil {
t.Fatalf("query login log failed: %v", err)
}
if stored.IsRevoked != model.IsRevokedYes {
t.Fatalf("expected session to be revoked, got %d", stored.IsRevoked)
}
if stored.RevokedReason != "force offline" || stored.RevokedAt == nil {
t.Fatalf("unexpected revoke fields: %#v", stored)
}
}
func TestRevokeSessionRejectsExpiredSession(t *testing.T) {
db := newSessionTestDB(t)
expires := utils.FormatDate{Time: time.Now().Add(-time.Minute)}
loginLog := model.AdminLoginLogs{
UID: 1,
Username: "admin",
JwtID: "jwt-id",
LoginStatus: model.LoginStatusSuccess,
IsRevoked: model.IsRevokedNo,
TokenExpires: &expires,
}
if err := db.Create(&loginLog).Error; err != nil {
t.Fatalf("create login log failed: %v", err)
}
service := NewLoginServiceWithDeps(LoginServiceDeps{LoginLogDB: db})
if err := service.RevokeSession(context.Background(), loginLog.ID, "force offline"); err == nil {
t.Fatal("expected expired session revoke to fail")
}
}
func newSessionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
statement := `CREATE TABLE admin_login_logs (
id integer primary key autoincrement,
created_at datetime,
updated_at datetime,
deleted_at integer not null default 0,
uid integer,
username text,
jwt_id text,
access_token text,
refresh_token text,
token_hash text,
refresh_token_hash text,
ip text,
user_agent text,
os text,
browser text,
execution_time integer,
login_status integer,
login_fail_reason text,
type integer,
is_revoked integer,
revoked_code integer,
revoked_reason text,
revoked_at datetime,
token_expires datetime,
refresh_expires datetime
)`
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("create login logs table failed: %v", err)
}
return db
}
================================================
FILE: internal/service/common.go
================================================
package service
import (
"context"
"errors"
"mime/multipart"
"os"
"path/filepath"
"time"
"github.com/wannanbigpig/gin-layout/internal/filestorage"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/pkg/utils"
"go.uber.org/zap"
)
// CommonService 通用服务
type CommonService struct {
Base
}
const maxUploadFileSize int64 = 10 * 1024 * 1024
// NewCommonService 创建通用服务实例。
func NewCommonService() *CommonService {
return &CommonService{}
}
// UploadImages 批量上传图片。
func (s CommonService) UploadImages(files []*multipart.FileHeader, path string) ([]*utils.FileInfo, error) {
filesInfo := make([]*utils.FileInfo, 0, len(files))
for _, fileHeader := range files {
file, err := s.UploadImage(fileHeader, true, path)
if err != nil {
log.Logger.Warn("文件上传失败",
zap.String("filename", fileHeader.Filename),
zap.Error(err),
)
}
filesInfo = append(filesInfo, file)
}
return summarizeImageUploadResults(filesInfo)
}
// UploadImage 上传单张图片并保存文件记录。
func (s CommonService) UploadImage(fileHeader *multipart.FileHeader, isPublic bool, path string) (*utils.FileInfo, error) {
fileInfo := initUploadResult(fileHeader)
isPublicFlag := visibilityFlag(isPublic)
basePath := storageBasePath(isPublic)
uploadPath, err := normalizeUploadPath(path)
if err != nil {
return setFileFailure(fileInfo, "上传目录不合法", err)
}
driver, storageConfig, activeDriver, err := NewActiveStorageDriver(context.Background())
if err != nil {
return setFileFailure(fileInfo, "存储配置异常", err)
}
uploadDir, err := resolveUploadDestination(basePath, uploadPath)
if err != nil {
return setFileFailure(fileInfo, "上传目录不合法", err)
}
if fileHeader.Size > maxUploadFileSize {
return setFileFailure(fileInfo, "文件大小不能大于10M", nil)
}
result, err := utils.SaveUploadedImageWithUUID(fileHeader, uploadDir)
if err != nil {
if errors.Is(err, utils.ErrInvalidImageType) {
return setFileFailure(fileInfo, "仅支持图片格式", err)
}
return setFileFailure(fileInfo, "文件保存失败", err)
}
savedPath := result.Path
fileInfo.Sha256 = result.Sha256
absBasePath, err := filepath.Abs(basePath)
if err != nil {
cleanupStoredUpload(result.Path)
return setFileFailure(fileInfo, "上传路径获取异常", err)
}
relPath, err := filepath.Rel(absBasePath, result.Path)
if err != nil {
cleanupStoredUpload(result.Path)
return setFileFailure(fileInfo, "上传路径获取异常", err)
}
result.Path = relPath
bucket := bucketForDriver(activeDriver, storageConfig, isPublicFlag)
objectKey := result.Path
etag := result.Sha256
if activeDriver != model.StorageDriverLocal {
file, err := os.Open(savedPath)
if err != nil {
cleanupStoredUpload(savedPath)
return setFileFailure(fileInfo, "读取上传文件失败", err)
}
putResult, putErr := driver.Put(context.Background(), filestorage.PutInput{
Bucket: bucket,
ObjectKey: objectKey,
Reader: file,
Size: result.Size,
ContentType: result.MimeType,
})
closeErr := file.Close()
cleanupStoredUpload(savedPath)
if putErr != nil {
return setFileFailure(fileInfo, "保存到对象存储失败", putErr)
}
if closeErr != nil {
return setFileFailure(fileInfo, "读取上传文件失败", closeErr)
}
bucket = putResult.Bucket
objectKey = putResult.ObjectKey
if putResult.ETag != "" {
etag = putResult.ETag
}
}
db, err := model.GetDB()
if err != nil {
if activeDriver == model.StorageDriverLocal {
cleanupStoredUpload(savedPath)
}
return setFileFailure(fileInfo, "保存文件信息失败", err)
}
object, reused, err := ensureFileObject(db, uploadFileObjectInput{
StorageDriver: activeDriver,
StorageBase: storageBaseForDriver(activeDriver, isPublic, bucket),
Bucket: bucket,
StoragePath: objectKey,
ObjectKey: objectKey,
Size: uint(result.Size),
Hash: result.Sha256,
MimeType: result.MimeType,
ETag: etag,
Status: model.StorageStatusStored,
})
if err != nil {
if activeDriver == model.StorageDriverLocal {
cleanupStoredUpload(savedPath)
}
return setFileFailure(fileInfo, "保存物理对象失败", err)
}
if activeDriver == model.StorageDriverLocal && reused {
cleanupStoredUpload(savedPath)
}
uploadFileModel := model.NewUploadFiles()
uploadFileModel.UID = s.GetAdminUserId()
uploadFileModel.FolderID = 0
uploadFileModel.LogicalPath = "/"
uploadFileModel.DisplayName = result.OriginName
uploadFileModel.OriginName = result.OriginName
uploadFileModel.Name = result.Name
uploadFileModel.Path = object.ObjectKey
uploadFileModel.Size = uint(result.Size)
uploadFileModel.Ext = result.Ext
uploadFileModel.Hash = result.Sha256
uploadFileModel.UUID = result.UUID
uploadFileModel.MimeType = result.MimeType
uploadFileModel.FileType = classifyUploadFileType(result.MimeType)
uploadFileModel.IsPublic = isPublicFlag
uploadFileModel.StorageDriver = activeDriver
uploadFileModel.StorageBase = object.StorageBase
uploadFileModel.Bucket = object.Bucket
uploadFileModel.StoragePath = object.StoragePath
uploadFileModel.ObjectKey = object.ObjectKey
uploadFileModel.ETag = object.ETag
uploadFileModel.StorageStatus = model.StorageStatusStored
uploadFileModel.UploadSource = model.UploadSourceBackend
uploadFileModel.UploadScene = "common"
uploadFileModel.UploadStatus = model.UploadStatusUploaded
applyObjectToUploadFile(uploadFileModel, object)
if err := uploadFileModel.Create(); err != nil {
if activeDriver == model.StorageDriverLocal && !reused {
cleanupStoredUpload(savedPath)
_ = db.Delete(&model.UploadFileObject{}, object.ID).Error
}
return setFileFailure(fileInfo, "保存文件信息失败", err)
}
fillFileInfoFromUploadResult(fileInfo, result)
return fileInfo, nil
}
// GetFileAccessPath 获取文件访问路径
// fileUUID: 文件UUID(32位十六进制字符串,不带连字符),用于URL访问
// checkAuth: 是否检查权限(私有文件需要检查)
// currentUID: 当前用户ID(用于权限检查,0表示未登录)
type FileAccessResult struct {
LocalPath string
RedirectURL string
}
func (s CommonService) GetFileAccessPath(fileUUID string, checkAuth bool, currentUID uint) (FileAccessResult, error) {
if len(fileUUID) != 32 {
return FileAccessResult{}, e.NewBusinessError(e.FileIdentifierInvalid)
}
uploadFile := model.NewUploadFiles()
// 通过UUID查询(更短,适合URL)
err := uploadFile.GetDetail("uuid = ?", fileUUID)
if err != nil {
return FileAccessResult{}, e.NewBusinessError(e.NotFound)
}
if uploadFile.IsPublic == global.No {
if !checkAuth || currentUID == 0 {
return FileAccessResult{}, e.NewBusinessError(e.FilePrivateAuthNeeded)
}
if uploadFile.UID != currentUID {
return FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)
}
}
storageDriver := uploadFile.StorageDriver
storageBase := uploadFile.StorageBase
bucket := uploadFile.Bucket
objectKey := firstNonEmpty(uploadFile.ObjectKey, uploadFile.StoragePath, uploadFile.Path)
if uploadFile.FileObjectID > 0 {
if db, dbErr := model.GetDB(); dbErr == nil {
var object model.UploadFileObject
if err := db.First(&object, uploadFile.FileObjectID).Error; err == nil {
storageDriver = object.StorageDriver
storageBase = object.StorageBase
bucket = object.Bucket
objectKey = firstNonEmpty(object.ObjectKey, object.StoragePath)
}
}
}
if storageDriver != "" && storageDriver != model.StorageDriverLocal {
driver, cfg, err := NewStorageDriverByName(context.Background(), storageDriver)
if err != nil {
return FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)
}
ttl := time.Duration(cfg.SignedURLTTLSeconds) * time.Second
if ttl <= 0 {
ttl = 5 * time.Minute
}
signedURL, err := driver.SignedURL(context.Background(), bucket, objectKey, ttl)
if err != nil {
return FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)
}
_ = uploadFile.UpdateById(uploadFile.ID, map[string]any{"last_accessed_at": time.Now()})
return FileAccessResult{RedirectURL: signedURL}, nil
}
filePath, err := resolveUploadDestination(firstNonEmpty(storageBase, storageBasePath(uploadFile.IsPublic == global.Yes)), objectKey)
if err != nil {
return FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)
}
_ = uploadFile.UpdateById(uploadFile.ID, map[string]any{"last_accessed_at": time.Now()})
return FileAccessResult{LocalPath: filePath}, nil
}
================================================
FILE: internal/service/common_test.go
================================================
package service
import (
"errors"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/pkg/utils"
)
func TestVisibilityFlag(t *testing.T) {
assert.Equal(t, uint8(global.Yes), visibilityFlag(true))
assert.Equal(t, uint8(global.No), visibilityFlag(false))
}
func TestStorageBasePath(t *testing.T) {
originBasePath := c.Config.BasePath
t.Cleanup(func() {
c.Config.BasePath = originBasePath
})
c.Config.BasePath = "/tmp/go-layout"
assert.Equal(t, filepath.Join("/tmp/go-layout", "storage/public"), storageBasePath(true))
assert.Equal(t, filepath.Join("/tmp/go-layout", "storage/private"), storageBasePath(false))
}
func TestNormalizeUploadPath(t *testing.T) {
path, err := normalizeUploadPath("")
assert.NoError(t, err)
assert.Equal(t, "default", path)
path, err = normalizeUploadPath("avatar")
assert.NoError(t, err)
assert.Equal(t, "avatar", path)
}
func TestBuildFileURL(t *testing.T) {
originBaseURL := c.Config.BaseURL
t.Cleanup(func() {
c.Config.BaseURL = originBaseURL
})
c.Config.BaseURL = "https://example.com/"
assert.Equal(t, "https://example.com/admin/v1/file/abc123", buildFileURL("abc123"))
c.Config.BaseURL = ""
assert.Equal(t, "/admin/v1/file/abc123", buildFileURL("abc123"))
assert.Equal(t, "", buildFileURL(""))
}
func TestFillFileInfoFromModel(t *testing.T) {
fileInfo := &utils.FileInfo{OriginName: "origin.png"}
uploadFile := &model.UploadFiles{
Name: "stored.png",
Path: "avatar/stored.png",
Size: 12,
Ext: ".png",
Hash: "hash",
UUID: "uuid123",
MimeType: "image/png",
}
fillFileInfoFromModel(fileInfo, uploadFile)
assert.Equal(t, "stored.png", fileInfo.Name)
assert.Equal(t, "avatar/stored.png", fileInfo.Path)
assert.Equal(t, int64(12), fileInfo.Size)
assert.Equal(t, ".png", fileInfo.Ext)
assert.Equal(t, "hash", fileInfo.Sha256)
assert.Equal(t, "uuid123", fileInfo.UUID)
assert.Equal(t, "image/png", fileInfo.MimeType)
assert.Equal(t, global.SUCCESS, fileInfo.Status)
}
func TestFillFileInfoFromUploadResult(t *testing.T) {
fileInfo := &utils.FileInfo{OriginName: "origin.png"}
result := &utils.FileInfo{
Name: "stored.png",
Path: "avatar/stored.png",
Size: 12,
Ext: ".png",
Sha256: "hash",
UUID: "uuid123",
MimeType: "image/png",
}
fillFileInfoFromUploadResult(fileInfo, result)
assert.Equal(t, "stored.png", fileInfo.Name)
assert.Equal(t, "avatar/stored.png", fileInfo.Path)
assert.Equal(t, int64(12), fileInfo.Size)
assert.Equal(t, ".png", fileInfo.Ext)
assert.Equal(t, "hash", fileInfo.Sha256)
assert.Equal(t, "uuid123", fileInfo.UUID)
assert.Equal(t, "image/png", fileInfo.MimeType)
assert.Equal(t, global.SUCCESS, fileInfo.Status)
}
func TestSummarizeImageUploadResults(t *testing.T) {
filesInfo := []*utils.FileInfo{
{Status: global.SUCCESS},
{Status: global.ERROR},
}
result, err := summarizeImageUploadResults(filesInfo)
assert.Len(t, result, 2)
assert.Error(t, err)
assert.True(t, IsPartialImageUploadError(err))
}
func TestSummarizeImageUploadResultsAllFailed(t *testing.T) {
filesInfo := []*utils.FileInfo{
{Status: global.ERROR},
{Status: global.ERROR},
}
_, err := summarizeImageUploadResults(filesInfo)
assert.Error(t, err)
assert.False(t, IsPartialImageUploadError(err))
}
func TestIsPartialImageUploadError(t *testing.T) {
assert.False(t, IsPartialImageUploadError(nil))
assert.False(t, IsPartialImageUploadError(errors.New("plain error")))
}
================================================
FILE: internal/service/common_upload_helpers.go
================================================
package service
import (
"errors"
"fmt"
"mime/multipart"
"os"
"path/filepath"
"strings"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/pkg/utils"
)
const defaultUploadSubDir = "default"
func buildFileURL(uuid string) string {
if uuid == "" {
return ""
}
baseURL := strings.TrimSuffix(c.GetConfig().BaseURL, "/")
if baseURL == "" {
return "/admin/v1/file/" + uuid
}
return baseURL + "/admin/v1/file/" + uuid
}
func setFileFailure(info *utils.FileInfo, reason string, err error) (*utils.FileInfo, error) {
info.FailureReason = reason
info.Status = global.ERROR
return info, err
}
func visibilityFlag(isPublic bool) uint8 {
if isPublic {
return global.Yes
}
return global.No
}
func storageBasePath(isPublic bool) string {
cfg := c.GetConfig()
if isPublic {
return filepath.Join(cfg.BasePath, "storage/public")
}
return filepath.Join(cfg.BasePath, "storage/private")
}
func storageBaseForDriver(driverName string, isPublic bool, bucket string) string {
if driverName == model.StorageDriverLocal {
return storageBasePath(isPublic)
}
return bucket
}
func normalizeUploadPath(path string) (string, error) {
normalized := strings.TrimSpace(path)
if normalized == "" {
return defaultUploadSubDir, nil
}
normalized = strings.ReplaceAll(normalized, "\\", "/")
cleaned := filepath.Clean(normalized)
if cleaned == "." || cleaned == string(filepath.Separator) {
return defaultUploadSubDir, nil
}
if filepath.IsAbs(cleaned) {
return "", fmt.Errorf("upload path must be relative")
}
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("upload path escapes storage root")
}
return cleaned, nil
}
func resolveUploadDestination(basePath, uploadPath string) (string, error) {
absBase, err := filepath.Abs(basePath)
if err != nil {
return "", fmt.Errorf("resolve storage base path: %w", err)
}
targetPath := filepath.Join(absBase, uploadPath)
absTarget, err := filepath.Abs(targetPath)
if err != nil {
return "", fmt.Errorf("resolve upload target path: %w", err)
}
if absTarget != absBase && !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) {
return "", fmt.Errorf("upload target escapes storage root")
}
return absTarget, nil
}
func findReusableUploadFile(hash string, isPublic uint8) (*model.UploadFiles, error) {
uploadFile := model.NewUploadFiles()
if err := uploadFile.GetDetail("hash = ? AND is_public = ?", hash, isPublic); err != nil {
return nil, err
}
return uploadFile, nil
}
func existingUploadFileExists(basePath, relativePath string) bool {
absolutePath, err := resolveUploadDestination(basePath, relativePath)
if err != nil {
return false
}
_, err = os.Stat(absolutePath)
return err == nil
}
func fillFileInfoFromModel(fileInfo *utils.FileInfo, uploadFile *model.UploadFiles) {
fileInfo.Path = uploadFile.Path
fileInfo.Name = uploadFile.Name
fileInfo.Size = int64(uploadFile.Size)
fileInfo.Ext = uploadFile.Ext
fileInfo.Sha256 = uploadFile.Hash
fileInfo.UUID = uploadFile.UUID
fileInfo.MimeType = uploadFile.MimeType
fileInfo.URL = buildFileURL(uploadFile.UUID)
fileInfo.Status = global.SUCCESS
}
func fillFileInfoFromUploadResult(fileInfo *utils.FileInfo, result *utils.FileInfo) {
fileInfo.Path = result.Path
fileInfo.Name = result.Name
fileInfo.Size = result.Size
fileInfo.Ext = result.Ext
fileInfo.Sha256 = result.Sha256
fileInfo.UUID = result.UUID
fileInfo.MimeType = result.MimeType
fileInfo.URL = buildFileURL(result.UUID)
fileInfo.Status = global.SUCCESS
}
func classifyUploadFileType(mimeType string) string {
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
switch {
case strings.HasPrefix(mimeType, "image/"):
return "image"
case mimeType == "application/pdf":
return "pdf"
case strings.Contains(mimeType, "word") || strings.Contains(mimeType, "wordprocessingml"):
return "word"
case strings.Contains(mimeType, "excel") || strings.Contains(mimeType, "spreadsheetml"):
return "excel"
case strings.Contains(mimeType, "powerpoint") || strings.Contains(mimeType, "presentation"):
return "ppt"
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "rar") || strings.Contains(mimeType, "7z") || strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "tar"):
return "archive"
case strings.HasPrefix(mimeType, "text/") || mimeType == "application/json" || mimeType == "application/xml" || mimeType == "application/yaml":
return "text"
case strings.HasPrefix(mimeType, "audio/"):
return "audio"
case strings.HasPrefix(mimeType, "video/"):
return "video"
default:
return "other"
}
}
func cleanupStoredUpload(path string) {
if path == "" {
return
}
_ = os.Remove(path)
}
func summarizeImageUploadResults(filesInfo []*utils.FileInfo) ([]*utils.FileInfo, error) {
if len(filesInfo) == 0 {
return filesInfo, nil
}
successCount := 0
for _, item := range filesInfo {
if item != nil && item.Status == global.SUCCESS {
successCount++
}
}
switch {
case successCount == len(filesInfo):
return filesInfo, nil
case successCount == 0:
return filesInfo, e.NewBusinessError(e.FAILURE)
default:
return filesInfo, e.NewBusinessError(e.FileUploadPartialFail)
}
}
// IsPartialImageUploadError 判断是否属于部分图片上传失败错误。
func IsPartialImageUploadError(err error) bool {
var businessErr *e.BusinessError
return errors.As(err, &businessErr) && businessErr.GetCode() == e.FileUploadPartialFail
}
func initUploadResult(fileHeader *multipart.FileHeader) *utils.FileInfo {
return &utils.FileInfo{
OriginName: fileHeader.Filename,
Size: fileHeader.Size,
Ext: filepath.Ext(fileHeader.Filename),
Status: global.ERROR,
}
}
================================================
FILE: internal/service/common_upload_helpers_test.go
================================================
package service
import "testing"
func TestNormalizeUploadPathRejectsTraversal(t *testing.T) {
invalidPaths := []string{"../secret", "../../tmp", "/tmp/uploads", `..\\escape`}
for _, input := range invalidPaths {
if _, err := normalizeUploadPath(input); err == nil {
t.Fatalf("expected path %q to be rejected", input)
}
}
}
func TestNormalizeUploadPathKeepsRelativeSubdirs(t *testing.T) {
path, err := normalizeUploadPath("avatars/admin")
if err != nil {
t.Fatalf("expected valid relative path, got %v", err)
}
if path != "avatars/admin" {
t.Fatalf("unexpected normalized path: %q", path)
}
}
================================================
FILE: internal/service/dashboard/overview.go
================================================
package dashboard
import (
"math"
"strconv"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/service"
)
type Metric struct {
Key string `json:"key"`
Title string `json:"title"`
Value float64 `json:"value"`
Suffix string `json:"suffix"`
Compare string `json:"compare"`
Change string `json:"change"`
Type string `json:"type"`
}
type ActivityItem struct {
Key string `json:"key"`
Title string `json:"title"`
Desc string `json:"desc"`
Time string `json:"time"`
Type string `json:"type"`
}
type UserLogin struct {
LastLogin string `json:"last_login"`
LastIP string `json:"last_ip"`
}
type Overview struct {
Metrics []Metric `json:"metrics"`
Activities []ActivityItem `json:"activities"`
UserLogin UserLogin `json:"user_login"`
}
type OverviewService struct {
service.Base
}
func NewOverviewService() *OverviewService {
return &OverviewService{}
}
func (s *OverviewService) Overview() (*Overview, error) {
db, err := model.GetDB()
if err != nil {
return nil, err
}
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tomorrowStart := todayStart.AddDate(0, 0, 1)
yesterdayStart := todayStart.AddDate(0, 0, -1)
activeUsers, 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")
if err != nil {
return nil, err
}
activeUsersYesterday, 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")
if err != nil {
return nil, err
}
requestsToday, err := countRows(db.Table("request_logs").Where("created_at >= ? AND created_at < ?", todayStart, tomorrowStart))
if err != nil {
return nil, err
}
requestsYesterday, err := countRows(db.Table("request_logs").Where("created_at >= ? AND created_at < ?", yesterdayStart, todayStart))
if err != nil {
return nil, err
}
errorsToday, err := countRows(db.Table("request_logs").Where("created_at >= ? AND created_at < ? AND (operation_status <> 0 OR response_status >= 400)", todayStart, tomorrowStart))
if err != nil {
return nil, err
}
errorsYesterday, err := countRows(db.Table("request_logs").Where("created_at >= ? AND created_at < ? AND (operation_status <> 0 OR response_status >= 400)", yesterdayStart, todayStart))
if err != nil {
return nil, err
}
taskCompletion, err := taskCompletionRate(db, todayStart, tomorrowStart)
if err != nil {
return nil, err
}
userLogin := UserLogin{}
if s.GetAdminUserId() > 0 {
var user model.AdminUser
if err := db.Table("admin_user").Select("last_login,last_ip").Where("id = ? AND deleted_at = 0", s.GetAdminUserId()).First(&user).Error; err == nil {
userLogin.LastLogin = user.LastLogin.String()
userLogin.LastIP = user.LastIp
}
}
return &Overview{
Metrics: []Metric{
{Key: "users", Title: "活跃用户", Value: float64(activeUsers), Compare: "较昨日", Change: formatChange(activeUsers, activeUsersYesterday), Type: changeType(activeUsers, activeUsersYesterday)},
{Key: "requests", Title: "请求总量", Value: float64(requestsToday), Compare: "较昨日", Change: formatChange(requestsToday, requestsYesterday), Type: changeType(requestsToday, requestsYesterday)},
{Key: "errors", Title: "异常告警", Value: float64(errorsToday), Compare: "较昨日", Change: formatChange(errorsToday, errorsYesterday), Type: inverseChangeType(errorsToday, errorsYesterday)},
{Key: "tasks", Title: "任务完成率", Value: taskCompletion, Suffix: "%", Compare: "计划完成", Change: "+0.0%", Type: "primary"},
},
Activities: buildActivities(db),
UserLogin: userLogin,
}, nil
}
func countRows(db *gorm.DB) (int64, error) {
var count int64
if err := db.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func countDistinct(db *gorm.DB, field string) (int64, error) {
var count int64
if err := db.Select("COUNT(DISTINCT " + field + ")").Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func taskCompletionRate(db *gorm.DB, start time.Time, end time.Time) (float64, error) {
total, err := countRows(db.Table("task_runs").Where("created_at >= ? AND created_at < ?", start, end))
if err != nil {
return 0, err
}
if total == 0 {
return 100, nil
}
success, err := countRows(db.Table("task_runs").Where("created_at >= ? AND created_at < ? AND status = ?", start, end, model.TaskRunStatusSuccess))
if err != nil {
return 0, err
}
return math.Round(float64(success)*1000/float64(total)) / 10, nil
}
func buildActivities(db *gorm.DB) []ActivityItem {
type row struct {
ID uint
OperatorAccount string
OperationName string
Method string
BaseURL string
CreatedAt time.Time
OperationStatus int
}
var rows []row
_ = db.Table("request_logs").
Select("id, operator_account, operation_name, method, base_url, created_at, operation_status").
Order("created_at DESC, id DESC").
Limit(4).
Scan(&rows).Error
activities := make([]ActivityItem, 0, len(rows))
for _, item := range rows {
title := item.OperationName
if title == "" {
title = item.Method + " " + item.BaseURL
}
activities = append(activities, ActivityItem{
Key: strconv.FormatUint(uint64(item.ID), 10),
Title: title,
Desc: item.OperatorAccount,
Time: item.CreatedAt.Format("15:04"),
Type: activityType(item.OperationStatus),
})
}
return activities
}
func formatChange(current int64, previous int64) string {
if previous == 0 {
if current == 0 {
return "+0.0%"
}
return "+100.0%"
}
change := (float64(current) - float64(previous)) * 100 / float64(previous)
prefix := "+"
if change < 0 {
prefix = ""
}
return prefix + strconvFormatFloat(change) + "%"
}
func strconvFormatFloat(value float64) string {
return strconv.FormatFloat(math.Round(value*10)/10, 'f', 1, 64)
}
func changeType(current int64, previous int64) string {
if current >= previous {
return "success"
}
return "warning"
}
func inverseChangeType(current int64, previous int64) string {
if current <= previous {
return "success"
}
return "danger"
}
func activityType(status int) string {
if status == 0 {
return "success"
}
return "warning"
}
================================================
FILE: internal/service/dept/audit_diff.go
================================================
package dept
import (
"sort"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var deptDiffRules = []auditdiff.FieldRule{
{Field: "id", Label: "部门ID"},
{Field: "code", Label: "部门编码"},
{Field: "name", Label: "部门名称"},
{Field: "pid", Label: "上级部门ID"},
{Field: "pids", Label: "上级路径"},
{Field: "level", Label: "层级"},
{Field: "sort", Label: "排序"},
{Field: "description", Label: "描述"},
{Field: "user_number", Label: "用户数量"},
{Field: "role_ids", Label: "角色ID列表"},
}
var deptRoleBindingDiffRules = []auditdiff.FieldRule{
{Field: "dept_id", Label: "部门ID"},
{Field: "role_ids", Label: "角色ID列表"},
}
// CreateWithAuditDiff 新增部门并返回精确 change_diff。
func (s *DeptService) CreateWithAuditDiff(params *form.CreateDept) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
deptModel, err := s.applyDeptMutation(&deptMutation{
Name: params.Name,
Pid: params.Pid,
Description: params.Description,
Sort: params.Sort,
})
if err != nil {
return "", err
}
after, err := s.snapshotDeptByID(deptModel.ID)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildDeptDiff(nil, after), nil
}
// UpdateWithAuditDiff 更新部门并返回精确 change_diff。
func (s *DeptService) UpdateWithAuditDiff(params *form.UpdateDept) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotDeptByID(params.Id)
if err != nil {
return "", err
}
if err := s.Update(params); err != nil {
return "", err
}
after, err := s.snapshotDeptByID(params.Id)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildDeptDiff(before, after), nil
}
// DeleteWithAuditDiff 删除部门并返回精确 change_diff。
func (s *DeptService) DeleteWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotDeptByID(id)
if err != nil {
return "", err
}
if err := s.Delete(id); err != nil {
return "", err
}
return buildDeptDiff(before, nil), nil
}
// BindRoleWithAuditDiff 绑定部门角色并返回精确 change_diff。
func (s *DeptService) BindRoleWithAuditDiff(params *form.DeptBindRole) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotDeptRoleBinding(params.DeptId)
if err != nil {
return "", err
}
if err := s.BindRole(params); err != nil {
return "", err
}
after, err := s.snapshotDeptRoleBinding(params.DeptId)
if err != nil {
return auditdiff.Marshal(nil), nil
}
items := auditdiff.BuildFieldDiff(before, after, deptRoleBindingDiffRules)
return auditdiff.Marshal(items), nil
}
func (s *DeptService) snapshotDeptByID(id uint) (map[string]any, error) {
dept := model.NewDepartment()
if err := dept.GetById(id); err != nil || dept.ID == 0 {
return nil, e.NewBusinessError(e.DepartmentNotFound)
}
roleIDs, err := model.NewDeptRoleMap().RoleIdsByDeptIds([]uint{id})
if err != nil {
return nil, err
}
sort.Slice(roleIDs, func(i, j int) bool {
return roleIDs[i] < roleIDs[j]
})
return map[string]any{
"id": dept.ID,
"code": dept.Code,
"name": dept.Name,
"pid": dept.Pid,
"pids": dept.Pids,
"level": dept.Level,
"sort": dept.Sort,
"description": dept.Description,
"user_number": dept.UserNumber,
"role_ids": roleIDs,
}, nil
}
func (s *DeptService) snapshotDeptRoleBinding(deptID uint) (map[string]any, error) {
dept := model.NewDepartment()
if err := dept.GetById(deptID); err != nil || dept.ID == 0 {
return nil, e.NewBusinessError(e.DepartmentNotFound)
}
roleIDs, err := model.NewDeptRoleMap().RoleIdsByDeptIds([]uint{deptID})
if err != nil {
return nil, err
}
sort.Slice(roleIDs, func(i, j int) bool {
return roleIDs[i] < roleIDs[j]
})
return map[string]any{
"dept_id": deptID,
"role_ids": roleIDs,
}, nil
}
func buildDeptDiff(before, after map[string]any) string {
items := auditdiff.BuildFieldDiff(before, after, deptDiffRules)
return auditdiff.Marshal(items)
}
================================================
FILE: internal/service/dept/audit_diff_test.go
================================================
package dept
import (
"encoding/json"
"testing"
)
func TestBuildDeptDiffContainsRoleIDs(t *testing.T) {
raw := buildDeptDiff(
map[string]any{"id": uint(1), "role_ids": []uint{1}},
map[string]any{"id": uint(1), "role_ids": []uint{1, 2}},
)
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
if len(items) != 1 {
t.Fatalf("expected 1 diff item, got %d", len(items))
}
if items[0]["field"] != "role_ids" {
t.Fatalf("expected role_ids diff, got %#v", items[0]["field"])
}
}
================================================
FILE: internal/service/dept/dept.go
================================================
package dept
import (
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
const (
maxDeptLevel = 5
deptRootPid = 0
)
// DeptService 处理部门的增删改查和角色绑定。
type DeptService struct {
service.Base
}
// NewDeptService 创建部门服务实例。
func NewDeptService() *DeptService {
return &DeptService{}
}
// List 返回部门树形列表。
func (s *DeptService) List(params *form.ListDept) any {
condition, args := s.buildListCondition(params)
deptModel := model.NewDepartment()
depts, err := model.ListE(deptModel, condition, args, model.ListOptionalParams{
OrderBy: "sort desc, id desc",
})
if err != nil {
return resources.NewDeptTreeTransformer().BuildTreeByNode(nil, 0)
}
return resources.NewDeptTreeTransformer().BuildTreeByNode(depts, 0)
}
func (s *DeptService) buildListCondition(params *form.ListDept) (string, []any) {
return query_builder.New().
AddLike("name", params.Name).
AddEq("pid", params.Pid).
Build()
}
// Create 新增部门。
func (s *DeptService) Create(params *form.CreateDept) error {
_, err := s.applyDeptMutation(&deptMutation{
Name: params.Name,
Pid: params.Pid,
Description: params.Description,
Sort: params.Sort,
})
return err
}
// Update 更新部门。
func (s *DeptService) Update(params *form.UpdateDept) error {
_, err := s.applyDeptMutation(&deptMutation{
Id: params.Id,
Name: params.Name,
Pid: params.Pid,
Description: params.Description,
Sort: params.Sort,
})
return err
}
// Delete 删除部门。
func (s *DeptService) Delete(id uint) error {
dept := model.NewDepartment()
if err := dept.GetById(id); err != nil || dept.ID == 0 {
return e.NewBusinessError(e.DepartmentNotFound)
}
if access.NewSystemDefaultsService().IsProtectedDepartment(dept) {
return e.NewBusinessError(e.FAILURE)
}
if dept.ChildrenNum > 0 {
return e.NewBusinessError(e.DepartmentHasChildren)
}
return s.executeDeleteTransaction(dept, id)
}
// Detail 获取部门详情。
func (s *DeptService) Detail(id uint) (any, error) {
dept := model.NewDepartment()
if err := dept.GetAllById(id); err != nil || dept.ID == 0 {
return nil, e.NewBusinessError(e.DepartmentNotFound)
}
return resources.NewDeptTreeTransformer().ToStruct(dept), nil
}
// BindRole 绑定角色到部门。
func (s *DeptService) BindRole(params *form.DeptBindRole) error {
deptModel := model.NewDepartment()
if err := deptModel.GetById(params.DeptId); err != nil || deptModel.ID == 0 {
return e.NewBusinessError(e.DepartmentNotFound)
}
roleIds, err := model.VerifyExistingIDs(model.NewRole(), params.RoleIds)
if err != nil {
return e.NewBusinessError(e.RoleNotFound)
}
db, err := model.NewDepartment().GetDB()
if err != nil {
return e.NewBusinessError(e.FAILURE)
}
err = access.NewPermissionSyncCoordinator().RunAfterCommitWithCode(db, e.FAILURE, func(tx *gorm.DB) error {
return s.updateDeptRole(deptModel.ID, roleIds, tx)
})
if err != nil {
return e.NewBusinessError(e.FAILURE)
}
return nil
}
// updateDeptRole 更新部门关联的角色,使用差分算法只变更差异部分。
// 处理逻辑:
// 1. 查询部门当前已关联的角色 ID 列表
// 2. 计算差异:toDelete 需删除,toAdd 需新增
// 3. 批量删除/新增角色关联
// 4. 同步部门下所有用户的权限缓存
func (s *DeptService) updateDeptRole(deptId uint, roleIds []uint, tx ...*gorm.DB) error {
deptRoleMap := model.NewDeptRoleMap()
if len(tx) > 0 {
deptRoleMap.SetDB(tx[0])
}
// 查询部门当前已关联的角色 ID 列表
existingIds, err := model.ExtractColumnsByCondition[model.DeptRoleMap, *model.DeptRoleMap, uint](
deptRoleMap,
"role_id",
"dept_id = ?",
deptId,
)
if err != nil {
return err
}
// 计算差异
toDelete, toAdd, _ := utils.CalculateChanges(existingIds, roleIds)
// 批量删除差异角色关联
if len(toDelete) > 0 {
if err := deptRoleMap.DeleteWhere("dept_id = ? AND role_id IN (?)", []any{deptId, toDelete}...); err != nil {
return err
}
}
// 批量新增角色关联
if len(toAdd) > 0 {
newMappings := lo.Map(toAdd, func(roleId uint, _ int) *model.DeptRoleMap {
return &model.DeptRoleMap{RoleId: roleId, DeptId: deptId}
})
if err := deptRoleMap.CreateBatch(newMappings); err != nil {
return err
}
}
// 同步部门下所有用户的权限缓存
userIDs, err := s.userIDsByDept(deptId, tx...)
if err != nil {
return err
}
return access.NewPermissionSyncCoordinator().SyncUsers(userIDs, tx...)
}
// userIDsByDept 查询部门下的所有用户 ID 列表。
func (s *DeptService) userIDsByDept(deptId uint, tx ...*gorm.DB) ([]uint, error) {
deptMapModel := model.NewAdminUserDeptMap()
if t := access.FirstTx(tx); t != nil {
deptMapModel.SetDB(t)
}
return deptMapModel.UidsByDeptIds([]uint{deptId})
}
================================================
FILE: internal/service/dept/dept_mutation.go
================================================
package dept
import (
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service/access"
utils2 "github.com/wannanbigpig/gin-layout/pkg/utils"
)
// deptMutation 部门变更参数,用于封装新增/更新部门的请求数据。
type deptMutation struct {
Id uint // 部门 ID,0 表示新增
Name string // 部门名称
Pid uint // 父部门 ID,0 表示顶级部门
Description string // 部门描述
Sort uint // 排序权重
}
// applyDeptMutation 执行部门变更操作(新增/更新)。
// 处理逻辑:
// 1. 验证部门是否存在(更新时)
// 2. 检查受保护部门(系统默认部门只允许改名称和描述)
// 3. 验证并构建树形路径(pids, level)
// 4. 填充部门基础字段
// 5. 事务保存:部门数据、级联更新子部门 pids、更新子部门数量
func (s *DeptService) applyDeptMutation(params *deptMutation) (*model.Department, error) {
dept := model.NewDepartment()
originPids := "0"
originPid := uint(0)
// 更新场景:加载现有部门数据,记录原始 pids 用于后续级联判断
if params.Id > 0 {
if err := dept.GetById(params.Id); err != nil || dept.ID == 0 {
return nil, e.NewBusinessError(e.DepartmentNotFound)
}
originPids = dept.Pids
originPid = dept.Pid
}
// 检查是否为受保护部门(系统默认部门只允许修改名称和描述)
if params.Id > 0 && access.NewSystemDefaultsService().IsProtectedDepartment(dept) {
if params.Pid != dept.Pid || params.Sort != dept.Sort {
return nil, e.NewBusinessError(e.FAILURE)
}
}
// 处理父部门变更:验证父部门、检测环路、计算层级和路径
if params.Pid > 0 && params.Pid != dept.Pid {
parentDept := model.NewDepartment()
if err := parentDept.GetById(params.Pid); err != nil || parentDept.ID == 0 {
return nil, e.NewBusinessError(e.ParentDeptNotExists)
}
// 环路检测:当前部门若已在父部门的祖先路径上,选择该父部门会形成环
if dept.ID > 0 && utils2.WouldCauseCycle(dept.ID, params.Pid, parentDept.Pids) {
return nil, e.NewBusinessError(e.ParentDeptInvalid)
}
// 构建新的层级和路径:父层级 +1,pids = 父 pids + 父 ID
dept.Level = parentDept.Level + 1
if parentDept.Pids == "0" || parentDept.Pids == "" {
dept.Pids = fmt.Sprintf("%d", parentDept.ID)
} else {
dept.Pids = fmt.Sprintf("%s,%d", parentDept.Pids, parentDept.ID)
}
dept.Pid = params.Pid
} else if params.Pid == deptRootPid {
// 设置为顶级部门
dept.Level = 1
dept.Pids = "0"
dept.Pid = deptRootPid
} else {
// 父部门未变更,仅同步 pid 字段
dept.Pid = params.Pid
}
// 检查部门层级深度是否超限
if dept.Level > maxDeptLevel {
return nil, e.NewBusinessError(e.MaxDeptDepth)
}
// 生成部门 code(为空时)
if dept.Code == "" {
dept.Code = s.generateDeptCode()
}
// 填充可变更字段
dept.Name = params.Name
dept.Description = params.Description
dept.Sort = params.Sort
db, err := dept.GetDB()
if err != nil {
return nil, err
}
// 事务执行:保存部门、级联更新子部门 pids、更新子部门数量
if err := access.RunInTransaction(db, func(tx *gorm.DB) error {
dept.SetDB(tx)
if err := dept.Save(); err != nil {
return err
}
// pids 变更时,级联更新所有子部门的 pids 路径
if dept.Pids != originPids {
updateExpr := s.buildPidsUpdateExpr(originPids, dept.Pids)
deptModel := model.NewDepartment()
deptModel.SetDB(tx)
if err := deptModel.UpdateChildrenPidsByParent(dept.ID, updateExpr); err != nil {
return err
}
}
// 原父部门的子部门数量减 1
if originPid > 0 && originPid != dept.Pid {
if err := model.UpdateChildrenNum(model.NewDepartment(), originPid, tx); err != nil {
return err
}
}
// 新父部门的子部门数量加 1
if dept.Pid > 0 && dept.Pid != originPid {
if err := model.UpdateChildrenNum(model.NewDepartment(), dept.Pid, tx); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return dept, nil
}
// generateDeptCode 生成部门唯一编码,格式:dept_{uuid}。
func (s *DeptService) generateDeptCode() string {
return "dept_" + uuid.NewString()
}
// buildPidsUpdateExpr 构建 SQL CASE 表达式,用于级联更新子部门的 pids 路径。
// 场景:当某部门的 pids 变更时,其所有子部门的 pids 前缀需要同步更新。
// 参数:
// - originPids: 原始路径
// - newPids: 新路径
//
// 返回:SQL CASE 表达式字符串
// 示例:originPids="1,2", newPids="1,8" 时,子部门 "1,2,3" → "1,8,3"
func (s *DeptService) buildPidsUpdateExpr(originPids, newPids string) string {
if originPids == "0" {
return fmt.Sprintf(
"CASE WHEN pids = '0' THEN '%s' WHEN pids LIKE '0,%%' THEN CONCAT('%s,', SUBSTRING(pids, 3)) ELSE pids END",
newPids, newPids,
)
}
return fmt.Sprintf(
"CASE WHEN pids = '%s' THEN '%s' WHEN pids LIKE '%s,%%' THEN CONCAT('%s,', SUBSTRING(pids, %d)) ELSE pids END",
originPids, newPids, originPids, newPids, len(originPids)+2,
)
}
// executeDeleteTransaction 执行部门删除事务。
// 处理逻辑:
// 1. 查询部门关联的用户 ID 列表
// 2. 删除部门 - 角色、用户 - 部门关联
// 3. 删除部门记录
// 4. 更新原父部门的子部门数量
// 5. 同步受影响用户的权限缓存
func (s *DeptService) executeDeleteTransaction(dept *model.Department, id uint) error {
db, err := dept.GetDB()
if err != nil {
return e.NewBusinessError(e.DepartmentCannotDelete)
}
err = access.NewPermissionSyncCoordinator().RunAfterCommitWithCode(db, e.DepartmentCannotDelete, func(tx *gorm.DB) error {
dept.SetDB(tx)
// 查询部门关联的所有用户 ID,用于后续权限同步
affectedUserIDs, err := s.userIDsByDept(id, tx)
if err != nil {
return err
}
// 删除部门关联的所有角色
deptRoleMap := model.NewDeptRoleMap()
deptRoleMap.SetDB(tx)
if err := deptRoleMap.DeleteWhere("dept_id = ?", id); err != nil {
return err
}
// 删除用户 - 部门关联
adminUserDeptMap := model.NewAdminUserDeptMap()
adminUserDeptMap.SetDB(tx)
if err := adminUserDeptMap.DeleteWhere("dept_id = ?", id); err != nil {
return err
}
// 删除部门记录
parentId := dept.Pid
if _, err := dept.DeleteByID(id); err != nil {
return err
}
// 更新原父部门的子部门数量(减 1)
if parentId > 0 {
if err := model.UpdateChildrenNum(model.NewDepartment(), parentId, tx); err != nil {
return err
}
}
return access.NewPermissionSyncCoordinator().SyncUsers(affectedUserIDs, tx)
})
if err != nil {
return e.NewBusinessError(e.DepartmentCannotDelete)
}
return nil
}
================================================
FILE: internal/service/dept/dept_test.go
================================================
package dept
import "testing"
func TestGenerateDeptCodeUsesUniqueDefaultPrefix(t *testing.T) {
service := NewDeptService()
first := service.generateDeptCode()
second := service.generateDeptCode()
if first == "" || second == "" {
t.Fatal("expected generated dept code")
}
if first == second {
t.Fatalf("expected different dept codes, got %s", first)
}
if first[:5] != "dept_" || second[:5] != "dept_" {
t.Fatalf("expected dept_ prefix, got %s and %s", first, second)
}
}
================================================
FILE: internal/service/file_object.go
================================================
package service
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/filestorage"
"github.com/wannanbigpig/gin-layout/internal/model"
)
type uploadFileObjectInput struct {
StorageDriver string
StorageBase string
Bucket string
StoragePath string
ObjectKey string
Size uint
Hash string
MimeType string
ETag string
Status string
}
func findReusableFileObject(tx *gorm.DB, storageDriver, bucket, hash string) (*model.UploadFileObject, error) {
if hash == "" {
return nil, gorm.ErrRecordNotFound
}
bucket = normalizeFileObjectBucket(storageDriver, bucket)
var object model.UploadFileObject
query := tx.Where("storage_driver = ? AND bucket = ? AND hash = ? AND status = ?", storageDriver, bucket, hash, model.StorageStatusStored)
if err := query.Order("id ASC").First(&object).Error; err != nil {
return nil, err
}
return &object, nil
}
func findFileObjectByID(tx *gorm.DB, id uint) (*model.UploadFileObject, error) {
if id == 0 {
return nil, gorm.ErrRecordNotFound
}
var object model.UploadFileObject
if err := tx.First(&object, id).Error; err != nil {
return nil, err
}
return &object, nil
}
func createFileObject(tx *gorm.DB, input uploadFileObjectInput) (*model.UploadFileObject, error) {
status := input.Status
if status == "" {
status = model.StorageStatusStored
}
bucket := normalizeFileObjectBucket(input.StorageDriver, input.Bucket)
object := &model.UploadFileObject{
StorageDriver: input.StorageDriver,
StorageBase: input.StorageBase,
Bucket: bucket,
StoragePath: firstNonEmpty(input.StoragePath, input.ObjectKey),
ObjectKey: firstNonEmpty(input.ObjectKey, input.StoragePath),
Size: input.Size,
Hash: input.Hash,
MimeType: input.MimeType,
ETag: input.ETag,
Status: status,
}
if err := tx.Create(object).Error; err != nil {
if existing, findErr := findReusableFileObject(tx, input.StorageDriver, bucket, input.Hash); findErr == nil {
return existing, nil
}
return nil, err
}
return object, nil
}
func ensureFileObject(tx *gorm.DB, input uploadFileObjectInput) (*model.UploadFileObject, bool, error) {
if object, err := findReusableFileObject(tx, input.StorageDriver, input.Bucket, input.Hash); err == nil {
return object, true, nil
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
object, err := createFileObject(tx, input)
return object, false, err
}
func normalizeFileObjectBucket(storageDriver, bucket string) string {
if storageDriver == model.StorageDriverLocal {
return ""
}
return bucket
}
func applyObjectToUploadFile(uploadFile *model.UploadFiles, object *model.UploadFileObject) {
if uploadFile == nil || object == nil {
return
}
uploadFile.FileObjectID = object.ID
uploadFile.StorageDriver = object.StorageDriver
uploadFile.StorageBase = object.StorageBase
uploadFile.Bucket = object.Bucket
uploadFile.StoragePath = object.StoragePath
uploadFile.ObjectKey = object.ObjectKey
uploadFile.ETag = object.ETag
uploadFile.StorageStatus = object.Status
if uploadFile.Path == "" {
uploadFile.Path = object.ObjectKey
}
if uploadFile.Hash == "" {
uploadFile.Hash = object.Hash
}
if uploadFile.Size == 0 {
uploadFile.Size = object.Size
}
if uploadFile.MimeType == "" {
uploadFile.MimeType = object.MimeType
}
}
func (s *FileResourceService) deletePhysicalObject(object *model.UploadFileObject) error {
if object == nil {
return nil
}
driverName := firstNonEmpty(object.StorageDriver, model.StorageDriverLocal)
var driver filestorage.Driver
var err error
if driverName == model.StorageDriverLocal && object.StorageBase != "" {
driver = filestorage.NewLocalDriver(filestorage.LocalConfig{
PublicBasePath: object.StorageBase,
PrivateBasePath: object.StorageBase,
}, object.StorageBase, object.StorageBase)
} else {
driver, _, err = s.storageDriverByName(context.Background(), driverName)
}
if err != nil {
return err
}
objectKey := firstNonEmpty(object.ObjectKey, object.StoragePath)
if objectKey == "" {
return nil
}
return driver.Delete(context.Background(), object.Bucket, objectKey)
}
func (s *FileResourceService) deleteObjectIfUnreferenced(db *gorm.DB, objectID uint) error {
if objectID == 0 {
return nil
}
var count int64
if err := db.Unscoped().Model(&model.UploadFiles{}).Where("file_object_id = ?", objectID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
object, err := findFileObjectByID(db, objectID)
if err != nil {
return err
}
if err := s.deletePhysicalObject(object); err != nil {
_ = db.Model(&model.UploadFileObject{}).Where("id = ?", objectID).Updates(map[string]any{
"status": model.StorageStatusDeleteFailed,
"updated_at": time.Now(),
}).Error
return fmt.Errorf("delete physical object: %w", err)
}
return db.Delete(&model.UploadFileObject{}, objectID).Error
}
================================================
FILE: internal/service/file_reference.go
================================================
package service
import (
"net/url"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
type FileReferenceService struct {
db *gorm.DB
}
func NewFileReferenceService(tx ...*gorm.DB) *FileReferenceService {
var db *gorm.DB
if len(tx) > 0 {
db = tx[0]
}
return &FileReferenceService{db: db}
}
func (s *FileReferenceService) BindReference(fileURL, ownerType string, ownerID uint, ownerField string) error {
uuid := ExtractFileUUID(fileURL)
if uuid == "" || ownerType == "" || ownerID == 0 || ownerField == "" {
return nil
}
db, err := s.dbOrDefault()
if err != nil {
return err
}
if !db.Migrator().HasTable(&model.UploadFileReference{}) {
return nil
}
var file model.UploadFiles
if err := db.Where("uuid = ? AND deleted_at = 0", uuid).First(&file).Error; err != nil {
return nil
}
row := model.UploadFileReference{
FileID: file.ID,
UUID: file.UUID,
OwnerType: ownerType,
OwnerID: ownerID,
OwnerField: ownerField,
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner_type"}, {Name: "owner_id"}, {Name: "owner_field"}, {Name: "file_id"}},
DoUpdates: clause.AssignmentColumns([]string{"uuid", "updated_at"}),
}).Create(&row).Error
}
func (s *FileReferenceService) ReleaseReference(fileURL, ownerType string, ownerID uint, ownerField string) error {
uuid := ExtractFileUUID(fileURL)
if uuid == "" {
return nil
}
db, err := s.dbOrDefault()
if err != nil {
return err
}
if !db.Migrator().HasTable(&model.UploadFileReference{}) {
return nil
}
return db.Where("uuid = ? AND owner_type = ? AND owner_id = ? AND owner_field = ?", uuid, ownerType, ownerID, ownerField).Delete(&model.UploadFileReference{}).Error
}
func (s *FileReferenceService) ReleaseReferencesByOwner(ownerType string, ownerID uint, ownerField string) error {
db, err := s.dbOrDefault()
if err != nil {
return err
}
if !db.Migrator().HasTable(&model.UploadFileReference{}) {
return nil
}
query := db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
if ownerField != "" {
query = query.Where("owner_field = ?", ownerField)
}
return query.Delete(&model.UploadFileReference{}).Error
}
func (s *FileReferenceService) HasActiveReferences(fileID uint) (bool, error) {
db, err := s.dbOrDefault()
if err != nil {
return false, err
}
if !db.Migrator().HasTable(&model.UploadFileReference{}) {
return false, nil
}
var count int64
if err := db.Model(&model.UploadFileReference{}).Where("file_id = ?", fileID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *FileReferenceService) List(params *form.FileReferenceList) *resources.Collection {
query := buildFileReferenceListQuery(params)
condition, args := query.Build()
modelRef := model.NewUploadFileReference()
if s.db != nil {
modelRef.SetDB(s.db)
}
db, err := s.dbOrDefault()
if err != nil || !db.Migrator().HasTable(&model.UploadFileReference{}) {
return resources.NewFileReferenceTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
transformer := resources.NewFileReferenceTransformer()
total, rows, err := model.ListPageE(modelRef, params.Page, params.PerPage, condition, args, model.ListOptionalParams{OrderBy: "created_at DESC, id DESC"})
if err != nil {
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
return transformer.ToCollection(params.Page, params.PerPage, total, rows)
}
func buildFileReferenceListQuery(params *form.FileReferenceList) *query_builder.QueryBuilder {
if params == nil {
return query_builder.New()
}
fileID := referenceListFileID(params)
query := query_builder.New().
AddLike("uuid", params.UUID).
AddEq("owner_type", params.OwnerType).
AddEq("owner_field", params.OwnerField)
if fileID > 0 {
query.AddEq("file_id", fileID)
}
if params.OwnerID > 0 {
query.AddEq("owner_id", params.OwnerID)
}
return query
}
func referenceListFileID(params *form.FileReferenceList) uint {
if params == nil {
return 0
}
if params.FileID > 0 {
return params.FileID
}
return params.ID
}
func (s *FileReferenceService) ReferencesByFileID(fileID uint) ([]*model.UploadFileReference, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
if !db.Migrator().HasTable(&model.UploadFileReference{}) {
return nil, nil
}
var rows []*model.UploadFileReference
err = db.Where("file_id = ?", fileID).Order("created_at DESC, id DESC").Find(&rows).Error
return rows, err
}
func ExtractFileUUID(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Path != "" {
raw = parsed.Path
}
parts := strings.Split(strings.TrimRight(raw, "/"), "/")
if len(parts) == 0 {
return ""
}
uuid := parts[len(parts)-1]
if len(uuid) != 32 {
return ""
}
return uuid
}
func (s *FileReferenceService) dbOrDefault() (*gorm.DB, error) {
if s.db != nil {
return s.db, nil
}
return model.GetDB()
}
================================================
FILE: internal/service/file_reference_test.go
================================================
package service
import (
"reflect"
"testing"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestExtractFileUUID(t *testing.T) {
uuid := "1234567890abcdef1234567890abcdef"
cases := []string{
"/admin/v1/file/" + uuid,
"https://example.com/admin/v1/file/" + uuid,
}
for _, input := range cases {
if got := ExtractFileUUID(input); got != uuid {
t.Fatalf("ExtractFileUUID(%q) = %q", input, got)
}
}
if got := ExtractFileUUID("/admin/v1/file/not-found"); got != "" {
t.Fatalf("expected invalid uuid to be empty, got %q", got)
}
}
func TestReferenceListFileIDUsesIDAlias(t *testing.T) {
got := referenceListFileID(&form.FileReferenceList{ID: 12})
if got != 12 {
t.Fatalf("expected id alias to be used as file id, got %d", got)
}
}
func TestReferenceListFileIDPrefersFileID(t *testing.T) {
got := referenceListFileID(&form.FileReferenceList{ID: 12, FileID: 34})
if got != 34 {
t.Fatalf("expected file_id to take precedence, got %d", got)
}
}
func TestBuildFileReferenceListQuerySkipsZeroOwnerID(t *testing.T) {
condition, args := buildFileReferenceListQuery(&form.FileReferenceList{ID: 1}).Build()
if condition != "file_id = ?" {
t.Fatalf("expected only file_id condition, got %q", condition)
}
expectedArgs := []any{uint(1)}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("expected args %#v, got %#v", expectedArgs, args)
}
}
func TestBuildFileReferenceListQueryAddsPositiveOwnerID(t *testing.T) {
condition, args := buildFileReferenceListQuery(&form.FileReferenceList{ID: 1, OwnerID: 2}).Build()
if condition != "file_id = ? AND owner_id = ?" {
t.Fatalf("expected file_id and owner_id conditions, got %q", condition)
}
expectedArgs := []any{uint(1), uint(2)}
if !reflect.DeepEqual(args, expectedArgs) {
t.Fatalf("expected args %#v, got %#v", expectedArgs, args)
}
}
================================================
FILE: internal/service/file_resource.go
================================================
package service
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/filestorage"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"go.uber.org/zap"
)
// FileResourceService 文件资源管理服务。
type FileResourceService struct {
Base
db *gorm.DB
storageDriverResolver func(context.Context, string) (filestorage.Driver, filestorage.Config, error)
activeStorageResolver func(context.Context) (filestorage.Driver, filestorage.Config, string, error)
}
// FileReferencedDeleteError 表示文件仍被业务数据引用,删除需要返回引用来源给前端。
type FileReferencedDeleteError struct {
businessErr *e.BusinessError
References []*resources.FileReferenceResources
}
func NewFileReferencedDeleteError(references []*resources.FileReferenceResources) *FileReferencedDeleteError {
return &FileReferencedDeleteError{
businessErr: e.NewBusinessError(e.FileReferenced),
References: references,
}
}
func (err *FileReferencedDeleteError) Error() string {
if err == nil || err.businessErr == nil {
return ""
}
return err.businessErr.Error()
}
func (err *FileReferencedDeleteError) Unwrap() error {
if err == nil {
return nil
}
return err.businessErr
}
func (err *FileReferencedDeleteError) BusinessError() *e.BusinessError {
if err == nil {
return nil
}
return err.businessErr
}
// FileResourceServiceDeps 描述 FileResourceService 可注入依赖。
type FileResourceServiceDeps struct {
DB *gorm.DB
StorageDriverResolver func(context.Context, string) (filestorage.Driver, filestorage.Config, error)
ActiveStorageResolver func(context.Context) (filestorage.Driver, filestorage.Config, string, error)
}
func NewFileResourceService() *FileResourceService {
return NewFileResourceServiceWithDeps(FileResourceServiceDeps{})
}
func NewFileResourceServiceWithDeps(deps FileResourceServiceDeps) *FileResourceService {
return &FileResourceService{db: deps.DB, storageDriverResolver: deps.StorageDriverResolver, activeStorageResolver: deps.ActiveStorageResolver}
}
// List 分页查询文件资源列表。
func (s *FileResourceService) List(params *form.FileResourceList) *resources.Collection {
query := query_builder.New().
AddLike("origin_name", params.OriginName).
AddLike("uuid", params.UUID).
AddLike("mime_type", params.MimeType).
AddEq("file_type", params.FileType).
AddEq("is_public", params.IsPublic).
AddEq("storage_driver", params.StorageDriver).
AddEq("storage_status", params.StorageStatus)
if params.UID > 0 {
query.AddEq("uid", params.UID)
}
if params.FolderID != nil {
if params.IncludeSubfolder == global.Yes {
folderIDs := s.descendantFolderIDs(*params.FolderID)
folderIDs = append(folderIDs, *params.FolderID)
query.AddCondition("folder_id IN ?", folderIDs)
} else {
query.AddEq("folder_id", *params.FolderID)
}
}
if params.IsReferenced != nil {
if *params.IsReferenced == global.Yes {
query.AddCondition("EXISTS (SELECT 1 FROM upload_file_references WHERE upload_file_references.file_id = upload_files.id)")
} else {
query.AddCondition("NOT EXISTS (SELECT 1 FROM upload_file_references WHERE upload_file_references.file_id = upload_files.id)")
}
}
if params.StartTime != "" {
query.AddCondition("created_at >= ?", params.StartTime)
}
if params.EndTime != "" {
query.AddCondition("created_at <= ?", params.EndTime)
}
condition, args := query.Build()
transformer := resources.NewFileResourceTransformer()
total, collection, err := s.listUploadFiles(params, condition, args)
if err != nil {
log.Logger.Error("查询文件资源列表失败", zap.Error(err))
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
// Detail 查询文件资源详情。
func (s *FileResourceService) Detail(id uint) (any, error) {
uploadFile, err := s.findByID(id)
if err != nil {
return nil, err
}
refs, _ := NewFileReferenceService(s.db).ReferencesByFileID(uploadFile.ID)
uploadFile.ReferenceCount = int64(len(refs))
s.fillObjectReuseCounts([]*model.UploadFiles{uploadFile})
result := resources.NewFileResourceTransformer().ToStruct(uploadFile)
if data := result; data != nil {
items := make([]any, 0, len(refs))
transformer := resources.NewFileReferenceTransformer()
for _, ref := range refs {
items = append(items, transformer.ToStruct(ref))
}
data.References = items
}
return result, nil
}
// Delete 软删除文件记录。
func (s *FileResourceService) Delete(id uint, deletedBy uint, reason string) error {
uploadFile, err := s.findByID(id)
if err != nil {
return err
}
refs, err := NewFileReferenceService(s.db).ReferencesByFileID(id)
if err != nil {
return err
}
if len(refs) > 0 {
return NewFileReferencedDeleteError(buildFileReferenceResources(refs))
}
if s.db != nil {
uploadFile.SetDB(s.db)
}
_ = uploadFile.UpdateById(id, map[string]any{"deleted_by": deletedBy, "deleted_reason": reason})
rowsAffected, err := uploadFile.DeleteByID(id)
if err != nil {
log.Logger.Error("删除文件资源记录失败", zap.Error(err), zap.Uint("id", id))
return err
}
if rowsAffected == 0 {
return e.NewBusinessError(e.NotFound)
}
return nil
}
func (s *FileResourceService) Restore(id uint) error {
db, err := s.dbOrDefault()
if err != nil {
return err
}
result := db.Unscoped().Model(&model.UploadFiles{}).Where("id = ? AND deleted_at <> 0", id).Updates(map[string]any{
"deleted_at": 0,
"deleted_by": 0,
"deleted_reason": "",
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return e.NewBusinessError(e.NotFound)
}
return nil
}
func (s *FileResourceService) Destroy(id uint) error {
uploadFile, err := s.findByIDUnscoped(id)
if err != nil {
return err
}
db, err := s.dbOrDefault()
if err != nil {
return err
}
result := db.Unscoped().Delete(&model.UploadFiles{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return e.NewBusinessError(e.NotFound)
}
_ = db.Where("file_id = ?", id).Delete(&model.UploadFileReference{}).Error
if uploadFile.FileObjectID > 0 {
return s.deleteObjectIfUnreferenced(db, uploadFile.FileObjectID)
}
if err := s.deletePhysicalFile(uploadFile); err != nil {
return err
}
return nil
}
func (s *FileResourceService) References(params *form.FileReferenceList) *resources.Collection {
return NewFileReferenceService(s.db).List(params)
}
func buildFileReferenceResources(refs []*model.UploadFileReference) []*resources.FileReferenceResources {
items := make([]*resources.FileReferenceResources, 0, len(refs))
transformer := resources.NewFileReferenceTransformer()
for _, ref := range refs {
items = append(items, transformer.ToStruct(ref))
}
return items
}
func (s *FileResourceService) findByID(id uint) (*model.UploadFiles, error) {
uploadFile := model.NewUploadFiles()
if s.db != nil {
uploadFile.SetDB(s.db)
}
if err := uploadFile.GetById(id); err != nil || uploadFile.ID == 0 {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Logger.Error("查询文件资源失败", zap.Error(err), zap.Uint("id", id))
}
return nil, e.NewBusinessError(e.NotFound)
}
return uploadFile, nil
}
func (s *FileResourceService) findByIDUnscoped(id uint) (*model.UploadFiles, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
var uploadFile model.UploadFiles
if err := db.Unscoped().Where("id = ?", id).First(&uploadFile).Error; err != nil || uploadFile.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return &uploadFile, nil
}
func (s *FileResourceService) deletePhysicalFile(uploadFile *model.UploadFiles) error {
if uploadFile == nil {
return nil
}
driverName := firstNonEmpty(uploadFile.StorageDriver, model.StorageDriverLocal)
var driver filestorage.Driver
var err error
if driverName == model.StorageDriverLocal && uploadFile.StorageBase != "" {
driver = filestorage.NewLocalDriver(filestorage.LocalConfig{
PublicBasePath: uploadFile.StorageBase,
PrivateBasePath: uploadFile.StorageBase,
}, uploadFile.StorageBase, uploadFile.StorageBase)
} else {
driver, _, err = s.storageDriverByName(context.Background(), driverName)
}
if err != nil {
return err
}
objectKey := firstNonEmpty(uploadFile.ObjectKey, uploadFile.StoragePath, uploadFile.Path)
if objectKey == "" {
return nil
}
if err := driver.Delete(context.Background(), uploadFile.Bucket, objectKey); err != nil {
log.Logger.Error("删除物理文件失败", zap.Error(err), zap.Uint("id", uploadFile.ID), zap.String("object_key", objectKey))
return err
}
return nil
}
func (s *FileResourceService) storageDriverByName(ctx context.Context, driverName string) (filestorage.Driver, filestorage.Config, error) {
if s.storageDriverResolver != nil {
return s.storageDriverResolver(ctx, driverName)
}
return NewStorageDriverByName(ctx, driverName)
}
func (s *FileResourceService) activeStorageDriver(ctx context.Context) (filestorage.Driver, filestorage.Config, string, error) {
if s.activeStorageResolver != nil {
return s.activeStorageResolver(ctx)
}
return NewActiveStorageDriver(ctx)
}
func (s *FileResourceService) descendantFolderIDs(folderID uint) []uint {
db, err := s.dbOrDefault()
if err != nil || folderID == 0 {
return nil
}
var ids []uint
current := []uint{folderID}
for len(current) > 0 {
var children []uint
if err := db.Model(&model.UploadFileFolder{}).Where("parent_id IN ?", current).Pluck("id", &children).Error; err != nil || len(children) == 0 {
break
}
ids = append(ids, children...)
current = children
}
return ids
}
func (s *FileResourceService) listUploadFiles(params *form.FileResourceList, condition string, args []any) (int64, []*model.UploadFiles, error) {
db, err := s.dbOrDefault()
if err != nil {
return 0, nil, err
}
query := db.Model(&model.UploadFiles{})
isDeleted := uint8(0)
if params.IsDeleted != nil {
isDeleted = *params.IsDeleted
}
if isDeleted == global.Yes {
query = query.Unscoped().Where("upload_files.deleted_at <> 0")
}
if condition != "" {
query = query.Where(condition, args...)
}
var total int64
if err := query.Count(&total).Error; err != nil || total == 0 {
return total, nil, err
}
page, perPage := params.Page, params.PerPage
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = global.PerPage
}
var rows []*model.UploadFiles
if err := query.Order("created_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&rows).Error; err != nil {
return total, nil, err
}
s.fillReferenceCounts(rows)
s.fillObjectReuseCounts(rows)
return total, rows, nil
}
func (s *FileResourceService) fillReferenceCounts(rows []*model.UploadFiles) {
if len(rows) == 0 {
return
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.ID)
}
db, err := s.dbOrDefault()
if err != nil {
return
}
type countRow struct {
FileID uint
Count int64
}
var counts []countRow
if err := db.Model(&model.UploadFileReference{}).Select("file_id, COUNT(*) AS count").Where("file_id IN ?", ids).Group("file_id").Scan(&counts).Error; err != nil {
return
}
countMap := make(map[uint]int64, len(counts))
for _, item := range counts {
countMap[item.FileID] = item.Count
}
for _, row := range rows {
row.ReferenceCount = countMap[row.ID]
}
}
func (s *FileResourceService) fillObjectReuseCounts(rows []*model.UploadFiles) {
if len(rows) == 0 {
return
}
objectIDs := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.FileObjectID == 0 {
continue
}
if _, ok := seen[row.FileObjectID]; ok {
continue
}
seen[row.FileObjectID] = struct{}{}
objectIDs = append(objectIDs, row.FileObjectID)
}
if len(objectIDs) == 0 {
return
}
db, err := s.dbOrDefault()
if err != nil {
return
}
type countRow struct {
FileObjectID uint
Count int64
}
var counts []countRow
if 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 {
return
}
countMap := make(map[uint]int64, len(counts))
for _, item := range counts {
countMap[item.FileObjectID] = item.Count
}
var objects []model.UploadFileObject
if err := db.Where("id IN ?", objectIDs).Find(&objects).Error; err != nil {
return
}
statusMap := make(map[uint]string, len(objects))
for _, object := range objects {
statusMap[object.ID] = object.Status
}
for _, row := range rows {
row.ObjectReuseCount = countMap[row.FileObjectID]
row.ObjectStatus = statusMap[row.FileObjectID]
}
}
func (s *FileResourceService) markDeleteFailed(id uint) error {
db, err := s.dbOrDefault()
if err != nil {
return err
}
return db.Unscoped().Model(&model.UploadFiles{}).Where("id = ?", id).Updates(map[string]any{
"storage_status": model.StorageStatusDeleteFailed,
"updated_at": time.Now(),
}).Error
}
func (s *FileResourceService) dbOrDefault() (*gorm.DB, error) {
if s.db != nil {
return s.db, nil
}
return model.GetDB()
}
================================================
FILE: internal/service/file_resource_folder_upload.go
================================================
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/filestorage"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
dateutils "github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
fileutils "github.com/wannanbigpig/gin-layout/pkg/utils"
)
func (s *FileResourceService) FolderTree() ([]*resources.FileFolderResources, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
var folders []*model.UploadFileFolder
if err := db.Order("sort ASC, id ASC").Find(&folders).Error; err != nil {
return nil, err
}
stats, err := s.folderStats()
if err != nil {
return nil, err
}
transformer := resources.NewFileFolderTransformer()
nodes := make(map[uint]*resources.FileFolderResources, len(folders))
roots := make([]*resources.FileFolderResources, 0)
for _, folder := range folders {
node := transformer.ToStruct(folder)
if stat, ok := stats[folder.ID]; ok {
node.FileCount = stat.FileCount
node.TotalSize = stat.TotalSize
}
nodes[folder.ID] = node
if folder.ParentID == 0 {
roots = append(roots, node)
}
}
for _, folder := range folders {
if folder.ParentID == 0 {
continue
}
parent := nodes[folder.ParentID]
if parent == nil {
roots = append(roots, nodes[folder.ID])
continue
}
parent.Children = append(parent.Children, nodes[folder.ID])
}
return roots, nil
}
func (s *FileResourceService) CreateFolder(params *form.FileFolderCreate, uid uint) (*resources.FileFolderResources, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
name, err := normalizeFolderName(params.Name)
if err != nil {
return nil, err
}
parentPath, err := s.folderLogicalPath(params.ParentID)
if err != nil {
return nil, err
}
if err := ensureFolderNameUnique(db, 0, params.ParentID, name); err != nil {
return nil, err
}
folder := &model.UploadFileFolder{
ParentID: params.ParentID,
Name: name,
LogicalPath: joinLogicalPath(parentPath, name),
CreatedBy: uid,
UpdatedBy: uid,
}
if err := db.Create(folder).Error; err != nil {
return nil, err
}
return resources.NewFileFolderTransformer().ToStruct(folder), nil
}
func (s *FileResourceService) UpdateFolder(params *form.FileFolderUpdate, uid uint) (*resources.FileFolderResources, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
name, err := normalizeFolderName(params.Name)
if err != nil {
return nil, err
}
var updated model.UploadFileFolder
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
var folder model.UploadFileFolder
if err := tx.First(&folder, params.ID).Error; err != nil {
return err
}
if err := ensureFolderNameUnique(tx, folder.ID, folder.ParentID, name); err != nil {
return err
}
oldPath := folder.LogicalPath
parentPath, err := folderLogicalPathTx(tx, folder.ParentID)
if err != nil {
return err
}
folder.Name = name
folder.LogicalPath = joinLogicalPath(parentPath, name)
folder.UpdatedBy = uid
if err := tx.Save(&folder).Error; err != nil {
return err
}
if err := updateLogicalPathSnapshots(tx, folder.ID, oldPath, folder.LogicalPath); err != nil {
return err
}
updated = folder
return nil
})
if err != nil {
return nil, err
}
return resources.NewFileFolderTransformer().ToStruct(&updated), nil
}
func (s *FileResourceService) DeleteFolder(id uint) error {
db, err := s.dbOrDefault()
if err != nil {
return err
}
var childCount int64
if err := db.Model(&model.UploadFileFolder{}).Where("parent_id = ?", id).Count(&childCount).Error; err != nil {
return err
}
if childCount > 0 {
return fmt.Errorf("folder is not empty")
}
var fileCount int64
if err := db.Model(&model.UploadFiles{}).Where("folder_id = ?", id).Count(&fileCount).Error; err != nil {
return err
}
if fileCount > 0 {
return fmt.Errorf("folder is not empty")
}
result := db.Delete(&model.UploadFileFolder{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *FileResourceService) MoveFolder(params *form.FileFolderMove, uid uint) (*resources.FileFolderResources, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
targetParentID := params.ParentID
if params.TargetParentID > 0 {
targetParentID = params.TargetParentID
}
var moved model.UploadFileFolder
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
var folder model.UploadFileFolder
if err := tx.First(&folder, params.ID).Error; err != nil {
return err
}
if targetParentID == folder.ID {
return fmt.Errorf("folder cannot move to itself")
}
if targetParentID > 0 {
parentPath, err := folderLogicalPathTx(tx, targetParentID)
if err != nil {
return err
}
if parentPath == folder.LogicalPath || strings.HasPrefix(parentPath, folder.LogicalPath+"/") {
return fmt.Errorf("folder cannot move to descendant")
}
}
if err := ensureFolderNameUnique(tx, folder.ID, targetParentID, folder.Name); err != nil {
return err
}
oldPath := folder.LogicalPath
parentPath, err := folderLogicalPathTx(tx, targetParentID)
if err != nil {
return err
}
folder.ParentID = targetParentID
folder.LogicalPath = joinLogicalPath(parentPath, folder.Name)
folder.UpdatedBy = uid
if err := tx.Save(&folder).Error; err != nil {
return err
}
if err := updateLogicalPathSnapshots(tx, folder.ID, oldPath, folder.LogicalPath); err != nil {
return err
}
moved = folder
return nil
})
if err != nil {
return nil, err
}
return resources.NewFileFolderTransformer().ToStruct(&moved), nil
}
func (s *FileResourceService) MoveFiles(params *form.FileMove) (*resources.FileMoveResult, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
logicalPath, err := s.folderLogicalPath(params.FolderID)
if err != nil {
return nil, err
}
result := db.Model(&model.UploadFiles{}).Where("id IN ?", params.IDs).Updates(map[string]any{
"folder_id": params.FolderID,
"logical_path": logicalPath,
"updated_at": time.Now(),
})
if result.Error != nil {
return nil, result.Error
}
total := int64(len(params.IDs))
return &resources.FileMoveResult{Total: total, Moved: result.RowsAffected, Skipped: total - result.RowsAffected}, nil
}
func (s *FileResourceService) UploadLocal(files []*multipart.FileHeader, params *form.FileLocalUpload, uid uint) ([]*resources.FileResourceResources, error) {
items := make([]*resources.FileResourceResources, 0, len(files))
for _, file := range files {
item, err := s.uploadLocalOne(file, params, uid)
if err != nil {
return items, err
}
items = append(items, item)
}
return items, nil
}
func (s *FileResourceService) UploadCredential(params *form.FileUploadCredential) (*resources.FileUploadCredentialResources, error) {
_, cfg, activeDriver, err := s.activeStorageDriver(context.Background())
if err != nil {
return nil, err
}
if params.Driver != "" {
activeDriver = params.Driver
}
if activeDriver == model.StorageDriverLocal {
return nil, fmt.Errorf("local storage does not support direct upload")
}
bucket := bucketForDriver(activeDriver, cfg, params.IsPublic)
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
if object, err := findReusableFileObject(db, activeDriver, bucket, params.Hash); err == nil {
return &resources.FileUploadCredentialResources{
StorageDriver: activeDriver,
Driver: activeDriver,
Bucket: object.Bucket,
ObjectKey: object.ObjectKey,
Reuse: true,
FileObjectID: object.ID,
Size: object.Size,
Hash: object.Hash,
MimeType: object.MimeType,
ETag: object.ETag,
ObjectStatus: object.Status,
CompletePayload: buildCredentialCompletePayload(params, activeDriver, object.Bucket, object.ObjectKey, true, object.ID),
}, nil
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
driver, _, err := s.storageDriverByName(context.Background(), activeDriver)
if err != nil {
return nil, err
}
fileName := firstNonEmpty(params.FileName, params.OriginName)
if strings.TrimSpace(fileName) == "" {
return nil, fmt.Errorf("file name is required")
}
objectKey := buildUploadObjectKey(fileName)
ttl := time.Duration(cfg.SignedURLTTLSeconds) * time.Second
if ttl <= 0 {
ttl = 5 * time.Minute
}
url, err := driver.SignedURL(context.Background(), bucket, objectKey, ttl)
if err != nil {
return nil, err
}
return &resources.FileUploadCredentialResources{
StorageDriver: activeDriver,
Driver: activeDriver,
Bucket: bucket,
ObjectKey: objectKey,
UploadURL: url,
URL: url,
Method: "PUT",
Headers: map[string]string{"Content-Type": params.MimeType},
ExpireAt: dateutils.FormatDate{Time: time.Now().Add(ttl)},
Reuse: false,
Size: uint(params.Size),
Hash: params.Hash,
MimeType: params.MimeType,
CompletePayload: buildCredentialCompletePayload(params, activeDriver, bucket, objectKey, false, 0),
}, nil
}
func buildCredentialCompletePayload(params *form.FileUploadCredential, driver, bucket, objectKey string, reuse bool, fileObjectID uint) map[string]any {
return map[string]any{
"folder_id": params.FolderID,
"reuse": reuse,
"file_object_id": fileObjectID,
"origin_name": firstNonEmpty(params.OriginName, params.FileName),
"display_name": firstNonEmpty(params.OriginName, params.FileName),
"name": filepath.Base(objectKey),
"size": params.Size,
"hash": params.Hash,
"mime_type": params.MimeType,
"is_public": params.IsPublic,
"storage_driver": driver,
"driver": driver,
"bucket": bucket,
"object_key": objectKey,
"upload_scene": params.UploadScene,
}
}
func (s *FileResourceService) CompleteDirectUpload(params *form.FileUploadComplete, uid uint) (*resources.FileResourceResources, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
storageDriver := firstNonEmpty(params.StorageDriver, params.Driver)
if storageDriver == "" {
return nil, fmt.Errorf("storage driver is required")
}
if _, _, err := s.storageDriverByName(context.Background(), storageDriver); err != nil {
return nil, err
}
logicalPath, err := s.folderLogicalPath(params.FolderID)
if err != nil {
return nil, err
}
var object *model.UploadFileObject
if params.FileObjectID > 0 {
object, err = findFileObjectByID(db, params.FileObjectID)
if err != nil {
return nil, err
}
if object.StorageDriver != storageDriver {
return nil, fmt.Errorf("file object storage driver mismatch")
}
} else {
if strings.TrimSpace(params.ObjectKey) == "" {
return nil, fmt.Errorf("object_key is required")
}
object, _, err = ensureFileObject(db, uploadFileObjectInput{
StorageDriver: storageDriver,
StorageBase: storageBaseForDriver(storageDriver, params.IsPublic == global.Yes, params.Bucket),
Bucket: params.Bucket,
StoragePath: params.ObjectKey,
ObjectKey: params.ObjectKey,
Size: params.Size,
Hash: params.Hash,
MimeType: params.MimeType,
ETag: params.ETag,
Status: model.StorageStatusStored,
})
if err != nil {
return nil, err
}
}
fileUUID := params.UUID
if fileUUID == "" {
fileUUID = strings.ReplaceAll(uuid.NewString(), "-", "")
}
displayName := firstNonEmpty(params.DisplayName, params.OriginName)
fileType := params.FileType
if fileType == "" {
fileType = classifyUploadFileType(params.MimeType)
}
uploadFile := &model.UploadFiles{
UID: uid,
FolderID: params.FolderID,
LogicalPath: logicalPath,
DisplayName: displayName,
OriginName: params.OriginName,
Name: firstNonEmpty(params.Name, filepath.Base(params.ObjectKey)),
Path: params.ObjectKey,
Size: params.Size,
Ext: firstNonEmpty(params.Ext, filepath.Ext(params.OriginName)),
Hash: params.Hash,
UUID: fileUUID,
MimeType: params.MimeType,
FileType: fileType,
IsPublic: params.IsPublic,
StorageDriver: storageDriver,
StorageBase: params.Bucket,
Bucket: params.Bucket,
StoragePath: params.ObjectKey,
ObjectKey: params.ObjectKey,
ETag: params.ETag,
StorageStatus: model.StorageStatusStored,
UploadSource: model.UploadSourceDirect,
UploadScene: params.UploadScene,
UploadStatus: model.UploadStatusUploaded,
}
applyObjectToUploadFile(uploadFile, object)
if err := db.Create(uploadFile).Error; err != nil {
return nil, err
}
s.fillObjectReuseCounts([]*model.UploadFiles{uploadFile})
return resources.NewFileResourceTransformer().ToStruct(uploadFile), nil
}
func (s *FileResourceService) CreateFromReader(ctx context.Context, input ServerGeneratedFileInput) (*model.UploadFiles, error) {
driver, cfg, activeDriver, err := s.activeStorageDriver(ctx)
if err != nil {
return nil, err
}
data, err := io.ReadAll(input.Reader)
if err != nil {
return nil, err
}
sum := sha256.Sum256(data)
hash := hex.EncodeToString(sum[:])
fileUUID := strings.ReplaceAll(uuid.NewString(), "-", "")
ext := filepath.Ext(input.OriginName)
objectKey := buildUploadObjectKey(fileUUID + ext)
bucket := bucketForDriver(activeDriver, cfg, input.IsPublic)
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
object, reused, err := ensureFileObject(db, uploadFileObjectInput{
StorageDriver: activeDriver,
StorageBase: storageBaseForDriver(activeDriver, input.IsPublic == global.Yes, bucket),
Bucket: bucket,
StoragePath: objectKey,
ObjectKey: objectKey,
Size: uint(len(data)),
Hash: hash,
MimeType: input.MimeType,
ETag: hash,
Status: model.StorageStatusStored,
})
if err != nil {
return nil, err
}
if !reused {
putResult, err := driver.Put(ctx, filestorage.PutInput{
Bucket: bucket,
ObjectKey: objectKey,
Reader: bytes.NewReader(data),
Size: int64(len(data)),
ContentType: input.MimeType,
})
if err != nil {
_ = db.Delete(&model.UploadFileObject{}, object.ID).Error
return nil, err
}
updates := map[string]any{
"bucket": putResult.Bucket,
"storage_path": putResult.ObjectKey,
"object_key": putResult.ObjectKey,
"etag": firstNonEmpty(putResult.ETag, hash),
"updated_at": time.Now(),
}
if err := db.Model(object).Updates(updates).Error; err != nil {
return nil, err
}
object.Bucket = putResult.Bucket
object.StoragePath = putResult.ObjectKey
object.ObjectKey = putResult.ObjectKey
object.ETag = firstNonEmpty(putResult.ETag, hash)
}
logicalPath, err := s.folderLogicalPath(input.FolderID)
if err != nil {
return nil, err
}
uploadFile := &model.UploadFiles{
UID: input.UID,
FolderID: input.FolderID,
LogicalPath: logicalPath,
DisplayName: firstNonEmpty(input.DisplayName, input.OriginName),
OriginName: input.OriginName,
Name: fileUUID + ext,
Path: object.ObjectKey,
Size: uint(len(data)),
Ext: ext,
Hash: hash,
UUID: fileUUID,
MimeType: input.MimeType,
FileType: classifyUploadFileType(input.MimeType),
IsPublic: input.IsPublic,
StorageDriver: activeDriver,
StorageBase: object.StorageBase,
Bucket: object.Bucket,
StoragePath: object.StoragePath,
ObjectKey: object.ObjectKey,
ETag: object.ETag,
StorageStatus: model.StorageStatusStored,
UploadSource: model.UploadSourceSystem,
UploadScene: input.UploadScene,
UploadStatus: model.UploadStatusUploaded,
}
applyObjectToUploadFile(uploadFile, object)
if err := db.Create(uploadFile).Error; err != nil {
return nil, err
}
return uploadFile, nil
}
type ServerGeneratedFileInput struct {
UID uint
FolderID uint
OriginName string
DisplayName string
MimeType string
IsPublic uint8
UploadScene string
Reader io.Reader
}
func (s *FileResourceService) CreateFromLocalPath(ctx context.Context, path string, input ServerGeneratedFileInput) (*model.UploadFiles, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
if input.OriginName == "" {
input.OriginName = filepath.Base(path)
}
if input.MimeType == "" {
input.MimeType = mime.TypeByExtension(filepath.Ext(path))
}
input.Reader = file
return s.CreateFromReader(ctx, input)
}
func (s *FileResourceService) uploadLocalOne(file *multipart.FileHeader, params *form.FileLocalUpload, uid uint) (*resources.FileResourceResources, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
logicalPath, err := s.folderLogicalPath(params.FolderID)
if err != nil {
return nil, err
}
basePath := storageBasePath(params.IsPublic == global.Yes)
uploadDir, err := resolveUploadDestination(basePath, filepath.Join("file-resource", time.Now().Format("20060102")))
if err != nil {
return nil, err
}
result, err := fileutils.SaveUploadedFileWithUUID(file, uploadDir)
if err != nil {
return nil, err
}
absBasePath, err := filepath.Abs(basePath)
if err != nil {
cleanupStoredUpload(result.Path)
return nil, err
}
relPath, err := filepath.Rel(absBasePath, result.Path)
if err != nil {
cleanupStoredUpload(result.Path)
return nil, err
}
relPath = filepath.ToSlash(relPath)
bucket := bucketForDriver(model.StorageDriverLocal, filestorage.Config{}, params.IsPublic)
object, reused, err := ensureFileObject(db, uploadFileObjectInput{
StorageDriver: model.StorageDriverLocal,
StorageBase: basePath,
Bucket: bucket,
StoragePath: relPath,
ObjectKey: relPath,
Size: uint(result.Size),
Hash: result.Sha256,
MimeType: result.MimeType,
ETag: result.Sha256,
Status: model.StorageStatusStored,
})
if err != nil {
cleanupStoredUpload(result.Path)
return nil, err
}
if reused {
cleanupStoredUpload(result.Path)
}
uploadFile := &model.UploadFiles{
UID: uid,
FolderID: params.FolderID,
LogicalPath: logicalPath,
DisplayName: result.OriginName,
OriginName: result.OriginName,
Name: result.Name,
Path: object.ObjectKey,
Size: uint(result.Size),
Ext: result.Ext,
Hash: result.Sha256,
UUID: result.UUID,
MimeType: result.MimeType,
FileType: classifyUploadFileType(result.MimeType),
IsPublic: params.IsPublic,
StorageDriver: model.StorageDriverLocal,
StorageBase: object.StorageBase,
Bucket: object.Bucket,
StoragePath: object.StoragePath,
ObjectKey: object.ObjectKey,
ETag: object.ETag,
StorageStatus: model.StorageStatusStored,
UploadSource: model.UploadSourceBackend,
UploadScene: params.UploadScene,
UploadStatus: model.UploadStatusUploaded,
}
applyObjectToUploadFile(uploadFile, object)
if err := db.Create(uploadFile).Error; err != nil {
if !reused {
cleanupStoredUpload(result.Path)
_ = db.Delete(&model.UploadFileObject{}, object.ID).Error
}
return nil, err
}
s.fillObjectReuseCounts([]*model.UploadFiles{uploadFile})
return resources.NewFileResourceTransformer().ToStruct(uploadFile), nil
}
type folderStat struct {
FolderID uint
FileCount int64
TotalSize int64
}
func (s *FileResourceService) folderStats() (map[uint]folderStat, error) {
db, err := s.dbOrDefault()
if err != nil {
return nil, err
}
var rows []folderStat
if 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 {
return nil, err
}
result := make(map[uint]folderStat, len(rows))
for _, row := range rows {
result[row.FolderID] = row
}
return result, nil
}
func (s *FileResourceService) folderLogicalPath(folderID uint) (string, error) {
db, err := s.dbOrDefault()
if err != nil {
return "", err
}
return folderLogicalPathTx(db, folderID)
}
func folderLogicalPathTx(tx *gorm.DB, folderID uint) (string, error) {
if folderID == 0 {
return "/", nil
}
var folder model.UploadFileFolder
if err := tx.First(&folder, folderID).Error; err != nil {
return "", err
}
return folder.LogicalPath, nil
}
func normalizeFolderName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || name == "." || name == ".." {
return "", fmt.Errorf("invalid folder name")
}
return name, nil
}
func ensureFolderNameUnique(tx *gorm.DB, currentID, parentID uint, name string) error {
query := tx.Model(&model.UploadFileFolder{}).Where("parent_id = ? AND name = ?", parentID, name)
if currentID > 0 {
query = query.Where("id <> ?", currentID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return err
}
if count > 0 {
return fmt.Errorf("folder name already exists")
}
return nil
}
func joinLogicalPath(parentPath, name string) string {
parentPath = strings.TrimRight(strings.TrimSpace(parentPath), "/")
if parentPath == "" {
return "/" + name
}
return parentPath + "/" + name
}
func updateLogicalPathSnapshots(tx *gorm.DB, folderID uint, oldPath, newPath string) error {
if err := tx.Model(&model.UploadFiles{}).Where("folder_id = ?", folderID).Update("logical_path", newPath).Error; err != nil {
return err
}
var children []model.UploadFileFolder
if err := tx.Where("logical_path LIKE ?", oldPath+"/%").Find(&children).Error; err != nil {
return err
}
for _, child := range children {
nextPath := newPath + strings.TrimPrefix(child.LogicalPath, oldPath)
if err := tx.Model(&model.UploadFileFolder{}).Where("id = ?", child.ID).Update("logical_path", nextPath).Error; err != nil {
return err
}
if err := tx.Model(&model.UploadFiles{}).Where("folder_id = ?", child.ID).Update("logical_path", nextPath).Error; err != nil {
return err
}
}
return nil
}
func buildUploadObjectKey(filename string) string {
ext := filepath.Ext(filename)
return "uploads/" + time.Now().Format("20060102") + "/" + strings.ReplaceAll(uuid.NewString(), "-", "") + ext
}
================================================
FILE: internal/service/file_resource_test.go
================================================
package service
import (
"bytes"
"context"
stderrors "errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/filestorage"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestFileResourceDeleteReturnsReferencedError(t *testing.T) {
db := newFileResourceSQLiteDB(t)
uuid := fmt.Sprintf("%032x", time.Now().UnixNano())
originName := "test-referenced-" + uuid + ".jpg"
file := model.UploadFiles{
OriginName: originName,
Name: uuid + ".jpg",
UUID: uuid,
StorageDriver: model.StorageDriverLocal,
StorageStatus: model.StorageStatusStored,
}
if err := db.Create(&file).Error; err != nil {
t.Fatalf("create upload file failed: %v", err)
}
ref := model.UploadFileReference{
FileID: file.ID,
UUID: file.UUID,
OwnerType: "admin_user",
OwnerID: 1,
OwnerField: "avatar",
}
if err := db.Create(&ref).Error; err != nil {
t.Fatalf("create upload file reference failed: %v", err)
}
err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).Delete(file.ID, 1, "")
var referencedErr *FileReferencedDeleteError
if !stderrors.As(err, &referencedErr) {
t.Fatalf("expected FileReferencedDeleteError, got %v", err)
}
businessErr := referencedErr.BusinessError()
if businessErr == nil || businessErr.GetCode() != e.FileReferenced {
t.Fatalf("expected FileReferenced business error, got %#v", businessErr)
}
if len(referencedErr.References) != 1 {
t.Fatalf("expected one reference, got %d", len(referencedErr.References))
}
if referencedErr.References[0].SourceName != "管理员" || referencedErr.References[0].FieldName != "头像" {
t.Fatalf("unexpected reference display fields: %#v", referencedErr.References[0])
}
var stored model.UploadFiles
if err := db.First(&stored, file.ID).Error; err != nil {
t.Fatalf("query upload file failed: %v", err)
}
if stored.DeletedAt != 0 {
t.Fatalf("expected referenced file to remain undeleted, got deleted_at=%d", stored.DeletedAt)
}
}
func currentConfigFileResourceTestDB(t *testing.T) *gorm.DB {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test file path failed")
}
projectRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
if err := config.InitConfig(filepath.Join(projectRoot, "config.yaml")); err != nil {
t.Fatalf("init current config failed: %v", err)
}
if err := data.InitData(); err != nil {
t.Fatalf("init current configured data failed: %v", err)
}
db, err := model.GetDB()
if err != nil {
t.Fatalf("get current configured db failed: %v", err)
}
return db
}
func cleanupFileResourceTestData(t *testing.T, db *gorm.DB, uuid string) {
t.Helper()
if err := db.Where("uuid = ?", uuid).Delete(&model.UploadFileReference{}).Error; err != nil {
t.Fatalf("cleanup upload file references failed: %v", err)
}
if err := db.Unscoped().Where("uuid = ?", uuid).Delete(&model.UploadFiles{}).Error; err != nil {
t.Fatalf("cleanup upload files failed: %v", err)
}
}
func TestFileResourceMoveFolderRejectsDescendant(t *testing.T) {
db := newFileResourceSQLiteDB(t)
root := model.UploadFileFolder{Name: "root", LogicalPath: "/root"}
if err := db.Create(&root).Error; err != nil {
t.Fatalf("create root folder failed: %v", err)
}
child := model.UploadFileFolder{ParentID: root.ID, Name: "child", LogicalPath: "/root/child"}
if err := db.Create(&child).Error; err != nil {
t.Fatalf("create child folder failed: %v", err)
}
_, err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).MoveFolder(&form.FileFolderMove{ID: root.ID, ParentID: child.ID}, 1)
if err == nil {
t.Fatal("expected moving folder to descendant to fail")
}
}
func TestFileResourceMoveFilesReturnsStatsAndUpdatesLogicalPath(t *testing.T) {
db := newFileResourceSQLiteDB(t)
folder := model.UploadFileFolder{Name: "docs", LogicalPath: "/docs"}
if err := db.Create(&folder).Error; err != nil {
t.Fatalf("create folder failed: %v", err)
}
file := model.UploadFiles{OriginName: "a.txt", UUID: fmt.Sprintf("%032x", time.Now().UnixNano()), LogicalPath: "/", StorageDriver: model.StorageDriverLocal, StorageStatus: model.StorageStatusStored}
if err := db.Create(&file).Error; err != nil {
t.Fatalf("create upload file failed: %v", err)
}
result, err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).MoveFiles(&form.FileMove{IDs: []uint{file.ID, file.ID + 1000}, FolderID: folder.ID})
if err != nil {
t.Fatalf("move files failed: %v", err)
}
if result.Total != 2 || result.Moved != 1 || result.Skipped != 1 {
t.Fatalf("unexpected move stats: %#v", result)
}
var stored model.UploadFiles
if err := db.First(&stored, file.ID).Error; err != nil {
t.Fatalf("query moved file failed: %v", err)
}
if stored.FolderID != folder.ID || stored.LogicalPath != "/docs" {
t.Fatalf("unexpected moved file folder/path: folder=%d path=%q", stored.FolderID, stored.LogicalPath)
}
}
func TestFileResourceUploadLocalRecordsLocationFields(t *testing.T) {
db := newFileResourceSQLiteDB(t)
config.Config.BasePath = t.TempDir()
folder := model.UploadFileFolder{Name: "images", LogicalPath: "/images"}
if err := db.Create(&folder).Error; err != nil {
t.Fatalf("create folder failed: %v", err)
}
header := newMultipartFileHeader(t, "hello.txt", []byte("hello"))
result, err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).UploadLocal([]*multipart.FileHeader{header}, &form.FileLocalUpload{FolderID: folder.ID, IsPublic: global.Yes, UploadScene: "test"}, 9)
if err != nil {
t.Fatalf("upload local failed: %v", err)
}
if len(result) != 1 {
t.Fatalf("expected one upload result, got %d", len(result))
}
var stored model.UploadFiles
if err := db.First(&stored, result[0].ID).Error; err != nil {
t.Fatalf("query uploaded file failed: %v", err)
}
if stored.FolderID != folder.ID || stored.LogicalPath != "/images" || stored.DisplayName != "hello.txt" {
t.Fatalf("unexpected logical fields: %#v", stored)
}
if stored.StorageDriver != model.StorageDriverLocal || stored.StorageBase == "" || stored.StoragePath == "" || stored.ObjectKey == "" {
t.Fatalf("unexpected storage fields: %#v", stored)
}
if stored.UploadSource != model.UploadSourceBackend || stored.UploadScene != "test" || stored.UploadStatus != model.UploadStatusUploaded {
t.Fatalf("unexpected upload fields: %#v", stored)
}
}
func TestFileResourceUploadLocalReusesPhysicalObject(t *testing.T) {
db := newFileResourceSQLiteDB(t)
config.Config.BasePath = t.TempDir()
service := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db})
content := []byte("same content")
first, err := service.UploadLocal([]*multipart.FileHeader{newMultipartFileHeader(t, "first.txt", content)}, &form.FileLocalUpload{IsPublic: global.Yes}, 1)
if err != nil {
t.Fatalf("first upload failed: %v", err)
}
second, err := service.UploadLocal([]*multipart.FileHeader{newMultipartFileHeader(t, "second.txt", content)}, &form.FileLocalUpload{IsPublic: global.No}, 2)
if err != nil {
t.Fatalf("second upload failed: %v", err)
}
if first[0].FileObjectID == 0 || first[0].FileObjectID != second[0].FileObjectID {
t.Fatalf("expected same file object, first=%d second=%d", first[0].FileObjectID, second[0].FileObjectID)
}
if first[0].ObjectKey != second[0].ObjectKey {
t.Fatalf("expected same object key, first=%q second=%q", first[0].ObjectKey, second[0].ObjectKey)
}
var objectCount int64
if err := db.Model(&model.UploadFileObject{}).Count(&objectCount).Error; err != nil {
t.Fatalf("count objects failed: %v", err)
}
if objectCount != 1 {
t.Fatalf("expected one physical object, got %d", objectCount)
}
storedPath := filepath.Join(config.Config.BasePath, "storage/public", filepath.FromSlash(first[0].ObjectKey))
if _, err := os.Stat(storedPath); err != nil {
t.Fatalf("expected reused physical file to exist: %v", err)
}
}
func TestFileResourceDestroyKeepsPhysicalObjectUntilLastRecord(t *testing.T) {
db := newFileResourceSQLiteDB(t)
basePath := t.TempDir()
objectKey := "objects/shared.txt"
physicalPath := filepath.Join(basePath, filepath.FromSlash(objectKey))
if err := os.MkdirAll(filepath.Dir(physicalPath), 0o755); err != nil {
t.Fatalf("create physical dir failed: %v", err)
}
if err := os.WriteFile(physicalPath, []byte("shared"), 0o644); err != nil {
t.Fatalf("write physical file failed: %v", err)
}
object := model.UploadFileObject{
StorageDriver: model.StorageDriverLocal,
StorageBase: basePath,
Bucket: "public",
StoragePath: objectKey,
ObjectKey: objectKey,
Hash: strings.Repeat("a", 64),
Status: model.StorageStatusStored,
}
if err := db.Create(&object).Error; err != nil {
t.Fatalf("create object failed: %v", err)
}
first := model.UploadFiles{FileObjectID: object.ID, UUID: fmt.Sprintf("%032x", time.Now().UnixNano()), StorageDriver: model.StorageDriverLocal, StorageStatus: model.StorageStatusStored}
second := model.UploadFiles{FileObjectID: object.ID, UUID: fmt.Sprintf("%032x", time.Now().UnixNano()+1), StorageDriver: model.StorageDriverLocal, StorageStatus: model.StorageStatusStored}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("create first file failed: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("create second file failed: %v", err)
}
service := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db})
if err := service.Destroy(first.ID); err != nil {
t.Fatalf("destroy first failed: %v", err)
}
if _, err := os.Stat(physicalPath); err != nil {
t.Fatalf("expected physical file to remain after non-last destroy: %v", err)
}
var objectCount int64
if err := db.Model(&model.UploadFileObject{}).Where("id = ?", object.ID).Count(&objectCount).Error; err != nil {
t.Fatalf("count object failed: %v", err)
}
if objectCount != 1 {
t.Fatalf("expected object to remain, got %d", objectCount)
}
if err := service.Destroy(second.ID); err != nil {
t.Fatalf("destroy second failed: %v", err)
}
if _, err := os.Stat(physicalPath); !os.IsNotExist(err) {
t.Fatalf("expected physical file removed after last destroy, err=%v", err)
}
if err := db.Model(&model.UploadFileObject{}).Where("id = ?", object.ID).Count(&objectCount).Error; err != nil {
t.Fatalf("count object after destroy failed: %v", err)
}
if objectCount != 0 {
t.Fatalf("expected object removed, got %d", objectCount)
}
}
func TestFileResourceUploadCredentialReturnsReuse(t *testing.T) {
db := newFileResourceSQLiteDB(t)
hash := strings.Repeat("b", 64)
object := model.UploadFileObject{
StorageDriver: model.StorageDriverS3,
Bucket: "assets",
StoragePath: "uploads/existing.txt",
ObjectKey: "uploads/existing.txt",
Size: 12,
Hash: hash,
MimeType: "text/plain",
ETag: "etag-1",
Status: model.StorageStatusStored,
}
if err := db.Create(&object).Error; err != nil {
t.Fatalf("create object failed: %v", err)
}
service := NewFileResourceServiceWithDeps(FileResourceServiceDeps{
DB: db,
ActiveStorageResolver: func(context.Context) (filestorage.Driver, filestorage.Config, string, error) {
return fakeFileResourceDriver{name: model.StorageDriverLocal}, filestorage.Config{
S3: filestorage.S3Config{Bucket: "assets"},
}, model.StorageDriverLocal, nil
},
})
result, err := service.UploadCredential(&form.FileUploadCredential{
Driver: model.StorageDriverS3,
Hash: hash,
MimeType: "text/plain",
Size: 12,
})
if err != nil {
t.Fatalf("upload credential failed: %v", err)
}
if !result.Reuse || result.FileObjectID != object.ID || result.ObjectKey != object.ObjectKey {
t.Fatalf("unexpected reuse credential: %#v", result)
}
if result.UploadURL != "" || result.Method != "" {
t.Fatalf("reuse credential should not require direct upload: %#v", result)
}
}
func TestFileResourceDeletePhysicalUsesFileStorageDriver(t *testing.T) {
calls := make([]string, 0, 1)
service := NewFileResourceServiceWithDeps(FileResourceServiceDeps{
StorageDriverResolver: func(_ context.Context, driverName string) (filestorage.Driver, filestorage.Config, error) {
calls = append(calls, driverName)
return fakeFileResourceDriver{name: driverName}, filestorage.Config{}, nil
},
})
err := service.deletePhysicalFile(&model.UploadFiles{
StorageDriver: model.StorageDriverS3,
Bucket: "bucket",
ObjectKey: "old/object.txt",
})
if err != nil {
t.Fatalf("delete physical file failed: %v", err)
}
if len(calls) != 1 || calls[0] != model.StorageDriverS3 {
t.Fatalf("expected resolver to use file storage driver, got %#v", calls)
}
}
func newFileResourceSQLiteDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
execFileResourceTestSchema(t, db)
return db
}
func execFileResourceTestSchema(t *testing.T, db *gorm.DB) {
t.Helper()
statements := []string{
`CREATE TABLE upload_file_folders (
id integer primary key autoincrement,
parent_id integer not null default 0,
name text not null default '',
logical_path text not null default '/',
sort integer not null default 0,
created_by integer not null default 0,
updated_by integer not null default 0,
created_at datetime,
updated_at datetime,
deleted_at integer not null default 0
)`,
`CREATE TABLE upload_files (
id integer primary key autoincrement,
file_object_id integer not null default 0,
uid integer not null default 0,
folder_id integer not null default 0,
logical_path text not null default '/',
display_name text not null default '',
origin_name text not null default '',
name text not null default '',
path text not null default '',
size integer not null default 0,
ext text not null default '',
hash text not null default '',
uuid text not null default '',
mime_type text not null default '',
file_type text not null default '',
is_public integer not null default 0,
storage_driver text not null default 'local',
storage_base text not null default '',
bucket text not null default '',
storage_path text not null default '',
object_key text not null default '',
etag text not null default '',
storage_status text not null default 'stored',
upload_source text not null default '',
upload_scene text not null default '',
upload_status text not null default '',
last_accessed_at datetime,
deleted_by integer not null default 0,
deleted_reason text not null default '',
created_at datetime,
updated_at datetime,
deleted_at integer not null default 0
)`,
`CREATE TABLE upload_file_objects (
id integer primary key autoincrement,
storage_driver text not null default 'local',
storage_base text not null default '',
bucket text not null default '',
storage_path text not null default '',
object_key text not null default '',
size integer not null default 0,
hash text not null default '',
mime_type text not null default '',
etag text not null default '',
status text not null default 'stored',
created_at datetime,
updated_at datetime
)`,
`CREATE TABLE upload_file_references (
id integer primary key autoincrement,
file_id integer not null default 0,
uuid text not null default '',
owner_type text not null default '',
owner_id integer not null default 0,
owner_field text not null default '',
created_at datetime,
updated_at datetime
)`,
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("create test schema failed: %v", err)
}
}
}
func newMultipartFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", filename)
if err != nil {
t.Fatalf("create multipart file failed: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write multipart file failed: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer failed: %v", err)
}
req, err := http.NewRequest(http.MethodPost, "/", body)
if err != nil {
t.Fatalf("create request failed: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(1024); err != nil {
t.Fatalf("parse multipart form failed: %v", err)
}
return req.MultipartForm.File["files"][0]
}
type fakeFileResourceDriver struct {
name string
}
func (d fakeFileResourceDriver) Name() string { return d.name }
func (d fakeFileResourceDriver) Put(context.Context, filestorage.PutInput) (filestorage.PutResult, error) {
return filestorage.PutResult{}, nil
}
func (d fakeFileResourceDriver) Open(context.Context, string, string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(nil)), nil
}
func (d fakeFileResourceDriver) Exists(context.Context, string, string) (bool, error) {
return true, nil
}
func (d fakeFileResourceDriver) Delete(context.Context, string, string) error {
return nil
}
func (d fakeFileResourceDriver) URL(string, string, bool) string {
return ""
}
func (d fakeFileResourceDriver) SignedURL(context.Context, string, string, time.Duration) (string, error) {
return "", nil
}
================================================
FILE: internal/service/i18n_text.go
================================================
package service
import (
"strings"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
)
// NormalizeLocaleTextMap 规范化多语言文本输入,保留未来可扩展语言。
func NormalizeLocaleTextMap(data map[string]string) map[string]string {
result := make(map[string]string, len(data))
for locale, text := range data {
normalizedLocale := NormalizeLocaleKey(locale)
trimmedText := strings.TrimSpace(text)
if normalizedLocale == "" || trimmedText == "" {
continue
}
result[normalizedLocale] = trimmedText
}
return result
}
// NormalizeLocaleKey 统一 zh/en 的历史写法,并保留其他语言原值。
func NormalizeLocaleKey(locale string) string {
trimmed := strings.TrimSpace(strings.ReplaceAll(locale, "_", "-"))
if trimmed == "" {
return ""
}
lower := strings.ToLower(trimmed)
switch {
case strings.HasPrefix(lower, "zh"):
return i18n.LocaleZhCN
case strings.HasPrefix(lower, "en"):
return i18n.LocaleEnUS
default:
return trimmed
}
}
// LocalePriority 返回读路径使用的语言优先级。
func LocalePriority(locale string) []string {
candidates := []string{
NormalizeLocaleKey(locale),
i18n.DefaultLocale,
i18n.LocaleEnUS,
}
result := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
result = append(result, candidate)
}
return result
}
// ResolveLocaleText 根据优先级解析展示文本。
func ResolveLocaleText(translations map[string]string, locale string) string {
for _, candidate := range LocalePriority(locale) {
if text := strings.TrimSpace(translations[candidate]); text != "" {
return text
}
}
for _, text := range translations {
if trimmed := strings.TrimSpace(text); trimmed != "" {
return trimmed
}
}
return ""
}
================================================
FILE: internal/service/menu/audit_diff.go
================================================
package menu
import (
"sort"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var menuDiffRules = []auditdiff.FieldRule{
{Field: "id", Label: "菜单ID"},
{Field: "icon", Label: "图标"},
{Field: "title_i18n", Label: "标题"},
{Field: "code", Label: "权限标识"},
{Field: "path", Label: "路由路径"},
{Field: "full_path", Label: "完整路径"},
{Field: "name", Label: "路由名称"},
{Field: "component", Label: "组件"},
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{
Field: "type",
Label: "类型",
ValueLabels: map[string]string{
"1": "目录",
"2": "菜单",
"3": "按钮",
},
},
{
Field: "is_show",
Label: "显示",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{
Field: "is_auth",
Label: "鉴权",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{
Field: "is_new_window",
Label: "新窗口",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{
Field: "is_external_links",
Label: "外链",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{Field: "sort", Label: "排序"},
{Field: "pid", Label: "上级菜单ID"},
{Field: "pids", Label: "上级路径"},
{Field: "level", Label: "层级"},
{Field: "redirect", Label: "重定向"},
{Field: "animate_enter", Label: "进入动画"},
{Field: "animate_leave", Label: "离开动画"},
{Field: "animate_duration", Label: "动画时长"},
{Field: "description", Label: "描述"},
{Field: "api_list", Label: "接口ID列表"},
}
// CreateWithAuditDiff 新增菜单并返回精确 change_diff。
func (s *MenuService) CreateWithAuditDiff(params *form.CreateMenu, _ string) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
menuModel, err := s.applyMenuMutation(&menuMutation{
Icon: params.Icon,
TitleI18n: params.TitleI18n,
Code: params.Code,
Path: params.Path,
Name: params.Name,
AnimateEnter: params.AnimateEnter,
AnimateLeave: params.AnimateLeave,
AnimateDuration: params.AnimateDuration,
IsShow: params.IsShow,
IsAuth: params.IsAuth,
IsNewWindow: params.IsNewWindow,
Sort: params.Sort,
Type: params.Type,
Pid: params.Pid,
Description: params.Description,
ApiList: params.ApiList,
Component: params.Component,
Status: params.Status,
Redirect: params.Redirect,
IsExternalLinks: params.IsExternalLinks,
})
if err != nil {
return "", err
}
after, err := s.snapshotMenuByID(menuModel.ID)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildMenuDiff(nil, after), nil
}
// UpdateWithAuditDiff 更新菜单并返回精确 change_diff。
func (s *MenuService) UpdateWithAuditDiff(params *form.UpdateMenu, _ string) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotMenuByID(params.Id)
if err != nil {
return "", err
}
if _, err := s.applyMenuMutation(&menuMutation{
Id: params.Id,
Icon: params.Icon,
TitleI18n: params.TitleI18n,
Code: params.Code,
Path: params.Path,
Name: params.Name,
AnimateEnter: params.AnimateEnter,
AnimateLeave: params.AnimateLeave,
AnimateDuration: params.AnimateDuration,
IsShow: params.IsShow,
IsAuth: params.IsAuth,
IsNewWindow: params.IsNewWindow,
Sort: params.Sort,
Type: params.Type,
Pid: params.Pid,
Description: params.Description,
ApiList: params.ApiList,
Component: params.Component,
Status: params.Status,
Redirect: params.Redirect,
IsExternalLinks: params.IsExternalLinks,
}); err != nil {
return "", err
}
after, err := s.snapshotMenuByID(params.Id)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildMenuDiff(before, after), nil
}
// DeleteWithAuditDiff 删除菜单并返回精确 change_diff。
func (s *MenuService) DeleteWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotMenuByID(id)
if err != nil {
return "", err
}
if err := s.Delete(id); err != nil {
return "", err
}
return buildMenuDiff(before, nil), nil
}
func (s *MenuService) snapshotMenuByID(id uint) (map[string]any, error) {
menuModel := model.NewMenu()
if err := menuModel.GetById(id); err != nil || menuModel.ID == 0 {
return nil, e.NewBusinessError(e.MenuNotFound)
}
titleI18n, err := model.NewMenuI18n().LocaleTitleMapByMenuID(menuModel.ID)
if err != nil {
return nil, err
}
apiIDs, err := model.NewMenuApiMap().ApiIdsByMenuId(menuModel.ID)
if err != nil {
return nil, err
}
sort.Slice(apiIDs, func(i, j int) bool {
return apiIDs[i] < apiIDs[j]
})
return map[string]any{
"id": menuModel.ID,
"icon": menuModel.Icon,
"title_i18n": titleI18n,
"code": menuModel.Code,
"path": menuModel.Path,
"full_path": menuModel.FullPath,
"name": menuModel.Name,
"component": menuModel.Component,
"status": menuModel.Status,
"type": menuModel.Type,
"is_show": menuModel.IsShow,
"is_auth": menuModel.IsAuth,
"is_new_window": menuModel.IsNewWindow,
"is_external_links": menuModel.IsExternalLinks,
"sort": menuModel.Sort,
"pid": menuModel.Pid,
"pids": menuModel.Pids,
"level": menuModel.Level,
"redirect": menuModel.Redirect,
"animate_enter": menuModel.AnimateEnter,
"animate_leave": menuModel.AnimateLeave,
"animate_duration": menuModel.AnimateDuration,
"description": menuModel.Description,
"api_list": apiIDs,
}, nil
}
func buildMenuDiff(before, after map[string]any) string {
items := auditdiff.BuildFieldDiff(before, after, menuDiffRules)
return auditdiff.Marshal(items)
}
================================================
FILE: internal/service/menu/audit_diff_test.go
================================================
package menu
import (
"encoding/json"
"testing"
)
func TestBuildMenuDiffIncludesTypeDisplay(t *testing.T) {
raw := buildMenuDiff(
map[string]any{"type": uint8(1)},
map[string]any{"type": uint8(2)},
)
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
if len(items) != 1 {
t.Fatalf("expected 1 diff item, got %d", len(items))
}
if items[0]["before_display"] != "目录" || items[0]["after_display"] != "菜单" {
t.Fatalf("unexpected type display mapping: %#v", items[0])
}
}
================================================
FILE: internal/service/menu/menu.go
================================================
package menu
import "github.com/wannanbigpig/gin-layout/internal/service"
const (
menuRootPid = "0"
menuRootLevel = 1
maxMenuLevel = 4 // 最多4层菜单
allStatus = 2
rootPath = "/"
)
// MenuService 菜单服务
type MenuService struct {
service.Base
}
// NewMenuService 创建菜单服务实例
func NewMenuService() *MenuService {
return &MenuService{}
}
================================================
FILE: internal/service/menu/menu_edit.go
================================================
package menu
import (
"fmt"
"strings"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
utils2 "github.com/wannanbigpig/gin-layout/pkg/utils"
)
// Create 新增菜单。
func (s *MenuService) Create(params *form.CreateMenu, _ string) error {
_, err := s.applyMenuMutation(&menuMutation{
Icon: params.Icon,
TitleI18n: params.TitleI18n,
Code: params.Code,
Path: params.Path,
Name: params.Name,
AnimateEnter: params.AnimateEnter,
AnimateLeave: params.AnimateLeave,
AnimateDuration: params.AnimateDuration,
IsShow: params.IsShow,
IsAuth: params.IsAuth,
IsNewWindow: params.IsNewWindow,
Sort: params.Sort,
Type: params.Type,
Pid: params.Pid,
Description: params.Description,
ApiList: params.ApiList,
Component: params.Component,
Status: params.Status,
Redirect: params.Redirect,
IsExternalLinks: params.IsExternalLinks,
})
return err
}
// Update 更新菜单。
func (s *MenuService) Update(params *form.UpdateMenu, _ string) error {
_, err := s.applyMenuMutation(&menuMutation{
Id: params.Id,
Icon: params.Icon,
TitleI18n: params.TitleI18n,
Code: params.Code,
Path: params.Path,
Name: params.Name,
AnimateEnter: params.AnimateEnter,
AnimateLeave: params.AnimateLeave,
AnimateDuration: params.AnimateDuration,
IsShow: params.IsShow,
IsAuth: params.IsAuth,
IsNewWindow: params.IsNewWindow,
Sort: params.Sort,
Type: params.Type,
Pid: params.Pid,
Description: params.Description,
ApiList: params.ApiList,
Component: params.Component,
Status: params.Status,
Redirect: params.Redirect,
IsExternalLinks: params.IsExternalLinks,
})
return err
}
// menuMutation 菜单变更参数,用于封装新增/更新菜单的请求数据。
type menuMutation struct {
Id uint // 菜单 ID,0 表示新增
Icon string // 图标
TitleI18n map[string]string
Code string // 权限标识
Path string // 路径
Name string // 路由名称
AnimateEnter string // 进入动画
AnimateLeave string // 离开动画
AnimateDuration float32 // 动画时长
IsShow uint8 // 是否显示
IsAuth uint8 // 是否鉴权
IsNewWindow uint8 // 是否新窗口打开
Sort uint // 排序权重
Type uint8 // 菜单类型(目录/菜单/按钮)
Pid uint // 父菜单 ID
Description string // 描述
ApiList []uint // 关联的 API ID 列表
Component string // 组件路径
Status uint8 // 状态
Redirect string // 重定向路径
IsExternalLinks uint8 // 是否外链
}
// menuEditContext 菜单编辑上下文,保存更新前的状态用于级联判断。
type menuEditContext struct {
originPids string // 原始路径
originPid uint // 原始父 ID
originFullPath string // 原始完整路径
excludeId uint // 排除的当前菜单 ID
}
// applyMenuMutation 执行菜单变更操作(新增/更新)。
// 处理逻辑:
// 1. 验证菜单是否存在(更新时)
// 2. 构建树形层级(pids, level, full_path)
// 3. 检查层级深度
// 4. 填充菜单字段
// 5. 验证唯一字段(code, name, path)
// 6. 验证 API 列表
// 7. 事务保存:菜单数据、级联更新子菜单、更新子菜单数量、同步菜单权限
func (s *MenuService) applyMenuMutation(params *menuMutation) (*model.Menu, error) {
menu, editContext, err := s.prepareMutationContext(params)
if err != nil {
return nil, err
}
// 1) 规范化标题输入
if err := s.normalizeMenuTitles(params); err != nil {
return nil, err
}
// 2) 构建树形层级并验证合法性
if err := s.resolveMenuHierarchy(menu, params); err != nil {
return nil, err
}
// 3) 检查层级深度
if menu.Level > maxMenuLevel {
return nil, e.NewBusinessError(e.MaxMenuDepth)
}
// 4) 填充字段并验证唯一性
s.assignMenuFields(menu, params)
if err := s.validateUniqueFields(menu, params, editContext.excludeId); err != nil {
return nil, err
}
// 5) 规范化关联 API 列表(仅保留有效且去重后的 ID)
if err := s.normalizeMenuAPIList(params); err != nil {
return nil, err
}
// 6) 持久化及事务后处理
if err := s.executeEditTransaction(menu, params.ApiList, params.TitleI18n, editContext); err != nil {
return nil, err
}
return menu, nil
}
// prepareMutationContext 根据新增/更新场景初始化菜单模型与编辑上下文。
func (s *MenuService) prepareMutationContext(params *menuMutation) (*model.Menu, *menuEditContext, error) {
menu := model.NewMenu()
editContext := newMenuEditContext()
if params.Id == 0 {
return menu, editContext, nil
}
// 更新场景:加载原菜单,供唯一性校验与子树级联更新使用。
if err := menu.GetById(params.Id); err != nil || menu.ID == 0 {
return nil, nil, e.NewBusinessError(e.MenuNotFound)
}
editContext.originPids = menu.Pids
editContext.originPid = menu.Pid
editContext.originFullPath = menu.FullPath
editContext.excludeId = params.Id
return menu, editContext, nil
}
func newMenuEditContext() *menuEditContext {
return &menuEditContext{
originPids: menuRootPid,
originPid: 0,
originFullPath: "",
excludeId: 0,
}
}
// normalizeMenuAPIList 校验 API ID 是否存在,并仅保留有效去重结果。
func (s *MenuService) normalizeMenuAPIList(params *menuMutation) error {
if len(params.ApiList) == 0 {
return nil
}
apis, err := model.NewApi().FindByIds(params.ApiList)
if err != nil {
return err
}
params.ApiList = lo.Map(apis, func(api model.Api, _ int) uint {
return api.ID
})
return nil
}
// normalizeMenuTitles 规范化菜单标题输入,要求中英至少一种语言非空。
func (s *MenuService) normalizeMenuTitles(params *menuMutation) error {
normalized := make(map[string]string, len(params.TitleI18n))
for locale, title := range params.TitleI18n {
normalizedLocale := i18n.NormalizeLocale(locale)
if !isSupportedMenuLocale(normalizedLocale) {
return e.NewBusinessError(e.InvalidParameter)
}
trimmedTitle := strings.TrimSpace(title)
if trimmedTitle == "" {
continue
}
normalized[normalizedLocale] = trimmedTitle
}
zhTitle := strings.TrimSpace(normalized[i18n.LocaleZhCN])
enTitle := strings.TrimSpace(normalized[i18n.LocaleEnUS])
if zhTitle == "" && enTitle == "" {
return e.NewBusinessError(e.InvalidParameter)
}
params.TitleI18n = normalized
return nil
}
func isSupportedMenuLocale(locale string) bool {
return locale == i18n.LocaleZhCN || locale == i18n.LocaleEnUS
}
// resolveMenuHierarchy 统一处理菜单层级、父级合法性和 full_path 计算,避免主流程在多个小函数间跳转。
// 处理逻辑:
// 1. 判断是否需要更新父级信息(pid 变更或 path 变更)
// 2. 验证父菜单是否存在且不是按钮类型
// 3. 检测环路
// 4. 计算新的 level、pids、full_path
func (s *MenuService) resolveMenuHierarchy(menu *model.Menu, params *menuMutation) error {
needRefreshByParent := (params.Pid > 0 && params.Pid != menu.Pid) ||
(params.Pid > 0 && params.Path != menu.Path)
if !needRefreshByParent {
// 无需更新父级信息,仅处理顶级菜单场景
if params.Pid == 0 {
menu.Level = menuRootLevel
menu.Pids = menuRootPid
menu.FullPath = s.buildFullPath(params.Path, rootPath, params.Type)
}
menu.Pid = params.Pid
return nil
}
// 验证父菜单是否存在
parentMenu := model.NewMenu()
if err := parentMenu.GetById(params.Pid); err != nil || parentMenu.ID == 0 {
return e.NewBusinessError(e.ParentMenuNotExists)
}
// 父菜单不能是按钮类型
if parentMenu.Type == model.BUTTON {
return e.NewBusinessError(e.ParentMenuTypeInvalid)
}
// 环路检测
if utils2.WouldCauseCycle(menu.ID, params.Pid, parentMenu.Pids) {
return e.NewBusinessError(e.ParentMenuInvalid)
}
// 计算新的层级和路径
menu.Level = parentMenu.Level + 1
menu.Pids = s.buildPids(parentMenu.Pids, parentMenu.ID)
menu.FullPath = s.buildFullPath(params.Path, parentMenu.FullPath, params.Type)
menu.Pid = params.Pid
return nil
}
// buildPids 构建子节点的 pids 路径:父 pids + 父 ID。
func (s *MenuService) buildPids(parentPids string, parentID uint) string {
return strings.TrimPrefix(fmt.Sprintf("%s,%d", parentPids, parentID), ",")
}
// buildFullPath 构建菜单的完整路径。
// 规则:
// 1. 按钮类型无 full_path
// 2. 已有完整路径前缀(/、http、https)则直接使用
// 3. 否则拼接父路径 + 当前路径
func (s *MenuService) buildFullPath(path, parentPath string, menuType uint8) string {
if menuType == model.BUTTON {
return ""
}
if parentPath == "" {
parentPath = rootPath
}
if strings.HasPrefix(path, rootPath) ||
strings.HasPrefix(path, "https://") ||
strings.HasPrefix(path, "http://") {
return path
}
if !strings.HasSuffix(parentPath, "/") {
parentPath += "/"
}
return parentPath + path
}
// assignMenuFields 填充菜单模型字段。
func (s *MenuService) assignMenuFields(menu *model.Menu, params *menuMutation) {
menu.Icon = params.Icon
menu.Pid = params.Pid
menu.Code = params.Code
menu.Path = params.Path
menu.Name = params.Name
menu.Component = params.Component
menu.Status = params.Status
menu.Redirect = params.Redirect
menu.AnimateEnter = params.AnimateEnter
menu.AnimateLeave = params.AnimateLeave
menu.AnimateDuration = params.AnimateDuration
menu.IsShow = params.IsShow
menu.IsAuth = params.IsAuth
menu.IsNewWindow = params.IsNewWindow
menu.Sort = params.Sort
menu.Type = params.Type
menu.Description = params.Description
menu.IsExternalLinks = params.IsExternalLinks
// 按钮类型无 full_path
if params.Type == model.BUTTON {
menu.FullPath = ""
}
}
// validateUniqueFields 验证菜单唯一字段:code、name、full_path。
func (s *MenuService) validateUniqueFields(menu *model.Menu, params *menuMutation, excludeId uint) error {
// 验证 code 唯一性
codeExists, err := menu.ExistsExcludeId("code", params.Code, excludeId)
if err != nil {
return err
}
if params.Code != "" && codeExists {
return e.NewBusinessError(e.MenuCodeExists)
}
// 验证 name 唯一性
nameExists, err := menu.ExistsExcludeId("name", params.Name, excludeId)
if err != nil {
return err
}
if params.Name != "" && nameExists {
return e.NewBusinessError(e.MenuRouteNameExists)
}
// 验证 full_path 唯一性(按钮类型除外)
if params.Type != model.BUTTON && menu.Path != "" {
pathExists, err := menu.ExistsExcludeId("full_path", menu.FullPath, excludeId)
if err != nil {
return err
}
if pathExists {
return e.NewBusinessError(e.MenuPathExists)
}
}
return nil
}
// executeEditTransaction 执行菜单编辑事务。
// 处理逻辑:
// 1. 保存菜单数据
// 2. 级联更新子菜单的 level、pids、full_path
// 3. 更新原父菜单和新父菜单的子菜单数量
// 4. 更新菜单关联的 API 权限
// 5. 同步受影响用户的权限缓存
func (s *MenuService) executeEditTransaction(menu *model.Menu, apiList []uint, titleI18n map[string]string, editContext *menuEditContext) error {
db, err := menu.GetDB()
if err != nil {
return err
}
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
// 保存菜单数据
if err := s.persistMenu(menu, tx); err != nil {
return err
}
if err := model.NewMenuI18n().UpsertMenuTitles(menu.ID, titleI18n, tx); err != nil {
return err
}
// 级联更新子菜单
if err := s.updateChildrenLevels(menu, editContext.originPids, editContext.originFullPath, tx); err != nil {
return err
}
if err := s.updateParentChildrenNum(menu, editContext, tx); err != nil {
return err
}
// 更新菜单关联的 API 权限
return s.updateMenuPermissions(menu, apiList, tx)
})
if err != nil {
return err
}
// 同步受影响用户的权限缓存
return access.NewPermissionSyncCoordinator().SyncUsersAffectedByMenus([]uint{menu.ID})
}
// persistMenu 持久化菜单数据。
func (s *MenuService) persistMenu(menu *model.Menu, tx *gorm.DB) error {
menu.SetDB(tx)
return menu.Save()
}
// updateParentChildrenNum 在父节点变更后刷新原父与新父的 children_num。
func (s *MenuService) updateParentChildrenNum(menu *model.Menu, editContext *menuEditContext, tx *gorm.DB) error {
// 原父节点的子节点数减一
if editContext.originPid > 0 && editContext.originPid != menu.Pid {
if err := model.UpdateChildrenNum(model.NewMenu(), editContext.originPid, tx); err != nil {
return err
}
}
// 新父节点的子节点数加一
if menu.Pid > 0 && menu.Pid != editContext.originPid {
if err := model.UpdateChildrenNum(model.NewMenu(), menu.Pid, tx); err != nil {
return err
}
}
return nil
}
// updateChildrenLevels 更新子菜单的层级信息(pids, level, full_path)。
// 当菜单的 pids 或 full_path 变更时,需要级联更新所有子代菜单。
func (s *MenuService) updateChildrenLevels(menu *model.Menu, originPids string, originFullPath string, tx *gorm.DB) error {
// pids 和 full_path 都未变更,无需级联更新
if menu.Pids == originPids && menu.FullPath == originFullPath {
return nil
}
// 查询所有子代菜单
descendantModel := model.NewMenu()
descendantModel.SetDB(tx)
descendants, err := descendantModel.FindDescendantsById(menu.ID)
if err != nil {
return err
}
if len(descendants) == 0 {
return nil
}
// 按 pid 分组,便于递归重建
childrenByPID := s.groupDescendantsByPID(descendants)
return s.rebuildMenuDescendants(tx, menu, childrenByPID)
}
// groupDescendantsByPID 按父节点分组子菜单。
// 这里必须使用索引取址,避免 range 临时变量取址导致所有指针指向同一对象。
func (s *MenuService) groupDescendantsByPID(descendants []model.Menu) map[uint][]*model.Menu {
childrenByPID := make(map[uint][]*model.Menu, len(descendants))
for i := range descendants {
child := &descendants[i]
childrenByPID[child.Pid] = append(childrenByPID[child.Pid], child)
}
return childrenByPID
}
// rebuildMenuDescendants 递归重建子代菜单的层级信息。
func (s *MenuService) rebuildMenuDescendants(tx *gorm.DB, parent *model.Menu, childrenByPID map[uint][]*model.Menu) error {
menuModel := model.NewMenu()
menuModel.SetDB(tx)
for _, child := range childrenByPID[parent.ID] {
// 重建子菜单的 pids、level、full_path
child.Pids = s.buildPids(parent.Pids, parent.ID)
child.Level = parent.Level + 1
child.FullPath = s.buildFullPath(child.Path, parent.FullPath, child.Type)
if child.Type == model.BUTTON {
child.FullPath = ""
}
// 批量更新数据库
if err := menuModel.UpdateById(child.ID, map[string]any{
"pids": child.Pids,
"level": child.Level,
"full_path": child.FullPath,
}); err != nil {
return err
}
// 递归处理下一级子菜单
if err := s.rebuildMenuDescendants(tx, child, childrenByPID); err != nil {
return err
}
}
return nil
}
// updateMenuPermissions 更新菜单关联的 API 权限。
// 使用差分算法:计算需要删除和新增的 API ID,只变更差异部分。
func (s *MenuService) updateMenuPermissions(menu *model.Menu, apiList []uint, tx ...*gorm.DB) error {
menuApiMap := model.NewMenuApiMap()
if len(tx) > 0 {
menuApiMap.SetDB(tx[0])
}
// 查询菜单当前已关联的 API ID 列表
existingMaps, err := model.ListE(menuApiMap, "menu_id = ?", []any{menu.ID}, model.ListOptionalParams{
SelectFields: []string{"api_id"},
})
if err != nil {
return err
}
existingIDs := lo.Map(existingMaps, func(m *model.MenuApiMap, _ int) uint {
return m.ApiId
})
apiList = lo.Uniq(apiList)
// 计算差异
toDelete, toAdd := lo.Difference(existingIDs, apiList)
// 删除差异 API 关联
if len(toDelete) > 0 {
if err := menuApiMap.DeleteWhere("menu_id = ? AND api_id IN (?)", []any{menu.ID, toDelete}...); err != nil {
return err
}
}
if len(toAdd) == 0 {
return nil
}
// 新增 API 关联
newMappings := lo.Map(toAdd, func(apiID uint, _ int) *model.MenuApiMap {
return &model.MenuApiMap{MenuId: menu.ID, ApiId: apiID}
})
return menuApiMap.CreateBatch(newMappings)
}
// UpdateAllMenuPermissions 批量更新所有菜单的权限到 Casbin。
func (s *MenuService) UpdateAllMenuPermissions() error {
return access.NewPermissionSyncCoordinator().SyncAll()
}
================================================
FILE: internal/service/menu/menu_query.go
================================================
package menu
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// ListPage 分页查询菜单列表
func (s *MenuService) ListPage(params *form.ListMenu) *resources.Collection {
condition, args := s.buildListCondition(params, false)
menu := model.NewMenu()
total, collection, err := model.ListPageE(menu, params.Page, params.PerPage, condition, args)
if err != nil {
return resources.NewMenuTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
return resources.NewMenuTransformer().ToCollectionWithTitles(params.Page, params.PerPage, total, collection, nil)
}
// List 查询菜单树形列表
func (s *MenuService) List(params *form.ListMenu, locale string) any {
condition, args := s.buildListCondition(params, true)
menus, err := model.ListE(model.NewMenu(), condition, args, model.ListOptionalParams{
OrderBy: "sort desc, id desc",
})
if err != nil {
return resources.BuildMenuTree(nil, 0, nil)
}
localeTitles, err := s.loadLocalizedTitles(menus, locale)
if err != nil {
return resources.BuildMenuTree(menus, 0, nil)
}
return resources.BuildMenuTree(menus, 0, localeTitles)
}
// Delete 删除菜单
func (s *MenuService) Delete(id uint) error {
menu := model.NewMenu()
if err := menu.GetById(id); err != nil || menu.ID == 0 {
return e.NewBusinessError(e.MenuNotFound)
}
if menu.ChildrenNum > 0 {
return e.NewBusinessError(e.MenuHasChildren)
}
db, err := menu.GetDB()
if err != nil {
return e.NewBusinessError(e.MenuCannotDelete)
}
affectedUserIDs, err := access.NewAffectedUsersResolver().Resolve(access.PermissionChangeScope{MenuIDs: []uint{id}})
if err != nil {
return e.NewBusinessError(e.MenuCannotDelete)
}
coordinator := access.NewPermissionSyncCoordinator()
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
menu.SetDB(tx)
parentID := menu.Pid
menuApiMap := model.NewMenuApiMap()
menuApiMap.SetDB(tx)
if err := menuApiMap.DeleteWhere("menu_id = ?", id); err != nil {
return err
}
roleMenuMap := model.NewRoleMenuMap()
roleMenuMap.SetDB(tx)
if err := roleMenuMap.DeleteWhere("menu_id = ?", id); err != nil {
return err
}
if _, deleteErr := menu.DeleteByID(id); deleteErr != nil {
return deleteErr
}
if err := model.NewMenuI18n().DeleteByMenuIDs([]uint{id}, tx); err != nil {
return err
}
if parentID > 0 {
if err := model.UpdateChildrenNum(model.NewMenu(), parentID, tx); err != nil {
return err
}
}
return coordinator.SyncUsers(affectedUserIDs, tx)
})
if err != nil {
return e.NewBusinessError(e.MenuCannotDelete)
}
return coordinator.ReloadPolicyCacheWithCode(e.MenuCannotDelete)
}
// Detail 获取菜单详情
func (s *MenuService) Detail(id uint, _ string) (any, error) {
menu := model.NewMenu()
if err := menu.GetAllById(id); err != nil || menu.ID == 0 {
return nil, e.NewBusinessError(e.MenuNotFound)
}
titleI18n, err := model.NewMenuI18n().LocaleTitleMapByMenuID(menu.ID)
if err != nil {
return nil, err
}
return resources.NewMenuTransformer().ToStructWithTitles(menu, "", titleI18n), nil
}
func (s *MenuService) buildListCondition(params *form.ListMenu, includeStatus bool) (string, []any) {
qb := query_builder.New()
if params.Keyword != "" {
qb.AddCondition("(id IN (SELECT menu_id FROM menu_i18n WHERE title like ?) OR path like ? OR code = ?)", "%"+params.Keyword+"%", "%"+params.Keyword+"%", params.Keyword)
}
qb.AddEq("is_auth", params.IsAuth)
if includeStatus && params.Status != nil && *params.Status != allStatus {
qb.AddEq("status", params.Status)
}
return qb.Build()
}
func (s *MenuService) loadLocalizedTitles(menus []*model.Menu, locale string) (map[uint]string, error) {
menuIDs := make([]uint, 0, len(menus))
for _, menu := range menus {
if menu == nil {
continue
}
menuIDs = append(menuIDs, menu.ID)
}
if len(menuIDs) == 0 {
return map[uint]string{}, nil
}
return model.NewMenuI18n().LocalizedTitleMapByMenuIDs(menuIDs, menuLocalePriority(locale))
}
func menuLocalePriority(locale string) []string {
return []string{
i18n.NormalizeLocale(locale),
i18n.LocaleZhCN,
i18n.LocaleEnUS,
}
}
================================================
FILE: internal/service/menu/menu_test.go
================================================
package menu
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestMenuBuildListCondition(t *testing.T) {
isAuth := int8(1)
status := int8(1)
params := &form.ListMenu{
Keyword: "dashboard",
IsAuth: &isAuth,
Status: &status,
}
condition, args := NewMenuService().buildListCondition(params, true)
expected := "(id IN (SELECT menu_id FROM menu_i18n WHERE title like ?) OR path like ? OR code = ?) AND is_auth = ? AND status = ?"
if condition != expected {
t.Fatalf("unexpected condition: %s", condition)
}
if len(args) != 5 {
t.Fatalf("unexpected args len: %d", len(args))
}
}
func TestAssembleFullPath(t *testing.T) {
service := NewMenuService()
if got := service.buildFullPath("users", "/admin", model.MENU); got != "/admin/users" {
t.Fatalf("unexpected full path: %s", got)
}
if got := service.buildFullPath("https://example.com", "/admin", model.MENU); got != "https://example.com" {
t.Fatalf("unexpected external path: %s", got)
}
if got := service.buildFullPath("button", "/admin", model.BUTTON); got != "" {
t.Fatalf("expected empty path for button, got %s", got)
}
}
func TestBuildPids(t *testing.T) {
service := NewMenuService()
if got := service.buildPids("0,1", 10); got != "0,1,10" {
t.Fatalf("unexpected pids: %s", got)
}
if got := service.buildPids("", 10); got != "10" {
t.Fatalf("unexpected root pids: %s", got)
}
}
func TestMenuLocalePriority(t *testing.T) {
priorities := menuLocalePriority("ja-JP")
if len(priorities) != 3 {
t.Fatalf("unexpected priorities length: %d", len(priorities))
}
if priorities[0] != i18n.LocaleZhCN || priorities[2] != i18n.LocaleEnUS {
t.Fatalf("unexpected priorities: %+v", priorities)
}
}
================================================
FILE: internal/service/role/audit_diff.go
================================================
package role
import (
"sort"
"strings"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var roleDiffRules = []auditdiff.FieldRule{
{Field: "id", Label: "角色ID"},
{Field: "code", Label: "角色编码"},
{Field: "name", Label: "角色名称"},
{Field: "description", Label: "描述"},
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{Field: "pid", Label: "上级角色ID"},
{Field: "pids", Label: "上级路径"},
{Field: "level", Label: "层级"},
{Field: "sort", Label: "排序"},
{Field: "menu_list", Label: "菜单ID列表"},
}
// CreateWithAuditDiff 新增角色并返回精确 change_diff。
func (s *RoleService) CreateWithAuditDiff(params *form.CreateRole) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
payload := *params
payload.Code = strings.TrimSpace(payload.Code)
if payload.Code == "" {
payload.Code = s.generateRoleCode()
}
if err := s.Create(&payload); err != nil {
return "", err
}
after, err := s.snapshotRoleByCode(payload.Code)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildRoleDiff(nil, after), nil
}
// UpdateWithAuditDiff 更新角色并返回精确 change_diff。
func (s *RoleService) UpdateWithAuditDiff(params *form.UpdateRole) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotRoleByID(params.Id)
if err != nil {
return "", err
}
if err := s.Update(params); err != nil {
return "", err
}
after, err := s.snapshotRoleByID(params.Id)
if err != nil {
return auditdiff.Marshal(nil), nil
}
return buildRoleDiff(before, after), nil
}
// DeleteWithAuditDiff 删除角色并返回精确 change_diff。
func (s *RoleService) DeleteWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotRoleByID(id)
if err != nil {
return "", err
}
if err := s.Delete(id); err != nil {
return "", err
}
return buildRoleDiff(before, nil), nil
}
func (s *RoleService) snapshotRoleByCode(code string) (map[string]any, error) {
role := model.NewRole()
if err := role.FindByCode(strings.TrimSpace(code)); err != nil || role.ID == 0 {
return nil, e.NewBusinessError(e.RoleNotFound)
}
return s.snapshotRoleByID(role.ID)
}
func (s *RoleService) snapshotRoleByID(id uint) (map[string]any, error) {
role := model.NewRole()
if err := role.GetById(id); err != nil || role.ID == 0 {
return nil, e.NewBusinessError(e.RoleNotFound)
}
menuIDs, err := model.NewRoleMenuMap().MenuIdsByRoleIds([]uint{id})
if err != nil {
return nil, err
}
sort.Slice(menuIDs, func(i, j int) bool {
return menuIDs[i] < menuIDs[j]
})
return map[string]any{
"id": role.ID,
"code": role.Code,
"name": role.Name,
"description": role.Description,
"status": role.Status,
"pid": role.Pid,
"pids": role.Pids,
"level": role.Level,
"sort": role.Sort,
"menu_list": menuIDs,
}, nil
}
func buildRoleDiff(before, after map[string]any) string {
items := auditdiff.BuildFieldDiff(before, after, roleDiffRules)
return auditdiff.Marshal(items)
}
================================================
FILE: internal/service/role/audit_diff_test.go
================================================
package role
import (
"encoding/json"
"testing"
)
func TestBuildRoleDiffIncludesStatusDisplay(t *testing.T) {
raw := buildRoleDiff(
map[string]any{"status": uint8(0)},
map[string]any{"status": uint8(1)},
)
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
if len(items) != 1 {
t.Fatalf("expected 1 diff item, got %d", len(items))
}
if items[0]["before_display"] != "禁用" || items[0]["after_display"] != "启用" {
t.Fatalf("unexpected status display mapping: %#v", items[0])
}
}
================================================
FILE: internal/service/role/role.go
================================================
package role
import (
"strconv"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
const (
maxRoleLevel = 2
maxChildrenPerTop = 5
)
// RoleService 角色服务。
type RoleService struct {
service.Base
}
// NewRoleService 创建角色服务实例。
func NewRoleService() *RoleService {
return &RoleService{}
}
// List 分页查询角色列表。
func (s *RoleService) List(params *form.RoleList) interface{} {
condition, args := s.buildListCondition(params)
roleModel := model.NewRole()
total, collection, err := model.ListPageE(
roleModel,
params.Page,
params.PerPage,
condition,
args,
model.ListOptionalParams{OrderBy: "sort desc, id desc"},
)
if err != nil {
return resources.ToRawCollection(params.Page, params.PerPage, 0, make([]*model.Role, 0))
}
return resources.ToRawCollection(params.Page, params.PerPage, total, collection)
}
func (s *RoleService) buildListCondition(params *form.RoleList) (string, []any) {
return query_builder.New().
AddLike("name", params.Name).
AddEq("status", params.Status).
AddEq("pid", params.Pid).
Build()
}
// Create 新增角色。
func (s *RoleService) Create(params *form.CreateRole) error {
return s.applyRoleMutation(&roleMutation{
Code: params.Code,
Name: params.Name,
Description: params.Description,
Status: params.Status,
Pid: params.Pid,
Sort: params.Sort,
MenuList: params.MenuList,
})
}
// Update 更新角色。
func (s *RoleService) Update(params *form.UpdateRole) error {
return s.applyRoleMutation(&roleMutation{
Id: params.Id,
Name: params.Name,
Description: params.Description,
Status: params.Status,
Pid: params.Pid,
Sort: params.Sort,
MenuList: params.MenuList,
})
}
// Delete 删除角色。
func (s *RoleService) Delete(id uint) error {
role := model.NewRole()
if err := role.GetById(id); err != nil || role.ID == 0 {
return e.NewBusinessError(e.RoleNotFound)
}
if access.NewSystemDefaultsService().IsProtectedRole(role) {
return e.NewBusinessError(e.SuperAdminCannotDelete)
}
if role.ChildrenNum > 0 {
return e.NewBusinessError(e.RoleHasChildren)
}
return s.executeDeleteTransaction(role, id)
}
// Detail 获取角色详情。
func (s *RoleService) Detail(id uint) (any, error) {
role := model.NewRole()
if err := role.GetAllById(id); err != nil || role.ID == 0 {
return nil, e.NewBusinessError(e.RoleNotFound)
}
return resources.NewRoleTransformer().ToStruct(role), nil
}
// GetRoleMenus 获取角色的所有菜单标识列表。
// 调用跨包方法 access.UserPermissionSyncService.RoleMenuIDs 获取菜单 ID,再转换为字符串列表。
func (s *RoleService) GetRoleMenus(roleId uint) ([]string, error) {
role := model.NewRole()
if err := role.GetById(roleId); err != nil || role.ID == 0 {
return nil, e.NewBusinessError(e.RoleNotFound)
}
menuIDs, err := access.NewUserPermissionSyncService().RoleMenuIDs([]uint{roleId})
if err != nil {
return nil, e.NewBusinessError(e.FAILURE)
}
result := make([]string, 0, len(menuIDs))
for _, menuID := range menuIDs {
result = append(result, strconv.FormatUint(uint64(menuID), 10))
}
return result, nil
}
================================================
FILE: internal/service/role/role_mutation.go
================================================
package role
import (
"fmt"
"github.com/google/uuid"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
"github.com/wannanbigpig/gin-layout/internal/service/access"
utils2 "github.com/wannanbigpig/gin-layout/pkg/utils"
)
// roleMutation 角色变更参数,用于封装新增/更新角色的请求数据。
type roleMutation struct {
Id uint // 角色 ID,0 表示新增
Code string // 角色编码,新增时为空则自动生成
Name string // 角色名称
Description string // 角色描述
Status uint8 // 角色状态
Pid uint // 父角色 ID,0 表示顶级角色
Sort uint // 排序权重
MenuList []uint // 关联的菜单 ID 列表
}
// applyRoleMutation 执行角色变更操作(新增/更新)。
// 处理逻辑:
// 1. 验证角色是否存在(更新时)
// 2. 检查受保护角色(系统默认角色不可修改)
// 3. 验证并构建树形路径(pids, level)
// 4. 填充角色基础字段
// 5. 验证菜单列表
// 6. 事务保存:角色数据、级联更新子角色 pids、更新子角色数量、同步菜单关联
// 7. 同步受影响角色的用户权限缓存
func (s *RoleService) applyRoleMutation(params *roleMutation) error {
role := model.NewRole()
originPids := "0"
originPid := uint(0)
// 更新场景:加载现有角色数据,记录原始 pids 用于后续级联判断
if params.Id > 0 {
if err := role.GetById(params.Id); err != nil || role.ID == 0 {
return e.NewBusinessError(e.RoleNotFound)
}
originPids = role.Pids
originPid = role.Pid
}
// 检查是否为受保护角色(系统默认角色不可修改)
if params.Id > 0 && access.NewSystemDefaultsService().IsProtectedRole(role) {
return e.NewBusinessError(e.SuperAdminCannotModify)
}
// 处理父角色变更:验证父角色、检测环路、计算层级和路径
if params.Pid > 0 && params.Pid != role.Pid {
parentRole := model.NewRole()
if err := parentRole.GetById(params.Pid); err != nil || parentRole.ID == 0 {
return e.NewBusinessError(e.ParentRoleNotExists)
}
// 环路检测:当前角色若已在父角色的祖先路径上,选择该父角色会形成环
if role.ID > 0 && utils2.WouldCauseCycle(role.ID, params.Pid, parentRole.Pids) {
return e.NewBusinessError(e.ParentRoleInvalid)
}
// 限制顶级角色的子角色数量
if parentRole.Pid == 0 && (role.ID == 0 || role.Pid != params.Pid) && parentRole.ChildrenNum >= maxChildrenPerTop {
return e.NewBusinessError(e.MaxChildRoles)
}
// 构建新的层级和路径:父层级 +1,pids = 父 pids + 父 ID
role.Level = parentRole.Level + 1
if parentRole.Pids == "0" || parentRole.Pids == "" {
role.Pids = fmt.Sprintf("%d", parentRole.ID)
} else {
role.Pids = fmt.Sprintf("%s,%d", parentRole.Pids, parentRole.ID)
}
role.Pid = params.Pid
} else if params.Pid == 0 {
// 设置为顶级角色
role.Level = 1
role.Pids = "0"
role.Pid = 0
} else {
// 父角色未变更,仅同步 pid 字段
role.Pid = params.Pid
}
// 检查角色层级深度是否超限
if role.Level > maxRoleLevel {
return e.NewBusinessError(e.MaxRoleDepth)
}
// 新增角色时生成 code
if params.Id == 0 {
if params.Code != "" {
role.Code = params.Code
} else if role.Code == "" {
role.Code = s.generateRoleCode()
}
}
// 填充可变更字段
role.Name = params.Name
role.Description = params.Description
role.Status = params.Status
role.Sort = params.Sort
// 验证所有菜单 ID 是否存在
menuList, err := model.VerifyExistingIDs(model.NewMenu(), params.MenuList)
if err != nil {
return e.NewBusinessError(e.MenuNotFound)
}
db, err := role.GetDB()
if err != nil {
return err
}
// 事务执行:保存角色、级联更新、菜单同步
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
role.SetDB(tx)
if err := role.Save(); err != nil {
return err
}
// pids 变更时,级联更新所有子角色的 pids 路径
if role.Pids != originPids {
updateExpr := s.buildPidsUpdateExpr(originPids, role.Pids)
roleModel := model.NewRole()
roleModel.SetDB(tx)
if err := roleModel.UpdateChildrenPidsByParent(role.ID, updateExpr); err != nil {
return err
}
}
// 原父角色的子角色数量减 1
if originPid > 0 && originPid != role.Pid {
if err := model.UpdateChildrenNum(model.NewRole(), originPid, tx); err != nil {
return err
}
}
// 新父角色的子角色数量加 1
if role.Pid > 0 && role.Pid != originPid {
if err := model.UpdateChildrenNum(model.NewRole(), role.Pid, tx); err != nil {
return err
}
}
return s.updateRoleMenu(role.ID, menuList, tx)
})
if err != nil {
return err
}
// 同步受影响角色的用户权限缓存
return access.NewPermissionSyncCoordinator().SyncUsersAffectedByRoles([]uint{role.ID})
}
// generateRoleCode 生成角色唯一编码,格式:role_{uuid}。
func (s *RoleService) generateRoleCode() string {
return "role_" + uuid.NewString()
}
// buildPidsUpdateExpr 构建 SQL CASE 表达式,用于级联更新子角色的 pids 路径。
// 场景:当某角色的 pids 变更时,其所有子角色的 pids 前缀需要同步更新。
// 参数:
// - originPids: 原始路径
// - newPids: 新路径
//
// 返回:SQL CASE 表达式字符串
// 示例:originPids="1,2", newPids="1,8" 时,子角色 "1,2,3" → "1,8,3"
func (s *RoleService) buildPidsUpdateExpr(originPids, newPids string) string {
if originPids == "0" {
return fmt.Sprintf(
"CASE WHEN pids = '0' THEN '%s' WHEN pids LIKE '0,%%' THEN CONCAT('%s,', SUBSTRING(pids, 3)) ELSE pids END",
newPids, newPids,
)
}
return fmt.Sprintf(
"CASE WHEN pids = '%s' THEN '%s' WHEN pids LIKE '%s,%%' THEN CONCAT('%s,', SUBSTRING(pids, %d)) ELSE pids END",
originPids, newPids, originPids, newPids, len(originPids)+2,
)
}
// updateRoleMenu 更新角色的菜单关联关系。
// 使用差分算法:计算需要删除和新增的菜单 ID,只变更差异部分。
// 参数:
// - roleId: 角色 ID
// - menuList: 目标菜单 ID 列表
// - tx: 可选的事务 DB 实例
func (s *RoleService) updateRoleMenu(roleId uint, menuList []uint, tx ...*gorm.DB) error {
roleMenuMap := model.NewRoleMenuMap()
if len(tx) > 0 {
roleMenuMap.SetDB(tx[0])
}
// 查询角色当前已关联的菜单 ID 列表
existingIds, err := model.ExtractColumnsByCondition[model.RoleMenuMap, *model.RoleMenuMap, uint](roleMenuMap, "menu_id", "role_id = ?", roleId)
if err != nil {
return err
}
// 计算差异:toDelete 需删除,toAdd 需新增
toDelete, toAdd, _ := utils.CalculateChanges(existingIds, menuList)
// 批量删除差异菜单关联
if len(toDelete) > 0 {
if err := roleMenuMap.DeleteWhere("role_id = ? AND menu_id IN (?)", []any{roleId, toDelete}...); err != nil {
return err
}
}
// 批量新增菜单关联
if len(toAdd) > 0 {
newMappings := lo.Map(toAdd, func(menuId uint, _ int) *model.RoleMenuMap {
return &model.RoleMenuMap{RoleId: roleId, MenuId: menuId}
})
if err := roleMenuMap.CreateBatch(newMappings); err != nil {
return err
}
}
return nil
}
// executeDeleteTransaction 执行角色删除事务。
// 处理逻辑:
// 1. 删除角色 - 菜单关联
// 2. 删除角色记录
// 3. 更新原父角色的子角色数量
// 4. 同步受影响用户的权限缓存
func (s *RoleService) executeDeleteTransaction(role *model.Role, id uint) error {
db, err := role.GetDB()
if err != nil {
return e.NewBusinessError(e.RoleCannotDelete)
}
affectedUserIDs, err := access.NewAffectedUsersResolver().Resolve(access.PermissionChangeScope{RoleIDs: []uint{id}})
if err != nil {
return e.NewBusinessError(e.RoleCannotDelete)
}
coordinator := access.NewPermissionSyncCoordinator()
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
role.SetDB(tx)
// 删除角色关联的所有菜单
roleMenuMap := model.NewRoleMenuMap()
roleMenuMap.SetDB(tx)
if err := roleMenuMap.DeleteWhere("role_id = ?", id); err != nil {
return err
}
adminUserRoleMap := model.NewAdminUserRoleMap()
adminUserRoleMap.SetDB(tx)
if err := adminUserRoleMap.DeleteWhere("role_id = ?", id); err != nil {
return err
}
deptRoleMap := model.NewDeptRoleMap()
deptRoleMap.SetDB(tx)
if err := deptRoleMap.DeleteWhere("role_id = ?", id); err != nil {
return err
}
// 删除角色记录
parentId := role.Pid
if _, err := role.DeleteByID(id); err != nil {
return err
}
// 更新原父角色的子角色数量(减 1)
if parentId > 0 {
if err := model.UpdateChildrenNum(model.NewRole(), parentId, tx); err != nil {
return err
}
}
return coordinator.SyncUsers(affectedUserIDs, tx)
})
if err != nil {
return e.NewBusinessError(e.RoleCannotDelete)
}
// 同步受影响用户的权限缓存
return coordinator.ReloadPolicyCacheWithCode(e.RoleCannotDelete)
}
================================================
FILE: internal/service/role/role_test.go
================================================
package role
import "testing"
func TestGenerateRoleCodeUsesUniqueDefaultPrefix(t *testing.T) {
service := NewRoleService()
first := service.generateRoleCode()
second := service.generateRoleCode()
if first == "" || second == "" {
t.Fatal("expected generated role code")
}
if first == second {
t.Fatalf("expected different role codes, got %s", first)
}
if first[:5] != "role_" || second[:5] != "role_" {
t.Fatalf("expected role_ prefix, got %s and %s", first, second)
}
}
================================================
FILE: internal/service/storage_config.go
================================================
package service
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/filestorage"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"gorm.io/gorm"
)
const (
StorageActiveDriverConfigKey = "storage.active_driver"
StorageConfigConfigKey = "storage.config"
storageConfigManageTab = "storage"
)
type StorageConfigService struct{}
func NewStorageConfigService() *StorageConfigService {
return &StorageConfigService{}
}
type StorageSettings struct {
ActiveDriver string `json:"active_driver"`
Config filestorage.Config `json:"config"`
}
func (s *StorageConfigService) Get(maskSensitive bool) (StorageSettings, error) {
settings := StorageSettings{ActiveDriver: model.StorageDriverLocal, Config: filestorage.DefaultConfig()}
if value, err := storageSysConfigValue(StorageActiveDriverConfigKey); err == nil && strings.TrimSpace(value) != "" {
settings.ActiveDriver = strings.TrimSpace(value)
}
if value, err := storageSysConfigValue(StorageConfigConfigKey); err == nil && strings.TrimSpace(value) != "" {
_ = json.Unmarshal([]byte(value), &settings.Config)
}
if settings.Config.SignedURLTTLSeconds <= 0 {
settings.Config.SignedURLTTLSeconds = 300
}
if settings.Config.MaxFileSizeMB <= 0 {
settings.Config.MaxFileSizeMB = 10
}
applyLocalStorageDefaults(&settings)
if maskSensitive {
maskStorageSettings(&settings)
}
return settings, nil
}
func (s *StorageConfigService) Save(next StorageSettings) error {
if err := validateStorageDriver(next.ActiveDriver); err != nil {
return err
}
current, _ := s.Get(false)
mergeSensitiveStorageConfig(&next.Config, current.Config)
payload, err := json.Marshal(next.Config)
if err != nil {
return err
}
db, err := model.GetDB()
if err != nil {
return err
}
err = access.RunInTransaction(db, func(tx *gorm.DB) error {
if err := upsertStorageSysConfig(tx, StorageActiveDriverConfigKey, next.ActiveDriver, model.SysConfigValueTypeString, 0, "当前启用的文件存储驱动", map[string]string{
i18n.LocaleZhCN: "当前存储驱动",
i18n.LocaleEnUS: "Active Storage Driver",
}); err != nil {
return err
}
return upsertStorageSysConfig(tx, StorageConfigConfigKey, string(payload), model.SysConfigValueTypeJSON, 1, "文件存储配置", map[string]string{
i18n.LocaleZhCN: "文件存储配置",
i18n.LocaleEnUS: "File Storage Config",
})
})
if err != nil {
return err
}
return nil
}
func storageSysConfigValue(key string) (string, error) {
configModel := model.NewSysConfig()
if err := configModel.FindByKey(key); err != nil {
return "", err
}
if configModel.Status != global.Yes {
return "", gorm.ErrRecordNotFound
}
return configModel.ConfigValue, nil
}
func (s *StorageConfigService) Test(ctx context.Context, settings StorageSettings) error {
driver, err := buildStorageDriver(ctx, settings.ActiveDriver, settings.Config)
if err != nil {
return err
}
key := "storage-test/" + time.Now().Format("20060102150405") + ".txt"
bucket := bucketForDriver(settings.ActiveDriver, settings.Config, global.Yes)
if _, err := driver.Put(ctx, filestorage.PutInput{Bucket: bucket, ObjectKey: key, Reader: strings.NewReader("ok"), Size: 2, ContentType: "text/plain"}); err != nil {
return err
}
exists, err := driver.Exists(ctx, bucket, key)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("storage object not found after put")
}
if _, err := driver.SignedURL(ctx, bucket, key, time.Minute); err != nil {
return err
}
return driver.Delete(ctx, bucket, key)
}
func NewActiveStorageDriver(ctx context.Context) (filestorage.Driver, filestorage.Config, string, error) {
settings, err := NewStorageConfigService().Get(false)
if err != nil {
return nil, filestorage.Config{}, "", err
}
driver, err := buildStorageDriver(ctx, settings.ActiveDriver, settings.Config)
return driver, settings.Config, settings.ActiveDriver, err
}
func NewStorageDriverByName(ctx context.Context, driverName string) (filestorage.Driver, filestorage.Config, error) {
settings, err := NewStorageConfigService().Get(false)
if err != nil {
return nil, filestorage.Config{}, err
}
driver, err := buildStorageDriver(ctx, driverName, settings.Config)
return driver, settings.Config, err
}
func buildStorageDriver(ctx context.Context, driverName string, config filestorage.Config) (filestorage.Driver, error) {
if err := validateStorageDriver(driverName); err != nil {
return nil, err
}
switch driverName {
case model.StorageDriverLocal:
return filestorage.NewLocalDriver(config.Local, storageBasePath(true), storageBasePath(false)), nil
case model.StorageDriverAliyunOSS:
if strings.TrimSpace(config.AliyunOSS.Bucket) == "" {
return nil, fmt.Errorf("aliyun_oss.bucket is required")
}
return filestorage.NewAliyunOSSDriver(config.AliyunOSS), nil
case model.StorageDriverS3:
if strings.TrimSpace(config.S3.Bucket) == "" {
return nil, fmt.Errorf("s3.bucket is required")
}
return filestorage.NewS3Driver(ctx, config.S3)
default:
return nil, fmt.Errorf("unsupported storage driver: %s", driverName)
}
}
func bucketForDriver(driverName string, config filestorage.Config, isPublic uint8) string {
switch driverName {
case model.StorageDriverAliyunOSS:
return config.AliyunOSS.Bucket
case model.StorageDriverS3:
return config.S3.Bucket
default:
if isPublic == global.Yes {
return "public"
}
return "private"
}
}
func validateStorageDriver(driverName string) error {
switch driverName {
case model.StorageDriverLocal, model.StorageDriverAliyunOSS, model.StorageDriverS3:
return nil
default:
return fmt.Errorf("unsupported storage driver: %s", driverName)
}
}
func upsertStorageSysConfig(tx *gorm.DB, key, value, valueType string, sensitive uint8, remark string, names map[string]string) error {
configModel := model.NewSysConfig()
configModel.SetDB(tx)
err := configModel.FindByKey(key)
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err == gorm.ErrRecordNotFound {
configModel.ConfigKey = key
}
configModel.ConfigValue = value
configModel.ValueType = valueType
configModel.GroupCode = "storage"
configModel.IsSystem = 1
configModel.IsSensitive = sensitive
configModel.IsVisible = 0
configModel.ManageTab = storageConfigManageTab
configModel.Status = 1
configModel.Sort = 90
configModel.Remark = remark
if err := tx.Save(configModel).Error; err != nil {
return err
}
return model.NewSysConfigI18n().UpsertConfigNames(configModel.ID, names, tx)
}
func maskStorageSettings(settings *StorageSettings) {
settings.Config.AliyunOSS.AccessKeyID = maskIfNotEmpty(settings.Config.AliyunOSS.AccessKeyID)
settings.Config.AliyunOSS.AccessKeySecret = maskIfNotEmpty(settings.Config.AliyunOSS.AccessKeySecret)
settings.Config.S3.AccessKeyID = maskIfNotEmpty(settings.Config.S3.AccessKeyID)
settings.Config.S3.SecretAccessKey = maskIfNotEmpty(settings.Config.S3.SecretAccessKey)
}
func mergeSensitiveStorageConfig(next *filestorage.Config, current filestorage.Config) {
if shouldKeepSecret(next.AliyunOSS.AccessKeyID) {
next.AliyunOSS.AccessKeyID = current.AliyunOSS.AccessKeyID
}
if shouldKeepSecret(next.AliyunOSS.AccessKeySecret) {
next.AliyunOSS.AccessKeySecret = current.AliyunOSS.AccessKeySecret
}
if shouldKeepSecret(next.S3.AccessKeyID) {
next.S3.AccessKeyID = current.S3.AccessKeyID
}
if shouldKeepSecret(next.S3.SecretAccessKey) {
next.S3.SecretAccessKey = current.S3.SecretAccessKey
}
}
func applyLocalStorageDefaults(settings *StorageSettings) {
if settings == nil {
return
}
cfg := c.GetConfig()
if cfg == nil {
return
}
basePath := filepath.Join(cfg.BasePath, "storage")
if strings.TrimSpace(settings.Config.Local.BasePath) == "" {
settings.Config.Local.BasePath = basePath
}
if strings.TrimSpace(settings.Config.Local.PublicBasePath) == "" {
settings.Config.Local.PublicBasePath = filepath.Join(basePath, "public")
}
if strings.TrimSpace(settings.Config.Local.PrivateBasePath) == "" {
settings.Config.Local.PrivateBasePath = filepath.Join(basePath, "private")
}
}
func maskIfNotEmpty(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return filestorage.MaskPlaceholder
}
func shouldKeepSecret(value string) bool {
value = strings.TrimSpace(value)
return value == "" || value == filestorage.MaskPlaceholder
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func ensureLocalStorageDirs() error {
cfg := c.GetConfig()
if cfg == nil {
return nil
}
for _, path := range []string{filepath.Join(cfg.BasePath, "storage/public"), filepath.Join(cfg.BasePath, "storage/private")} {
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
}
return nil
}
================================================
FILE: internal/service/sys_base.go
================================================
package service
import "github.com/gin-gonic/gin"
// Base 为服务层提供上下文和当前管理员信息。
type Base struct {
ctx *gin.Context
adminUserId uint
}
// SetAdminUserId 设置管理员ID
func (b *Base) SetAdminUserId(userId uint) {
b.adminUserId = userId
}
// GetAdminUserId 获取管理员ID
func (b *Base) GetAdminUserId() uint {
return b.adminUserId
}
// SetCtx 设置上下文
func (b *Base) SetCtx(c *gin.Context) {
b.ctx = c
}
// GetCtx 获取上下文
func (b *Base) GetCtx() *gin.Context {
return b.ctx
}
================================================
FILE: internal/service/sys_config/audit_diff.go
================================================
package sys_config
import (
"strings"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var sysConfigDiffRules = []auditdiff.FieldRule{
{Field: "config_key", Label: "参数键名"},
{Field: "config_name_i18n", Label: "参数名称"},
{Field: "config_value", Label: "参数值"},
{
Field: "value_type",
Label: "值类型",
ValueLabels: map[string]string{
model.SysConfigValueTypeString: "字符串",
model.SysConfigValueTypeNumber: "数字",
model.SysConfigValueTypeBool: "布尔",
model.SysConfigValueTypeJSON: "JSON",
},
},
{Field: "group_code", Label: "参数分组"},
{
Field: "is_sensitive",
Label: "敏感参数",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{
Field: "is_visible",
Label: "系统参数页展示",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{Field: "manage_tab", Label: "专属配置Tab"},
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{Field: "sort", Label: "排序"},
{Field: "remark", Label: "备注"},
}
// CreateWithAuditDiff 创建系统参数并返回字段级 change_diff JSON。
func (s *SysConfigService) CreateWithAuditDiff(params *form.CreateSysConfig) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
if err := s.applyMutation(0, ¶ms.SysConfigPayload); err != nil {
return "", err
}
after, err := s.snapshotConfigByKey(params.ConfigKey)
if err != nil {
return "", nil
}
return buildSysConfigDiffJSON(nil, after), nil
}
// UpdateWithAuditDiff 更新系统参数并返回字段级 change_diff JSON。
func (s *SysConfigService) UpdateWithAuditDiff(params *form.UpdateSysConfig) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotConfigByID(params.Id)
if err != nil {
return "", err
}
if err := s.applyMutation(params.Id, ¶ms.SysConfigPayload); err != nil {
return "", err
}
after, err := s.snapshotConfigByID(params.Id)
if err != nil {
return "", nil
}
return buildSysConfigDiffJSON(before, after), nil
}
// DeleteWithAuditDiff 删除系统参数并返回字段级 change_diff JSON。
func (s *SysConfigService) DeleteWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotConfigByID(id)
if err != nil {
return "", err
}
if err := s.deleteConfig(id); err != nil {
return "", err
}
return buildSysConfigDiffJSON(before, nil), nil
}
func (s *SysConfigService) deleteConfig(id uint) error {
config := model.NewSysConfig()
if err := config.GetById(id); err != nil || config.ID == 0 {
return e.NewBusinessError(e.NotFound)
}
if config.IsProtected() {
return e.NewBusinessError(e.InvalidParameter)
}
db, err := config.GetDB()
if err != nil {
return err
}
if err := access.RunInTransaction(db, func(tx *gorm.DB) error {
config.SetDB(tx)
if _, deleteErr := config.DeleteByID(id); deleteErr != nil {
return deleteErr
}
return model.NewSysConfigI18n().DeleteByConfigIDs([]uint{id}, tx)
}); err != nil {
return err
}
return s.RefreshCache()
}
func (s *SysConfigService) snapshotConfigByID(id uint) (map[string]any, error) {
config := model.NewSysConfig()
if err := config.GetById(id); err != nil || config.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return snapshotConfig(config)
}
func (s *SysConfigService) snapshotConfigByKey(key string) (map[string]any, error) {
config := model.NewSysConfig()
if err := config.FindByKey(strings.TrimSpace(key)); err != nil {
return nil, err
}
return snapshotConfig(config)
}
func snapshotConfig(config *model.SysConfig) (map[string]any, error) {
if config == nil || config.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
nameI18n, err := model.NewSysConfigI18n().LocaleNameMapByConfigID(config.ID)
if err != nil {
return nil, err
}
configValue := config.ConfigValue
if config.IsSensitive == 1 {
configValue = maskedConfigValue
}
return map[string]any{
"config_key": config.ConfigKey,
"config_name_i18n": nameI18n,
"config_value": configValue,
"value_type": model.NormalizeValueType(config.ValueType),
"group_code": config.GroupCode,
"is_sensitive": config.IsSensitive,
"is_visible": config.IsVisible,
"manage_tab": config.ManageTab,
"status": config.Status,
"sort": config.Sort,
"remark": config.Remark,
}, nil
}
func buildSysConfigDiffJSON(before, after map[string]any) string {
return auditdiff.Marshal(auditdiff.BuildFieldDiff(before, after, sysConfigDiffRules))
}
================================================
FILE: internal/service/sys_config/audit_request_body.go
================================================
package sys_config
import (
"encoding/json"
"strings"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// MaskedAuditRequestBody builds a request-body snapshot for request logs.
// It only overrides the generic body mask when the target config is sensitive.
func (s *SysConfigService) MaskedAuditRequestBody(id uint, params *form.SysConfigPayload) string {
if params == nil {
return ""
}
if !s.shouldMaskConfigMutationValue(id, params) {
return ""
}
payload := map[string]any{
"config_key": strings.TrimSpace(params.ConfigKey),
"config_name_i18n": params.ConfigNameI18n,
"config_value": maskedConfigValue,
"value_type": model.NormalizeValueType(params.ValueType),
"group_code": strings.TrimSpace(params.GroupCode),
"is_sensitive": params.IsSensitive,
"is_visible": params.IsVisible,
"manage_tab": strings.TrimSpace(params.ManageTab),
"status": params.Status,
"sort": params.Sort,
"remark": strings.TrimSpace(params.Remark),
}
if id > 0 {
payload["id"] = id
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(raw)
}
func (s *SysConfigService) shouldMaskConfigMutationValue(id uint, params *form.SysConfigPayload) bool {
if params != nil && params.IsSensitive != nil && *params.IsSensitive == 1 {
return true
}
if id == 0 {
return false
}
config := model.NewSysConfig()
if err := config.GetById(id); err != nil || config.ID == 0 {
return false
}
return config.IsSensitive == 1
}
================================================
FILE: internal/service/sys_config/cache.go
================================================
package sys_config
import (
"sync"
"github.com/wannanbigpig/gin-layout/internal/model"
)
type ConfigCacheItem struct {
ConfigKey string `json:"config_key"`
ConfigValue string `json:"config_value"`
ValueType string `json:"value_type"`
GroupCode string `json:"group_code"`
IsSensitive uint8 `json:"is_sensitive"`
}
var runtimeCache = struct {
sync.RWMutex
loaded bool
items map[string]ConfigCacheItem
}{
items: make(map[string]ConfigCacheItem),
}
func replaceCache(configs []model.SysConfig) {
next := make(map[string]ConfigCacheItem, len(configs))
for _, config := range configs {
next[config.ConfigKey] = ConfigCacheItem{
ConfigKey: config.ConfigKey,
ConfigValue: config.ConfigValue,
ValueType: config.ValueType,
GroupCode: config.GroupCode,
IsSensitive: config.IsSensitive,
}
}
runtimeCache.Lock()
defer runtimeCache.Unlock()
runtimeCache.items = next
runtimeCache.loaded = true
}
func getCachedValue(key string) (ConfigCacheItem, bool) {
runtimeCache.RLock()
defer runtimeCache.RUnlock()
item, ok := runtimeCache.items[key]
return item, ok
}
func cacheLoaded() bool {
runtimeCache.RLock()
defer runtimeCache.RUnlock()
return runtimeCache.loaded
}
================================================
FILE: internal/service/sys_config/cache_sync.go
================================================
package sys_config
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"go.uber.org/zap"
)
const (
sysConfigCacheSyncChannel = "sys_config:cache:refresh"
sysConfigCacheSyncTimeout = 3 * time.Second
)
type sysConfigCacheSyncPayload struct {
Source string `json:"source"`
Timestamp int64 `json:"timestamp"`
}
var (
sysConfigCacheSyncSourceID = buildSysConfigCacheSyncSourceID()
sysConfigSubscriberState = struct {
mu sync.Mutex
started bool
}{}
)
func notifySysConfigCacheRefreshed() {
ensureSysConfigCacheSyncSubscriber()
cfg := config.GetConfig()
if cfg == nil || !cfg.Redis.Enable {
return
}
client := data.RedisClient()
if client == nil {
return
}
payload, err := json.Marshal(sysConfigCacheSyncPayload{
Source: sysConfigCacheSyncSourceID,
Timestamp: time.Now().Unix(),
})
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), sysConfigCacheSyncTimeout)
defer cancel()
if err := client.Publish(ctx, sysConfigCacheSyncChannel, payload).Err(); err != nil && log.Logger != nil {
log.Logger.Warn("发布系统参数缓存刷新通知失败", zap.Error(err))
}
}
func ensureSysConfigCacheSyncSubscriber() {
cfg := config.GetConfig()
if cfg == nil || !cfg.Redis.Enable {
return
}
client := data.RedisClient()
if client == nil {
return
}
sysConfigSubscriberState.mu.Lock()
if sysConfigSubscriberState.started {
sysConfigSubscriberState.mu.Unlock()
return
}
sysConfigSubscriberState.started = true
sysConfigSubscriberState.mu.Unlock()
go runSysConfigCacheSyncSubscriber(client)
}
func runSysConfigCacheSyncSubscriber(client *redis.Client) {
ctx := context.Background()
pubsub := client.Subscribe(ctx, sysConfigCacheSyncChannel)
defer func() {
_ = pubsub.Close()
sysConfigSubscriberState.mu.Lock()
sysConfigSubscriberState.started = false
sysConfigSubscriberState.mu.Unlock()
}()
if _, err := pubsub.Receive(ctx); err != nil {
if log.Logger != nil {
log.Logger.Warn("订阅系统参数缓存刷新通道失败", zap.Error(err))
}
return
}
for {
message, err := pubsub.ReceiveMessage(ctx)
if err != nil {
if log.Logger != nil {
log.Logger.Warn("系统参数缓存刷新订阅中断", zap.Error(err))
}
return
}
payload, ok := decodeSysConfigCacheSyncPayload(message.Payload)
if !ok || payload.Source == sysConfigCacheSyncSourceID {
continue
}
if err := NewSysConfigService().refreshCache(false); err != nil && log.Logger != nil {
log.Logger.Warn("处理系统参数缓存刷新通知失败", zap.Error(err))
}
}
}
func decodeSysConfigCacheSyncPayload(raw string) (sysConfigCacheSyncPayload, bool) {
payload := sysConfigCacheSyncPayload{}
raw = strings.TrimSpace(raw)
if raw == "" {
return payload, true
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return payload, false
}
return payload, true
}
func buildSysConfigCacheSyncSourceID() string {
host, err := os.Hostname()
if err != nil {
host = "unknown"
}
return host + ":" + strconv.Itoa(os.Getpid())
}
================================================
FILE: internal/service/sys_config/cache_sync_test.go
================================================
package sys_config
import "testing"
func TestDecodeSysConfigCacheSyncPayload(t *testing.T) {
payload, ok := decodeSysConfigCacheSyncPayload(`{"source":"node-1","timestamp":123}`)
if !ok {
t.Fatal("expected payload to decode")
}
if payload.Source != "node-1" || payload.Timestamp != 123 {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestDecodeSysConfigCacheSyncPayloadRejectsInvalidJSON(t *testing.T) {
_, ok := decodeSysConfigCacheSyncPayload("{invalid")
if ok {
t.Fatal("expected invalid payload to be rejected")
}
}
================================================
FILE: internal/service/sys_config/runtime_audit.go
================================================
package sys_config
import (
"encoding/json"
"fmt"
"strings"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive"
)
const AuditSensitiveFieldsConfigKey = "audit.sensitive_fields"
var loadSensitiveFieldsConfigFn = sensitive.LoadSensitiveFieldsConfig
// WarmupRuntimeConfigIfAvailable 在表存在时预热系统参数缓存和运行时配置。
func (s *SysConfigService) WarmupRuntimeConfigIfAvailable() error {
db, err := model.GetDB()
if err != nil {
return err
}
if !db.Migrator().HasTable("sys_config") {
return nil
}
if err := s.refreshCache(false); err != nil {
return err
}
ensureSysConfigCacheSyncSubscriber()
return nil
}
func applyRuntimeConfig(configs []model.SysConfig) error {
config, err := resolveAuditSensitiveFieldsConfig(configs)
if err != nil {
return err
}
loadSensitiveFieldsConfigFn(config)
return nil
}
func resolveAuditSensitiveFieldsConfig(configs []model.SysConfig) (sensitive.SensitiveFieldsConfig, error) {
defaultConfig := sensitive.DefaultSensitiveFieldsConfig()
for _, item := range configs {
if item.ConfigKey != AuditSensitiveFieldsConfigKey {
continue
}
if model.NormalizeValueType(item.ValueType) != model.SysConfigValueTypeJSON {
return defaultConfig, fmt.Errorf("%s value_type must be json", AuditSensitiveFieldsConfigKey)
}
raw := strings.TrimSpace(item.ConfigValue)
if raw == "" {
return defaultConfig, nil
}
var runtimeConfig sensitive.SensitiveFieldsConfig
if err := json.Unmarshal([]byte(raw), &runtimeConfig); err != nil {
return defaultConfig, fmt.Errorf("decode %s failed: %w", AuditSensitiveFieldsConfigKey, err)
}
return normalizeSensitiveFieldsConfig(runtimeConfig), nil
}
return defaultConfig, nil
}
func normalizeSensitiveFieldsConfig(config sensitive.SensitiveFieldsConfig) sensitive.SensitiveFieldsConfig {
return sensitive.SensitiveFieldsConfig{
Common: normalizeStringList(config.Common),
RequestHeader: normalizeStringList(config.RequestHeader),
RequestBody: normalizeStringList(config.RequestBody),
ResponseHeader: normalizeStringList(config.ResponseHeader),
ResponseBody: normalizeStringList(config.ResponseBody),
}
}
func normalizeStringList(input []string) []string {
if len(input) == 0 {
return []string{}
}
result := make([]string, 0, len(input))
seen := make(map[string]struct{}, len(input))
for _, item := range input {
trimmed := strings.ToLower(strings.TrimSpace(item))
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
================================================
FILE: internal/service/sys_config/runtime_audit_test.go
================================================
package sys_config
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive"
)
func TestResolveAuditSensitiveFieldsConfigReturnsDefaultWhenMissing(t *testing.T) {
got, err := resolveAuditSensitiveFieldsConfig(nil)
if err != nil {
t.Fatalf("resolveAuditSensitiveFieldsConfig returned error: %v", err)
}
defaultConfig := sensitive.DefaultSensitiveFieldsConfig()
if len(got.Common) != len(defaultConfig.Common) {
t.Fatalf("expected default common fields, got %#v", got.Common)
}
}
func TestResolveAuditSensitiveFieldsConfigNormalizesValues(t *testing.T) {
got, err := resolveAuditSensitiveFieldsConfig([]model.SysConfig{
{
ConfigKey: AuditSensitiveFieldsConfigKey,
ValueType: model.SysConfigValueTypeJSON,
ConfigValue: `{"common":[" Password ","token","password"],"request_body":[" Phone ","phone"],"response_header":[" Set-Cookie "]}`,
},
})
if err != nil {
t.Fatalf("resolveAuditSensitiveFieldsConfig returned error: %v", err)
}
assertStringSliceEqual(t, got.Common, []string{"password", "token"})
assertStringSliceEqual(t, got.RequestBody, []string{"phone"})
assertStringSliceEqual(t, got.ResponseHeader, []string{"set-cookie"})
}
func TestResolveAuditSensitiveFieldsConfigRejectsNonJSONValueType(t *testing.T) {
_, err := resolveAuditSensitiveFieldsConfig([]model.SysConfig{
{
ConfigKey: AuditSensitiveFieldsConfigKey,
ValueType: model.SysConfigValueTypeString,
ConfigValue: `{"common":["password"]}`,
},
})
if err == nil {
t.Fatal("expected non-json audit config to fail")
}
}
func TestApplyRuntimeConfigLoadsSensitiveManager(t *testing.T) {
previous := loadSensitiveFieldsConfigFn
t.Cleanup(func() {
loadSensitiveFieldsConfigFn = previous
})
var captured sensitive.SensitiveFieldsConfig
loadSensitiveFieldsConfigFn = func(config sensitive.SensitiveFieldsConfig) {
captured = config
}
err := applyRuntimeConfig([]model.SysConfig{
{
ConfigKey: AuditSensitiveFieldsConfigKey,
ValueType: model.SysConfigValueTypeJSON,
ConfigValue: `{"common":["password"],"request_header":["authorization"]}`,
},
})
if err != nil {
t.Fatalf("applyRuntimeConfig returned error: %v", err)
}
assertStringSliceEqual(t, captured.Common, []string{"password"})
assertStringSliceEqual(t, captured.RequestHeader, []string{"authorization"})
}
func assertStringSliceEqual(t *testing.T, got []string, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("unexpected slice length: got=%#v want=%#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected slice content: got=%#v want=%#v", got, want)
}
}
}
================================================
FILE: internal/service/sys_config/sys_config.go
================================================
package sys_config
import (
"encoding/json"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// SysConfigService 系统参数服务。
type SysConfigService struct {
service.Base
}
const maskedConfigValue = "******"
func NewSysConfigService() *SysConfigService {
return &SysConfigService{}
}
func (s *SysConfigService) List(params *form.SysConfigList, locale string) *resources.Collection {
condition, args := s.buildListCondition(params)
configModel := model.NewSysConfig()
total, collection, err := model.ListPageE(configModel, params.Page, params.PerPage, condition, args, model.ListOptionalParams{
OrderBy: "sort desc, id desc",
})
if err != nil {
return resources.NewSysConfigTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
s.maskSensitiveValues(collection)
s.fillLocalizedNames(collection, locale)
return resources.NewSysConfigTransformer().ToCollection(params.Page, params.PerPage, total, collection)
}
func (s *SysConfigService) Detail(id uint, locale string) (any, error) {
config := model.NewSysConfig()
if err := config.GetById(id); err != nil || config.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
translations, err := model.NewSysConfigI18n().LocaleNameMapByConfigID(config.ID)
if err != nil {
return nil, err
}
config.ConfigNameI18n = translations
config.ConfigName = service.ResolveLocaleText(translations, locale)
// 详情接口用于编辑回填,敏感参数保留真实值;列表和 PublicValue 继续脱敏。
return resources.NewSysConfigTransformer().ToStruct(config), nil
}
func (s *SysConfigService) Create(params *form.CreateSysConfig) error {
_, err := s.CreateWithAuditDiff(params)
return err
}
func (s *SysConfigService) Update(params *form.UpdateSysConfig) error {
_, err := s.UpdateWithAuditDiff(params)
return err
}
func (s *SysConfigService) Delete(id uint) error {
_, err := s.DeleteWithAuditDiff(id)
return err
}
func (s *SysConfigService) Value(key string) (ConfigCacheItem, error) {
key = strings.TrimSpace(key)
if key == "" {
return ConfigCacheItem{}, e.NewBusinessError(e.InvalidParameter)
}
if !cacheLoaded() {
if err := s.RefreshCache(); err != nil {
return ConfigCacheItem{}, err
}
}
if item, ok := getCachedValue(key); ok {
return item, nil
}
return ConfigCacheItem{}, e.NewBusinessError(e.NotFound)
}
// PublicValue 对外暴露的系统参数值接口,敏感参数自动脱敏。
func (s *SysConfigService) PublicValue(key string) (ConfigCacheItem, error) {
item, err := s.Value(key)
if err != nil {
return item, err
}
if item.IsSensitive == 1 {
item.ConfigValue = maskedConfigValue
}
return item, nil
}
// RefreshCache 刷新本进程参数缓存;加载失败时保留旧缓存。
func (s *SysConfigService) RefreshCache() error {
return s.refreshCache(true)
}
func (s *SysConfigService) refreshCache(notify bool) error {
configs, err := model.NewSysConfig().EnabledConfigs()
if err != nil {
return err
}
if err := applyRuntimeConfig(configs); err != nil {
return err
}
replaceCache(configs)
if notify {
notifySysConfigCacheRefreshed()
}
return nil
}
func (s *SysConfigService) applyMutation(id uint, params *form.SysConfigPayload) error {
params.ConfigKey = strings.TrimSpace(params.ConfigKey)
params.ConfigNameI18n = service.NormalizeLocaleTextMap(params.ConfigNameI18n)
params.GroupCode = strings.TrimSpace(params.GroupCode)
params.ValueType = model.NormalizeValueType(params.ValueType)
params.ManageTab = strings.TrimSpace(params.ManageTab)
params.Remark = strings.TrimSpace(params.Remark)
if params.GroupCode == "" {
params.GroupCode = "default"
}
if len(params.ConfigNameI18n) == 0 {
return e.NewBusinessError(e.InvalidParameter)
}
config := model.NewSysConfig()
if id > 0 {
if err := config.GetById(id); err != nil || config.ID == 0 {
return e.NewBusinessError(e.NotFound)
}
// 系统内置参数禁止修改稳定字段。
if config.IsProtected() {
if params.ConfigKey != config.ConfigKey {
return e.NewBusinessError(e.InvalidParameter)
}
if model.NormalizeValueType(params.ValueType) != config.ValueType {
return e.NewBusinessError(e.InvalidParameter)
}
if strings.TrimSpace(params.GroupCode) != config.GroupCode {
return e.NewBusinessError(e.InvalidParameter)
}
// 敏感标记禁止降级。
if params.IsSensitive != nil && *params.IsSensitive == 0 && config.IsSensitive == 1 {
return e.NewBusinessError(e.InvalidParameter)
}
}
}
configValue := resolveMutationConfigValue(config, params.ConfigValue)
if err := validateConfigValue(params.ValueType, configValue); err != nil {
return err
}
if exists, err := model.NewSysConfig().ExistsByKeyExcludeID(params.ConfigKey, id); err != nil {
return err
} else if exists {
return e.NewBusinessError(e.InvalidParameter)
}
config.ConfigKey = params.ConfigKey
config.ConfigValue = configValue
config.ValueType = params.ValueType
config.GroupCode = params.GroupCode
config.IsSensitive = valueOrDefault(params.IsSensitive, config.IsSensitive)
config.IsVisible = valueOrDefault(params.IsVisible, defaultVisible(config.IsVisible, id))
config.ManageTab = params.ManageTab
config.Status = valueOrDefault(params.Status, defaultStatus(config.Status, id))
config.Sort = params.Sort
config.Remark = params.Remark
db, err := config.GetDB()
if err != nil {
return err
}
if err := access.RunInTransaction(db, func(tx *gorm.DB) error {
config.SetDB(tx)
if saveErr := config.Save(); saveErr != nil {
return saveErr
}
return model.NewSysConfigI18n().UpsertConfigNames(config.ID, params.ConfigNameI18n, tx)
}); err != nil {
return err
}
return s.RefreshCache()
}
func resolveMutationConfigValue(existing *model.SysConfig, incoming string) string {
if existing != nil && existing.ID > 0 && existing.IsSensitive == 1 && strings.TrimSpace(incoming) == maskedConfigValue {
return existing.ConfigValue
}
return incoming
}
func (s *SysConfigService) buildListCondition(params *form.SysConfigList) (string, []any) {
qb := query_builder.New().
AddLike("config_key", params.ConfigKey).
AddEq("group_code", params.GroupCode).
AddEq("value_type", params.ValueType).
AddEq("manage_tab", params.ManageTab).
AddEq("status", params.Status)
if params.IsVisible != nil {
qb.AddEq("is_visible", params.IsVisible)
} else if params.IncludeHidden == nil || *params.IncludeHidden == 0 {
qb.AddEq("is_visible", uint8(1))
}
if keyword := strings.TrimSpace(params.ConfigName); keyword != "" {
qb.AddCondition("id IN (SELECT config_id FROM sys_config_i18n WHERE config_name like ?)", "%"+keyword+"%")
}
return qb.Build()
}
func (s *SysConfigService) fillLocalizedNames(configs []*model.SysConfig, locale string) {
ids := make([]uint, 0, len(configs))
for _, config := range configs {
if config == nil {
continue
}
ids = append(ids, config.ID)
}
if len(ids) == 0 {
return
}
nameMap, err := model.NewSysConfigI18n().LocalizedNameMapByConfigIDs(ids, service.LocalePriority(locale))
if err != nil {
return
}
for _, config := range configs {
if config == nil {
continue
}
config.ConfigName = nameMap[config.ID]
}
}
func (s *SysConfigService) maskSensitiveValues(configs []*model.SysConfig) {
for _, config := range configs {
if config == nil {
continue
}
if config.IsSensitive == 1 {
config.ConfigValue = maskedConfigValue
}
}
}
func validateConfigValue(valueType, value string) error {
value = strings.TrimSpace(value)
switch valueType {
case model.SysConfigValueTypeString:
return nil
case model.SysConfigValueTypeNumber:
if _, err := strconv.ParseFloat(value, 64); err != nil {
return e.NewBusinessError(e.InvalidParameter)
}
case model.SysConfigValueTypeBool:
if _, err := strconv.ParseBool(value); err != nil {
return e.NewBusinessError(e.InvalidParameter)
}
case model.SysConfigValueTypeJSON:
if !json.Valid([]byte(value)) {
return e.NewBusinessError(e.InvalidParameter)
}
default:
return e.NewBusinessError(e.InvalidParameter)
}
return nil
}
func valueOrDefault(value *uint8, fallback uint8) uint8 {
if value == nil {
return fallback
}
return *value
}
func defaultVisible(current uint8, id uint) uint8 {
if id == 0 && current == 0 {
return 1
}
return current
}
func defaultStatus(current uint8, id uint) uint8 {
if id == 0 && current == 0 {
return 1
}
return current
}
================================================
FILE: internal/service/sys_config/sys_config_mask_test.go
================================================
package sys_config
import (
"strings"
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestMaskSensitiveValues(t *testing.T) {
service := NewSysConfigService()
configs := []*model.SysConfig{
{
ConfigKey: AuthLoginLockEnabledConfigKey,
ConfigValue: "true",
IsSensitive: 0,
},
{
ConfigKey: "audit.sensitive_fields",
ConfigValue: `{"common":["password"]}`,
IsSensitive: 1,
},
}
service.maskSensitiveValues(configs)
if configs[0].ConfigValue != "true" {
t.Fatalf("expected non-sensitive value unchanged, got %q", configs[0].ConfigValue)
}
if configs[1].ConfigValue != maskedConfigValue {
t.Fatalf("expected sensitive value masked, got %q", configs[1].ConfigValue)
}
}
func TestSysConfigDetailResourceKeepsSensitiveConfigValue(t *testing.T) {
config := &model.SysConfig{
ConfigKey: "audit.sensitive_fields",
ConfigValue: `{"common":["password"]}`,
IsSensitive: 1,
}
detail := resources.NewSysConfigTransformer().ToStruct(config)
if detail.ConfigValue != config.ConfigValue {
t.Fatalf("expected detail value to remain unmasked, got %q", detail.ConfigValue)
}
}
func TestMaskedAuditRequestBodyMasksSensitiveConfigValue(t *testing.T) {
isSensitive := uint8(1)
isVisible := uint8(0)
status := uint8(1)
raw := NewSysConfigService().MaskedAuditRequestBody(0, &form.SysConfigPayload{
ConfigKey: "secret.demo",
ConfigNameI18n: map[string]string{"zh-CN": "密钥"},
ConfigValue: "plain-secret",
ValueType: model.SysConfigValueTypeString,
IsSensitive: &isSensitive,
IsVisible: &isVisible,
ManageTab: "audit_mask",
Status: &status,
})
if raw == "" {
t.Fatal("expected masked audit request body")
}
if strings.Contains(raw, "plain-secret") {
t.Fatalf("expected raw secret to be masked, got %s", raw)
}
if !strings.Contains(raw, maskedConfigValue) {
t.Fatalf("expected masked value in audit request body, got %s", raw)
}
if !strings.Contains(raw, `"is_visible":0`) {
t.Fatalf("expected is_visible in audit request body, got %s", raw)
}
if !strings.Contains(raw, `"manage_tab":"audit_mask"`) {
t.Fatalf("expected manage_tab in audit request body, got %s", raw)
}
}
func TestMaskedAuditRequestBodySkipsNonSensitiveConfig(t *testing.T) {
isSensitive := uint8(0)
raw := NewSysConfigService().MaskedAuditRequestBody(0, &form.SysConfigPayload{
ConfigKey: "feature.demo",
ConfigValue: "visible",
ValueType: model.SysConfigValueTypeString,
IsSensitive: &isSensitive,
})
if raw != "" {
t.Fatalf("expected no override for non-sensitive config, got %s", raw)
}
}
func TestResolveMutationConfigValueKeepsSensitiveValueForMaskedPlaceholder(t *testing.T) {
existing := &model.SysConfig{
ConfigValue: "real-secret",
IsSensitive: 1,
}
existing.ID = 1
got := resolveMutationConfigValue(existing, maskedConfigValue)
if got != "real-secret" {
t.Fatalf("expected existing sensitive value to be kept, got %q", got)
}
}
func TestResolveMutationConfigValueUsesIncomingValueWhenChanged(t *testing.T) {
existing := &model.SysConfig{
ConfigValue: "real-secret",
IsSensitive: 1,
}
existing.ID = 1
got := resolveMutationConfigValue(existing, "new-secret")
if got != "new-secret" {
t.Fatalf("expected incoming value, got %q", got)
}
}
func TestResolveMutationConfigValuePreservesStringWhitespace(t *testing.T) {
got := resolveMutationConfigValue(&model.SysConfig{}, " display value ")
if got != " display value " {
t.Fatalf("expected incoming whitespace to be preserved, got %q", got)
}
}
func TestBuildSysConfigDiffIncludesVisibilityFields(t *testing.T) {
before := map[string]any{
"config_key": "audit.sensitive_fields",
"config_name_i18n": map[string]string{"zh-CN": "请求日志脱敏配置"},
"config_value": "before",
"value_type": model.SysConfigValueTypeJSON,
"group_code": "audit",
"is_sensitive": uint8(1),
"is_visible": uint8(1),
"manage_tab": "",
"status": uint8(1),
"sort": uint(95),
"remark": "before",
}
after := map[string]any{
"config_key": "audit.sensitive_fields",
"config_name_i18n": map[string]string{"zh-CN": "请求日志脱敏配置"},
"config_value": maskedConfigValue,
"value_type": model.SysConfigValueTypeJSON,
"group_code": "audit",
"is_sensitive": uint8(1),
"is_visible": uint8(0),
"manage_tab": "audit_mask",
"status": uint8(1),
"sort": uint(95),
"remark": "before",
}
raw := buildSysConfigDiffJSON(before, after)
if !strings.Contains(raw, `"field":"is_visible"`) {
t.Fatalf("expected diff to include is_visible, got %s", raw)
}
if !strings.Contains(raw, `"field":"manage_tab"`) {
t.Fatalf("expected diff to include manage_tab, got %s", raw)
}
}
================================================
FILE: internal/service/sys_config/typed_value.go
================================================
package sys_config
import "strconv"
const (
TaskCronDemoEnabledConfigKey = "task.cron_demo_enabled"
AuthLoginLockEnabledConfigKey = "auth.login_lock_enabled"
AuthLoginMaxFailuresConfigKey = "auth.login_max_failures"
AuthLoginLockMinutesConfigKey = "auth.login_lock_minutes"
)
// BoolValue 读取 bool 类型系统参数;读取失败或解析失败时返回 fallback。
func BoolValue(key string, fallback bool) bool {
item, err := NewSysConfigService().Value(key)
if err != nil {
return fallback
}
value, err := strconv.ParseBool(item.ConfigValue)
if err != nil {
return fallback
}
return value
}
// IntValue 读取 int 类型系统参数;读取失败或解析失败时返回 fallback。
func IntValue(key string, fallback int) int {
item, err := NewSysConfigService().Value(key)
if err != nil {
return fallback
}
value, err := strconv.Atoi(item.ConfigValue)
if err != nil {
return fallback
}
return value
}
================================================
FILE: internal/service/sys_config/typed_value_test.go
================================================
package sys_config
import (
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestBoolValueFallbackWhenMissing(t *testing.T) {
if got := BoolValue("missing.key", true); !got {
t.Fatal("expected fallback true when config is missing")
}
if got := BoolValue("missing.key", false); got {
t.Fatal("expected fallback false when config is missing")
}
}
func TestBoolValueFromCache(t *testing.T) {
restore := setRuntimeCacheForTest([]model.SysConfig{
{ConfigKey: AuthLoginLockEnabledConfigKey, ConfigValue: "true", Status: 1},
})
t.Cleanup(restore)
if !BoolValue(AuthLoginLockEnabledConfigKey, false) {
t.Fatal("expected bool value from cache")
}
}
func TestIntValueFromCache(t *testing.T) {
restore := setRuntimeCacheForTest([]model.SysConfig{
{ConfigKey: AuthLoginMaxFailuresConfigKey, ConfigValue: "7", Status: 1},
})
t.Cleanup(restore)
if got := IntValue(AuthLoginMaxFailuresConfigKey, 5); got != 7 {
t.Fatalf("expected int value 7, got %d", got)
}
}
func TestIntValueFallbackWhenInvalid(t *testing.T) {
restore := setRuntimeCacheForTest([]model.SysConfig{
{ConfigKey: AuthLoginLockMinutesConfigKey, ConfigValue: "invalid", Status: 1},
})
t.Cleanup(restore)
if got := IntValue(AuthLoginLockMinutesConfigKey, 15); got != 15 {
t.Fatalf("expected fallback int value 15, got %d", got)
}
}
func setRuntimeCacheForTest(configs []model.SysConfig) func() {
runtimeCache.Lock()
prevLoaded := runtimeCache.loaded
prevItems := runtimeCache.items
runtimeCache.Unlock()
replaceCache(configs)
return func() {
runtimeCache.Lock()
runtimeCache.loaded = prevLoaded
runtimeCache.items = prevItems
runtimeCache.Unlock()
}
}
================================================
FILE: internal/service/sys_dict/audit_diff.go
================================================
package sys_dict
import (
"strings"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var sysDictTypeDiffRules = []auditdiff.FieldRule{
{Field: "type_code", Label: "字典类型编码"},
{Field: "type_name_i18n", Label: "字典类型名称"},
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{Field: "sort", Label: "排序"},
{Field: "remark", Label: "备注"},
}
var sysDictItemDiffRules = []auditdiff.FieldRule{
{Field: "type_code", Label: "字典类型编码"},
{Field: "label_i18n", Label: "字典标签"},
{Field: "value", Label: "字典值"},
{Field: "color", Label: "颜色"},
{Field: "tag_type", Label: "标签类型"},
{
Field: "is_default",
Label: "默认项",
ValueLabels: map[string]string{
"0": "否",
"1": "是",
},
},
{
Field: "status",
Label: "状态",
ValueLabels: map[string]string{
"0": "禁用",
"1": "启用",
},
},
{Field: "sort", Label: "排序"},
{Field: "remark", Label: "备注"},
}
// CreateTypeWithAuditDiff 创建字典类型并返回字段级 change_diff JSON。
func (s *SysDictService) CreateTypeWithAuditDiff(params *form.CreateSysDictType) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
if err := s.applyTypeMutation(0, ¶ms.SysDictTypePayload); err != nil {
return "", err
}
after, err := s.snapshotTypeByTypeCode(params.TypeCode)
if err != nil {
return "", nil
}
return buildSysDictTypeDiffJSON(nil, after), nil
}
// UpdateTypeWithAuditDiff 更新字典类型并返回字段级 change_diff JSON。
func (s *SysDictService) UpdateTypeWithAuditDiff(params *form.UpdateSysDictType) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotTypeByID(params.Id)
if err != nil {
return "", err
}
if err := s.applyTypeMutation(params.Id, ¶ms.SysDictTypePayload); err != nil {
return "", err
}
after, err := s.snapshotTypeByID(params.Id)
if err != nil {
return "", nil
}
return buildSysDictTypeDiffJSON(before, after), nil
}
// DeleteTypeWithAuditDiff 删除字典类型并返回字段级 change_diff JSON。
func (s *SysDictService) DeleteTypeWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotTypeByID(id)
if err != nil {
return "", err
}
if err := s.deleteType(id); err != nil {
return "", err
}
return buildSysDictTypeDiffJSON(before, nil), nil
}
// CreateItemWithAuditDiff 创建字典项并返回字段级 change_diff JSON。
func (s *SysDictService) CreateItemWithAuditDiff(params *form.CreateSysDictItem) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
if err := s.applyItemMutation(0, ¶ms.SysDictItemPayload); err != nil {
return "", err
}
after, err := s.snapshotItemByTypeValue(params.TypeCode, params.Value)
if err != nil {
return "", nil
}
return buildSysDictItemDiffJSON(nil, after), nil
}
// UpdateItemWithAuditDiff 更新字典项并返回字段级 change_diff JSON。
func (s *SysDictService) UpdateItemWithAuditDiff(params *form.UpdateSysDictItem) (string, error) {
if params == nil {
return "", e.NewBusinessError(e.InvalidParameter)
}
before, err := s.snapshotItemByID(params.Id)
if err != nil {
return "", err
}
if err := s.applyItemMutation(params.Id, ¶ms.SysDictItemPayload); err != nil {
return "", err
}
after, err := s.snapshotItemByID(params.Id)
if err != nil {
return "", nil
}
return buildSysDictItemDiffJSON(before, after), nil
}
// DeleteItemWithAuditDiff 删除字典项并返回字段级 change_diff JSON。
func (s *SysDictService) DeleteItemWithAuditDiff(id uint) (string, error) {
before, err := s.snapshotItemByID(id)
if err != nil {
return "", err
}
if err := s.deleteItem(id); err != nil {
return "", err
}
return buildSysDictItemDiffJSON(before, nil), nil
}
func (s *SysDictService) deleteType(id uint) error {
dictType := model.NewSysDictType()
if err := dictType.GetById(id); err != nil || dictType.ID == 0 {
return e.NewBusinessError(e.NotFound)
}
if dictType.IsProtected() {
return e.NewBusinessError(e.InvalidParameter)
}
count, err := model.NewSysDictItem().CountByTypeCode(dictType.TypeCode)
if err != nil {
return err
}
if count > 0 {
return e.NewBusinessError(e.InvalidParameter)
}
db, err := dictType.GetDB()
if err != nil {
return err
}
return access.RunInTransaction(db, func(tx *gorm.DB) error {
dictType.SetDB(tx)
if _, deleteErr := dictType.DeleteByID(id); deleteErr != nil {
return deleteErr
}
return model.NewSysDictTypeI18n().DeleteByTypeIDs([]uint{id}, tx)
})
}
func (s *SysDictService) deleteItem(id uint) error {
item := model.NewSysDictItem()
if err := item.GetById(id); err != nil || item.ID == 0 {
return e.NewBusinessError(e.NotFound)
}
if item.IsProtected() {
return e.NewBusinessError(e.InvalidParameter)
}
db, err := item.GetDB()
if err != nil {
return err
}
return access.RunInTransaction(db, func(tx *gorm.DB) error {
item.SetDB(tx)
if _, deleteErr := item.DeleteByID(id); deleteErr != nil {
return deleteErr
}
return model.NewSysDictItemI18n().DeleteByItemIDs([]uint{id}, tx)
})
}
func (s *SysDictService) snapshotTypeByID(id uint) (map[string]any, error) {
dictType := model.NewSysDictType()
if err := dictType.GetById(id); err != nil || dictType.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return snapshotDictType(dictType)
}
func (s *SysDictService) snapshotTypeByTypeCode(typeCode string) (map[string]any, error) {
dictType := model.NewSysDictType()
if err := dictType.FindByTypeCode(strings.TrimSpace(typeCode)); err != nil {
return nil, err
}
return snapshotDictType(dictType)
}
func snapshotDictType(dictType *model.SysDictType) (map[string]any, error) {
if dictType == nil || dictType.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
typeNameI18n, err := model.NewSysDictTypeI18n().LocaleNameMapByTypeID(dictType.ID)
if err != nil {
return nil, err
}
return map[string]any{
"type_code": dictType.TypeCode,
"type_name_i18n": typeNameI18n,
"status": dictType.Status,
"sort": dictType.Sort,
"remark": dictType.Remark,
}, nil
}
func (s *SysDictService) snapshotItemByID(id uint) (map[string]any, error) {
item := model.NewSysDictItem()
if err := item.GetById(id); err != nil || item.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return snapshotDictItem(item)
}
func (s *SysDictService) snapshotItemByTypeValue(typeCode, value string) (map[string]any, error) {
item := model.NewSysDictItem()
if err := item.FindByTypeCodeAndValue(strings.TrimSpace(typeCode), strings.TrimSpace(value)); err != nil {
return nil, err
}
return snapshotDictItem(item)
}
func snapshotDictItem(item *model.SysDictItem) (map[string]any, error) {
if item == nil || item.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
labelI18n, err := model.NewSysDictItemI18n().LocaleLabelMapByItemID(item.ID)
if err != nil {
return nil, err
}
return map[string]any{
"type_code": item.TypeCode,
"label_i18n": labelI18n,
"value": item.Value,
"color": item.Color,
"tag_type": item.TagType,
"is_default": item.IsDefault,
"status": item.Status,
"sort": item.Sort,
"remark": item.Remark,
}, nil
}
func buildSysDictTypeDiffJSON(before, after map[string]any) string {
return auditdiff.Marshal(auditdiff.BuildFieldDiff(before, after, sysDictTypeDiffRules))
}
func buildSysDictItemDiffJSON(before, after map[string]any) string {
return auditdiff.Marshal(auditdiff.BuildFieldDiff(before, after, sysDictItemDiffRules))
}
================================================
FILE: internal/service/sys_dict/sys_dict.go
================================================
package sys_dict
import (
"errors"
"strings"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
// SysDictService 系统字典服务。
type SysDictService struct {
service.Base
}
func NewSysDictService() *SysDictService {
return &SysDictService{}
}
func (s *SysDictService) TypeList(params *form.SysDictTypeList, locale string) *resources.Collection {
condition, args := s.buildTypeCondition(params)
total, collection, err := model.ListPageE(model.NewSysDictType(), params.Page, params.PerPage, condition, args, model.ListOptionalParams{
OrderBy: "sort desc, id desc",
})
if err != nil {
return resources.NewSysDictTypeTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
s.fillLocalizedTypeNames(collection, locale)
return resources.NewSysDictTypeTransformer().ToCollection(params.Page, params.PerPage, total, collection)
}
func (s *SysDictService) TypeDetail(id uint, locale string) (any, error) {
dictType := model.NewSysDictType()
if err := dictType.GetById(id); err != nil || dictType.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
translations, err := model.NewSysDictTypeI18n().LocaleNameMapByTypeID(dictType.ID)
if err != nil {
return nil, err
}
dictType.TypeNameI18n = translations
dictType.TypeName = service.ResolveLocaleText(translations, locale)
return resources.NewSysDictTypeTransformer().ToStruct(dictType), nil
}
func (s *SysDictService) CreateType(params *form.CreateSysDictType) error {
_, err := s.CreateTypeWithAuditDiff(params)
return err
}
func (s *SysDictService) UpdateType(params *form.UpdateSysDictType) error {
_, err := s.UpdateTypeWithAuditDiff(params)
return err
}
func (s *SysDictService) DeleteType(id uint) error {
_, err := s.DeleteTypeWithAuditDiff(id)
return err
}
func (s *SysDictService) ItemList(params *form.SysDictItemList, locale string) *resources.Collection {
condition, args := s.buildItemCondition(params)
total, collection, err := model.ListPageE(model.NewSysDictItem(), params.Page, params.PerPage, condition, args, model.ListOptionalParams{
OrderBy: "sort desc, id asc",
})
if err != nil {
return resources.NewSysDictItemTransformer().ToCollection(params.Page, params.PerPage, 0, nil)
}
s.fillLocalizedItemLabels(collection, locale)
return resources.NewSysDictItemTransformer().ToCollection(params.Page, params.PerPage, total, collection)
}
func (s *SysDictService) CreateItem(params *form.CreateSysDictItem) error {
_, err := s.CreateItemWithAuditDiff(params)
return err
}
func (s *SysDictService) UpdateItem(params *form.UpdateSysDictItem) error {
_, err := s.UpdateItemWithAuditDiff(params)
return err
}
func (s *SysDictService) DeleteItem(id uint) error {
_, err := s.DeleteItemWithAuditDiff(id)
return err
}
func (s *SysDictService) Options(typeCode string, locale string) ([]resources.SysDictOptionResources, error) {
typeCode = strings.TrimSpace(typeCode)
if typeCode == "" {
return nil, e.NewBusinessError(e.InvalidParameter)
}
if err := s.ensureEnabledType(typeCode); err != nil {
return nil, err
}
items, err := model.NewSysDictItem().EnabledItemsByTypeCode(typeCode)
if err != nil {
return nil, err
}
s.fillLocalizedItemLabelsFromSlice(items, locale)
return resources.ToSysDictOptions(items), nil
}
func (s *SysDictService) applyTypeMutation(id uint, params *form.SysDictTypePayload) error {
params.TypeCode = strings.TrimSpace(params.TypeCode)
params.TypeNameI18n = service.NormalizeLocaleTextMap(params.TypeNameI18n)
params.Remark = strings.TrimSpace(params.Remark)
if len(params.TypeNameI18n) == 0 {
return e.NewBusinessError(e.InvalidParameter)
}
dictType := model.NewSysDictType()
if id > 0 {
if err := dictType.GetById(id); err != nil || dictType.ID == 0 {
return e.NewBusinessError(e.NotFound)
}
if dictType.IsProtected() && dictType.TypeCode != params.TypeCode {
return e.NewBusinessError(e.InvalidParameter)
}
}
if exists, err := model.NewSysDictType().ExistsByTypeCodeExcludeID(params.TypeCode, id); err != nil {
return err
} else if exists {
return e.NewBusinessError(e.InvalidParameter)
}
dictType.TypeCode = params.TypeCode
dictType.Status = valueOrDefault(params.Status, defaultStatus(dictType.Status, id))
dictType.Sort = params.Sort
dictType.Remark = params.Remark
db, err := dictType.GetDB()
if err != nil {
return err
}
return access.RunInTransaction(db, func(tx *gorm.DB) error {
dictType.SetDB(tx)
if saveErr := dictType.Save(); saveErr != nil {
return saveErr
}
return model.NewSysDictTypeI18n().UpsertTypeNames(dictType.ID, params.TypeNameI18n, tx)
})
}
func (s *SysDictService) applyItemMutation(id uint, params *form.SysDictItemPayload) error {
params.TypeCode = strings.TrimSpace(params.TypeCode)
params.LabelI18n = service.NormalizeLocaleTextMap(params.LabelI18n)
params.Value = strings.TrimSpace(params.Value)
params.Color = strings.TrimSpace(params.Color)
params.TagType = strings.TrimSpace(params.TagType)
params.Remark = strings.TrimSpace(params.Remark)
if len(params.LabelI18n) == 0 {
return e.NewBusinessError(e.InvalidParameter)
}
if err := s.ensureTypeExists(params.TypeCode); err != nil {
return err
}
item := model.NewSysDictItem()
if id > 0 {
if err := item.GetById(id); err != nil || item.ID == 0 {
return e.NewBusinessError(e.NotFound)
}
if item.IsProtected() && (item.TypeCode != params.TypeCode || item.Value != params.Value) {
return e.NewBusinessError(e.InvalidParameter)
}
}
if exists, err := model.NewSysDictItem().ExistsByValueExcludeID(params.TypeCode, params.Value, id); err != nil {
return err
} else if exists {
return e.NewBusinessError(e.InvalidParameter)
}
item.TypeCode = params.TypeCode
item.Value = params.Value
item.Color = params.Color
item.TagType = params.TagType
item.IsDefault = valueOrDefault(params.IsDefault, item.IsDefault)
item.Status = valueOrDefault(params.Status, defaultStatus(item.Status, id))
item.Sort = params.Sort
item.Remark = params.Remark
db, err := item.GetDB()
if err != nil {
return err
}
return access.RunInTransaction(db, func(tx *gorm.DB) error {
item.SetDB(tx)
if item.IsDefault == 1 {
if err := tx.Model(model.NewSysDictItem()).
Where("type_code = ? AND id <> ? AND deleted_at = 0", item.TypeCode, item.ID).
Update("is_default", 0).Error; err != nil {
return err
}
}
if err := item.Save(); err != nil {
return err
}
return model.NewSysDictItemI18n().UpsertLabels(item.ID, params.LabelI18n, tx)
})
}
func (s *SysDictService) ensureTypeExists(typeCode string) error {
dictType := model.NewSysDictType()
if err := dictType.FindByTypeCode(typeCode); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return e.NewBusinessError(e.InvalidParameter)
}
return err
}
return nil
}
func (s *SysDictService) ensureEnabledType(typeCode string) error {
dictType := model.NewSysDictType()
if err := dictType.FindByTypeCode(typeCode); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return e.NewBusinessError(e.NotFound)
}
return err
}
if dictType.Status != 1 {
return e.NewBusinessError(e.NotFound)
}
return nil
}
func (s *SysDictService) buildTypeCondition(params *form.SysDictTypeList) (string, []any) {
qb := query_builder.New().
AddLike("type_code", params.TypeCode).
AddEq("status", params.Status)
if keyword := strings.TrimSpace(params.TypeName); keyword != "" {
qb.AddCondition("id IN (SELECT dict_type_id FROM sys_dict_type_i18n WHERE type_name like ?)", "%"+keyword+"%")
}
return qb.Build()
}
func (s *SysDictService) buildItemCondition(params *form.SysDictItemList) (string, []any) {
qb := query_builder.New().
AddEq("type_code", params.TypeCode).
AddLike("value", params.Value).
AddEq("status", params.Status)
if keyword := strings.TrimSpace(params.Label); keyword != "" {
qb.AddCondition("id IN (SELECT dict_item_id FROM sys_dict_item_i18n WHERE label like ?)", "%"+keyword+"%")
}
return qb.Build()
}
func (s *SysDictService) fillLocalizedTypeNames(items []*model.SysDictType, locale string) {
ids := make([]uint, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
ids = append(ids, item.ID)
}
if len(ids) == 0 {
return
}
nameMap, err := model.NewSysDictTypeI18n().LocalizedNameMapByTypeIDs(ids, service.LocalePriority(locale))
if err != nil {
return
}
for _, item := range items {
if item == nil {
continue
}
item.TypeName = nameMap[item.ID]
}
}
func (s *SysDictService) fillLocalizedItemLabels(items []*model.SysDictItem, locale string) {
ids := make([]uint, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
ids = append(ids, item.ID)
}
if len(ids) == 0 {
return
}
labelMap, err := model.NewSysDictItemI18n().LocalizedLabelMapByItemIDs(ids, service.LocalePriority(locale))
if err != nil {
return
}
for _, item := range items {
if item == nil {
continue
}
item.Label = labelMap[item.ID]
}
}
func (s *SysDictService) fillLocalizedItemLabelsFromSlice(items []model.SysDictItem, locale string) {
ids := make([]uint, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
if len(ids) == 0 {
return
}
labelMap, err := model.NewSysDictItemI18n().LocalizedLabelMapByItemIDs(ids, service.LocalePriority(locale))
if err != nil {
return
}
for i := range items {
items[i].Label = labelMap[items[i].ID]
}
}
func valueOrDefault(value *uint8, fallback uint8) uint8 {
if value == nil {
return fallback
}
return *value
}
func defaultStatus(current uint8, id uint) uint8 {
if id == 0 && current == 0 {
return 1
}
return current
}
================================================
FILE: internal/service/system/init.go
================================================
package system
import (
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/routers"
"github.com/wannanbigpig/gin-layout/internal/service/access"
"github.com/wannanbigpig/gin-layout/internal/validator"
"github.com/wannanbigpig/gin-layout/pkg/utils"
)
const (
defaultSort = 100
defaultIsAuth = 0
defaultGroupCode = "other"
)
// InitApiRoutes 初始化API路由
func InitApiRoutes() error {
// 检查数据库连接
if err := checkDatabaseConnection(); err != nil {
return err
}
// 初始化验证器
if err := validator.InitValidatorTrans("zh"); err != nil {
return fmt.Errorf("初始化验证器失败: %w", err)
}
routeTree := routers.AppRouteTree()
engine, err := routers.SetRoutersWithTree(routeTree)
if err != nil {
return fmt.Errorf("初始化路由失败: %w", err)
}
apiMap := routers.CollectRouteMeta(routeTree)
// 构建API数据
apiData := buildApiData(engine.Routes(), apiMap)
// 保存API数据
if err := saveApiData(apiData); err != nil {
return fmt.Errorf("保存API数据失败: %w", err)
}
if err := access.NewMenuAPIDefaultsService().Sync(); err != nil {
return fmt.Errorf("同步默认菜单接口关系失败: %w", err)
}
return access.NewSystemDefaultsService().Ensure()
}
// RebuildUserPermissions 按数据库关系全量重建用户最终 API 权限。
func RebuildUserPermissions() error {
// 检查数据库连接
if err := checkDatabaseConnection(); err != nil {
return err
}
// 在方案A中,菜单-API 关系以数据库关系表为准,这里改为全量重建用户最终 API 权限
if err := access.NewMenuAPIDefaultsService().Sync(); err != nil {
return err
}
if err := access.NewSystemDefaultsService().Ensure(); err != nil {
return err
}
return access.NewUserPermissionSyncService().SyncAllUsers()
}
// buildApiData 构建API数据
func buildApiData(routes []gin.RouteInfo, apiMap routers.RouteMetaMap) []map[string]any {
date := time.Now().Format(time.DateTime)
apiData := make([]map[string]any, 0, len(routes))
for _, route := range routes {
code := utils.MD5(route.Method + "_" + route.Path)
name := route.Path
isAuth := defaultIsAuth
desc := ""
groupCode := defaultGroupCode
if val, ok := apiMap[code]; ok {
name = val.Title
isAuth = int(val.Auth)
desc = val.Desc
groupCode = val.GroupCode
}
apiData = append(apiData, map[string]any{
"code": code,
"name": name,
"route": route.Path,
"method": route.Method,
"func": extractHandlerName(route.Handler),
"func_path": route.Handler,
"is_auth": isAuth,
"description": desc,
"sort": defaultSort,
"is_effective": 1,
"created_at": date,
"updated_at": date,
"group_code": groupCode,
})
}
return apiData
}
// extractHandlerName 提取处理器名称
func extractHandlerName(handler string) string {
parts := strings.Split(handler, ".")
if len(parts) == 0 {
return handler
}
handlerName := parts[len(parts)-1]
// 移除方法接收器的后缀 "-fm"
return strings.TrimSuffix(handlerName, "-fm")
}
// saveApiData 保存API数据到数据库
func saveApiData(apiData []map[string]any) error {
apiModel := model.NewApi()
date := time.Now().Format(time.DateTime)
if err := apiModel.InitRegisters(apiData, date); err != nil {
return err
}
return access.NewApiRouteCacheService().RefreshCache()
}
// checkDatabaseConnection 检查数据库连接
func checkDatabaseConnection() error {
db := data.MysqlDB()
if db == nil {
return fmt.Errorf("数据库连接未初始化,请检查配置")
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取数据库连接失败: %w", err)
}
if err := sqlDB.Ping(); err != nil {
return fmt.Errorf("数据库连接测试失败: %w", err)
}
return nil
}
================================================
FILE: internal/service/system/migration_runner.go
================================================
package system
import (
"fmt"
"path/filepath"
"strings"
"github.com/golang-migrate/migrate/v4"
)
// ResolveMigrationsPath 解析迁移目录绝对路径。
func ResolveMigrationsPath() (string, error) {
return getMigrationsPath()
}
// NewMigrator 创建默认迁移执行器(自动解析迁移目录)。
func NewMigrator() (*migrate.Migrate, error) {
return NewResetService().createMigrateInstance()
}
// NewMigratorWithPath 创建指定迁移目录的迁移执行器。
func NewMigratorWithPath(path string) (*migrate.Migrate, error) {
trimmedPath := strings.TrimSpace(strings.TrimPrefix(path, "file://"))
if trimmedPath == "" {
return nil, fmt.Errorf("迁移目录不能为空")
}
absPath, err := filepath.Abs(trimmedPath)
if err != nil {
return nil, fmt.Errorf("解析迁移目录失败: %w", err)
}
dbURL := NewResetService().buildDatabaseURL()
m, err := migrate.New(fmt.Sprintf("file://%s", absPath), dbURL)
if err != nil {
return nil, fmt.Errorf("创建迁移实例失败: %w", err)
}
return m, nil
}
================================================
FILE: internal/service/system/reset.go
================================================
package system
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/mysql"
_ "github.com/golang-migrate/migrate/v4/source/file"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/model"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
const migrationsPathEnvKey = "GO_LAYOUT_MIGRATIONS_PATH"
// ResetService 保留历史入口,对外统一暴露系统维护能力。
// 当前不持有状态,仅为兼容旧调用保留。
type ResetService struct {
// configProvider 提供运行时配置读取入口。
configProvider func() *config.Conf
}
// NewResetService 创建兼容旧调用的系统维护服务。
func NewResetService() *ResetService {
return NewResetServiceWithDeps(ResetServiceDeps{})
}
// ResetServiceDeps 描述 ResetService 可注入依赖。
type ResetServiceDeps struct {
// ConfigProvider 自定义配置读取函数。
ConfigProvider func() *config.Conf
}
// NewResetServiceWithDeps 创建带依赖注入的系统维护服务实例。
func NewResetServiceWithDeps(deps ResetServiceDeps) *ResetService {
s := &ResetService{
configProvider: deps.ConfigProvider,
}
s.ensureRuntimeDeps()
return s
}
func (s *ResetService) ensureRuntimeDeps() {
if s.configProvider == nil {
s.configProvider = config.GetConfig
}
}
func (s *ResetService) currentConfig() *config.Conf {
s.ensureRuntimeDeps()
return config.GetConfigFrom(s.configProvider)
}
// ResetSystemData 兼容旧入口,实际执行日常清理任务。
func (s *ResetService) ResetSystemData() error {
return s.cleanupExpiredSystemData()
}
// ReinitializeSystemData 兼容旧入口,实际执行系统重建任务。
func (s *ResetService) ReinitializeSystemData() error {
return s.reinitializeSystemData()
}
// ResetSystemData 清理过期日志与已撤销 token 记录。
func ResetSystemData() error {
return NewResetService().ResetSystemData()
}
// ReinitializeSystemData 重新初始化系统数据。
func ReinitializeSystemData() error {
return NewResetService().ReinitializeSystemData()
}
func (s *ResetService) cleanupExpiredSystemData() error {
db := data.MysqlDB()
if db == nil {
err := model.ErrDBUninitialized
if initErr := data.MysqlInitError(); initErr != nil {
err = fmt.Errorf("%w: %v", model.ErrDBUninitialized, initErr)
}
log.Logger.Error("数据库连接未初始化", zap.Error(err))
return err
}
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
log.Logger.Info("开始执行系统日常清理", zap.String("cutoff_date", thirtyDaysAgo.Format("2006-01-02 15:04:05")))
var deletedRequestLogs, deletedLoginLogs, deletedRevokedTokens int64
requestLogs := model.NewRequestLogs()
result := db.Model(requestLogs).
Where("created_at < ?", thirtyDaysAgo).
Delete(requestLogs)
if result.Error != nil {
log.Logger.Error("清理请求日志失败", zap.Error(result.Error))
} else {
deletedRequestLogs = result.RowsAffected
log.Logger.Info("清理请求日志完成", zap.Int64("deleted_count", deletedRequestLogs))
}
loginLogs := model.NewAdminLoginLogs()
result = db.Model(loginLogs).
Where("created_at < ?", thirtyDaysAgo).
Delete(loginLogs)
if result.Error != nil {
log.Logger.Error("清理登录日志失败", zap.Error(result.Error))
} else {
deletedLoginLogs = result.RowsAffected
log.Logger.Info("清理登录日志完成", zap.Int64("deleted_count", deletedLoginLogs))
}
result = db.Model(loginLogs).
Where("is_revoked = 1 AND revoked_at < ?", thirtyDaysAgo).
Delete(loginLogs)
if result.Error != nil {
log.Logger.Error("清理已撤销Token失败", zap.Error(result.Error))
} else {
deletedRevokedTokens = result.RowsAffected
log.Logger.Info("清理已撤销Token完成", zap.Int64("deleted_count", deletedRevokedTokens))
}
log.Logger.Info("系统日常清理完成",
zap.Int64("deleted_request_logs", deletedRequestLogs),
zap.Int64("deleted_login_logs", deletedLoginLogs),
zap.Int64("deleted_revoked_tokens", deletedRevokedTokens),
)
return nil
}
func (s *ResetService) reinitializeSystemData() error {
log.Logger.Info("开始重新初始化系统数据")
if err := s.rollbackMigrations(); err != nil {
log.Logger.Error("回滚迁移失败", zap.Error(err))
return fmt.Errorf("回滚迁移失败: %w", err)
}
log.Logger.Info("回滚迁移完成")
if err := s.runMigrations(); err != nil {
log.Logger.Error("执行迁移失败", zap.Error(err))
return fmt.Errorf("执行迁移失败: %w", err)
}
log.Logger.Info("执行迁移完成")
if err := initAPIRoutes(); err != nil {
log.Logger.Error("初始化API路由失败", zap.Error(err))
return fmt.Errorf("初始化API路由失败: %w", err)
}
log.Logger.Info("初始化API路由完成")
if err := rebuildUserPermissions(); err != nil {
log.Logger.Error("重建用户最终 API 权限失败", zap.Error(err))
return fmt.Errorf("重建用户最终 API 权限失败: %w", err)
}
log.Logger.Info("重建用户最终 API 权限完成")
log.Logger.Info("系统数据重新初始化完成")
return nil
}
func (s *ResetService) rollbackMigrations() error {
m, err := s.createMigrateInstance()
if err != nil {
return err
}
defer m.Close()
if err := m.Down(); err != nil && err != migrate.ErrNoChange {
var dirtyErr migrate.ErrDirty
if errors.As(err, &dirtyErr) {
log.Logger.Warn("检测到 dirty 迁移状态,尝试自动修复并重试回滚", zap.Uint("version", uint(dirtyErr.Version)))
if forceErr := m.Force(int(dirtyErr.Version)); forceErr != nil {
return fmt.Errorf("自动修复 dirty 状态失败: %w", forceErr)
}
if retryErr := m.Down(); retryErr != nil && retryErr != migrate.ErrNoChange {
return retryErr
}
return nil
}
return err
}
return nil
}
func (s *ResetService) runMigrations() error {
m, err := s.createMigrateInstance()
if err != nil {
return err
}
defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
return nil
}
func (s *ResetService) createMigrateInstance() (*migrate.Migrate, error) {
migrationsPath, err := getMigrationsPath()
if err != nil {
return nil, fmt.Errorf("获取迁移文件路径失败: %w", err)
}
dbURL := s.buildDatabaseURL()
m, err := migrate.New(
fmt.Sprintf("file://%s", migrationsPath),
dbURL,
)
if err != nil {
return nil, fmt.Errorf("创建迁移实例失败: %w", err)
}
return m, nil
}
func getMigrationsPath() (string, error) {
possiblePaths := []string{}
if envPath := strings.TrimSpace(os.Getenv(migrationsPathEnvKey)); envPath != "" {
possiblePaths = append(possiblePaths, strings.TrimPrefix(envPath, "file://"))
}
if config.V != nil {
configPath := strings.TrimSpace(config.V.ConfigFileUsed())
if configPath != "" {
possiblePaths = append(possiblePaths, filepath.Join(filepath.Dir(configPath), "data", "migrations"))
}
}
if executablePath, err := os.Executable(); err == nil {
possiblePaths = append(possiblePaths, filepath.Join(filepath.Dir(executablePath), "data", "migrations"))
}
if _, currentFile, _, ok := runtime.Caller(0); ok {
possiblePaths = append(possiblePaths, filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "data", "migrations"))
}
possiblePaths = append(possiblePaths,
"data/migrations",
"./data/migrations",
"../data/migrations",
"../../data/migrations",
)
seen := make(map[string]struct{}, len(possiblePaths))
for _, path := range possiblePaths {
absPath, err := filepath.Abs(path)
if err != nil {
continue
}
if _, ok := seen[absPath]; ok {
continue
}
seen[absPath] = struct{}{}
matches, err := filepath.Glob(filepath.Join(absPath, "*.up.sql"))
if err == nil && len(matches) > 0 {
return absPath, nil
}
}
return "", fmt.Errorf("未找到迁移文件目录,请确保 data/migrations 目录存在,或通过环境变量 %s 指定路径", migrationsPathEnvKey)
}
func (s *ResetService) buildDatabaseURL() string {
cfg := s.currentConfig().Mysql
return fmt.Sprintf("mysql://%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.Username,
cfg.Password,
cfg.Host,
cfg.Port,
cfg.Database,
)
}
func initAPIRoutes() error {
return InitApiRoutes()
}
func rebuildUserPermissions() error {
return RebuildUserPermissions()
}
================================================
FILE: internal/service/system/reset_path_test.go
================================================
package system
import (
"os"
"path/filepath"
"testing"
)
func TestGetMigrationsPathPrefersEnvPath(t *testing.T) {
tempDir := t.TempDir()
migrationsDir := filepath.Join(tempDir, "migrations")
if err := os.MkdirAll(migrationsDir, 0o755); err != nil {
t.Fatalf("create migrations dir failed: %v", err)
}
// 只要存在一个 *.up.sql 即视为有效迁移目录。
upFile := filepath.Join(migrationsDir, "000001_init.up.sql")
if err := os.WriteFile(upFile, []byte("SELECT 1;"), 0o644); err != nil {
t.Fatalf("write migration file failed: %v", err)
}
t.Setenv(migrationsPathEnvKey, migrationsDir)
got, err := getMigrationsPath()
if err != nil {
t.Fatalf("expected migrations path from env, got error: %v", err)
}
if got != migrationsDir {
t.Fatalf("expected %s, got %s", migrationsDir, got)
}
}
================================================
FILE: internal/service/system/reset_test.go
================================================
package system
import (
"errors"
"testing"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/config/autoload"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestResetSystemDataReturnsErrorWhenMysqlUnavailable(t *testing.T) {
t.Cleanup(func() {
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql on cleanup: %v", err)
}
})
if err := data.CloseMysql(); err != nil {
t.Fatalf("close mysql: %v", err)
}
err := NewResetService().ResetSystemData()
if !errors.Is(err, model.ErrDBUninitialized) {
t.Fatalf("expected %v, got %v", model.ErrDBUninitialized, err)
}
}
func TestBuildDatabaseURLUsesInjectedConfig(t *testing.T) {
service := NewResetServiceWithDeps(ResetServiceDeps{
ConfigProvider: func() *config.Conf {
return &config.Conf{
Mysql: autoload.MysqlConfig{
Host: "127.0.0.1",
Port: 3307,
Database: "demo",
Username: "tester",
Password: "secret",
},
}
},
})
got := service.buildDatabaseURL()
want := "mysql://tester:secret@tcp(127.0.0.1:3307)/demo?charset=utf8mb4&parseTime=True&loc=Local"
if got != want {
t.Fatalf("expected %s, got %s", want, got)
}
}
================================================
FILE: internal/service/taskcenter/action.go
================================================
package taskcenter
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
taskcron "github.com/wannanbigpig/gin-layout/internal/cron"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/queue"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"go.uber.org/zap"
)
var loadTaskRunByID = func(runID uint) (*model.TaskRun, error) {
runModel := model.NewTaskRun()
if err := runModel.GetById(runID); err != nil {
return nil, err
}
return runModel, nil
}
var loadTaskDefinitionByCode = func(taskCode string) (*model.TaskDefinition, error) {
definition := model.NewTaskDefinition()
if err := definition.GetDetail("code = ? AND deleted_at = 0", taskCode); err != nil {
return nil, err
}
return definition, nil
}
var executeCronHandler = func(ctx context.Context, handler string, payload map[string]any) error {
return taskcron.ExecuteHandler(ctx, handler, payload)
}
// TriggerTask 手动触发任务(支持 async 与 cron)。
func (s *TaskCenterService) TriggerTask(ctx context.Context, params *form.TaskTriggerForm, triggerUserID uint, triggerAccount string) (map[string]any, error) {
taskCode := strings.TrimSpace(params.TaskCode)
if taskCode == "" {
return nil, e.NewBusinessError(e.InvalidParameter, "task_code 不能为空")
}
definition, err := loadTaskDefinitionByCode(taskCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, e.NewBusinessError(e.NotFound)
}
return nil, e.NewBusinessError(e.ServerErr, "读取任务定义失败")
}
if definition.Status != 1 {
return nil, e.NewBusinessError(e.InvalidParameter, "任务已停用,无法触发")
}
if definition.AllowManual != 1 {
return nil, e.NewBusinessError(e.InvalidParameter, "任务不允许手动触发")
}
if definition.IsHighRisk == model.TaskHighRisk && strings.TrimSpace(params.Confirm) == "" {
return nil, e.NewBusinessError(e.InvalidParameter, "高危任务触发需要确认")
}
switch definition.Kind {
case model.TaskKindAsync:
return s.triggerAsyncTask(ctx, definition, params, triggerUserID, triggerAccount)
case model.TaskKindCron:
return s.triggerCronTask(ctx, definition, params, triggerUserID, triggerAccount)
default:
return nil, e.NewBusinessError(e.InvalidParameter, "当前任务类型不支持手动触发")
}
}
func (s *TaskCenterService) triggerAsyncTask(ctx context.Context, definition *model.TaskDefinition, params *form.TaskTriggerForm, triggerUserID uint, triggerAccount string) (map[string]any, error) {
queueName := strings.TrimSpace(params.Queue)
if queueName == "" {
queueName = strings.TrimSpace(definition.Queue)
}
if queueName == "" {
queueName = queue.DefaultQueue
}
taskID := strings.TrimSpace(params.TaskID)
if taskID == "" {
taskID = "manual:" + uuid.NewString()
}
payload := map[string]any{}
if params.Payload != nil {
payload = params.Payload
}
rawPayload, marshalErr := json.Marshal(payload)
if marshalErr != nil {
return nil, e.NewBusinessError(e.InvalidParameter, "payload 不是合法 JSON 对象")
}
recorder := NewRunRecorder()
run, err := recorder.Enqueue(ctx, RunStart{
TaskCode: definition.Code,
Kind: definition.Kind,
Source: model.TaskSourceManual,
SourceID: taskID,
Queue: queueName,
Payload: rawPayload,
TriggerUserID: triggerUserID,
TriggerAccount: triggerAccount,
TriggerConfirm: strings.TrimSpace(params.Confirm),
TriggerReason: strings.TrimSpace(params.Reason),
})
if err != nil {
return nil, err
}
jobInfo, publishErr := queue.PublishJSON(ctx, definition.Code, queueName, payload, queue.WithTaskID(taskID))
if publishErr != nil {
finishErr := recorder.Finish(ctx, run, RunFinish{
Status: model.TaskRunStatusFailed,
Error: publishErr,
})
if finishErr != nil {
log.Logger.Warn("手动触发任务失败后更新执行记录失败",
zap.Uint("run_id", run.ID),
zap.Error(finishErr))
}
if errors.Is(publishErr, queue.ErrPublisherUnavailable) {
return nil, e.NewBusinessError(e.ServiceDependencyNotReady, "队列未启用或未就绪")
}
return nil, e.NewBusinessError(e.ServerErr, "任务触发失败")
}
return map[string]any{
"run_id": run.ID,
"task_id": jobInfo.ID,
"queue": jobInfo.Queue,
"type": jobInfo.Type,
}, nil
}
func (s *TaskCenterService) triggerCronTask(ctx context.Context, definition *model.TaskDefinition, params *form.TaskTriggerForm, triggerUserID uint, triggerAccount string) (map[string]any, error) {
if strings.TrimSpace(definition.Handler) == "" {
return nil, e.NewBusinessError(e.InvalidParameter, "cron 任务处理器未配置")
}
taskID := strings.TrimSpace(params.TaskID)
if taskID == "" {
taskID = "manual:" + uuid.NewString()
}
payload := map[string]any{}
if params.Payload != nil {
payload = params.Payload
}
rawPayload, marshalErr := json.Marshal(payload)
if marshalErr != nil {
return nil, e.NewBusinessError(e.InvalidParameter, "payload 不是合法 JSON 对象")
}
run, err := NewRunRecorder().Start(ctx, RunStart{
TaskCode: definition.Code,
Kind: model.TaskKindCron,
Source: model.TaskSourceManual,
SourceID: taskID,
CronSpec: definition.CronSpec,
Payload: rawPayload,
TriggerUserID: triggerUserID,
TriggerAccount: triggerAccount,
TriggerConfirm: strings.TrimSpace(params.Confirm),
TriggerReason: strings.TrimSpace(params.Reason),
})
if err != nil {
return nil, err
}
execErr := executeCronHandler(ctx, definition.Handler, payload)
if finishErr := NewRunRecorder().Finish(ctx, run, RunFinish{Error: execErr}); finishErr != nil {
log.Logger.Warn("手动触发 cron 任务后更新执行记录失败",
zap.Uint("run_id", run.ID),
zap.String("task_code", definition.Code),
zap.Error(finishErr))
return nil, e.NewBusinessError(e.ServerErr, "任务触发后更新执行记录失败")
}
if execErr != nil {
return nil, e.NewBusinessError(e.ServerErr, "任务触发失败")
}
return map[string]any{
"run_id": run.ID,
"task_id": taskID,
"queue": "",
"type": definition.Code,
}, nil
}
// RetryTask 重试失败任务,重试时会创建一条新的执行记录。
func (s *TaskCenterService) RetryTask(ctx context.Context, runID uint, triggerUserID uint, triggerAccount string) (map[string]any, error) {
runModel, err := loadTaskRunByID(runID)
if err != nil || runModel == nil || runModel.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
if runModel.Status != model.TaskRunStatusFailed {
return nil, e.NewBusinessError(e.InvalidParameter, "仅失败任务允许重试")
}
if strings.TrimSpace(runModel.TaskCode) == "" {
return nil, e.NewBusinessError(e.InvalidParameter, "任务编码为空,无法重试")
}
// 校验当前任务定义是否允许重试。
definition, err := loadTaskDefinitionByCode(runModel.TaskCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, e.NewBusinessError(e.NotFound, "任务定义不存在")
}
return nil, e.NewBusinessError(e.ServerErr, "读取任务定义失败")
}
if definition.Status != 1 {
return nil, e.NewBusinessError(e.InvalidParameter, "任务已停用,无法重试")
}
if definition.AllowRetry != 1 {
return nil, e.NewBusinessError(e.InvalidParameter, "任务不允许重试")
}
if definition.Kind != runModel.Kind {
return nil, e.NewBusinessError(e.InvalidParameter, "任务类型与当前定义不一致,无法重试")
}
payloadAny := map[string]any{}
if strings.TrimSpace(runModel.Payload) != "" {
if err := json.Unmarshal([]byte(runModel.Payload), &payloadAny); err != nil {
return nil, e.NewBusinessError(e.InvalidParameter, "任务 payload 非法,无法重试")
}
}
taskID := fmt.Sprintf("retry:%d:%d:%s", runModel.ID, time.Now().UnixMilli(), uuid.NewString())
queueName := strings.TrimSpace(runModel.Queue)
if queueName == "" {
queueName = queue.DefaultQueue
}
rawPayload, _ := json.Marshal(payloadAny)
recorder := NewRunRecorder()
retryRun, err := recorder.Enqueue(ctx, RunStart{
TaskCode: runModel.TaskCode,
Kind: runModel.Kind,
Source: model.TaskSourceManual,
SourceID: taskID,
Queue: queueName,
Attempt: 1,
MaxRetry: runModel.MaxRetry,
Payload: rawPayload,
TriggerUserID: triggerUserID,
TriggerAccount: triggerAccount,
})
if err != nil {
return nil, err
}
options := []queue.JobOption{queue.WithTaskID(taskID)}
if runModel.MaxRetry > 0 {
options = append(options, queue.WithMaxRetry(runModel.MaxRetry))
}
jobInfo, publishErr := queue.PublishJSON(ctx, runModel.TaskCode, queueName, payloadAny, options...)
if publishErr != nil {
finishErr := recorder.Finish(ctx, retryRun, RunFinish{
Status: model.TaskRunStatusFailed,
Error: publishErr,
})
if finishErr != nil {
log.Logger.Warn("重试任务发布失败后更新执行记录失败",
zap.Uint("run_id", retryRun.ID),
zap.Error(finishErr))
}
if errors.Is(publishErr, queue.ErrPublisherUnavailable) {
return nil, e.NewBusinessError(e.ServiceDependencyNotReady, "队列未启用或未就绪")
}
return nil, e.NewBusinessError(e.ServerErr, "任务重试失败")
}
return map[string]any{
"run_id": retryRun.ID,
"task_id": jobInfo.ID,
"queue": jobInfo.Queue,
"type": jobInfo.Type,
"retry_from_run": runModel.ID,
}, nil
}
// CancelTask 取消待执行或执行中的异步任务。
func (s *TaskCenterService) CancelTask(ctx context.Context, runID uint, triggerUserID uint, triggerAccount string, cancelReason string) (map[string]any, error) {
runModel, err := loadTaskRunByID(runID)
if err != nil || runModel == nil || runModel.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
if runModel.Kind != model.TaskKindAsync {
return nil, e.NewBusinessError(e.InvalidParameter, "仅异步任务支持取消")
}
if strings.TrimSpace(runModel.SourceID) == "" {
return nil, e.NewBusinessError(e.InvalidParameter, "任务缺少 source_id,无法取消")
}
switch runModel.Status {
case model.TaskRunStatusSuccess, model.TaskRunStatusFailed, model.TaskRunStatusCanceled:
return nil, e.NewBusinessError(e.InvalidParameter, "已结束任务不支持取消")
case model.TaskRunStatusPending, model.TaskRunStatusRetrying, model.TaskRunStatusRunning:
// 允许取消
default:
return nil, e.NewBusinessError(e.InvalidParameter, "当前任务状态不支持取消")
}
queueName := strings.TrimSpace(runModel.Queue)
if queueName == "" {
queueName = queue.DefaultQueue
}
var cancelErr error
if runModel.Status == model.TaskRunStatusRunning {
cancelErr = queue.CancelProcessing(ctx, runModel.SourceID)
} else {
cancelErr = queue.DeleteTask(ctx, queueName, runModel.SourceID)
}
if cancelErr != nil {
if errors.Is(cancelErr, queue.ErrInspectorUnavailable) {
return nil, e.NewBusinessError(e.ServiceDependencyNotReady, "队列未启用或未就绪")
}
if errors.Is(cancelErr, queue.ErrTaskNotFound) || errors.Is(cancelErr, queue.ErrQueueNotFound) {
return nil, e.NewBusinessError(e.NotFound, "队列任务不存在或已结束")
}
return nil, e.NewBusinessError(e.ServerErr, "任务取消失败")
}
runModel.Status = model.TaskRunStatusCanceled
if err := NewRunRecorder().Finish(ctx, runModel, RunFinish{
Status: model.TaskRunStatusCanceled,
CanceledBy: triggerUserID,
CanceledByAccount: triggerAccount,
CancelReason: strings.TrimSpace(cancelReason),
}); err != nil {
return nil, err
}
result := map[string]any{
"run_id": runModel.ID,
"task_id": runModel.SourceID,
"status": model.TaskRunStatusCanceled,
"canceled_by": triggerUserID,
"canceled_by_account": triggerAccount,
}
if strings.TrimSpace(cancelReason) != "" {
result["cancel_reason"] = strings.TrimSpace(cancelReason)
}
return result, nil
}
================================================
FILE: internal/service/taskcenter/action_test.go
================================================
package taskcenter
import (
"context"
stderrors "errors"
"testing"
taskcron "github.com/wannanbigpig/gin-layout/internal/cron"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/queue"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
type stubPublisher struct {
info queue.JobInfo
err error
}
func (s *stubPublisher) Enqueue(ctx context.Context, job queue.Job) (queue.JobInfo, error) {
_ = ctx
_ = job
if s.err != nil {
return queue.JobInfo{}, s.err
}
return s.info, nil
}
type fakeActionRecorder struct {
enqueueInputs []RunStart
startInputs []RunStart
finishInputs []RunFinish
}
type stubInspector struct {
deleteTaskErr error
cancelErr error
deleted []struct {
queue string
taskID string
}
canceled []string
}
func (f *fakeActionRecorder) Enqueue(ctx context.Context, input RunStart) (*model.TaskRun, error) {
_ = ctx
f.enqueueInputs = append(f.enqueueInputs, input)
return &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.enqueueInputs))}, TaskCode: input.TaskCode}, nil
}
func (f *fakeActionRecorder) Start(ctx context.Context, input RunStart) (*model.TaskRun, error) {
_ = ctx
f.startInputs = append(f.startInputs, input)
return &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.startInputs))}, TaskCode: input.TaskCode}, nil
}
func (f *fakeActionRecorder) Finish(ctx context.Context, run *model.TaskRun, input RunFinish) error {
_ = ctx
_ = run
f.finishInputs = append(f.finishInputs, input)
return nil
}
func (s *stubInspector) DeleteTask(ctx context.Context, queueName, taskID string) error {
_ = ctx
s.deleted = append(s.deleted, struct {
queue string
taskID string
}{queue: queueName, taskID: taskID})
return s.deleteTaskErr
}
func (s *stubInspector) CancelProcessing(ctx context.Context, taskID string) error {
_ = ctx
s.canceled = append(s.canceled, taskID)
return s.cancelErr
}
func TestTriggerTaskSuccess(t *testing.T) {
restoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindAsync,
Status: 1,
AllowManual: 1,
}, nil
})
defer restoreTaskDefinition()
restorePublisher := queue.SetPublisherForTesting(&stubPublisher{
info: queue.JobInfo{ID: "task-1", Queue: "default", Type: "demo:send"},
})
defer restorePublisher()
fakeRecorder := &fakeActionRecorder{}
restoreRecorder := SetRecorderForTesting(fakeRecorder)
defer restoreRecorder()
svc := NewTaskCenterService()
result, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{
TaskCode: "demo:send",
Queue: "default",
Payload: map[string]any{"name": "codex"},
}, 1, "tester")
if err != nil {
t.Fatalf("TriggerTask returned error: %v", err)
}
if result["task_id"] != "task-1" {
t.Fatalf("unexpected task id: %#v", result["task_id"])
}
if len(fakeRecorder.enqueueInputs) != 1 {
t.Fatalf("expected enqueue record called once, got %d", len(fakeRecorder.enqueueInputs))
}
if len(fakeRecorder.finishInputs) != 0 {
t.Fatalf("did not expect finish record on success, got %d", len(fakeRecorder.finishInputs))
}
}
func TestTriggerTaskReturnsDependencyNotReadyWhenPublisherUnavailable(t *testing.T) {
restoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindAsync,
Status: 1,
AllowManual: 1,
}, nil
})
defer restoreTaskDefinition()
restorePublisher := queue.SetPublisherForTesting(nil)
defer restorePublisher()
fakeRecorder := &fakeActionRecorder{}
restoreRecorder := SetRecorderForTesting(fakeRecorder)
defer restoreRecorder()
svc := NewTaskCenterService()
_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{
TaskCode: "demo:send",
Queue: "default",
}, 1, "tester")
if err == nil {
t.Fatal("expected error when publisher unavailable")
}
var be *e.BusinessError
if !stderrors.As(err, &be) {
t.Fatalf("expected business error, got %T", err)
}
if be.GetCode() != e.ServiceDependencyNotReady {
t.Fatalf("expected code %d, got %d", e.ServiceDependencyNotReady, be.GetCode())
}
if len(fakeRecorder.finishInputs) != 1 {
t.Fatalf("expected finish record called once, got %d", len(fakeRecorder.finishInputs))
}
}
func TestTriggerTaskCronSuccess(t *testing.T) {
restoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindCron,
Handler: taskcron.HandlerCronDemo,
Status: 1,
AllowManual: 1,
}, nil
})
defer restoreTaskDefinition()
var calledHandler string
restoreExecutor := setCronExecutorForTest(func(ctx context.Context, handler string, payload map[string]any) error {
_ = ctx
_ = payload
calledHandler = handler
return nil
})
defer restoreExecutor()
fakeRecorder := &fakeActionRecorder{}
restoreRecorder := SetRecorderForTesting(fakeRecorder)
defer restoreRecorder()
svc := NewTaskCenterService()
result, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{
TaskCode: "cron:demo",
Payload: map[string]any{"source": "test"},
}, 1, "tester")
if err != nil {
t.Fatalf("TriggerTask returned error: %v", err)
}
if calledHandler != taskcron.HandlerCronDemo {
t.Fatalf("unexpected cron handler: %s", calledHandler)
}
if result["run_id"] != uint(1) {
t.Fatalf("unexpected run_id: %#v", result["run_id"])
}
if len(fakeRecorder.startInputs) != 1 {
t.Fatalf("expected start record called once, got %d", len(fakeRecorder.startInputs))
}
if len(fakeRecorder.finishInputs) != 1 {
t.Fatalf("expected finish record called once, got %d", len(fakeRecorder.finishInputs))
}
if fakeRecorder.finishInputs[0].Error != nil {
t.Fatalf("expected cron run success, got error: %v", fakeRecorder.finishInputs[0].Error)
}
}
func TestTriggerTaskReturnsErrorWhenManualNotAllowed(t *testing.T) {
restoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindCron,
Status: 1,
AllowManual: 0,
}, nil
})
defer restoreTaskDefinition()
svc := NewTaskCenterService()
_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{
TaskCode: "cron:demo",
}, 1, "tester")
if err == nil {
t.Fatal("expected error when task does not allow manual trigger")
}
var be *e.BusinessError
if !stderrors.As(err, &be) {
t.Fatalf("expected business error, got %T", err)
}
if be.GetCode() != e.InvalidParameter {
t.Fatalf("expected code %d, got %d", e.InvalidParameter, be.GetCode())
}
}
func TestTriggerTaskHighRiskRequiresConfirm(t *testing.T) {
restoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindAsync,
Status: 1,
AllowManual: 1,
IsHighRisk: model.TaskHighRisk,
}, nil
})
defer restoreTaskDefinition()
svc := NewTaskCenterService()
_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{
TaskCode: "demo:send",
}, 1, "tester")
if err == nil {
t.Fatal("expected error when high-risk task confirm is empty")
}
var be *e.BusinessError
if !stderrors.As(err, &be) {
t.Fatalf("expected business error, got %T", err)
}
if be.GetCode() != e.InvalidParameter {
t.Fatalf("expected code %d, got %d", e.InvalidParameter, be.GetCode())
}
}
func TestTriggerTaskRecordsConfirmAndReason(t *testing.T) {
restoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindAsync,
Status: 1,
AllowManual: 1,
IsHighRisk: model.TaskHighRisk,
}, nil
})
defer restoreTaskDefinition()
restorePublisher := queue.SetPublisherForTesting(&stubPublisher{
info: queue.JobInfo{ID: "task-1", Queue: "default", Type: "demo:send"},
})
defer restorePublisher()
fakeRecorder := &fakeActionRecorder{}
restoreRecorder := SetRecorderForTesting(fakeRecorder)
defer restoreRecorder()
svc := NewTaskCenterService()
_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{
TaskCode: "demo:send",
Confirm: "CONFIRM",
Reason: "manual high-risk operation",
}, 1, "tester")
if err != nil {
t.Fatalf("TriggerTask returned error: %v", err)
}
if len(fakeRecorder.enqueueInputs) != 1 {
t.Fatalf("expected enqueue record called once, got %d", len(fakeRecorder.enqueueInputs))
}
if fakeRecorder.enqueueInputs[0].TriggerConfirm != "CONFIRM" || fakeRecorder.enqueueInputs[0].TriggerReason != "manual high-risk operation" {
t.Fatalf("unexpected trigger audit meta: %#v", fakeRecorder.enqueueInputs[0])
}
}
func TestRetryTaskRespectsCurrentDefinition(t *testing.T) {
restoreRun := setTaskRunLoaderForTest(func(runID uint) (*model.TaskRun, error) {
return &model.TaskRun{
BaseModel: model.BaseModel{ID: runID},
TaskCode: "demo:send",
Kind: model.TaskKindAsync,
Queue: "default",
Status: model.TaskRunStatusFailed,
Payload: `{"name":"codex"}`,
}, nil
})
defer restoreRun()
restoreDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {
return &model.TaskDefinition{
Code: taskCode,
Kind: model.TaskKindAsync,
Status: model.TaskStatusEnabled,
AllowRetry: 0,
}, nil
})
defer restoreDefinition()
svc := NewTaskCenterService()
_, err := svc.RetryTask(context.Background(), 101, 1, "tester")
if err == nil {
t.Fatal("expected error when current definition disallows retry")
}
var be *e.BusinessError
if !stderrors.As(err, &be) {
t.Fatalf("expected business error, got %T", err)
}
if be.GetCode() != e.InvalidParameter {
t.Fatalf("expected code %d, got %d", e.InvalidParameter, be.GetCode())
}
}
func TestCancelTaskDeletesPendingTaskAndMarksRunCanceled(t *testing.T) {
restoreLoader := setTaskRunLoaderForTest(func(runID uint) (*model.TaskRun, error) {
return &model.TaskRun{
BaseModel: model.BaseModel{ID: runID},
TaskCode: "demo:send",
Kind: model.TaskKindAsync,
SourceID: "task-1",
Queue: "default",
Status: model.TaskRunStatusPending,
}, nil
})
defer restoreLoader()
fakeRecorder := &fakeActionRecorder{}
restoreRecorder := SetRecorderForTesting(fakeRecorder)
defer restoreRecorder()
inspector := &stubInspector{}
restoreInspector := queue.SetInspectorForTesting(inspector)
defer restoreInspector()
svc := NewTaskCenterService()
result, err := svc.CancelTask(context.Background(), 101, 1, "tester", "manual cancel")
if err != nil {
t.Fatalf("CancelTask returned error: %v", err)
}
if result["status"] != model.TaskRunStatusCanceled {
t.Fatalf("unexpected cancel status: %#v", result["status"])
}
if len(inspector.deleted) != 1 || inspector.deleted[0].taskID != "task-1" {
t.Fatalf("unexpected deleted tasks: %#v", inspector.deleted)
}
if len(fakeRecorder.finishInputs) != 1 || fakeRecorder.finishInputs[0].Status != model.TaskRunStatusCanceled {
t.Fatalf("expected recorder finish with canceled status, got %#v", fakeRecorder.finishInputs)
}
if fakeRecorder.finishInputs[0].CanceledBy != 1 || fakeRecorder.finishInputs[0].CanceledByAccount != "tester" || fakeRecorder.finishInputs[0].CancelReason != "manual cancel" {
t.Fatalf("unexpected cancel meta: %#v", fakeRecorder.finishInputs[0])
}
}
func TestCancelTaskReturnsDependencyNotReadyWhenInspectorUnavailable(t *testing.T) {
restoreLoader := setTaskRunLoaderForTest(func(runID uint) (*model.TaskRun, error) {
return &model.TaskRun{
BaseModel: model.BaseModel{ID: runID},
TaskCode: "demo:send",
Kind: model.TaskKindAsync,
SourceID: "task-1",
Queue: "default",
Status: model.TaskRunStatusPending,
}, nil
})
defer restoreLoader()
restoreInspector := queue.SetInspectorForTesting(nil)
defer restoreInspector()
svc := NewTaskCenterService()
_, err := svc.CancelTask(context.Background(), 101, 1, "tester", "")
if err == nil {
t.Fatal("expected error when inspector unavailable")
}
var be *e.BusinessError
if !stderrors.As(err, &be) {
t.Fatalf("expected business error, got %T", err)
}
if be.GetCode() != e.ServiceDependencyNotReady {
t.Fatalf("expected code %d, got %d", e.ServiceDependencyNotReady, be.GetCode())
}
}
func setTaskRunLoaderForTest(loader func(runID uint) (*model.TaskRun, error)) func() {
previous := loadTaskRunByID
loadTaskRunByID = loader
return func() {
loadTaskRunByID = previous
}
}
func setTaskDefinitionLoaderForTest(loader func(taskCode string) (*model.TaskDefinition, error)) func() {
previous := loadTaskDefinitionByCode
loadTaskDefinitionByCode = loader
return func() {
loadTaskDefinitionByCode = previous
}
}
func setCronExecutorForTest(executor func(ctx context.Context, handler string, payload map[string]any) error) func() {
previous := executeCronHandler
executeCronHandler = executor
return func() {
executeCronHandler = previous
}
}
================================================
FILE: internal/service/taskcenter/audit_diff.go
================================================
package taskcenter
import (
"sort"
"strings"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
var taskRunStatusLabels = map[string]string{
model.TaskRunStatusPending: "待执行",
model.TaskRunStatusRunning: "执行中",
model.TaskRunStatusSuccess: "成功",
model.TaskRunStatusFailed: "失败",
model.TaskRunStatusCanceled: "已取消",
model.TaskRunStatusRetrying: "重试中",
}
var taskActionDiffRules = []auditdiff.FieldRule{
{Field: "action", Label: "操作类型"},
{Field: "task_code", Label: "任务编码"},
{Field: "run_id", Label: "执行记录ID"},
{Field: "status", Label: "执行状态", ValueLabels: taskRunStatusLabels},
{Field: "queue", Label: "队列"},
{Field: "task_id", Label: "任务ID"},
{Field: "retry_from_run", Label: "重试来源执行记录ID"},
{Field: "payload_keys", Label: "Payload字段"},
{Field: "confirm", Label: "确认信息"},
{Field: "reason", Label: "操作原因"},
{Field: "canceled_by", Label: "取消人ID"},
{Field: "canceled_by_account", Label: "取消人账号"},
{Field: "cancel_reason", Label: "取消原因"},
}
// TaskRunAuditSnapshot 描述任务执行记录审计快照。
type TaskRunAuditSnapshot struct {
RunID uint
TaskCode string
Status string
Queue string
SourceID string
Kind string
MaxRetry int
Attempt int
HasRecord bool
}
func BuildTriggerAuditDiff(params *form.TaskTriggerForm, result map[string]any) string {
after := map[string]any{
"action": "trigger",
"task_code": strings.TrimSpace(params.TaskCode),
"run_id": result["run_id"],
"queue": result["queue"],
"task_id": result["task_id"],
"payload_keys": payloadKeys(params.Payload),
}
if strings.TrimSpace(params.Confirm) != "" {
after["confirm"] = strings.TrimSpace(params.Confirm)
}
if strings.TrimSpace(params.Reason) != "" {
after["reason"] = strings.TrimSpace(params.Reason)
}
items := auditdiff.BuildFieldDiff(nil, after, taskActionDiffRules)
return auditdiff.Marshal(items)
}
func BuildRetryAuditDiff(before *TaskRunAuditSnapshot, result map[string]any) string {
beforeState := map[string]any{}
if before != nil && before.HasRecord {
beforeState["action"] = "retry"
beforeState["task_code"] = before.TaskCode
beforeState["run_id"] = before.RunID
beforeState["status"] = before.Status
beforeState["queue"] = before.Queue
}
after := map[string]any{
"action": "retry",
"task_code": beforeState["task_code"],
"run_id": result["run_id"],
"status": model.TaskRunStatusPending,
"queue": result["queue"],
"task_id": result["task_id"],
"retry_from_run": result["retry_from_run"],
}
items := auditdiff.BuildFieldDiff(beforeState, after, taskActionDiffRules)
return auditdiff.Marshal(items)
}
func BuildCancelAuditDiff(before *TaskRunAuditSnapshot, result map[string]any) string {
beforeState := map[string]any{}
if before != nil && before.HasRecord {
beforeState["action"] = "cancel"
beforeState["task_code"] = before.TaskCode
beforeState["run_id"] = before.RunID
beforeState["status"] = before.Status
beforeState["queue"] = before.Queue
beforeState["task_id"] = before.SourceID
}
after := map[string]any{
"action": "cancel",
"task_code": beforeState["task_code"],
"run_id": result["run_id"],
"status": result["status"],
"queue": beforeState["queue"],
"task_id": result["task_id"],
}
if result["canceled_by"] != nil {
after["canceled_by"] = result["canceled_by"]
}
if result["canceled_by_account"] != nil {
after["canceled_by_account"] = result["canceled_by_account"]
}
if result["cancel_reason"] != nil {
after["cancel_reason"] = result["cancel_reason"]
}
items := auditdiff.BuildFieldDiff(beforeState, after, taskActionDiffRules)
return auditdiff.Marshal(items)
}
func payloadKeys(payload map[string]any) []string {
if len(payload) == 0 {
return []string{}
}
keys := make([]string, 0, len(payload))
for key := range payload {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
sort.Strings(keys)
return keys
}
================================================
FILE: internal/service/taskcenter/audit_diff_test.go
================================================
package taskcenter
import (
"encoding/json"
"testing"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
)
func TestBuildCancelAuditDiffContainsStatusMapping(t *testing.T) {
raw := BuildCancelAuditDiff(&TaskRunAuditSnapshot{
RunID: 101,
TaskCode: "demo:send",
Status: model.TaskRunStatusRunning,
Queue: "critical",
SourceID: "task-abc",
HasRecord: true,
}, map[string]any{
"run_id": uint(101),
"task_id": "task-abc",
"status": model.TaskRunStatusCanceled,
"canceled_by": uint(1),
"canceled_by_account": "tester",
"cancel_reason": "manual cancel",
})
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
foundStatus := false
foundAccount := false
foundReason := false
for _, item := range items {
switch item["field"] {
case "status":
foundStatus = true
if item["before_display"] != "执行中" || item["after_display"] != "已取消" {
t.Fatalf("unexpected status display mapping: %#v", item)
}
case "canceled_by_account":
foundAccount = true
if item["after"] != "tester" {
t.Fatalf("unexpected canceled_by_account item: %#v", item)
}
case "cancel_reason":
foundReason = true
if item["after"] != "manual cancel" {
t.Fatalf("unexpected cancel_reason item: %#v", item)
}
}
}
if !foundStatus {
t.Fatalf("expected status diff item, got %#v", items)
}
if !foundAccount || !foundReason {
t.Fatalf("expected cancel audit meta items, got %#v", items)
}
}
func TestBuildTriggerAuditDiffContainsPayloadKeys(t *testing.T) {
raw := BuildTriggerAuditDiff(&form.TaskTriggerForm{
TaskCode: "cron:demo",
Confirm: "CONFIRM",
Reason: "manual run",
Payload: map[string]any{
"z": 1,
"a": "x",
},
}, map[string]any{
"run_id": uint(1),
"task_id": "manual:abc",
"queue": "default",
})
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
t.Fatalf("expected valid json diff, got err=%v raw=%s", err, raw)
}
foundPayloadKeys := false
foundConfirm := false
foundReason := false
for _, item := range items {
switch item["field"] {
case "payload_keys":
foundPayloadKeys = true
after, ok := item["after"].([]any)
if !ok || len(after) != 2 {
t.Fatalf("unexpected payload_keys after value: %#v", item["after"])
}
if after[0] != "a" || after[1] != "z" {
t.Fatalf("expected sorted payload keys [a z], got %#v", after)
}
case "confirm":
foundConfirm = true
if item["after"] != "CONFIRM" {
t.Fatalf("unexpected confirm item: %#v", item)
}
case "reason":
foundReason = true
if item["after"] != "manual run" {
t.Fatalf("unexpected reason item: %#v", item)
}
}
}
if !foundPayloadKeys {
t.Fatalf("expected payload_keys diff item, got %#v", items)
}
if !foundConfirm || !foundReason {
t.Fatalf("expected trigger audit meta items, got %#v", items)
}
}
================================================
FILE: internal/service/taskcenter/list_helpers.go
================================================
package taskcenter
import "github.com/wannanbigpig/gin-layout/internal/pkg/query_builder"
type listQuery struct {
*query_builder.QueryBuilder
}
func newListQuery() *listQuery {
return &listQuery{QueryBuilder: query_builder.New()}
}
func (q *listQuery) addEq(field string, value any) *listQuery {
q.QueryBuilder.AddEq(field, value)
return q
}
func (q *listQuery) addLike(field, value string) *listQuery {
q.QueryBuilder.AddLike(field, value)
return q
}
func (q *listQuery) addCreatedAtRange(startTime, endTime string) *listQuery {
if startTime != "" {
q.QueryBuilder.AddCondition("created_at >= ?", startTime)
}
if endTime != "" {
q.QueryBuilder.AddCondition("created_at <= ?", endTime)
}
return q
}
================================================
FILE: internal/service/taskcenter/query.go
================================================
package taskcenter
import (
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/resources"
"github.com/wannanbigpig/gin-layout/internal/service"
"github.com/wannanbigpig/gin-layout/internal/validator/form"
"go.uber.org/zap"
)
// TaskCenterService 任务中心查询服务。
type TaskCenterService struct {
service.Base
db *gorm.DB
}
// TaskCenterServiceDeps 描述 TaskCenterService 可注入依赖。
type TaskCenterServiceDeps struct {
DB *gorm.DB
}
func NewTaskCenterService() *TaskCenterService {
return NewTaskCenterServiceWithDeps(TaskCenterServiceDeps{})
}
func NewTaskCenterServiceWithDeps(deps TaskCenterServiceDeps) *TaskCenterService {
return &TaskCenterService{db: deps.DB}
}
// ListTaskDefinitions 分页查询任务定义列表。
func (s *TaskCenterService) ListTaskDefinitions(params *form.TaskDefinitionList) *resources.Collection {
query := newListQuery().
addLike("code", params.Code).
addLike("name", params.Name).
addEq("kind", params.Kind).
addEq("status", params.Status).
addEq("allow_manual", params.AllowManual).
addEq("allow_retry", params.AllowRetry).
addEq("is_high_risk", params.IsHighRisk)
condition, args := query.Build()
definitionModel := model.NewTaskDefinition()
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"code",
"name",
"kind",
"queue",
"cron_spec",
"handler",
"status",
"allow_manual",
"allow_retry",
"is_high_risk",
"remark",
"created_at",
"updated_at",
},
OrderBy: "id DESC",
}
transformer := resources.NewTaskDefinitionTransformer()
total, collection, err := model.ListPageE(definitionModel, params.Page, params.PerPage, condition, args, listOptionalParams)
if err != nil {
log.Logger.Error("查询任务定义列表失败", zap.Error(err))
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
// ListTaskRuns 分页查询任务执行记录列表。
func (s *TaskCenterService) ListTaskRuns(params *form.TaskRunList) *resources.Collection {
query := newListQuery().
addLike("task_code", params.TaskCode).
addEq("kind", params.Kind).
addEq("source", params.Source).
addLike("source_id", params.SourceID).
addEq("status", params.Status).
addCreatedAtRange(params.StartTime, params.EndTime)
condition, args := query.Build()
runModel := model.NewTaskRun()
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"task_code",
"kind",
"source",
"source_id",
"queue",
"status",
"attempt",
"max_retry",
"error_message",
"duration_ms",
"started_at",
"finished_at",
"created_at",
"trigger_user_id",
"trigger_account",
},
OrderBy: "created_at DESC, id DESC",
}
transformer := resources.NewTaskRunTransformer()
total, collection, err := model.ListPageE(runModel, params.Page, params.PerPage, condition, args, listOptionalParams)
if err != nil {
log.Logger.Error("查询任务执行记录列表失败", zap.Error(err))
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
// TaskRunDetail 获取任务执行记录详情。
func (s *TaskCenterService) TaskRunDetail(id uint) (any, error) {
taskRun := model.NewTaskRun()
if s.db != nil {
taskRun.SetDB(s.db)
}
if err := taskRun.GetById(id); err != nil || taskRun.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return resources.NewTaskRunTransformer().ToStruct(taskRun), nil
}
// TaskRunEvents 查询任务执行事件列表。
func (s *TaskCenterService) TaskRunEvents(runID uint) ([]*resources.TaskRunEventResources, error) {
eventModel := model.NewTaskRunEvent()
if s.db != nil {
var events []*model.TaskRunEvent
err := s.db.Select([]string{
"id",
"run_id",
"event_type",
"message",
"meta",
"created_at",
}).Where("run_id = ?", runID).Order("created_at ASC, id ASC").Find(&events).Error
if err != nil {
log.Logger.Error("查询任务执行事件失败", zap.Error(err), zap.Uint("run_id", runID))
return nil, err
}
return buildTaskRunEventResources(events), nil
}
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{"id", "run_id", "event_type", "message", "meta", "created_at"},
OrderBy: "created_at ASC, id ASC",
}
events, err := model.ListE(eventModel, "run_id = ?", []any{runID}, listOptionalParams)
if err != nil {
log.Logger.Error("查询任务执行事件失败", zap.Error(err), zap.Uint("run_id", runID))
return nil, err
}
return buildTaskRunEventResources(events), nil
}
func buildTaskRunEventResources(events []*model.TaskRunEvent) []*resources.TaskRunEventResources {
transformer := resources.NewTaskRunEventTransformer()
response := make([]*resources.TaskRunEventResources, 0, len(events))
for _, item := range events {
response = append(response, transformer.ToStruct(item))
}
return response
}
// TaskRunAuditSnapshot 查询任务执行记录的审计快照(用于构建 change_diff)。
func (s *TaskCenterService) TaskRunAuditSnapshot(id uint) (*TaskRunAuditSnapshot, error) {
taskRun, err := loadTaskRunByID(id)
if err != nil || taskRun == nil || taskRun.ID == 0 {
return nil, e.NewBusinessError(e.NotFound)
}
return &TaskRunAuditSnapshot{
RunID: taskRun.ID,
TaskCode: taskRun.TaskCode,
Status: taskRun.Status,
Queue: taskRun.Queue,
SourceID: taskRun.SourceID,
Kind: taskRun.Kind,
MaxRetry: taskRun.MaxRetry,
Attempt: taskRun.Attempt,
HasRecord: true,
}, nil
}
// ListCronTaskStates 分页查询定时任务最近状态列表。
func (s *TaskCenterService) ListCronTaskStates(params *form.CronTaskStateList) *resources.Collection {
query := newListQuery().
addLike("task_code", params.TaskCode).
addEq("last_status", params.LastStatus)
condition, args := query.Build()
stateModel := model.NewCronTaskState()
listOptionalParams := model.ListOptionalParams{
SelectFields: []string{
"id",
"task_code",
"cron_spec",
"last_run_id",
"last_status",
"last_started_at",
"last_finished_at",
"next_run_at",
"last_error",
"updated_at",
},
OrderBy: "updated_at DESC, id DESC",
}
transformer := resources.NewCronTaskStateTransformer()
total, collection, err := model.ListPageE(stateModel, params.Page, params.PerPage, condition, args, listOptionalParams)
if err != nil {
log.Logger.Error("查询定时任务最近状态列表失败", zap.Error(err))
return transformer.ToCollection(params.Page, params.PerPage, 0, nil)
}
return transformer.ToCollection(params.Page, params.PerPage, total, collection)
}
================================================
FILE: internal/service/taskcenter/recorder.go
================================================
package taskcenter
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils"
)
const (
maxPayloadBytes = 64 * 1024
maxErrorBytes = 8 * 1024
)
// RunStart 描述一次任务开始执行时的上下文。
type RunStart struct {
TaskCode string
Kind string
Source string
SourceID string
Queue string
CronSpec string
Attempt int
MaxRetry int
Payload []byte
TriggerUserID uint
TriggerAccount string
TriggerConfirm string
TriggerReason string
}
// RunFinish 描述一次任务执行结束时的结果。
type RunFinish struct {
Status string
Error error
NextRunAt *time.Time
CanceledBy uint
CanceledByAccount string
CancelReason string
}
// Recorder 持久化任务执行记录。
type Recorder interface {
Enqueue(ctx context.Context, input RunStart) (*model.TaskRun, error)
Start(ctx context.Context, input RunStart) (*model.TaskRun, error)
Finish(ctx context.Context, run *model.TaskRun, input RunFinish) error
}
type recorder struct {
db *gorm.DB
}
var (
factoryMu sync.RWMutex
recorderFactory = func() Recorder {
return NewRecorder()
}
)
// NewRecorder 创建使用全局数据库连接的任务执行记录器。
func NewRecorder() Recorder {
return &recorder{}
}
// NewRecorderWithDB 创建使用指定数据库连接的任务执行记录器,主要用于测试。
func NewRecorderWithDB(db *gorm.DB) Recorder {
return &recorder{db: db}
}
// NewRunRecorder 返回当前默认记录器。
func NewRunRecorder() Recorder {
factoryMu.RLock()
defer factoryMu.RUnlock()
return recorderFactory()
}
// SetRecorderForTesting 临时替换默认记录器。
func SetRecorderForTesting(next Recorder) func() {
factoryMu.Lock()
previous := recorderFactory
recorderFactory = func() Recorder {
return next
}
factoryMu.Unlock()
return func() {
factoryMu.Lock()
recorderFactory = previous
factoryMu.Unlock()
}
}
func (r *recorder) Start(ctx context.Context, input RunStart) (*model.TaskRun, error) {
if strings.TrimSpace(input.TaskCode) == "" {
return nil, errors.New("task code is required")
}
if input.Kind == "" {
input.Kind = model.TaskKindAsync
}
if input.Source == "" {
input.Source = model.TaskSourceQueue
}
db, err := r.dbWithContext(ctx)
if err != nil {
return nil, err
}
now := utils.FormatDate{Time: time.Now()}
run := model.NewTaskRun()
run.TaskCode = input.TaskCode
run.Kind = input.Kind
run.Source = input.Source
run.SourceID = input.SourceID
run.Queue = input.Queue
run.TriggerUserID = input.TriggerUserID
run.TriggerAccount = input.TriggerAccount
run.Status = model.TaskRunStatusRunning
run.Attempt = input.Attempt
run.MaxRetry = input.MaxRetry
run.Payload = truncateBytes(input.Payload, maxPayloadBytes)
run.StartedAt = &now
err = db.Transaction(func(tx *gorm.DB) error {
// 如果存在同 source_id 的 pending/retrying 记录(例如手动触发后进入 worker),就复用该记录并推进状态。
if input.SourceID != "" {
var existing model.TaskRun
findErr := tx.Where("task_code = ? AND source_id = ? AND status IN ?",
input.TaskCode, input.SourceID, []string{model.TaskRunStatusPending, model.TaskRunStatusRetrying}).
Order("id DESC").First(&existing).Error
if findErr == nil {
run = &existing
run.Kind = input.Kind
run.Source = input.Source
run.Queue = input.Queue
run.Status = model.TaskRunStatusRunning
run.Attempt = input.Attempt
run.MaxRetry = input.MaxRetry
run.Payload = truncateBytes(input.Payload, maxPayloadBytes)
run.StartedAt = &now
if err := tx.Model(&model.TaskRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"kind": run.Kind,
"source": run.Source,
"queue": run.Queue,
"status": run.Status,
"attempt": run.Attempt,
"max_retry": run.MaxRetry,
"payload": run.Payload,
"started_at": run.StartedAt,
}).Error; err != nil {
return err
}
if err := tx.Create(newEvent(run.ID, model.TaskEventStart, "task started", inputMeta(input))).Error; err != nil {
return err
}
if input.Source == model.TaskSourceCron {
return upsertCronState(tx, run, input.CronSpec, "", nil)
}
return nil
}
if !errors.Is(findErr, gorm.ErrRecordNotFound) {
return findErr
}
}
if err := tx.Create(run).Error; err != nil {
return err
}
if err := tx.Create(newEvent(run.ID, model.TaskEventStart, "task started", inputMeta(input))).Error; err != nil {
return err
}
if input.Source == model.TaskSourceCron {
return upsertCronState(tx, run, input.CronSpec, "", nil)
}
return nil
})
if err != nil {
return nil, err
}
return run, nil
}
func (r *recorder) Enqueue(ctx context.Context, input RunStart) (*model.TaskRun, error) {
if strings.TrimSpace(input.TaskCode) == "" {
return nil, errors.New("task code is required")
}
if input.Kind == "" {
input.Kind = model.TaskKindAsync
}
if input.Source == "" {
input.Source = model.TaskSourceQueue
}
db, err := r.dbWithContext(ctx)
if err != nil {
return nil, err
}
run := model.NewTaskRun()
run.TaskCode = input.TaskCode
run.Kind = input.Kind
run.Source = input.Source
run.SourceID = input.SourceID
run.Queue = input.Queue
run.TriggerUserID = input.TriggerUserID
run.TriggerAccount = input.TriggerAccount
run.Status = model.TaskRunStatusPending
run.Attempt = input.Attempt
run.MaxRetry = input.MaxRetry
run.Payload = truncateBytes(input.Payload, maxPayloadBytes)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(run).Error; err != nil {
return err
}
return tx.Create(newEvent(run.ID, model.TaskEventEnqueue, "task enqueued", inputMeta(input))).Error
}); err != nil {
return nil, err
}
return run, nil
}
func (r *recorder) Finish(ctx context.Context, run *model.TaskRun, input RunFinish) error {
if run == nil || run.ID == 0 {
return nil
}
status := input.Status
if status == "" {
status = model.TaskRunStatusSuccess
if input.Error != nil {
status = model.TaskRunStatusFailed
}
}
db, err := r.dbWithContext(ctx)
if err != nil {
return err
}
finishedAt := utils.FormatDate{Time: time.Now()}
errorMessage := ""
eventType, eventMessage := resolveFinishEvent(status)
if input.Error != nil {
errorMessage = truncateString(input.Error.Error(), maxErrorBytes)
eventType = model.TaskEventFail
eventMessage = errorMessage
}
input.Status = status
durationMS := float64(0)
if run.StartedAt != nil && !run.StartedAt.IsZero() {
duration := finishedAt.Time.Sub(run.StartedAt.Time)
durationMS = float64(duration.Nanoseconds()) / 1000000.0
durationMS = float64(int(durationMS*10000+0.5)) / 10000.0
}
run.Status = status
run.ErrorMessage = errorMessage
run.FinishedAt = &finishedAt
run.DurationMS = durationMS
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.TaskRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": run.Status,
"error_message": run.ErrorMessage,
"finished_at": run.FinishedAt,
"duration_ms": run.DurationMS,
}).Error; err != nil {
return err
}
if err := tx.Create(newEvent(run.ID, eventType, eventMessage, finishMeta(input))).Error; err != nil {
return err
}
if run.Source == model.TaskSourceCron {
return upsertCronState(tx, run, "", errorMessage, input.NextRunAt)
}
return nil
})
}
func resolveFinishEvent(status string) (eventType string, message string) {
switch status {
case model.TaskRunStatusFailed:
return model.TaskEventFail, "task failed"
case model.TaskRunStatusCanceled:
return model.TaskEventCancel, "task canceled"
case model.TaskRunStatusRetrying:
return model.TaskEventRetry, "task retrying"
default:
return model.TaskEventSuccess, "task succeeded"
}
}
func (r *recorder) dbWithContext(ctx context.Context) (*gorm.DB, error) {
if ctx == nil {
ctx = context.Background()
}
if r.db != nil {
return r.db.WithContext(ctx), nil
}
db, err := model.GetDB()
if err != nil {
return nil, err
}
return db.WithContext(ctx), nil
}
func newEvent(runID uint, eventType, message string, meta map[string]any) *model.TaskRunEvent {
return &model.TaskRunEvent{
RunID: runID,
EventType: eventType,
Message: truncateString(message, maxErrorBytes),
Meta: marshalMeta(meta),
}
}
func inputMeta(input RunStart) map[string]any {
meta := map[string]any{
"kind": input.Kind,
"source": input.Source,
"source_id": input.SourceID,
"queue": input.Queue,
"attempt": input.Attempt,
"max_retry": input.MaxRetry,
"trigger_user_id": input.TriggerUserID,
"cron_spec": input.CronSpec,
}
if strings.TrimSpace(input.TriggerAccount) != "" {
meta["trigger_account"] = strings.TrimSpace(input.TriggerAccount)
}
if strings.TrimSpace(input.TriggerConfirm) != "" {
meta["trigger_confirm"] = strings.TrimSpace(input.TriggerConfirm)
}
if strings.TrimSpace(input.TriggerReason) != "" {
meta["trigger_reason"] = strings.TrimSpace(input.TriggerReason)
}
return meta
}
func finishMeta(input RunFinish) map[string]any {
meta := map[string]any{
"status": input.Status,
}
if input.NextRunAt != nil {
meta["next_run_at"] = input.NextRunAt.Format("2006-01-02 15:04:05")
}
if input.CanceledBy > 0 {
meta["canceled_by"] = input.CanceledBy
}
if strings.TrimSpace(input.CanceledByAccount) != "" {
meta["canceled_by_account"] = strings.TrimSpace(input.CanceledByAccount)
}
if input.CancelReason != "" {
meta["cancel_reason"] = strings.TrimSpace(input.CancelReason)
}
return meta
}
func marshalMeta(meta map[string]any) string {
if len(meta) == 0 {
return "{}"
}
raw, err := json.Marshal(meta)
if err != nil {
return "{}"
}
return string(raw)
}
func upsertCronState(tx *gorm.DB, run *model.TaskRun, cronSpec string, lastError string, nextRunAt *time.Time) error {
if run == nil || run.TaskCode == "" {
return nil
}
state := model.NewCronTaskState()
state.TaskCode = run.TaskCode
state.CronSpec = cronSpec
state.LastRunID = run.ID
state.LastStatus = run.Status
state.LastStartedAt = run.StartedAt
state.LastFinishedAt = run.FinishedAt
state.LastError = truncateString(lastError, maxErrorBytes)
if nextRunAt != nil {
state.NextRunAt = &utils.FormatDate{Time: *nextRunAt}
}
assignments := []string{
"last_run_id",
"last_status",
"last_started_at",
"last_finished_at",
"next_run_at",
"last_error",
"updated_at",
}
if cronSpec != "" {
assignments = append(assignments, "cron_spec")
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "task_code"}},
DoUpdates: clause.AssignmentColumns(assignments),
}).Create(state).Error
}
func truncateBytes(raw []byte, limit int) string {
if len(raw) == 0 {
return ""
}
if len(raw) <= limit {
return string(raw)
}
return string(raw[:limit]) + "...(truncated)"
}
func truncateString(raw string, limit int) string {
if raw == "" || len(raw) <= limit {
return raw
}
return raw[:limit] + "...(truncated)"
}
================================================
FILE: internal/service/taskcenter/recorder_test.go
================================================
package taskcenter
import (
"context"
"encoding/json"
"errors"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
)
func TestRecorderStartAndFinishSuccess(t *testing.T) {
db := newTaskCenterTestDB(t)
recorder := NewRecorderWithDB(db)
run, err := recorder.Start(context.Background(), RunStart{
TaskCode: "demo:send",
Kind: model.TaskKindAsync,
Source: model.TaskSourceQueue,
SourceID: "task-1",
Queue: "default",
Attempt: 1,
MaxRetry: 3,
Payload: []byte(`{"name":"codex"}`),
})
if err != nil {
t.Fatalf("Start returned error: %v", err)
}
if run.ID == 0 {
t.Fatal("expected run id to be assigned")
}
if err := recorder.Finish(context.Background(), run, RunFinish{}); err != nil {
t.Fatalf("Finish returned error: %v", err)
}
var stored model.TaskRun
if err := db.First(&stored, run.ID).Error; err != nil {
t.Fatalf("query task run failed: %v", err)
}
if stored.Status != model.TaskRunStatusSuccess {
t.Fatalf("unexpected status: %s", stored.Status)
}
if stored.Payload != `{"name":"codex"}` {
t.Fatalf("unexpected payload: %s", stored.Payload)
}
var count int64
if err := db.Model(&model.TaskRunEvent{}).Where("run_id = ?", run.ID).Count(&count).Error; err != nil {
t.Fatalf("count events failed: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 events, got %d", count)
}
}
func TestRecorderFinishFailureUpdatesCronState(t *testing.T) {
db := newTaskCenterTestDB(t)
recorder := NewRecorderWithDB(db)
run, err := recorder.Start(context.Background(), RunStart{
TaskCode: "cron:demo",
Kind: model.TaskKindCron,
Source: model.TaskSourceCron,
SourceID: "demo",
CronSpec: "0/5 * * * * *",
})
if err != nil {
t.Fatalf("Start returned error: %v", err)
}
taskErr := errors.New("boom")
if err := recorder.Finish(context.Background(), run, RunFinish{Error: taskErr}); err != nil {
t.Fatalf("Finish returned error: %v", err)
}
var stored model.TaskRun
if err := db.First(&stored, run.ID).Error; err != nil {
t.Fatalf("query task run failed: %v", err)
}
if stored.Status != model.TaskRunStatusFailed {
t.Fatalf("unexpected status: %s", stored.Status)
}
if stored.ErrorMessage != "boom" {
t.Fatalf("unexpected error message: %s", stored.ErrorMessage)
}
var state model.CronTaskState
if err := db.Where("task_code = ?", "cron:demo").First(&state).Error; err != nil {
t.Fatalf("query cron state failed: %v", err)
}
if state.LastRunID != run.ID || state.LastStatus != model.TaskRunStatusFailed {
t.Fatalf("unexpected cron state: %#v", state)
}
if state.LastError != "boom" {
t.Fatalf("unexpected cron state error: %s", state.LastError)
}
var event model.TaskRunEvent
if err := db.Where("run_id = ? AND event_type = ?", run.ID, model.TaskEventFail).First(&event).Error; err != nil {
t.Fatalf("query fail event failed: %v", err)
}
var meta map[string]any
if err := json.Unmarshal([]byte(event.Meta), &meta); err != nil {
t.Fatalf("unmarshal fail meta failed: %v", err)
}
if meta["status"] != model.TaskRunStatusFailed {
t.Fatalf("unexpected fail meta: %#v", meta)
}
}
func TestRecorderFinishCancelWritesOperatorMeta(t *testing.T) {
db := newTaskCenterTestDB(t)
recorder := NewRecorderWithDB(db)
run, err := recorder.Enqueue(context.Background(), RunStart{
TaskCode: "demo:send",
Kind: model.TaskKindAsync,
Source: model.TaskSourceManual,
SourceID: "task-1",
TriggerUserID: 7,
TriggerAccount: "starter",
TriggerConfirm: "CONFIRM",
TriggerReason: "manual run",
})
if err != nil {
t.Fatalf("Enqueue returned error: %v", err)
}
if err := recorder.Finish(context.Background(), run, RunFinish{
Status: model.TaskRunStatusCanceled,
CanceledBy: 9,
CanceledByAccount: "operator",
CancelReason: "manual cancel",
}); err != nil {
t.Fatalf("Finish returned error: %v", err)
}
var event model.TaskRunEvent
if err := db.Where("run_id = ? AND event_type = ?", run.ID, model.TaskEventCancel).First(&event).Error; err != nil {
t.Fatalf("query cancel event failed: %v", err)
}
var meta map[string]any
if err := json.Unmarshal([]byte(event.Meta), &meta); err != nil {
t.Fatalf("unmarshal cancel meta failed: %v", err)
}
if meta["canceled_by_account"] != "operator" || meta["cancel_reason"] != "manual cancel" {
t.Fatalf("unexpected cancel meta: %#v", meta)
}
var enqueueEvent model.TaskRunEvent
if err := db.Where("run_id = ? AND event_type = ?", run.ID, model.TaskEventEnqueue).First(&enqueueEvent).Error; err != nil {
t.Fatalf("query enqueue event failed: %v", err)
}
var enqueueMeta map[string]any
if err := json.Unmarshal([]byte(enqueueEvent.Meta), &enqueueMeta); err != nil {
t.Fatalf("unmarshal enqueue meta failed: %v", err)
}
if enqueueMeta["trigger_account"] != "starter" || enqueueMeta["trigger_confirm"] != "CONFIRM" || enqueueMeta["trigger_reason"] != "manual run" {
t.Fatalf("unexpected enqueue meta: %#v", enqueueMeta)
}
}
func TestTaskRunEventsReturnsCreatedAtAscending(t *testing.T) {
db := newTaskCenterTestDB(t)
events := []model.TaskRunEvent{
{RunID: 7, EventType: model.TaskEventStart, Message: "start"},
{RunID: 7, EventType: model.TaskEventSuccess, Message: "success"},
}
for i := range events {
if err := db.Create(&events[i]).Error; err != nil {
t.Fatalf("create event failed: %v", err)
}
}
result, err := NewTaskCenterServiceWithDeps(TaskCenterServiceDeps{DB: db}).TaskRunEvents(7)
if err != nil {
t.Fatalf("TaskRunEvents returned error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 events, got %d", len(result))
}
if result[0].EventType != model.TaskEventStart || result[1].EventType != model.TaskEventSuccess {
t.Fatalf("unexpected event order: %#v", result)
}
}
func newTaskCenterTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
statements := []string{
`CREATE TABLE task_runs (
id integer primary key autoincrement,
created_at datetime,
updated_at datetime,
task_code text,
kind text,
source text,
source_id text,
queue text,
trigger_user_id integer,
trigger_account text,
status text,
attempt integer,
max_retry integer,
payload text,
error_message text,
started_at datetime,
finished_at datetime,
duration_ms real
)`,
`CREATE TABLE task_run_events (
id integer primary key autoincrement,
created_at datetime,
updated_at datetime,
run_id integer,
event_type text,
message text,
meta text
)`,
`CREATE TABLE cron_task_states (
id integer primary key autoincrement,
created_at datetime,
updated_at datetime,
task_code text unique,
cron_spec text,
last_run_id integer,
last_status text,
last_started_at datetime,
last_finished_at datetime,
next_run_at datetime,
last_error text
)`,
}
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("create test table failed: %v", err)
}
}
return db
}
================================================
FILE: internal/validator/binding.go
================================================
package validator
import (
"encoding/json"
"errors"
"regexp"
"strings"
"github.com/gin-gonic/gin"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
errcode "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
r "github.com/wannanbigpig/gin-layout/internal/pkg/response"
)
const (
eofErrorPattern = `^multipart:nextpart:eof$`
typeConvertErrorPattern = `parsing .*?: invalid syntax`
)
var (
eofRegex = regexp.MustCompile(eofErrorPattern)
typeConvertRegex = regexp.MustCompile(typeConvertErrorPattern)
)
// ResponseError 处理错误并返回给前端。
func ResponseError(c *gin.Context, err error) {
var errs validator.ValidationErrors
if errors.As(err, &errs) {
handleValidationError(c, errs)
} else {
handleBindingError(c, err)
}
}
func handleValidationError(c *gin.Context, errs validator.ValidationErrors) {
primary := validatorRuntime.translatorForRequest(c)
fallback := validatorRuntime.fallbackTranslator(primary)
for _, fieldErr := range errs {
message := translateFieldError(fieldErr, primary, fallback)
r.Resp().FailCode(c, errcode.InvalidParameter, message)
return
}
}
func translateFieldError(fieldErr validator.FieldError, primary, fallback ut.Translator) string {
if primary != nil {
if translated := fieldErr.Translate(primary); translated != "" && translated != fieldErr.Error() {
return translated
}
}
if fallback != nil {
if translated := fieldErr.Translate(fallback); translated != "" && translated != fieldErr.Error() {
return translated
}
}
return fieldErr.Error()
}
func handleBindingError(c *gin.Context, err error) {
var typeErr *json.UnmarshalTypeError
var syntaxErr *json.SyntaxError
switch {
case errors.As(err, &typeErr):
r.Resp().FailCode(c, errcode.InvalidParameter)
case errors.As(err, &syntaxErr):
r.Resp().FailCode(c, errcode.InvalidParameter)
default:
errStr := err.Error()
switch {
case isEOFError(errStr):
r.Resp().FailCode(c, errcode.InvalidParameter)
case isConvertError(errStr):
r.Resp().FailCode(c, errcode.InvalidParameter)
default:
r.Resp().FailCode(c, errcode.InvalidParameter)
}
}
}
func isEOFError(errStr string) bool {
if len(errStr) == 0 {
return false
}
if errStr[0] == ' ' || errStr[len(errStr)-1] == ' ' {
return eofRegex.MatchString(strings.TrimSpace(errStr))
}
return eofRegex.MatchString(errStr)
}
func isConvertError(errStr string) bool {
if len(errStr) == 0 {
return false
}
if errStr[0] == ' ' || errStr[len(errStr)-1] == ' ' {
return typeConvertRegex.MatchString(strings.TrimSpace(errStr))
}
return typeConvertRegex.MatchString(errStr)
}
// CheckParams 检查请求参数。
func CheckParams(c *gin.Context, obj interface{}, bindFunc func(obj interface{}) error) error {
if err := bindFunc(obj); err != nil {
ResponseError(c, err)
return err
}
return nil
}
// CheckQueryParams 检查 GET 请求的查询参数。
func CheckQueryParams(c *gin.Context, obj interface{}) error {
return CheckParams(c, obj, c.ShouldBindQuery)
}
// CheckPostParams 检查 POST 请求的参数。
func CheckPostParams(c *gin.Context, obj interface{}) error {
return CheckParams(c, obj, c.ShouldBind)
}
================================================
FILE: internal/validator/binding_i18n_test.go
================================================
package validator
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/wannanbigpig/gin-layout/internal/global"
)
type phonePayload struct {
Phone string `json:"phone" form:"phone" label:"手机号" binding:"required,phone_number"`
}
type validationResult struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
func TestValidationErrorUsesRequestLocale(t *testing.T) {
resetValidatorRuntimeForI18nTest(t)
if err := InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
zhMsg := validatePayloadAndReadMessage(t, "zh-CN")
if zhMsg != "手机号格式不正确" {
t.Fatalf("expected zh translation, got %q", zhMsg)
}
enMsg := validatePayloadAndReadMessage(t, "en-US")
if enMsg != "手机号 format is invalid" {
t.Fatalf("expected en translation, got %q", enMsg)
}
}
func TestValidationErrorFallsBackToDefaultTranslator(t *testing.T) {
resetValidatorRuntimeForI18nTest(t)
if err := InitValidatorTrans("invalid-locale"); err != nil {
t.Fatalf("init validator fallback: %v", err)
}
msg := validatePayloadAndReadMessage(t, "fr-FR")
if msg != "手机号格式不正确" {
t.Fatalf("expected fallback zh translation, got %q", msg)
}
}
func validatePayloadAndReadMessage(t *testing.T, locale string) string {
t.Helper()
gin.SetMode(gin.TestMode)
body := strings.NewReader(`{"phone":"123"}`)
req := httptest.NewRequest(http.MethodPost, "/demo", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", locale)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = req
ctx.Set(global.ContextKeyRequestStartTime, time.Now())
ctx.Set(global.ContextKeyRequestID, "validator-i18n")
ctx.Set(global.ContextKeyLocale, locale)
payload := &phonePayload{}
if err := CheckPostParams(ctx, payload); err == nil {
t.Fatal("expected validation error")
}
var result validationResult
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
return result.Msg
}
func resetValidatorRuntimeForI18nTest(t *testing.T) {
t.Helper()
validatorRuntime = newValidatorRuntime()
regexCache = sync.Map{}
}
================================================
FILE: internal/validator/form/admin_user.go
================================================
package form
type adminUserEditableFields struct {
Username *string `form:"username" json:"username" label:"用户名" binding:"omitempty,min=3,max=20,regexp=^[a-zA-Z0-9_]+$"`
Nickname *string `form:"nickname" json:"nickname" label:"昵称" binding:"omitempty"`
Password *string `form:"password" json:"password" label:"密码" binding:"omitempty,min=6,max=32"`
adminUserOptionalFields
}
type adminUserOptionalFields struct {
PhoneNumber *string `form:"phone_number" json:"phone_number" label:"手机号" binding:"omitempty,phone_number"`
CountryCode *string `form:"country_code" json:"country_code" label:"国家代码" binding:"omitempty"`
Email *string `form:"email" json:"email" label:"邮箱" binding:"omitempty,email"`
Status *uint8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"`
Avatar *string `form:"avatar" json:"avatar" label:"头像" binding:"omitempty"`
DeptIds *[]uint `form:"dept_ids" json:"dept_ids" label:"部门ID" binding:"omitempty,dive,gt=0"`
}
type CreateAdminUser struct {
Username *string `form:"username" json:"username" label:"用户名" binding:"required,min=3,max=20,regexp=^[a-zA-Z0-9_]+$"`
Nickname *string `form:"nickname" json:"nickname" label:"昵称" binding:"required"`
Password *string `form:"password" json:"password" label:"密码" binding:"required,min=6,max=32"`
adminUserOptionalFields
}
func NewCreateAdminUser() *CreateAdminUser {
return &CreateAdminUser{}
}
type UpdateAdminUser struct {
Id uint `form:"id" json:"id" label:"用户ID" binding:"required"`
adminUserEditableFields
}
func NewUpdateAdminUser() *UpdateAdminUser {
return &UpdateAdminUser{}
}
type AdminUserList struct {
Paginate
Email string `form:"email" json:"email" binding:"omitempty,email"`
UserName string `form:"username" json:"username" binding:"omitempty"`
Status *uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
PhoneNumber string `form:"phone_number" json:"phone_number" binding:"omitempty,phone_number"`
NickName string `form:"nickname" json:"nickname" binding:"omitempty"`
ID uint `form:"id" json:"id" binding:"omitempty"`
DeptId uint `form:"dept_id" json:"dept_id" binding:"omitempty"`
}
func NewAdminUserListQuery() *AdminUserList {
return &AdminUserList{}
}
type UpdateProfile struct {
Nickname *string `form:"nickname" json:"nickname" label:"昵称" binding:"omitempty"`
Password *string `form:"password" json:"password" label:"密码" binding:"omitempty,min=6,max=32"`
PhoneNumber *string `form:"phone_number" json:"phone_number" label:"手机号" binding:"omitempty,phone_number"`
CountryCode *string `form:"country_code" json:"country_code" label:"国家代码" binding:"omitempty"`
Email *string `form:"email" json:"email" label:"邮箱" binding:"omitempty,email"`
Avatar *string `form:"avatar" json:"avatar" label:"头像" binding:"omitempty"`
}
func NewUpdateProfile() *UpdateProfile {
return &UpdateProfile{}
}
type BindRole struct {
UserId uint `form:"user_id" json:"user_id" label:"用户ID" binding:"required"` // 验证规则:必填
RoleIds []uint `form:"role_ids" json:"role_ids" label:"角色ID" binding:"required,dive,gt=0"` // 验证规则:必填
}
// NewBindRole 绑定角色
func NewBindRole() *BindRole {
return &BindRole{}
}
================================================
FILE: internal/validator/form/admin_user_test.go
================================================
package form
import (
"encoding/json"
"testing"
)
func TestUpdateAdminUserDeptIDsDistinguishEmptyArrayFromAbsentField(t *testing.T) {
var withEmpty UpdateAdminUser
if err := json.Unmarshal([]byte(`{"id":1,"dept_ids":[]}`), &withEmpty); err != nil {
t.Fatalf("unmarshal with empty dept_ids: %v", err)
}
if withEmpty.DeptIds == nil {
t.Fatal("expected dept_ids empty array to keep non-nil pointer")
}
if len(*withEmpty.DeptIds) != 0 {
t.Fatalf("expected empty dept_ids, got %#v", *withEmpty.DeptIds)
}
var withoutField UpdateAdminUser
if err := json.Unmarshal([]byte(`{"id":1}`), &withoutField); err != nil {
t.Fatalf("unmarshal without dept_ids: %v", err)
}
if withoutField.DeptIds != nil {
t.Fatalf("expected absent dept_ids to stay nil, got %#v", *withoutField.DeptIds)
}
}
func TestCreateAdminUserRequiresPassword(t *testing.T) {
err := bindJSONBody(t, `{"username":"admin_user","nickname":"管理员"}`, NewCreateAdminUser())
if err == nil {
t.Fatal("expected missing password to fail validation")
}
}
func TestCreateAdminUserAllowsRequiredFields(t *testing.T) {
err := bindJSONBody(t, `{"username":"admin_user","nickname":"管理员","password":"123456"}`, NewCreateAdminUser())
if err != nil {
t.Fatalf("expected required create fields to pass validation, got %v", err)
}
}
================================================
FILE: internal/validator/form/auth.go
================================================
package form
type LoginAuth struct {
UserName string `form:"username" json:"username" label:"用户名" binding:"required,min=3,max=16"` // 验证规则:必填,最小长度为3
PassWord string `form:"password" json:"password" label:"密码" binding:"required,min=6,max=18"` // 验证规则:必填,最小长度为6
Captcha string `form:"captcha" json:"captcha" label:"验证码" binding:"required"`
CaptchaID string `form:"captcha_id" json:"captcha_id" binding:"required"`
}
// NewLoginForm 创建登录表单。
func NewLoginForm() *LoginAuth {
return &LoginAuth{}
}
================================================
FILE: internal/validator/form/common.go
================================================
package form
type Paginate struct {
Page int `form:"page" json:"page" binding:"omitempty,gt=0"` // 必填,页面值>=1
PerPage int `form:"per_page" json:"per_page" binding:"omitempty,gt=0"` // 必填,每页条数值>=1
}
// NewPaginate 创建一个新的分页查询
func NewPaginate() *Paginate {
return &Paginate{}
}
type ID struct {
ID uint `form:"id" json:"id" binding:"required"`
}
// NewIdForm ID表单
func NewIdForm() *ID {
return &ID{}
}
================================================
FILE: internal/validator/form/dept.go
================================================
package form
type deptPayload struct {
Name string `form:"name" json:"name" label:"部门名称" binding:"required"`
Pid uint `form:"pid" json:"pid" label:"上级部门" binding:"omitempty"`
Description string `form:"description" json:"description" label:"描述" binding:"omitempty"`
Sort uint `form:"sort" json:"sort" label:"排序" binding:"omitempty"`
}
type CreateDept struct {
deptPayload
}
func NewCreateDeptForm() *CreateDept {
return &CreateDept{}
}
type UpdateDept struct {
Id uint `form:"id" json:"id" binding:"required"`
deptPayload
}
func NewUpdateDeptForm() *UpdateDept {
return &UpdateDept{}
}
func (f *UpdateDept) GetIDPointer() *uint {
return &f.Id
}
type ListDept struct {
Paginate
Name string `form:"name" json:"name" label:"部门名称" binding:"omitempty"` // 关键字
Pid *uint `form:"pid" json:"pid" label:"上级部门" binding:"omitempty"`
}
// NewDeptListQuery 创建部门列表查询表单。
func NewDeptListQuery() *ListDept {
return &ListDept{}
}
// DeptBindRole 部门绑定角色表单
type DeptBindRole struct {
DeptId uint `form:"dept_id" json:"dept_id" label:"部门 ID" binding:"required"` // 验证规则:必填
RoleIds []uint `form:"role_ids" json:"role_ids" label:"角色 ID" binding:"required,dive,gt=0"` // 验证规则:必填
}
// NewDeptBindRole 部门绑定角色
func NewDeptBindRole() *DeptBindRole {
return &DeptBindRole{}
}
================================================
FILE: internal/validator/form/file_resource.go
================================================
package form
// FileResourceList 文件资源列表查询参数。
type FileResourceList struct {
Paginate
OriginName string `form:"origin_name" json:"origin_name" binding:"omitempty"`
UUID string `form:"uuid" json:"uuid" binding:"omitempty"`
MimeType string `form:"mime_type" json:"mime_type" binding:"omitempty"`
FileType string `form:"file_type" json:"file_type" binding:"omitempty,oneof=image pdf word excel ppt archive text audio video"`
IsPublic *uint8 `form:"is_public" json:"is_public" binding:"omitempty,oneof=0 1"`
FolderID *uint `form:"folder_id" json:"folder_id" binding:"omitempty"`
IncludeSubfolder uint8 `form:"include_subfolder" json:"include_subfolder" binding:"omitempty,oneof=0 1"`
StorageDriver string `form:"storage_driver" json:"storage_driver" binding:"omitempty,oneof=local aliyun_oss s3"`
StorageStatus string `form:"storage_status" json:"storage_status" binding:"omitempty,oneof=stored delete_failed"`
IsReferenced *uint8 `form:"is_referenced" json:"is_referenced" binding:"omitempty,oneof=0 1"`
IsDeleted *uint8 `form:"is_deleted" json:"is_deleted" binding:"omitempty,oneof=0 1"`
UID uint `form:"uid" json:"uid" binding:"omitempty,gt=0"`
StartTime string `form:"start_time" json:"start_time" binding:"omitempty"`
EndTime string `form:"end_time" json:"end_time" binding:"omitempty"`
}
func NewFileResourceListQuery() *FileResourceList {
return &FileResourceList{}
}
// FileResourceID 文件资源 ID 参数。
type FileResourceID struct {
ID uint `form:"id" json:"id" binding:"required,gt=0"`
DeletedReason string `form:"deleted_reason" json:"deleted_reason" binding:"omitempty,max=255"`
}
func NewFileResourceIDForm() *FileResourceID {
return &FileResourceID{}
}
type FileFolderCreate struct {
ParentID uint `form:"parent_id" json:"parent_id" binding:"omitempty"`
Name string `form:"name" json:"name" binding:"required,max=120"`
}
func NewFileFolderCreateForm() *FileFolderCreate {
return &FileFolderCreate{}
}
type FileFolderUpdate struct {
ID uint `form:"id" json:"id" binding:"required,gt=0"`
Name string `form:"name" json:"name" binding:"required,max=120"`
}
func NewFileFolderUpdateForm() *FileFolderUpdate {
return &FileFolderUpdate{}
}
type FileFolderDelete struct {
ID uint `form:"id" json:"id" binding:"required,gt=0"`
}
func NewFileFolderDeleteForm() *FileFolderDelete {
return &FileFolderDelete{}
}
type FileFolderMove struct {
ID uint `form:"id" json:"id" binding:"required,gt=0"`
ParentID uint `form:"parent_id" json:"parent_id" binding:"omitempty"`
TargetParentID uint `form:"target_parent_id" json:"target_parent_id" binding:"omitempty"`
}
func NewFileFolderMoveForm() *FileFolderMove {
return &FileFolderMove{}
}
type FileMove struct {
IDs []uint `form:"ids" json:"ids" binding:"required,dive,gt=0"`
FolderID uint `form:"folder_id" json:"folder_id" binding:"omitempty"`
}
func NewFileMoveForm() *FileMove {
return &FileMove{}
}
type FileLocalUpload struct {
FolderID uint `form:"folder_id" json:"folder_id" binding:"omitempty"`
IsPublic uint8 `form:"is_public" json:"is_public" binding:"omitempty,oneof=0 1"`
UploadScene string `form:"upload_scene" json:"upload_scene" binding:"omitempty,max=60"`
}
func NewFileLocalUploadForm() *FileLocalUpload {
return &FileLocalUpload{IsPublic: 1}
}
type FileUploadCredential struct {
FolderID uint `form:"folder_id" json:"folder_id" binding:"omitempty"`
FileName string `form:"file_name" json:"file_name" binding:"omitempty,max=255"`
OriginName string `form:"origin_name" json:"origin_name" binding:"omitempty,max=255"`
MimeType string `form:"mime_type" json:"mime_type" binding:"omitempty,max=100"`
Size int64 `form:"size" json:"size" binding:"omitempty,gte=0"`
Hash string `form:"hash" json:"hash" binding:"omitempty,len=64"`
IsPublic uint8 `form:"is_public" json:"is_public" binding:"omitempty,oneof=0 1"`
UploadScene string `form:"upload_scene" json:"upload_scene" binding:"omitempty,max=60"`
Driver string `form:"driver" json:"driver" binding:"omitempty,oneof=local aliyun_oss s3"`
}
func NewFileUploadCredentialForm() *FileUploadCredential {
return &FileUploadCredential{IsPublic: 1}
}
type FileUploadComplete struct {
FolderID uint `form:"folder_id" json:"folder_id" binding:"omitempty"`
Reuse bool `form:"reuse" json:"reuse" binding:"omitempty"`
FileObjectID uint `form:"file_object_id" json:"file_object_id" binding:"omitempty,gt=0"`
OriginName string `form:"origin_name" json:"origin_name" binding:"required,max=255"`
DisplayName string `form:"display_name" json:"display_name" binding:"omitempty,max=255"`
Name string `form:"name" json:"name" binding:"omitempty,max=255"`
Size uint `form:"size" json:"size" binding:"omitempty"`
Ext string `form:"ext" json:"ext" binding:"omitempty,max=20"`
Hash string `form:"hash" json:"hash" binding:"omitempty,max=64"`
UUID string `form:"uuid" json:"uuid" binding:"omitempty,len=32"`
MimeType string `form:"mime_type" json:"mime_type" binding:"omitempty,max=100"`
FileType string `form:"file_type" json:"file_type" binding:"omitempty,oneof=image pdf word excel ppt archive text audio video other"`
IsPublic uint8 `form:"is_public" json:"is_public" binding:"omitempty,oneof=0 1"`
StorageDriver string `form:"storage_driver" json:"storage_driver" binding:"omitempty,oneof=aliyun_oss s3"`
Driver string `form:"driver" json:"driver" binding:"omitempty,oneof=aliyun_oss s3"`
Bucket string `form:"bucket" json:"bucket" binding:"omitempty,max=128"`
ObjectKey string `form:"object_key" json:"object_key" binding:"omitempty,max=512"`
ETag string `form:"etag" json:"etag" binding:"omitempty,max=128"`
UploadScene string `form:"upload_scene" json:"upload_scene" binding:"omitempty,max=60"`
}
func NewFileUploadCompleteForm() *FileUploadComplete {
return &FileUploadComplete{IsPublic: 1}
}
type FileReferenceList struct {
Paginate
ID uint `form:"id" json:"id" binding:"omitempty,gt=0"`
FileID uint `form:"file_id" json:"file_id" binding:"omitempty,gt=0"`
UUID string `form:"uuid" json:"uuid" binding:"omitempty"`
OwnerType string `form:"owner_type" json:"owner_type" binding:"omitempty,max=60"`
OwnerID uint `form:"owner_id" json:"owner_id" binding:"omitempty,gt=0"`
OwnerField string `form:"owner_field" json:"owner_field" binding:"omitempty,max=60"`
}
func NewFileReferenceListQuery() *FileReferenceList {
return &FileReferenceList{}
}
================================================
FILE: internal/validator/form/file_resource_test.go
================================================
package form
import "testing"
func TestFileResourceListRejectsInvalidIsPublic(t *testing.T) {
err := bindJSONBody(t, `{"is_public":2}`, NewFileResourceListQuery())
if err == nil {
t.Fatal("expected invalid is_public to fail validation")
}
}
func TestFileResourceListRejectsInvalidFileType(t *testing.T) {
err := bindJSONBody(t, `{"file_type":"exe"}`, NewFileResourceListQuery())
if err == nil {
t.Fatal("expected invalid file_type to fail validation")
}
}
func TestFileResourceIDRejectsZero(t *testing.T) {
err := bindJSONBody(t, `{"id":0}`, NewFileResourceIDForm())
if err == nil {
t.Fatal("expected zero id to fail validation")
}
}
func TestFileResourceListRejectsInvalidStorageDriver(t *testing.T) {
err := bindJSONBody(t, `{"storage_driver":"ftp"}`, NewFileResourceListQuery())
if err == nil {
t.Fatal("expected invalid storage_driver to fail validation")
}
}
func TestFileResourceListAllowsStorageFilters(t *testing.T) {
err := bindJSONBody(t, `{"storage_driver":"s3","storage_status":"stored","is_referenced":1,"is_deleted":0}`, NewFileResourceListQuery())
if err != nil {
t.Fatalf("expected storage filters to pass validation, got %v", err)
}
}
func TestFileReferenceListAllowsIDAlias(t *testing.T) {
err := bindJSONBody(t, `{"id":1}`, NewFileReferenceListQuery())
if err != nil {
t.Fatalf("expected id alias to pass validation, got %v", err)
}
}
func TestFileMoveRejectsZeroID(t *testing.T) {
err := bindJSONBody(t, `{"ids":[1,0],"folder_id":2}`, NewFileMoveForm())
if err == nil {
t.Fatal("expected zero file id to fail validation")
}
}
func TestFileUploadCompleteRejectsLocalDriver(t *testing.T) {
err := bindJSONBody(t, `{"origin_name":"a.txt","storage_driver":"local","object_key":"a.txt"}`, NewFileUploadCompleteForm())
if err == nil {
t.Fatal("expected local direct complete to fail validation")
}
}
func TestFileResourceListAllowsFolderFilters(t *testing.T) {
err := bindJSONBody(t, `{"folder_id":1,"include_subfolder":1}`, NewFileResourceListQuery())
if err != nil {
t.Fatalf("expected folder filters to pass validation, got %v", err)
}
}
================================================
FILE: internal/validator/form/id_array_validation_test.go
================================================
package form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
validatorx "github.com/wannanbigpig/gin-layout/internal/validator"
)
func bindJSONBody(t *testing.T, body string, payload any) error {
t.Helper()
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
return ctx.ShouldBind(payload)
}
func TestDeptBindRoleRejectsZeroRoleID(t *testing.T) {
payload := NewDeptBindRole()
err := bindJSONBody(t, `{"dept_id":1,"role_ids":[0]}`, payload)
if err == nil {
t.Fatal("expected role_ids containing 0 to fail validation")
}
}
func TestAdminUserBindRoleRejectsZeroRoleID(t *testing.T) {
payload := NewBindRole()
err := bindJSONBody(t, `{"user_id":1,"role_ids":[0]}`, payload)
if err == nil {
t.Fatal("expected role_ids containing 0 to fail validation")
}
}
func TestCreateRoleRejectsZeroMenuID(t *testing.T) {
payload := NewCreateRoleForm()
err := bindJSONBody(t, `{"name":"测试角色","menu_list":[0]}`, payload)
if err == nil {
t.Fatal("expected menu_list containing 0 to fail validation")
}
}
func TestCreateMenuRejectsZeroAPIID(t *testing.T) {
payload := NewCreateMenuForm()
err := bindJSONBody(t, `{"title_i18n":{"zh-CN":"测试菜单"},"sort":1,"type":1,"api_list":[0]}`, payload)
if err == nil {
t.Fatal("expected api_list containing 0 to fail validation")
}
}
func TestIDArraysAllowPositiveValues(t *testing.T) {
if err := bindJSONBody(t, `{"dept_id":1,"role_ids":[1]}`, NewDeptBindRole()); err != nil {
t.Fatalf("expected positive dept role_ids to pass, got %v", err)
}
if err := bindJSONBody(t, `{"user_id":1,"role_ids":[1]}`, NewBindRole()); err != nil {
t.Fatalf("expected positive user role_ids to pass, got %v", err)
}
if err := bindJSONBody(t, `{"name":"测试角色","menu_list":[1]}`, NewCreateRoleForm()); err != nil {
t.Fatalf("expected positive menu_list to pass, got %v", err)
}
if err := bindJSONBody(t, `{"title_i18n":{"zh-CN":"测试菜单"},"sort":1,"type":1,"api_list":[1]}`, NewCreateMenuForm()); err != nil {
t.Fatalf("expected positive api_list to pass, got %v", err)
}
}
================================================
FILE: internal/validator/form/login_log.go
================================================
package form
// LoginLogList 登录日志列表查询表单
type AdminLoginLogList struct {
Paginate
Username string `form:"username" json:"username" binding:"omitempty"` // 登录账号
LoginStatus *int8 `form:"login_status" json:"login_status" binding:"omitempty,oneof=0 1"` // 登录状态:1=成功, 0=失败
IP string `form:"ip" json:"ip" binding:"omitempty"` // 登录IP
StartTime string `form:"start_time" json:"start_time" binding:"omitempty"` // 开始时间
EndTime string `form:"end_time" json:"end_time" binding:"omitempty"` // 结束时间
}
// NewAdminLoginLogListQuery 创建登录日志列表查询表单
func NewAdminLoginLogListQuery() *AdminLoginLogList {
return &AdminLoginLogList{}
}
================================================
FILE: internal/validator/form/menu.go
================================================
package form
type menuPayload struct {
Icon string `form:"icon" json:"icon" label:"图标" binding:"omitempty,max=255"`
TitleI18n map[string]string `form:"title_i18n" json:"title_i18n" label:"多语言标题" binding:"required"`
Code string `form:"code" json:"code" label:"前端按钮权限标识" binding:"required_if=Type 3"`
Path string `form:"path" json:"path" label:"路由地址" binding:"omitempty"`
Name string `form:"name" json:"name" label:"前端路由名称" binding:"required_if_exist=Type 2"`
AnimateEnter string `form:"animate_enter" json:"animate_enter" label:"进入动画,动画类参考URL_ADDRESS" binding:"omitempty"`
AnimateLeave string `form:"animate_leave" json:"animate_leave" label:"离开动画,动画类参考URL_ADDRESS" binding:"omitempty"`
AnimateDuration float32 `form:"animate_duration" json:"animate_duration" label:"动画持续时间" binding:"omitempty"`
IsShow uint8 `form:"is_show" json:"is_show" label:"是否显示" binding:"omitempty,oneof=0 1"` // 0 否 1 是
IsAuth uint8 `form:"is_auth" json:"is_auth" label:"是否需要授权" binding:"omitempty,oneof=0 1"` // 0 否 1 是
IsNewWindow uint8 `form:"is_new_window" json:"is_new_window" label:"新窗口打开" binding:"omitempty,oneof=0 1"` // 0 否 1 是
Sort uint `form:"sort" json:"sort" label:"排序" binding:"required"`
Type uint8 `form:"type" json:"type" label:"菜单类型" binding:"required,oneof=1 2 3"` // 1 目录 2 菜单 3 按钮
Pid uint `form:"pid" json:"pid" label:"上级菜单" binding:"omitempty"`
Description string `form:"description" json:"description" label:"描述" binding:"omitempty"`
ApiList []uint `form:"api_list" json:"api_list" label:"接口列表" binding:"omitempty,dive,gt=0"`
Component string `form:"component" json:"component" label:"前端组件路径"`
Status uint8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"` // 0 禁用 1 启用
Redirect string `form:"redirect" json:"redirect" label:"重定向地址" binding:"omitempty"`
IsExternalLinks uint8 `form:"is_external_links" json:"is_external_links" label:"是否外链" binding:"omitempty,oneof=0 1"`
}
type CreateMenu struct {
menuPayload
}
func NewCreateMenuForm() *CreateMenu {
return &CreateMenu{}
}
type UpdateMenu struct {
Id uint `form:"id" json:"id" binding:"required"`
menuPayload
}
func NewUpdateMenuForm() *UpdateMenu {
return &UpdateMenu{}
}
func (f *UpdateMenu) GetIDPointer() *uint {
return &f.Id
}
type ListMenu struct {
Paginate
Keyword string `form:"keyword" json:"keyword" binding:"omitempty"` // 关键字
IsAuth *int8 `form:"is_auth" json:"is_auth" binding:"omitempty,oneof=0 1"` // 是否授权
Status *int8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"` // 状态
}
// NewMenuListQuery 创建菜单列表查询表单。
func NewMenuListQuery() *ListMenu {
return &ListMenu{}
}
================================================
FILE: internal/validator/form/menu_test.go
================================================
package form
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
validatorx "github.com/wannanbigpig/gin-layout/internal/validator"
)
func TestListMenuAllowsBinaryEnumFilters(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?is_auth=1&status=0", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewMenuListQuery()
if err := ctx.ShouldBindQuery(payload); err != nil {
t.Fatalf("expected is_auth/status in [0,1] to pass validation, got %v", err)
}
}
func TestListMenuRejectsInvalidIsAuth(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?is_auth=2", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewMenuListQuery()
if err := ctx.ShouldBindQuery(payload); err == nil {
t.Fatal("expected is_auth=2 to fail validation")
}
}
func TestListMenuRejectsInvalidStatus(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?status=2", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewMenuListQuery()
if err := ctx.ShouldBindQuery(payload); err == nil {
t.Fatal("expected status=2 to fail validation")
}
}
================================================
FILE: internal/validator/form/permission.go
================================================
package form
type apiBasePayload struct {
Name string `form:"name" json:"name" binding:"required,max=60"` // 权限名称
Description string `form:"description" json:"description" binding:"omitempty"` // 描述
IsAuth *int8 `form:"is_auth" json:"is_auth" binding:"required,api_auth_mode"` // 接口鉴权模式
Sort int32 `form:"sort" json:"sort" binding:"required"` // 排序
}
type CreatePermission struct {
apiBasePayload
Method string `form:"method" json:"method" binding:"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH" label:"接口请求方法"` // 接口请求方法
Route string `form:"route" json:"route" binding:"omitempty"` // 接口路由
Func string `form:"func" json:"func" binding:"omitempty"` // 接口方法
FuncPath string `form:"func_path" json:"func_path" binding:"omitempty"` // 接口方法
}
func NewCreateApiForm() *CreatePermission {
return &CreatePermission{}
}
type UpdatePermission struct {
Id uint `form:"id" json:"id" binding:"required"` // id
apiBasePayload
}
func NewUpdateApiForm() *UpdatePermission {
return &UpdatePermission{}
}
func (f *UpdatePermission) GetIDPointer() *uint {
return &f.Id
}
type ListPermission struct {
Paginate
Name string `form:"name" json:"name" binding:"omitempty,max=60"` // 权限名称
Method string `form:"method" json:"method" binding:"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH" label:"接口请求方法"` // 接口请求方法
Route string `form:"route" json:"route" binding:"omitempty"` // 接口路由
Keyword string `form:"keyword" json:"keyword" binding:"omitempty"` // 关键字
IsAuth *int8 `form:"is_auth" json:"is_auth" binding:"omitempty,api_auth_mode"` // 接口鉴权模式
IsEffective *int8 `form:"is_effective" json:"is_effective" binding:"omitempty,oneof=0 1"` // 是否授权
}
// NewListApiQuery 创建 API 列表查询表单。
func NewListApiQuery() *ListPermission {
return &ListPermission{}
}
================================================
FILE: internal/validator/form/permission_test.go
================================================
package form
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
validatorx "github.com/wannanbigpig/gin-layout/internal/validator"
)
func TestUpdatePermissionAllowsThreeStateIsAuth(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
body := `{"id":1,"name":"route-authz","description":"test","is_auth":2,"sort":100}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewUpdateApiForm()
if err := ctx.ShouldBind(payload); err != nil {
t.Fatalf("expected is_auth=2 to pass validation, got %v", err)
}
}
func TestListPermissionAllowsThreeStateIsAuth(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?is_auth=2", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewListApiQuery()
if err := ctx.ShouldBindQuery(payload); err != nil {
t.Fatalf("expected list is_auth=2 to pass validation, got %v", err)
}
}
func TestUpdatePermissionRejectsUnknownIsAuth(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
body := `{"id":1,"name":"route-invalid","is_auth":3,"sort":100}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewUpdateApiForm()
if err := ctx.ShouldBind(payload); err == nil {
t.Fatal("expected is_auth=3 to fail validation")
}
}
================================================
FILE: internal/validator/form/request_log.go
================================================
package form
// RequestLogList 请求日志列表查询表单
type RequestLogList struct {
Paginate
OperatorID uint `form:"operator_id" json:"operator_id" binding:"omitempty"` // 操作ID(用户ID)
OperatorAccount string `form:"operator_account" json:"operator_account" binding:"omitempty"` // 操作账号
OperationStatus *int `form:"operation_status" json:"operation_status" binding:"omitempty,oneof=0 1"` // 操作状态:0=成功,1=失败
IsHighRisk *uint8 `form:"is_high_risk" json:"is_high_risk" binding:"omitempty,oneof=0 1"` // 是否高危操作
Method string `form:"method" json:"method" binding:"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH"` // HTTP请求方法
BaseURL string `form:"base_url" json:"base_url" binding:"omitempty"` // 请求基础URL
OperationName string `form:"operation_name" json:"operation_name" binding:"omitempty"` // 操作接口
IP string `form:"ip" json:"ip" binding:"omitempty"` // 操作IP
StartTime string `form:"start_time" json:"start_time" binding:"omitempty"` // 开始时间
EndTime string `form:"end_time" json:"end_time" binding:"omitempty"` // 结束时间
}
// NewRequestLogListQuery 创建请求日志列表查询表单
func NewRequestLogListQuery() *RequestLogList {
return &RequestLogList{}
}
// RequestLogExport 请求日志导出查询参数。
type RequestLogExport struct {
OperatorID uint `form:"operator_id" json:"operator_id" binding:"omitempty"`
OperatorAccount string `form:"operator_account" json:"operator_account" binding:"omitempty"`
OperationStatus *int `form:"operation_status" json:"operation_status" binding:"omitempty,oneof=0 1"`
IsHighRisk *uint8 `form:"is_high_risk" json:"is_high_risk" binding:"omitempty,oneof=0 1"`
Method string `form:"method" json:"method" binding:"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH"`
BaseURL string `form:"base_url" json:"base_url" binding:"omitempty"`
OperationName string `form:"operation_name" json:"operation_name" binding:"omitempty"`
IP string `form:"ip" json:"ip" binding:"omitempty"`
StartTime string `form:"start_time" json:"start_time" binding:"omitempty"`
EndTime string `form:"end_time" json:"end_time" binding:"omitempty"`
Limit int `form:"limit" json:"limit" binding:"omitempty,min=1,max=5000"`
}
func NewRequestLogExportQuery() *RequestLogExport {
return &RequestLogExport{Limit: 1000}
}
// RequestLogMaskConfigForm 请求日志脱敏配置。
type RequestLogMaskConfigForm struct {
Common []string `form:"common" json:"common" binding:"omitempty,dive,max=64"`
RequestHeader []string `form:"request_header" json:"request_header" binding:"omitempty,dive,max=64"`
RequestBody []string `form:"request_body" json:"request_body" binding:"omitempty,dive,max=64"`
ResponseHeader []string `form:"response_header" json:"response_header" binding:"omitempty,dive,max=64"`
ResponseBody []string `form:"response_body" json:"response_body" binding:"omitempty,dive,max=64"`
}
func NewRequestLogMaskConfigForm() *RequestLogMaskConfigForm {
return &RequestLogMaskConfigForm{}
}
================================================
FILE: internal/validator/form/request_log_test.go
================================================
package form
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
validatorx "github.com/wannanbigpig/gin-layout/internal/validator"
)
func TestRequestLogListAllowsKnownHTTPMethod(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?method=GET", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewRequestLogListQuery()
if err := ctx.ShouldBindQuery(payload); err != nil {
t.Fatalf("expected method=GET to pass validation, got %v", err)
}
}
func TestRequestLogListRejectsUnknownHTTPMethod(t *testing.T) {
if err := validatorx.InitValidatorTrans("zh"); err != nil {
t.Fatalf("init validator: %v", err)
}
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?method=TRACE", nil)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = req
payload := NewRequestLogListQuery()
if err := ctx.ShouldBindQuery(payload); err == nil {
t.Fatal("expected method=TRACE to fail validation")
}
}
================================================
FILE: internal/validator/form/role.go
================================================
package form
type RoleList struct {
Paginate
Status *int8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
Name string `form:"name" json:"name" binding:"omitempty"`
Pid *uint `form:"pid" json:"pid" binding:"omitempty"`
}
// NewRoleListQuery 初始化查询参数
func NewRoleListQuery() *RoleList {
return &RoleList{}
}
type rolePayload struct {
Code string `form:"code" json:"code" binding:"omitempty,max=60"`
Name string `form:"name" json:"name" binding:"required"`
Description string `form:"description" json:"description" binding:"omitempty"`
Status uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
Pid uint `form:"pid" json:"pid" binding:"omitempty"`
Sort uint `form:"sort" json:"sort" binding:"omitempty"`
MenuList []uint `form:"menu_ids" json:"menu_list" binding:"omitempty,dive,gt=0"`
}
type CreateRole struct {
rolePayload
}
func NewCreateRoleForm() *CreateRole {
return &CreateRole{}
}
type UpdateRole struct {
Id uint `form:"id" json:"id" binding:"required"`
rolePayload
}
func NewUpdateRoleForm() *UpdateRole {
return &UpdateRole{}
}
func (f *UpdateRole) GetIDPointer() *uint {
return &f.Id
}
================================================
FILE: internal/validator/form/role_test.go
================================================
package form
import "testing"
func TestRoleStatusUsesBinaryEnum(t *testing.T) {
if err := bindJSONBody(t, `{"name":"审计员","status":0}`, NewCreateRoleForm()); err != nil {
t.Fatalf("expected status=0 to pass validation, got %v", err)
}
if err := bindJSONBody(t, `{"name":"审计员","status":1}`, NewCreateRoleForm()); err != nil {
t.Fatalf("expected status=1 to pass validation, got %v", err)
}
if err := bindJSONBody(t, `{"name":"审计员","status":2}`, NewCreateRoleForm()); err == nil {
t.Fatal("expected status=2 to fail validation")
}
}
================================================
FILE: internal/validator/form/session.go
================================================
package form
// SessionList 在线会话列表查询参数。
type SessionList struct {
Paginate
UID uint `form:"uid" json:"uid" binding:"omitempty,gt=0"`
Username string `form:"username" json:"username" binding:"omitempty"`
IP string `form:"ip" json:"ip" binding:"omitempty"`
IsRevoked *uint8 `form:"is_revoked" json:"is_revoked" binding:"omitempty,oneof=0 1"`
StartTime string `form:"start_time" json:"start_time" binding:"omitempty"`
EndTime string `form:"end_time" json:"end_time" binding:"omitempty"`
}
func NewSessionListQuery() *SessionList {
return &SessionList{}
}
// SessionRevoke 撤销在线会话参数。
type SessionRevoke struct {
ID uint `form:"id" json:"id" binding:"required,gt=0"`
Reason string `form:"reason" json:"reason" binding:"omitempty,max=255"`
}
func NewSessionRevokeForm() *SessionRevoke {
return &SessionRevoke{}
}
================================================
FILE: internal/validator/form/session_test.go
================================================
package form
import "testing"
func TestSessionListRejectsInvalidIsRevoked(t *testing.T) {
err := bindJSONBody(t, `{"is_revoked":2}`, NewSessionListQuery())
if err == nil {
t.Fatal("expected invalid is_revoked to fail validation")
}
}
func TestSessionRevokeRejectsZeroID(t *testing.T) {
err := bindJSONBody(t, `{"id":0}`, NewSessionRevokeForm())
if err == nil {
t.Fatal("expected zero id to fail validation")
}
}
================================================
FILE: internal/validator/form/storage_config.go
================================================
package form
import "github.com/wannanbigpig/gin-layout/internal/filestorage"
type StorageConfigPayload struct {
ActiveDriver string `form:"active_driver" json:"active_driver" label:"存储驱动" binding:"required,oneof=local aliyun_oss s3"`
Config filestorage.Config `form:"config" json:"config" label:"存储配置" binding:"required"`
}
func NewStorageConfigPayload() *StorageConfigPayload {
return &StorageConfigPayload{}
}
================================================
FILE: internal/validator/form/storage_config_test.go
================================================
package form
import "testing"
func TestStorageConfigRejectsInvalidDriver(t *testing.T) {
err := bindJSONBody(t, `{"active_driver":"ftp","config":{}}`, NewStorageConfigPayload())
if err == nil {
t.Fatal("expected invalid active_driver to fail validation")
}
}
func TestStorageConfigAllowsLocalDriver(t *testing.T) {
err := bindJSONBody(t, `{"active_driver":"local","config":{"signed_url_ttl_seconds":300,"max_file_size_mb":10}}`, NewStorageConfigPayload())
if err != nil {
t.Fatalf("expected local storage config to pass validation, got %v", err)
}
}
================================================
FILE: internal/validator/form/sys_config.go
================================================
package form
type SysConfigList struct {
Paginate
ConfigKey string `form:"config_key" json:"config_key" binding:"omitempty,max=100"`
ConfigName string `form:"config_name" json:"config_name" binding:"omitempty,max=100"`
GroupCode string `form:"group_code" json:"group_code" binding:"omitempty,max=60"`
ValueType string `form:"value_type" json:"value_type" binding:"omitempty,oneof=string number bool json"`
Status *uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
IsVisible *uint8 `form:"is_visible" json:"is_visible" binding:"omitempty,oneof=0 1"`
ManageTab string `form:"manage_tab" json:"manage_tab" binding:"omitempty,max=60"`
IncludeHidden *uint8 `form:"include_hidden" json:"include_hidden" binding:"omitempty,oneof=0 1"`
}
type SysConfigPayload struct {
ConfigKey string `form:"config_key" json:"config_key" label:"参数键名" binding:"required,max=100"`
ConfigNameI18n map[string]string `form:"config_name_i18n" json:"config_name_i18n" label:"参数名称多语言" binding:"required"`
ConfigValue string `form:"config_value" json:"config_value" label:"参数值" binding:"omitempty"`
ValueType string `form:"value_type" json:"value_type" label:"值类型" binding:"required,oneof=string number bool json"`
GroupCode string `form:"group_code" json:"group_code" label:"参数分组" binding:"omitempty,max=60"`
IsSensitive *uint8 `form:"is_sensitive" json:"is_sensitive" label:"是否敏感" binding:"omitempty,oneof=0 1"`
IsVisible *uint8 `form:"is_visible" json:"is_visible" label:"是否展示" binding:"omitempty,oneof=0 1"`
ManageTab string `form:"manage_tab" json:"manage_tab" label:"专属配置Tab" binding:"omitempty,max=60"`
Status *uint8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"`
Sort uint `form:"sort" json:"sort" label:"排序" binding:"omitempty"`
Remark string `form:"remark" json:"remark" label:"备注" binding:"omitempty,max=255"`
}
type CreateSysConfig struct {
SysConfigPayload
}
type UpdateSysConfig struct {
Id uint `form:"id" json:"id" label:"参数ID" binding:"required"`
SysConfigPayload
}
type SysConfigKeyQuery struct {
ConfigKey string `form:"config_key" json:"config_key" label:"参数键名" binding:"required,max=100"`
}
func NewSysConfigListQuery() *SysConfigList {
return &SysConfigList{}
}
func NewCreateSysConfigForm() *CreateSysConfig {
return &CreateSysConfig{}
}
func NewUpdateSysConfigForm() *UpdateSysConfig {
return &UpdateSysConfig{}
}
func NewSysConfigKeyQuery() *SysConfigKeyQuery {
return &SysConfigKeyQuery{}
}
================================================
FILE: internal/validator/form/sys_config_test.go
================================================
package form
import "testing"
func TestCreateSysConfigAllowsValidValueTypes(t *testing.T) {
cases := []string{
`{"config_key":"feature.demo","config_name_i18n":{"zh-CN":"演示开关","en-US":"Feature Toggle"},"config_value":"true","value_type":"bool","status":1}`,
`{"config_key":"number.demo","config_name_i18n":{"zh-CN":"数字参数"},"config_value":"10.5","value_type":"number","status":0}`,
`{"config_key":"json.demo","config_name_i18n":{"zh-CN":"JSON参数"},"config_value":"{\"a\":1}","value_type":"json","status":1}`,
}
for _, body := range cases {
if err := bindJSONBody(t, body, NewCreateSysConfigForm()); err != nil {
t.Fatalf("expected sys_config payload to pass validation, got %v", err)
}
}
}
func TestCreateSysConfigRejectsInvalidValueType(t *testing.T) {
err := bindJSONBody(t, `{"config_key":"feature.demo","config_name_i18n":{"zh-CN":"演示开关"},"config_value":"1","value_type":"yaml"}`, NewCreateSysConfigForm())
if err == nil {
t.Fatal("expected unsupported value_type to fail validation")
}
}
func TestSysConfigListRejectsInvalidStatus(t *testing.T) {
err := bindJSONBody(t, `{"status":2}`, NewSysConfigListQuery())
if err == nil {
t.Fatal("expected status=2 to fail validation")
}
}
================================================
FILE: internal/validator/form/sys_dict.go
================================================
package form
type SysDictTypeList struct {
Paginate
TypeCode string `form:"type_code" json:"type_code" binding:"omitempty,max=100"`
TypeName string `form:"type_name" json:"type_name" binding:"omitempty,max=100"`
Status *uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
}
type SysDictTypePayload struct {
TypeCode string `form:"type_code" json:"type_code" label:"字典类型编码" binding:"required,max=100"`
TypeNameI18n map[string]string `form:"type_name_i18n" json:"type_name_i18n" label:"字典类型名称多语言" binding:"required"`
Status *uint8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"`
Sort uint `form:"sort" json:"sort" label:"排序" binding:"omitempty"`
Remark string `form:"remark" json:"remark" label:"备注" binding:"omitempty,max=255"`
}
type CreateSysDictType struct {
SysDictTypePayload
}
type UpdateSysDictType struct {
Id uint `form:"id" json:"id" label:"字典类型ID" binding:"required"`
SysDictTypePayload
}
type SysDictItemList struct {
Paginate
TypeCode string `form:"type_code" json:"type_code" label:"字典类型编码" binding:"required,max=100"`
Label string `form:"label" json:"label" binding:"omitempty,max=100"`
Value string `form:"value" json:"value" binding:"omitempty,max=100"`
Status *uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
}
type SysDictItemPayload struct {
TypeCode string `form:"type_code" json:"type_code" label:"字典类型编码" binding:"required,max=100"`
LabelI18n map[string]string `form:"label_i18n" json:"label_i18n" label:"字典标签多语言" binding:"required"`
Value string `form:"value" json:"value" label:"字典值" binding:"required,max=100"`
Color string `form:"color" json:"color" label:"展示颜色" binding:"omitempty,max=30"`
TagType string `form:"tag_type" json:"tag_type" label:"标签类型" binding:"omitempty,max=30"`
IsDefault *uint8 `form:"is_default" json:"is_default" label:"是否默认" binding:"omitempty,oneof=0 1"`
Status *uint8 `form:"status" json:"status" label:"状态" binding:"omitempty,oneof=0 1"`
Sort uint `form:"sort" json:"sort" label:"排序" binding:"omitempty"`
Remark string `form:"remark" json:"remark" label:"备注" binding:"omitempty,max=255"`
}
type CreateSysDictItem struct {
SysDictItemPayload
}
type UpdateSysDictItem struct {
Id uint `form:"id" json:"id" label:"字典项ID" binding:"required"`
SysDictItemPayload
}
type SysDictOptionsQuery struct {
TypeCode string `form:"type_code" json:"type_code" label:"字典类型编码" binding:"required,max=100"`
}
func NewSysDictTypeListQuery() *SysDictTypeList {
return &SysDictTypeList{}
}
func NewCreateSysDictTypeForm() *CreateSysDictType {
return &CreateSysDictType{}
}
func NewUpdateSysDictTypeForm() *UpdateSysDictType {
return &UpdateSysDictType{}
}
func NewSysDictItemListQuery() *SysDictItemList {
return &SysDictItemList{}
}
func NewCreateSysDictItemForm() *CreateSysDictItem {
return &CreateSysDictItem{}
}
func NewUpdateSysDictItemForm() *UpdateSysDictItem {
return &UpdateSysDictItem{}
}
func NewSysDictOptionsQuery() *SysDictOptionsQuery {
return &SysDictOptionsQuery{}
}
================================================
FILE: internal/validator/form/sys_dict_test.go
================================================
package form
import "testing"
func TestCreateSysDictTypeRejectsInvalidStatus(t *testing.T) {
err := bindJSONBody(t, `{"type_code":"test_type","type_name_i18n":{"zh-CN":"测试字典"},"status":2}`, NewCreateSysDictTypeForm())
if err == nil {
t.Fatal("expected status=2 to fail validation")
}
}
func TestCreateSysDictItemAllowsValidBinaryFlags(t *testing.T) {
body := `{"type_code":"test_type","label_i18n":{"zh-CN":"启用","en-US":"Enabled"},"value":"1","is_default":1,"status":0}`
if err := bindJSONBody(t, body, NewCreateSysDictItemForm()); err != nil {
t.Fatalf("expected sys_dict_item payload to pass validation, got %v", err)
}
}
func TestCreateSysDictItemRejectsInvalidDefaultFlag(t *testing.T) {
body := `{"type_code":"test_type","label_i18n":{"zh-CN":"启用"},"value":"1","is_default":2}`
err := bindJSONBody(t, body, NewCreateSysDictItemForm())
if err == nil {
t.Fatal("expected is_default=2 to fail validation")
}
}
================================================
FILE: internal/validator/form/task_center.go
================================================
package form
// TaskDefinitionList 任务定义列表查询参数。
type TaskDefinitionList struct {
Paginate
Code string `form:"code" json:"code" binding:"omitempty"`
Name string `form:"name" json:"name" binding:"omitempty"`
Kind string `form:"kind" json:"kind" binding:"omitempty,oneof=async cron"`
Status *uint8 `form:"status" json:"status" binding:"omitempty,oneof=0 1"`
AllowManual *uint8 `form:"allow_manual" json:"allow_manual" binding:"omitempty,oneof=0 1"`
AllowRetry *uint8 `form:"allow_retry" json:"allow_retry" binding:"omitempty,oneof=0 1"`
IsHighRisk *uint8 `form:"is_high_risk" json:"is_high_risk" binding:"omitempty,oneof=0 1"`
}
func NewTaskDefinitionListQuery() *TaskDefinitionList {
return &TaskDefinitionList{}
}
// TaskRunList 任务执行记录列表查询参数。
type TaskRunList struct {
Paginate
TaskCode string `form:"task_code" json:"task_code" binding:"omitempty"`
Kind string `form:"kind" json:"kind" binding:"omitempty,oneof=async cron"`
Source string `form:"source" json:"source" binding:"omitempty,oneof=queue cron manual"`
SourceID string `form:"source_id" json:"source_id" binding:"omitempty"`
Status string `form:"status" json:"status" binding:"omitempty,oneof=pending running success failed canceled retrying"`
StartTime string `form:"start_time" json:"start_time" binding:"omitempty"`
EndTime string `form:"end_time" json:"end_time" binding:"omitempty"`
}
func NewTaskRunListQuery() *TaskRunList {
return &TaskRunList{}
}
// TaskRunEvents 任务执行事件查询参数。
type TaskRunEvents struct {
RunID uint `form:"run_id" json:"run_id" binding:"required,gt=0"`
}
func NewTaskRunEventsQuery() *TaskRunEvents {
return &TaskRunEvents{}
}
// CronTaskStateList 定时任务状态列表查询参数。
type CronTaskStateList struct {
Paginate
TaskCode string `form:"task_code" json:"task_code" binding:"omitempty"`
LastStatus string `form:"last_status" json:"last_status" binding:"omitempty,oneof=pending running success failed canceled retrying"`
}
func NewCronTaskStateListQuery() *CronTaskStateList {
return &CronTaskStateList{}
}
// TaskTriggerForm 手动触发任务参数。
type TaskTriggerForm struct {
TaskCode string `form:"task_code" json:"task_code" binding:"required"`
Queue string `form:"queue" json:"queue" binding:"omitempty"`
TaskID string `form:"task_id" json:"task_id" binding:"omitempty"`
Payload map[string]any `form:"payload" json:"payload" binding:"omitempty"`
Confirm string `form:"confirm" json:"confirm" binding:"omitempty,max=120"`
Reason string `form:"reason" json:"reason" binding:"omitempty,max=255"`
}
func NewTaskTriggerForm() *TaskTriggerForm {
return &TaskTriggerForm{}
}
// TaskRetryForm 重试任务参数。
type TaskRetryForm struct {
RunID uint `form:"run_id" json:"run_id" binding:"required,gt=0"`
}
func NewTaskRetryForm() *TaskRetryForm {
return &TaskRetryForm{}
}
// TaskCancelForm 取消任务参数。
type TaskCancelForm struct {
RunID uint `form:"run_id" json:"run_id" binding:"required,gt=0"`
Reason string `form:"reason" json:"reason" binding:"omitempty,max=255"`
}
func NewTaskCancelForm() *TaskCancelForm {
return &TaskCancelForm{}
}
================================================
FILE: internal/validator/form/task_center_test.go
================================================
package form
import "testing"
func TestTaskRunListRejectsInvalidStatus(t *testing.T) {
err := bindJSONBody(t, `{"status":"succeeded"}`, NewTaskRunListQuery())
if err == nil {
t.Fatal("expected invalid task run status to fail validation")
}
}
func TestTaskRunListAllowsKnownStatus(t *testing.T) {
err := bindJSONBody(t, `{"status":"success"}`, NewTaskRunListQuery())
if err != nil {
t.Fatalf("expected known task run status to pass validation, got %v", err)
}
}
func TestCronTaskStateListRejectsInvalidLastStatus(t *testing.T) {
err := bindJSONBody(t, `{"last_status":"succeeded"}`, NewCronTaskStateListQuery())
if err == nil {
t.Fatal("expected invalid cron task status to fail validation")
}
}
func TestCronTaskStateListAllowsKnownLastStatus(t *testing.T) {
err := bindJSONBody(t, `{"last_status":"retrying"}`, NewCronTaskStateListQuery())
if err != nil {
t.Fatalf("expected known cron task status to pass validation, got %v", err)
}
}
================================================
FILE: internal/validator/rules.go
================================================
package validator
import (
"errors"
"reflect"
"regexp"
"strconv"
"sync"
"github.com/go-playground/validator/v10"
)
var (
phoneNumberRegex = regexp.MustCompile(`^1[3456789]\d{9}$`)
regexCache sync.Map // map[string]*regexp.Regexp
)
// RegexpValidator 通用正则表达式验证器。
func RegexpValidator(fl validator.FieldLevel) bool {
param := fl.Param()
if param == "" {
return false
}
value := fl.Field().String()
if cached, ok := regexCache.Load(param); ok {
return cached.(*regexp.Regexp).MatchString(value)
}
reg, err := regexp.Compile(param)
if err != nil {
return false
}
regexCache.Store(param, reg)
return reg.MatchString(value)
}
func initCustomRules(validate *validator.Validate) error {
err := validate.RegisterValidation("phone_number", func(fl validator.FieldLevel) bool {
return phoneNumberRegex.MatchString(fl.Field().String())
})
if err != nil {
return errors.New("注册 phone_number 校验规则失败")
}
err = validate.RegisterValidation("required_if_exist", requiredIf)
if err != nil {
return errors.New("注册 required_if_exist 校验规则失败")
}
err = validate.RegisterValidation("regexp", RegexpValidator)
if err != nil {
return errors.New("注册 regexp 校验规则失败")
}
err = validate.RegisterValidation("api_auth_mode", apiAuthModeValidator)
if err != nil {
return errors.New("注册 api_auth_mode 校验规则失败")
}
return nil
}
func apiAuthModeValidator(fl validator.FieldLevel) bool {
field := fl.Field()
for field.Kind() == reflect.Ptr {
if field.IsNil() {
return true
}
field = field.Elem()
}
switch field.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value := field.Int()
return value >= 0 && value <= 2
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
value := field.Uint()
return value <= 2
default:
return false
}
}
// requiredIf 字段B存在时,字段A必填。
func requiredIf(fl validator.FieldLevel) bool {
param := fl.Param()
if param == "" {
return false
}
params := make([]string, 0, 4)
start := 0
for i := 0; i < len(param); i++ {
if param[i] == ' ' || param[i] == '\t' {
if start < i {
params = append(params, param[start:i])
}
start = i + 1
}
}
if start < len(param) {
params = append(params, param[start:])
}
if len(params) < 2 {
return false
}
targetField := params[0]
validValues := params[1:]
fieldValue := fl.Field().String()
targetFieldValue := fl.Parent().FieldByName(targetField)
if !targetFieldValue.IsValid() {
return true
}
switch targetFieldValue.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
targetInt := targetFieldValue.Int()
for _, val := range validValues {
if intVal, err := strconv.ParseInt(val, 10, 64); err == nil && targetInt == intVal {
return fieldValue != ""
}
}
case reflect.String:
targetStr := targetFieldValue.String()
for _, val := range validValues {
if targetStr == val {
return fieldValue != ""
}
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
targetUint := targetFieldValue.Uint()
for _, val := range validValues {
if uintVal, err := strconv.ParseUint(val, 10, 64); err == nil && targetUint == uintVal {
return fieldValue != ""
}
}
case reflect.Float32, reflect.Float64:
targetFloat := targetFieldValue.Float()
for _, val := range validValues {
if floatVal, err := strconv.ParseFloat(val, 64); err == nil && targetFloat == floatVal {
return fieldValue != ""
}
}
default:
return false
}
return true
}
================================================
FILE: internal/validator/runtime.go
================================================
package validator
import (
"errors"
"reflect"
"sync"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
"go.uber.org/zap"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
var validatorRuntime = newValidatorRuntime()
type validatorRuntimeState struct {
once sync.Once
validate *validator.Validate
translators map[string]ut.Translator
translatorLocale string
rulesReady bool
tagNameReady bool
initErr error
}
func newValidatorRuntime() *validatorRuntimeState {
return &validatorRuntimeState{}
}
// InitValidatorTrans 初始化验证器和翻译器。
func InitValidatorTrans(locale string) error {
err := validatorRuntime.initOnce(locale)
if err != nil && log.Logger != nil {
log.Logger.Error("初始化 validator 失败", zap.String("locale", locale), zap.Error(err))
}
return err
}
func (s *validatorRuntimeState) initOnce(locale string) error {
s.once.Do(func() {
s.initErr = s.init(locale)
})
return s.initErr
}
func (s *validatorRuntimeState) init(locale string) error {
engine, ok := getValidatorEngine()
if !ok {
return errors.New("初始化 validator 失败")
}
s.validate = engine
if err := s.ensureRules(); err != nil {
return err
}
s.ensureTagNameFunc()
translators, err := initTranslators(s.validate)
if err != nil {
return err
}
s.translators = translators
s.translatorLocale = normalizeValidatorLocale(locale)
return nil
}
func (s *validatorRuntimeState) ensureRules() error {
if s.rulesReady {
return nil
}
if err := initCustomRules(s.validate); err != nil {
return err
}
s.rulesReady = true
return nil
}
func (s *validatorRuntimeState) ensureTagNameFunc() {
if s.tagNameReady {
return
}
registerTagNameFunc(s.validate)
s.tagNameReady = true
}
func getValidatorEngine() (*validator.Validate, bool) {
engine := binding.Validator.Engine()
if engine == nil {
return nil, false
}
validate, ok := engine.(*validator.Validate)
return validate, ok
}
func registerTagNameFunc(validate *validator.Validate) {
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
if label := field.Tag.Get("label"); label != "" && label != "-" {
return label
}
if json := field.Tag.Get("json"); json != "" && json != "-" {
return json
}
if form := field.Tag.Get("form"); form != "" && form != "-" {
return form
}
return field.Name
})
}
func (s *validatorRuntimeState) translatorForRequest(c *gin.Context) ut.Translator {
if s == nil {
return nil
}
locale := s.translatorLocale
if c != nil {
if localeValue, exists := c.Get(global.ContextKeyLocale); exists {
if localeText, ok := localeValue.(string); ok {
locale = normalizeValidatorLocale(i18n.ToErrorLanguage(localeText))
}
}
}
if trans := s.translators[locale]; trans != nil {
return trans
}
if trans := s.translators[s.translatorLocale]; trans != nil {
return trans
}
if trans := s.translators["zh"]; trans != nil {
return trans
}
return s.translators["en"]
}
func (s *validatorRuntimeState) fallbackTranslator(primary ut.Translator) ut.Translator {
if s == nil {
return nil
}
defaultTrans := s.translators[s.translatorLocale]
if primary != nil && defaultTrans != nil && primary != defaultTrans {
return defaultTrans
}
if primary != nil && s.translators["zh"] != nil && primary != s.translators["zh"] {
return s.translators["zh"]
}
if primary != nil && s.translators["en"] != nil && primary != s.translators["en"] {
return s.translators["en"]
}
return nil
}
================================================
FILE: internal/validator/translation.go
================================================
package validator
import (
"fmt"
"strings"
"github.com/go-playground/locales/en"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
enTranslations "github.com/go-playground/validator/v10/translations/en"
zhTranslations "github.com/go-playground/validator/v10/translations/zh"
"go.uber.org/zap"
log "github.com/wannanbigpig/gin-layout/internal/pkg/logger"
)
func initTranslators(validate *validator.Validate) (map[string]ut.Translator, error) {
zhT := zh.New()
enT := en.New()
uni := ut.New(enT, zhT, enT)
locales := []string{"zh", "en"}
translators := make(map[string]ut.Translator, len(locales))
for _, locale := range locales {
trans, ok := uni.GetTranslator(locale)
if !ok {
return nil, fmt.Errorf("validator translator locale not supported: %s", locale)
}
if err := registerLocaleTranslations(validate, trans, locale); err != nil {
return nil, err
}
translators[locale] = trans
}
return translators, nil
}
func registerLocaleTranslations(validate *validator.Validate, trans ut.Translator, locale string) error {
var err error
switch locale {
case "en":
err = enTranslations.RegisterDefaultTranslations(validate, trans)
case "zh":
err = zhTranslations.RegisterDefaultTranslations(validate, trans)
default:
err = enTranslations.RegisterDefaultTranslations(validate, trans)
}
if err != nil {
return fmt.Errorf("注册默认翻译器失败: %w", err)
}
if err := customRegisTranslation(validate, trans, locale); err != nil {
return fmt.Errorf("注册自定义翻译失败: %w", err)
}
return nil
}
type translation struct {
tag string
translation string
override bool
customRegisFunc validator.RegisterTranslationsFunc
customTransFunc validator.TranslationFunc
}
func customRegisTranslation(validate *validator.Validate, trans ut.Translator, locale string) error {
return registerTranslation(validate, trans, localeTranslations(locale))
}
func localeTranslations(locale string) []translation {
switch normalizeValidatorLocale(locale) {
case "en":
return []translation{
{tag: "phone_number", translation: "{0} format is invalid", override: false},
{tag: "required_if_exist", translation: "{0} is required", override: false},
{tag: "regexp", translation: "{0} format is invalid", override: false},
}
default:
return []translation{
{tag: "phone_number", translation: "{0}格式不正确", override: false},
{tag: "required_if_exist", translation: "{0}字段必填", override: false},
{tag: "regexp", translation: "{0}字段规则不匹配", override: false},
}
}
}
func registerTranslation(validate *validator.Validate, trans ut.Translator, translations []translation) error {
for _, t := range translations {
regFunc := t.customRegisFunc
if regFunc == nil {
regFunc = registrationFunc(t.tag, t.translation, t.override)
}
transFunc := t.customTransFunc
if transFunc == nil {
transFunc = translateFunc
}
if err := validate.RegisterTranslation(t.tag, trans, regFunc, transFunc); err != nil {
return err
}
}
return nil
}
func registrationFunc(tag string, translation string, override bool) validator.RegisterTranslationsFunc {
return func(ut ut.Translator) (err error) {
if err = ut.Add(tag, translation, override); err != nil {
return
}
return
}
}
func translateFunc(ut ut.Translator, fe validator.FieldError) string {
t, err := ut.T(fe.Tag(), fe.Field())
if err != nil {
log.Logger.Warn("警告: 翻译字段错误", zap.Any("Error reason", fe))
return fe.Error()
}
return t
}
func normalizeValidatorLocale(locale string) string {
normalized := strings.ToLower(strings.TrimSpace(locale))
switch {
case strings.HasPrefix(normalized, "en"):
return "en"
default:
return "zh"
}
}
================================================
FILE: internal/validator/validator_test.go
================================================
package validator
import (
"sync"
"testing"
)
func TestInitValidatorTransCanRetryAfterFailure(t *testing.T) {
resetValidatorRuntimeForTest()
t.Cleanup(resetValidatorRuntimeForTest)
if err := InitValidatorTrans("invalid-locale"); err != nil {
t.Fatalf("expected invalid locale to fallback successfully, got %v", err)
}
if validatorRuntime.translatorLocale != "zh" {
t.Fatalf("expected invalid locale to fallback to zh, got %q", validatorRuntime.translatorLocale)
}
resetValidatorRuntimeForTest()
if err := InitValidatorTrans("en"); err != nil {
t.Fatalf("expected english locale initialization to succeed, got %v", err)
}
if validatorRuntime.translatorLocale != "en" {
t.Fatalf("expected default translator locale en, got %q", validatorRuntime.translatorLocale)
}
}
func resetValidatorRuntimeForTest() {
validatorRuntime = newValidatorRuntime()
regexCache = sync.Map{}
}
================================================
FILE: main.go
================================================
package main
import (
"github.com/wannanbigpig/gin-layout/cmd"
)
func main() {
cmd.Execute()
}
================================================
FILE: pkg/convert/convert.go
================================================
package convert
import "time"
func GetString(val interface{}) (s string) {
s, _ = val.(string)
return
}
// GetBool returns the value associated with the key as a boolean.
func GetBool(val interface{}) (b bool) {
b, _ = val.(bool)
return
}
// GetInt returns the value associated with the key as an integer.
func GetInt(val interface{}) (i int) {
i, _ = val.(int)
return
}
// GetInt64 returns the value associated with the key as an integer.
func GetInt64(val interface{}) (i64 int64) {
i64, _ = val.(int64)
return
}
// GetUint returns the value associated with the key as an unsigned integer.
func GetUint(val interface{}) (ui uint) {
ui, _ = val.(uint)
return
}
// GetUint8 returns the value associated with the key as an unsigned integer.
func GetUint8(val interface{}) (ui uint8) {
ui, _ = val.(uint8)
return
}
// GetUint64 returns the value associated with the key as an unsigned integer.
func GetUint64(val interface{}) (ui64 uint64) {
ui64, _ = val.(uint64)
return
}
// GetFloat64 returns the value associated with the key as a float64.
func GetFloat64(val interface{}) (f64 float64) {
f64, _ = val.(float64)
return
}
// GetTime returns the value associated with the key as time.
func GetTime(val interface{}) (t time.Time) {
t, _ = val.(time.Time)
return
}
// GetDuration returns the value associated with the key as a duration.
func GetDuration(val interface{}) (d time.Duration) {
d, _ = val.(time.Duration)
return
}
================================================
FILE: pkg/utils/captcha/captcha.go
================================================
package captcha
import (
"context"
"sync"
"time"
"github.com/google/uuid"
"github.com/mojocn/base64Captcha"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
)
type Item struct {
Id string `json:"id"`
B64s string `json:"b64s"`
Answer string `json:"answer"`
}
// 内存存储(当 Redis 不可用时使用)
type memoryStore struct {
data map[string]string
mu sync.RWMutex
}
var memStore = &memoryStore{
data: make(map[string]string),
}
// captchaInstance 验证码实例
var captchaInstance *base64Captcha.Captcha
var captchaOnce sync.Once
var captchaBaseContext = context.Background
func (m *memoryStore) Set(id, answer string, expiration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[id] = answer
// 使用 time.AfterFunc 替代 goroutine + sleep,避免 goroutine 泄漏
time.AfterFunc(expiration, func() {
m.mu.Lock()
delete(m.data, id)
m.mu.Unlock()
})
}
func (m *memoryStore) Get(id string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
answer, ok := m.data[id]
return answer, ok
}
func (m *memoryStore) Delete(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, id)
}
const (
// captchaRedisKeyPrefix Redis key 前缀
captchaRedisKeyPrefix = "captcha:"
// captchaExpiration 验证码过期时间(5分钟)
captchaExpiration = 5 * time.Minute
// captchaLength 验证码长度
captchaLength = 4
// captchaRedisTimeout Redis 操作超时时间
captchaRedisTimeout = 2 * time.Second
// captchaCharset 验证码字符集:使用库提供的字符集,避免乱码
// 组合字母和数字,排除容易混淆的字符(如 0/O, 1/l/I)
captchaCharset = base64Captcha.TxtAlphabet + base64Captcha.TxtNumbers
)
func withRedisTimeout() (context.Context, context.CancelFunc) {
return context.WithTimeout(captchaBaseContext(), captchaRedisTimeout)
}
// initCaptcha 初始化验证码实例
func initCaptcha() {
captchaOnce.Do(func() {
// 创建字母数字混合验证码驱动
// 使用 NewDriverString 支持自定义字符集
// 参数:高度80,宽度240,干扰线数量2,显示选项,长度4,字符集
driver := base64Captcha.NewDriverString(
80, // 高度
240, // 宽度
2, // 干扰线数量
base64Captcha.OptionShowHollowLine|base64Captcha.OptionShowSlimeLine, // 显示选项
captchaLength, // 长度
captchaCharset, // 字符集(字母数字混合)
nil, // 背景色(nil 使用默认)
base64Captcha.DefaultEmbeddedFonts, // 字体存储
nil, // 字体列表(nil 使用默认字体)
)
// 创建内存存储
store := base64Captcha.NewMemoryStore(1000, captchaExpiration)
// 创建验证码实例
captchaInstance = base64Captcha.NewCaptcha(driver, store)
})
}
// setCaptchaAnswer 存储验证码答案
func setCaptchaAnswer(id, answer string) {
cfg := config.GetConfig()
redisClient := data.RedisClient()
if cfg != nil && cfg.Redis.Enable && redisClient != nil {
// 使用 Redis 存储
ctx, cancel := withRedisTimeout()
defer cancel()
key := captchaRedisKeyPrefix + id
if err := redisClient.Set(ctx, key, answer, captchaExpiration).Err(); err != nil {
// 记录日志但不返回错误,验证码仍可通过内存存储工作
}
} else {
// 使用内存存储
memStore.Set(id, answer, captchaExpiration)
}
}
// getCaptchaAnswer 获取验证码答案
func getCaptchaAnswer(id string) (string, bool) {
cfg := config.GetConfig()
redisClient := data.RedisClient()
if cfg != nil && cfg.Redis.Enable && redisClient != nil {
// 从 Redis 获取
ctx, cancel := withRedisTimeout()
defer cancel()
key := captchaRedisKeyPrefix + id
answer, err := redisClient.Get(ctx, key).Result()
if err != nil {
return "", false
}
return answer, true
}
// 从内存获取
return memStore.Get(id)
}
// deleteCaptchaAnswer 删除验证码答案(验证后删除)
func deleteCaptchaAnswer(id string) {
cfg := config.GetConfig()
redisClient := data.RedisClient()
if cfg != nil && cfg.Redis.Enable && redisClient != nil {
// 从 Redis 删除
ctx, cancel := withRedisTimeout()
defer cancel()
key := captchaRedisKeyPrefix + id
if err := redisClient.Del(ctx, key).Err(); err != nil {
// 记录日志但不返回错误,验证码仍可通过内存存储工作
}
} else {
// 从内存删除
memStore.Delete(id)
}
}
// Generate 创建验证码
// 返回验证码 ID、base64 编码的图片和答案(本地环境返回答案,其他环境不返回)
// 验证码为4位字母数字混合
func Generate() (item *Item, err error) {
// 初始化验证码实例
initCaptcha()
// 生成唯一的验证码 ID(我们使用 UUID)
captchaID := uuid.New().String()
// 生成验证码(返回内部 ID、base64 编码的图片、答案和可能的错误)
internalID, b64s, answer, err := captchaInstance.Generate()
if err != nil {
return nil, err
}
// 存储验证码答案(使用我们的 UUID 作为 key,存储实际的验证码文本)
setCaptchaAnswer(captchaID, answer)
// 同时存储内部 ID 到 UUID 的映射,以便后续验证时能找到
setCaptchaAnswer("internal:"+captchaID, internalID)
// 添加 data URI 前缀(base64Captcha 已经返回了 base64 字符串)
if len(b64s) > 0 && b64s[:5] != "data:" {
b64s = "data:image/png;base64," + b64s
}
// 获取验证码答案(仅用于本地/测试环境)
var answerForClient string
cfg := config.GetConfig()
if cfg != nil && (cfg.AppEnv == "local" || cfg.AppEnv == "test") {
answerForClient = answer
}
return &Item{
Id: captchaID,
B64s: b64s,
Answer: answerForClient,
}, nil
}
// Verify 校验验证码
func Verify(id, value string) bool {
// 初始化验证码实例
initCaptcha()
// 获取存储的内部验证码 ID
internalID, ok := getCaptchaAnswer("internal:" + id)
if !ok {
// 如果找不到内部 ID,尝试从存储中获取答案进行直接验证
answer, ok := getCaptchaAnswer(id)
if !ok {
return false
}
// 比较验证码(不区分大小写)
if !equalIgnoreCase(answer, value) {
return false
}
// 验证成功后删除
deleteCaptchaAnswer(id)
return true
}
// 使用 base64Captcha 的验证方法
// 第三个参数 true 表示验证后删除
if captchaInstance.Verify(internalID, value, true) {
// 验证成功后删除我们的存储
deleteCaptchaAnswer(id)
deleteCaptchaAnswer("internal:" + id)
return true
}
return false
}
// equalIgnoreCase 不区分大小写比较字符串
func equalIgnoreCase(s1, s2 string) bool {
if len(s1) != len(s2) {
return false
}
for i := 0; i < len(s1); i++ {
c1 := s1[i]
c2 := s2[i]
if c1 >= 'A' && c1 <= 'Z' {
c1 += 32 // 转小写
}
if c2 >= 'A' && c2 <= 'Z' {
c2 += 32 // 转小写
}
if c1 != c2 {
return false
}
}
return true
}
================================================
FILE: pkg/utils/crypto/README.md
================================================
# 加密工具使用说明
本包提供 AES-256-GCM 加密算法,用于字符串的加密和解密。支持通过参数选择加密算法。
## 快速开始
```go
import "github.com/wannanbigpig/gin-layout/pkg/utils/crypto"
// 使用默认算法加密(推荐,不传算法参数)
encrypted, err := crypto.Encrypt("your-secret-key", "plaintext")
if err != nil {
// 处理错误
}
// 使用默认算法解密
decrypted, err := crypto.Decrypt("your-secret-key", encrypted)
if err != nil {
// 处理错误
}
// 使用指定算法加密(可选)
encrypted, err := crypto.Encrypt("your-secret-key", "plaintext", crypto.AlgorithmAES256GCM)
// 使用指定算法解密(可选)
decrypted, err := crypto.Decrypt("your-secret-key", encrypted, crypto.AlgorithmAES256GCM)
```
## 算法说明
### AES-256-GCM
**特点:**
- 使用 AES-256(高级加密标准,256 位密钥)
- GCM 模式(Galois/Counter Mode),提供认证加密(AEAD)
- 国际标准,广泛使用
- 性能优秀,硬件加速支持好
- 兼容性好,所有平台支持
**密钥处理:**
- 输入密钥为字符串,通过 SHA256 哈希派生为 32 字节密钥(AES-256 需要 32 字节)
- 每次加密使用随机 nonce(12 字节),确保相同明文产生不同密文
**密文格式:**
- 密文格式:`nonce + encrypted_data`
- 最终以 base64 编码返回
## 支持的加密算法
### AlgorithmAES256GCM
AES-256-GCM 加密算法(默认算法)
```go
crypto.AlgorithmAES256GCM
```
## API 文档
### Encrypt
```go
func Encrypt(key, plaintext string, algorithm ...Algorithm) (string, error)
```
**参数:**
- `key`: 加密密钥(字符串)
- `plaintext`: 待加密的明文
- `algorithm`: 加密算法(可选参数,可变参数,不传则使用默认算法 `AlgorithmAES256GCM`)
**返回:**
- `string`: base64 编码的密文
- `error`: 错误信息(如果算法不支持、密钥为空或加密失败)
**示例:**
```go
// 使用默认算法(推荐)
encrypted, err := crypto.Encrypt("key", "plaintext")
// 使用指定算法
encrypted, err := crypto.Encrypt("key", "plaintext", crypto.AlgorithmAES256GCM)
```
### Decrypt
```go
func Decrypt(key, ciphertext string, algorithm ...Algorithm) (string, error)
```
**参数:**
- `key`: 解密密钥(字符串,必须与加密时使用的密钥相同)
- `ciphertext`: base64 编码的密文
- `algorithm`: 解密算法(可选参数,可变参数,不传则使用默认算法 `AlgorithmAES256GCM`)
**返回:**
- `string`: 解密后的明文
- `error`: 错误信息(如果算法不支持、密钥为空、密文格式错误或解密失败)
**示例:**
```go
// 使用默认算法(推荐)
decrypted, err := crypto.Decrypt("key", encrypted)
// 使用指定算法
decrypted, err := crypto.Decrypt("key", encrypted, crypto.AlgorithmAES256GCM)
```
## 使用示例
### 示例 1:使用默认算法(推荐)
```go
package main
import (
"fmt"
"github.com/wannanbigpig/gin-layout/pkg/utils/crypto"
)
func main() {
key := "my-secret-key-12345"
plaintext := "Hello, World!"
// 使用默认算法加密(不传算法参数)
encrypted, err := crypto.Encrypt(key, plaintext)
if err != nil {
fmt.Printf("加密失败: %v\n", err)
return
}
fmt.Printf("密文: %s\n", encrypted)
// 使用默认算法解密(不传算法参数)
decrypted, err := crypto.Decrypt(key, encrypted)
if err != nil {
fmt.Printf("解密失败: %v\n", err)
return
}
fmt.Printf("明文: %s\n", decrypted)
}
```
### 示例 2:使用指定算法
```go
package main
import (
"fmt"
"github.com/wannanbigpig/gin-layout/pkg/utils/crypto"
)
func main() {
key := "my-secret-key-12345"
plaintext := "Hello, World!"
// 使用指定算法加密
encrypted, err := crypto.Encrypt(key, plaintext, crypto.AlgorithmAES256GCM)
if err != nil {
fmt.Printf("加密失败: %v\n", err)
return
}
fmt.Printf("密文: %s\n", encrypted)
// 使用指定算法解密
decrypted, err := crypto.Decrypt(key, encrypted, crypto.AlgorithmAES256GCM)
if err != nil {
fmt.Printf("解密失败: %v\n", err)
return
}
fmt.Printf("明文: %s\n", decrypted)
}
```
## 注意事项
1. **密钥管理**:请妥善保管加密密钥,建议使用环境变量或密钥管理服务
2. **密钥长度**:密钥通过 SHA256 派生为 32 字节,建议使用足够长的密钥字符串
3. **安全性**:每次加密使用随机 nonce,相同明文会产生不同密文,提高安全性
4. **错误处理**:请务必检查返回的错误,确保加密/解密操作成功
5. **空值处理**:空字符串会直接返回空字符串,不会进行加密操作
## 性能
- **加密速度**:快(有硬件加速支持)
- **解密速度**:快(有硬件加速支持)
- **CPU 占用**:低
- **内存占用**:低
## 适用场景
- 敏感数据加密存储(如 token、密码等)
- 配置文件加密
- 数据库字段加密
- 日志敏感信息加密
================================================
FILE: pkg/utils/crypto/crypto.go
================================================
package crypto
import "errors"
// Encrypt 使用指定算法加密字符串(默认使用 AES-256-GCM)
// key: 加密密钥(字符串,会通过 SHA256 派生为 32 字节密钥)
// plaintext: 待加密的明文
// algorithm: 加密算法(可选参数,不传则使用默认算法 AlgorithmAES256GCM)
// 返回: base64 编码的密文
func Encrypt(key, plaintext string, algorithm ...Algorithm) (string, error) {
// 确定使用的算法
var algo Algorithm
if len(algorithm) > 0 && algorithm[0] != "" {
algo = algorithm[0]
} else {
algo = AlgorithmAES256GCM
}
// 验证算法有效性
if !algo.IsValid() {
return "", errors.New("不支持的加密算法: " + algo.String())
}
// 根据算法选择加密方法
switch algo {
case AlgorithmAES256GCM:
return AESEncrypt(key, plaintext)
default:
return "", errors.New("不支持的加密算法: " + algo.String())
}
}
// Decrypt 使用指定算法解密字符串(默认使用 AES-256-GCM)
// key: 解密密钥(字符串,会通过 SHA256 派生为 32 字节密钥)
// ciphertext: base64 编码的密文
// algorithm: 解密算法(可选参数,不传则使用默认算法 AlgorithmAES256GCM)
// 返回: 解密后的明文
func Decrypt(key, ciphertext string, algorithm ...Algorithm) (string, error) {
// 确定使用的算法
var algo Algorithm
if len(algorithm) > 0 && algorithm[0] != "" {
algo = algorithm[0]
} else {
algo = AlgorithmAES256GCM
}
// 验证算法有效性
if !algo.IsValid() {
return "", errors.New("不支持的解密算法: " + algo.String())
}
// 根据算法选择解密方法
switch algo {
case AlgorithmAES256GCM:
return AESDecrypt(key, ciphertext)
default:
return "", errors.New("不支持的解密算法: " + algo.String())
}
}
================================================
FILE: pkg/utils/crypto/crypto_aes.go
================================================
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
)
// AESEncrypt 使用 AES-256-GCM 加密字符串
// key: 加密密钥(字符串,会通过 SHA256 派生为 32 字节密钥)
// plaintext: 待加密的明文
// 返回: base64 编码的密文
func AESEncrypt(key, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
if key == "" {
return "", errors.New("加密密钥不能为空")
}
// 从字符串密钥派生 32 字节密钥(AES-256 需要 32 字节)
derivedKey := deriveKey256(key)
// 创建 AES cipher
block, err := aes.NewCipher(derivedKey)
if err != nil {
return "", err
}
// 创建 GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// 生成随机 nonce(12 字节,GCM 推荐大小)
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// 加密
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
// 返回 base64 编码的密文
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// AESDecrypt 使用 AES-256-GCM 解密字符串
// key: 解密密钥(字符串,会通过 SHA256 派生为 32 字节密钥)
// ciphertext: base64 编码的密文
// 返回: 解密后的明文
func AESDecrypt(key, ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
if key == "" {
return "", errors.New("解密密钥不能为空")
}
// 从字符串密钥派生 32 字节密钥
derivedKey := deriveKey256(key)
// 解码 base64
ciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
// 创建 AES cipher
block, err := aes.NewCipher(derivedKey)
if err != nil {
return "", err
}
// 创建 GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// 检查密文长度
nonceSize := gcm.NonceSize()
if len(ciphertextBytes) < nonceSize {
return "", errors.New("密文长度不足")
}
// 提取 nonce 和密文
nonce, ciphertextBytes := ciphertextBytes[:nonceSize], ciphertextBytes[nonceSize:]
// 解密
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// deriveKey256 从字符串密钥派生 32 字节密钥(用于 AES-256)
func deriveKey256(key string) []byte {
hash := sha256.Sum256([]byte(key))
return hash[:]
}
================================================
FILE: pkg/utils/crypto/types.go
================================================
package crypto
// Algorithm 加密算法类型
type Algorithm string
const (
// AlgorithmAES256GCM AES-256-GCM 加密算法(默认)
AlgorithmAES256GCM Algorithm = "aes-256-gcm"
)
// String 返回算法名称
func (a Algorithm) String() string {
return string(a)
}
// IsValid 检查算法是否有效
func (a Algorithm) IsValid() bool {
switch a {
case AlgorithmAES256GCM:
return true
default:
return false
}
}
================================================
FILE: pkg/utils/helpers.go
================================================
package utils
import (
"strings"
"golang.org/x/crypto/bcrypt"
)
const passwordHashCost = 12
// MaskSensitiveInfo 对于字符串脱敏
// s 需要脱敏的字符串
// start 从第几位开始脱敏
// maskNumber 需要脱敏长度
// maskChars 掩饰字符串,替代需要脱敏处理的字符串
func MaskSensitiveInfo(s string, start int, maskNumber int, maskChars ...string) string {
// 将字符串s的[start, end)区间用maskChar替换,并返回替换后的结果。
maskChar := "*"
if len(maskChars) > 0 && maskChars[0] != "" {
maskChar = maskChars[0]
}
if maskNumber <= 0 || len(s) == 0 {
return s
}
// 处理起始位置超出边界的情况
if start < 0 {
start = 0
}
if start > len(s) {
start = len(s)
}
// 处理结束位置超出边界的情况
end := start + maskNumber
if end > len(s) {
end = len(s)
}
return s[:start] + strings.Repeat(maskChar, end-start) + s[end:]
}
// PasswordHash 密码hash并自动加盐
func PasswordHash(pwd string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), passwordHashCost)
return string(hash), err
}
// ComparePasswords 比对用户密码是否正确
func ComparePasswords(oldPassword, password string) bool {
if err := bcrypt.CompareHashAndPassword([]byte(oldPassword), []byte(password)); err != nil {
return false
}
return true
}
// SliceToAny 将切片转换为any类型切片
func SliceToAny[T any](data []T) []any {
anyData := make([]any, len(data))
for i, v := range data {
anyData[i] = v
}
return anyData
}
================================================
FILE: pkg/utils/helpers_test.go
================================================
package utils
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestMaskSensitiveInfo(t *testing.T) {
mobile := "13200000000"
m := MaskSensitiveInfo(mobile, 3, 4)
if m != "132****0000" {
t.Error("手机号脱敏失败")
}
m1 := MaskSensitiveInfo(mobile, -1, 15)
if m1 != "***********" {
t.Error("手机号脱敏失败")
}
idNumber := "110101199001010010"
id := MaskSensitiveInfo(idNumber, 6, 8)
if id != "110101********0010" {
t.Error("身份证脱敏失败")
}
// 空可选参数切片不应触发 panic
emptyMaskChars := []string{}
noPanicMasked := MaskSensitiveInfo(mobile, 3, 4, emptyMaskChars...)
if noPanicMasked != "132****0000" {
t.Error("空掩码参数脱敏失败")
}
// start 超出长度时应直接返回原值
outOfRange := MaskSensitiveInfo(mobile, len(mobile)+3, 2)
if outOfRange != mobile {
t.Error("start 越界处理失败")
}
// maskNumber 非正时应直接返回原值
unchanged := MaskSensitiveInfo(mobile, 3, 0)
if unchanged != mobile {
t.Error("maskNumber 非正处理失败")
}
}
func TestPasswordHashUsesStrongerCost(t *testing.T) {
hashed, err := PasswordHash("hello-password")
if err != nil {
t.Fatalf("password hash failed: %v", err)
}
cost, err := bcrypt.Cost([]byte(hashed))
if err != nil {
t.Fatalf("read bcrypt cost failed: %v", err)
}
if cost < 12 {
t.Fatalf("expected bcrypt cost >= 12, got %d", cost)
}
}
================================================
FILE: pkg/utils/http.go
================================================
package utils
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
)
// HttpRequest 封装带状态的 HTTP 请求客户端。
type HttpRequest struct {
http.Client
Response *http.Response
Error error
}
// JsonRequest 发送默认 Content-Type 为 application/json 的请求。
func (hr *HttpRequest) JsonRequest(method string, url string, body io.Reader, args ...any) *HttpRequest {
var options map[string]string
if args != nil {
var ok bool
if options, ok = args[0].(map[string]string); ok {
options["Content-Type"] = "application/json"
}
} else {
options = map[string]string{
"Content-Type": "application/json",
}
}
return hr.Request(method, url, body, options)
}
// GetRequest 发送 GET 请求并拼接查询参数。
func (hr *HttpRequest) GetRequest(url string, params *url.Values, args ...any) *HttpRequest {
r := url
if params != nil {
r = url + "?" + params.Encode()
}
return hr.Request("GET", r, nil, args...)
}
// Request 构造并发送 HTTP 请求。
func (hr *HttpRequest) Request(method string, url string, body io.Reader, args ...any) *HttpRequest {
req, err := http.NewRequest(method, url, body)
if err != nil {
hr.Error = err
}
if args != nil {
if options, ok := args[0].(map[string]string); ok {
for k, v := range options {
req.Header.Set(k, v)
}
}
}
hr.Response, hr.Error = hr.Do(req)
return hr
}
// ParseJson 将响应体解析为目标 JSON 结构。
func (hr *HttpRequest) ParseJson(payload any) error {
bytes, err := hr.ParseBytes()
if err != nil {
return err
}
return json.Unmarshal(bytes, &payload)
}
// ParseBytes 读取并返回原始响应体字节。
func (hr *HttpRequest) ParseBytes() (body []byte, err error) {
if hr.Error != nil {
return nil, hr.Error
}
if hr.Response == nil || hr.Response.Body == nil {
return nil, errors.New("http response body is nil")
}
defer func() {
if closeErr := hr.Response.Body.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
body, err = io.ReadAll(hr.Response.Body)
return body, err
}
// Raw 以字符串形式返回原始响应体。
func (hr *HttpRequest) Raw() (string, error) {
str, err := hr.ParseBytes()
if err != nil {
return "", err
}
return string(str), nil
}
================================================
FILE: pkg/utils/http_test.go
================================================
package utils
import (
"io"
"net/http"
"net/url"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestGetRequest(t *testing.T) {
client := HttpRequest{}
client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Query().Get("name") != "world" {
t.Fatalf("unexpected query: %s", req.URL.RawQuery)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"hello":"world"}`)),
Header: make(http.Header),
}, nil
})
params := &url.Values{}
params.Set("name", "world")
resp := client.GetRequest("http://example.com", params)
if resp.Error != nil {
t.Fatalf("request failed: %v", resp.Error)
}
var payload map[string]string
if err := resp.ParseJson(&payload); err != nil {
t.Fatalf("parse failed: %v", err)
}
if payload["hello"] != "world" {
t.Fatalf("unexpected payload: %#v", payload)
}
}
func TestJsonRequestSetsContentType(t *testing.T) {
client := HttpRequest{}
client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
if got := req.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("unexpected content-type: %s", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
Header: make(http.Header),
}, nil
})
options := map[string]string{}
resp := client.JsonRequest(http.MethodPost, "http://example.com", strings.NewReader(`{"x":1}`), options)
if resp.Error != nil {
t.Fatalf("request failed: %v", resp.Error)
}
raw, err := resp.Raw()
if err != nil {
t.Fatalf("raw failed: %v", err)
}
if raw != "ok" {
t.Fatalf("unexpected raw: %s", raw)
}
}
================================================
FILE: pkg/utils/upload.go
================================================
package utils
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/h2non/filetype"
"github.com/h2non/filetype/types"
)
// FileInfo 描述上传文件的存储结果和对外展示字段。
type FileInfo struct {
FileID uint `json:"-"` // 文件ID(数据库ID,不返回给前端)
Sha256 string `json:"sha256"` // 文件SHA256哈希值(用于去重)
UUID string `json:"uuid"` // 文件UUID(用于URL访问,32位十六进制字符串)
Name string `json:"name"` // 存储的文件名(UUID+扩展名)
OriginName string `json:"origin_name"` // 原始文件名
Size int64 `json:"size"` // 文件大小(字节)
Path string `json:"path"` // 文件路径
Ext string `json:"ext"` // 文件扩展名
MimeType string `json:"mime_type"` // MIME类型
URL string `json:"url"` // 文件访问完整URL
FailureReason string `json:"failure_reason"` // 失败原因
Status string `json:"status"` // 上传状态:SUCCESS、ERROR
}
const fileHeaderSampleSize = 261
const uploadBufferSize = 32 * 1024
// ErrInvalidImageType 表示上传文件不是允许的图片类型。
var ErrInvalidImageType = errors.New("uploaded file is not an allowed image")
// UploadFile 接收上传文件并保存到目标目录。
func UploadFile(fileHeader *multipart.FileHeader, path ...string) (fileInfo *FileInfo, err error) {
uploadSubDir := "default"
if len(path) > 0 && path[0] != "" {
uploadSubDir = path[0]
}
absolutePath, err := resolveUploadDir(uploadSubDir)
if err != nil {
return nil, err
}
return SaveUploadedFileWithUUID(fileHeader, absolutePath)
}
// SaveUploadedFileWithUUID 保存文件、计算摘要并生成 UUID 文件名。
func SaveUploadedFileWithUUID(fileHeader *multipart.FileHeader, uploadDir string) (*FileInfo, error) {
return saveUploadedFile(fileHeader, uploadDir, false)
}
// SaveUploadedImageWithUUID 保存图片文件,拒绝非允许图片类型。
func SaveUploadedImageWithUUID(fileHeader *multipart.FileHeader, uploadDir string) (*FileInfo, error) {
return saveUploadedFile(fileHeader, uploadDir, true)
}
// EnsureAbsPath 将相对路径转换为绝对路径。
func EnsureAbsPath(path string, baseDir ...string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
var base string
if len(baseDir) > 0 && baseDir[0] != "" {
base = baseDir[0]
} else {
// 默认使用二进制文件所在目录
var err error
base, err = GetCurrentAbPathByExecutable()
if err != nil {
return "", fmt.Errorf("获取执行文件目录失败: %w", err)
}
}
return filepath.Join(base, path), nil
}
// GetFileSha256AndSizeFromHeader 计算文件的 SHA-256 和大小。
func GetFileSha256AndSizeFromHeader(file io.ReadSeeker) (string, int64, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", 0, fmt.Errorf("指针重置失败: %w", err)
}
hash := sha256.New()
size, err := io.Copy(hash, file)
if err != nil {
return "", 0, fmt.Errorf("计算SHA-256失败: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", 0, fmt.Errorf("指针重置失败: %w", err)
}
return hex.EncodeToString(hash.Sum(nil)), size, nil
}
// IsAllowedImage 判断文件头是否匹配允许的图片类型。
func IsAllowedImage(file io.ReadSeeker) (string, bool, error) {
head := make([]byte, fileHeaderSampleSize)
n, err := file.Read(head)
if err != nil && !errors.Is(err, io.EOF) {
return "", false, fmt.Errorf("读取文件头失败: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", false, fmt.Errorf("重置文件指针失败: %w", err)
}
if n == 0 {
return "", false, nil
}
ext, allowed, _, err := detectAllowedImage(head[:n])
if err != nil {
return "", false, fmt.Errorf("检测文件类型失败: %w", err)
}
return ext, allowed, nil
}
// getMimeTypeByExt 根据扩展名获取MIME类型
func getMimeTypeByExt(ext string) string {
extMap := map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".zip": "application/zip",
".txt": "text/plain",
}
if mime, ok := extMap[strings.ToLower(ext)]; ok {
return mime
}
return "application/octet-stream"
}
func resolveUploadDir(uploadSubDir string) (string, error) {
if filepath.IsAbs(uploadSubDir) {
if err := ensureUploadDir(uploadSubDir); err != nil {
return "", err
}
return uploadSubDir, nil
}
baseDir, err := GetCurrentAbPathByExecutable()
if err != nil {
return "", fmt.Errorf("获取执行文件目录失败: %w", err)
}
absolutePath := filepath.Join(baseDir, "storage", uploadSubDir)
if err := ensureUploadDir(absolutePath); err != nil {
return "", err
}
return absolutePath, nil
}
func ensureUploadDir(uploadDir string) error {
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
}
return nil
}
func saveUploadedFile(fileHeader *multipart.FileHeader, uploadDir string, imagesOnly bool) (*FileInfo, error) {
if err := ensureUploadDir(uploadDir); err != nil {
return nil, err
}
src, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("打开上传文件失败: %w", err)
}
defer src.Close()
tempFile, err := os.CreateTemp(uploadDir, "upload-*")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
tempPath := tempFile.Name()
keepTemp := false
defer func() {
_ = tempFile.Close()
if !keepTemp {
_ = os.Remove(tempPath)
}
}()
headerBytes, size, sum, err := copyUploadedContent(tempFile, src)
if err != nil {
return nil, err
}
detectedExt, mimeType, err := detectMimeType(headerBytes)
if err != nil {
return nil, fmt.Errorf("检测文件类型失败: %w", err)
}
if imagesOnly {
if _, allowed, detectedMime, detectErr := detectAllowedImage(headerBytes); detectErr != nil {
return nil, fmt.Errorf("检测文件类型失败: %w", detectErr)
} else if !allowed {
return nil, ErrInvalidImageType
} else if detectedMime != "" {
mimeType = detectedMime
}
}
ext := filepath.Ext(fileHeader.Filename)
if detectedExt != "" {
ext = "." + detectedExt
}
if mimeType == "" {
mimeType = getMimeTypeByExt(ext)
}
fileUUID := uuid.New()
fileUUIDStr := strings.ReplaceAll(fileUUID.String(), "-", "")
newFilename := fileUUID.String() + ext
savePath := filepath.Join(uploadDir, newFilename)
if err := tempFile.Chmod(0644); err != nil {
return nil, fmt.Errorf("设置文件权限失败: %w", err)
}
if err := tempFile.Close(); err != nil {
return nil, fmt.Errorf("关闭临时文件失败: %w", err)
}
if err := os.Rename(tempPath, savePath); err != nil {
return nil, fmt.Errorf("重命名文件失败: %w", err)
}
keepTemp = true
return &FileInfo{
OriginName: fileHeader.Filename,
Name: newFilename,
Path: savePath,
Size: size,
Sha256: sum,
UUID: fileUUIDStr,
Ext: ext,
MimeType: mimeType,
Status: "SUCCESS",
}, nil
}
func copyUploadedContent(dst io.Writer, src io.Reader) ([]byte, int64, string, error) {
hash := sha256.New()
writer := io.MultiWriter(dst, hash)
header := make([]byte, 0, fileHeaderSampleSize)
buffer := make([]byte, uploadBufferSize)
var total int64
for {
n, readErr := src.Read(buffer)
if n > 0 {
chunk := buffer[:n]
header = appendHeaderSample(header, chunk)
written, err := writeUploadChunk(writer, chunk)
if err != nil {
return nil, 0, "", err
}
total += int64(written)
}
done, err := shouldStopUploadRead(readErr)
if !done {
continue
}
if err == nil {
return header, total, hex.EncodeToString(hash.Sum(nil)), nil
}
return nil, 0, "", fmt.Errorf("读取文件失败: %w", err)
}
}
func appendHeaderSample(header []byte, chunk []byte) []byte {
if len(header) >= fileHeaderSampleSize {
return header
}
remaining := fileHeaderSampleSize - len(header)
if remaining > len(chunk) {
remaining = len(chunk)
}
return append(header, chunk[:remaining]...)
}
func writeUploadChunk(dst io.Writer, chunk []byte) (int, error) {
written, err := dst.Write(chunk)
if err != nil {
return 0, fmt.Errorf("写入临时文件失败: %w", err)
}
return written, nil
}
func shouldStopUploadRead(err error) (bool, error) {
if err == nil {
return false, nil
}
if errors.Is(err, io.EOF) {
return true, nil
}
return true, err
}
func detectMimeType(header []byte) (string, string, error) {
if len(header) == 0 {
return "", "", nil
}
kind, err := filetype.Match(header)
if err != nil {
return "", "", err
}
if kind == filetype.Unknown {
return "", "", nil
}
return kind.Extension, kind.MIME.Value, nil
}
func detectAllowedImage(header []byte) (string, bool, string, error) {
if len(header) == 0 {
return "", false, "", nil
}
kind, err := filetype.Match(header)
if err != nil {
return "", false, "", err
}
if kind == filetype.Unknown {
return "", false, "", nil
}
allowed := map[string]types.Type{
"jpg": filetype.GetType("jpg"),
"jpeg": filetype.GetType("jpeg"),
"png": filetype.GetType("png"),
"gif": filetype.GetType("gif"),
}
for _, imageType := range allowed {
if kind.MIME.Value == imageType.MIME.Value {
return kind.Extension, true, kind.MIME.Value, nil
}
}
return kind.Extension, false, kind.MIME.Value, nil
}
================================================
FILE: pkg/utils/upload_test.go
================================================
package utils
import (
"bytes"
"errors"
"io"
"testing"
)
func TestIsAllowedImageHandlesShortNonImageFile(t *testing.T) {
file := bytes.NewReader([]byte("x"))
ext, allowed, err := IsAllowedImage(file)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if allowed {
t.Fatalf("expected non-image short file to be rejected")
}
if ext != "" {
t.Fatalf("expected empty extension, got %q", ext)
}
}
func TestAppendHeaderSampleRespectsLimit(t *testing.T) {
header := make([]byte, 0, fileHeaderSampleSize)
chunk := bytes.Repeat([]byte("a"), fileHeaderSampleSize+10)
header = appendHeaderSample(header, chunk)
if len(header) != fileHeaderSampleSize {
t.Fatalf("expected header size %d, got %d", fileHeaderSampleSize, len(header))
}
header = appendHeaderSample(header, []byte("b"))
if len(header) != fileHeaderSampleSize {
t.Fatalf("expected header size to stay %d, got %d", fileHeaderSampleSize, len(header))
}
}
func TestShouldStopUploadRead(t *testing.T) {
done, err := shouldStopUploadRead(nil)
if done || err != nil {
t.Fatalf("expected continue reading, got done=%v err=%v", done, err)
}
done, err = shouldStopUploadRead(io.EOF)
if !done || err != nil {
t.Fatalf("expected eof to finish cleanly, got done=%v err=%v", done, err)
}
wantErr := errors.New("read failed")
done, err = shouldStopUploadRead(wantErr)
if !done || !errors.Is(err, wantErr) {
t.Fatalf("expected done with original err, got done=%v err=%v", done, err)
}
}
================================================
FILE: pkg/utils/utils.go
================================================
package utils
import (
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// If 模拟简单的三元操作
func If[T any](condition bool, trueVal, falseVal T) T {
if condition {
return trueVal
}
return falseVal
}
// WouldCauseCycle 检查新的父节点是否是当前节点的子节点,防止循环引用
func WouldCauseCycle(id, parentPid uint, parentPids string) bool {
if id == 0 {
return false
}
if parentPid == id {
return true
}
// 检测循环引用
idStr := fmt.Sprintf("%d", id)
pidsSlice := strings.Split(parentPids, ",")
for _, pid := range pidsSlice {
if pid == idStr {
return true
}
}
return false
}
// GetRunPath 获取执行目录作为默认目录
func GetRunPath() string {
currentPath, err := os.Getwd()
if err != nil {
return ""
}
return currentPath
}
// GetFileDirectoryToCaller 根据运行堆栈信息获取文件目录,skip 默认1
func GetFileDirectoryToCaller(opts ...int) (directory string, ok bool) {
var filename string
directory = ""
skip := 1
if opts != nil {
skip = opts[0]
}
if _, filename, _, ok = runtime.Caller(skip); ok {
directory = filepath.Dir(filename)
}
return
}
// GetCurrentAbPathByExecutable 获取当前执行文件所在目录的绝对路径
// 这是最可靠的获取二进制文件所在目录的方法,适用于所有环境
func GetCurrentAbPathByExecutable() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("获取执行文件路径失败: %w", err)
}
// 解析符号链接,获取真实路径
realPath, err := filepath.EvalSymlinks(exePath)
if err != nil {
// 如果解析符号链接失败,使用原始路径
realPath = exePath
}
// 获取目录路径并转换为绝对路径
dir := filepath.Dir(realPath)
absDir, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("获取绝对路径失败: %w", err)
}
return absDir, nil
}
// GetCurrentPath 获取当前执行文件路径(始终使用二进制文件所在目录)
// 这是统一的路径获取方法,确保所有环境行为一致
func GetCurrentPath() (dir string, err error) {
return GetCurrentAbPathByExecutable()
}
// GetDefaultPath 获取当前执行文件路径,如果是临时目录则获取运行命令的工作目录
func GetDefaultPath() (dir string, err error) {
if os.Getenv("GO_ENV") != "development" {
dir, err = GetCurrentAbPathByExecutable()
if err != nil {
return "", err
}
} else {
dir = GetRunPath()
}
return dir, nil
}
// MD5 计算字符串的 MD5 值
func MD5(str string) string {
// 计算 MD5 哈希
hash := md5.Sum([]byte(str))
// 将哈希值转换为十六进制字符串
return hex.EncodeToString(hash[:])
}
================================================
FILE: pkg/utils/utils_test.go
================================================
package utils
import (
"testing"
)
func TestGetRunPath(t *testing.T) {
path := GetRunPath()
if path == "" {
t.Error("获取运行路径失败")
}
}
func TestGetCurrentPath(t *testing.T) {
_, err := GetCurrentPath()
if err != nil {
t.Error("获取运行路径失败")
}
}
func TestGetCurrentAbPathByExecutable(t *testing.T) {
_, err := GetCurrentAbPathByExecutable()
if err != nil {
t.Error("获取路径失败")
}
}
func TestGetCurrentFileDirectory(t *testing.T) {
path, ok := GetFileDirectoryToCaller()
if !ok {
t.Error("获取路径失败", path)
}
path, ok = GetFileDirectoryToCaller(1)
if !ok {
t.Error("获取路径失败", path)
}
}
func TestIf(t *testing.T) {
if 3 != If(false, 1, 3) {
t.Error("模拟三元操作失败")
}
if 1 != If(true, 1, 3) {
t.Error("模拟三元操作失败")
}
}
================================================
FILE: policy.csv
================================================
# 用户-角色绑定
g, user:1, dept:2
# 角色继承
g, role:1, role:2
g, role:2, role:3
# 部门-》角色绑定
g, dept:1, role:1
g, dept:2, role:2
g, dept:2, role:4
# 角色-》菜单绑定
g, role:1, menu:1
g, role:3, menu:3
g, role:2, menu:2
# 菜单-》权限绑定
p, menu:1, /menu/*, GET
p, menu:1, /api/hr/*, POST
p, menu:2, /api/v1/menu/update, POST
p, menu:2, /api/data, GET
p, menu:3, /api/v1/menu/list, GET
p, menu:3, /api/v1/menu/add, POST
# 测试用例
# user:1, /api/data, GET
# user:1, /api/v1/menu/update, POST
# user:1, /api/v1/menu/add1, POST
================================================
FILE: rbac_model.conf
================================================
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = (g(r.sub, p.sub) && keyMatch3(r.obj, p.obj) && regexMatch(r.act, p.act))
================================================
FILE: tests/README.md
================================================
# tests 目录说明
当前项目对外路由共 `42` 个:
- 根路由 `GET /ping` 1 个
- 管理端 `/admin/v1` 路由 41 个
目前这 `42` 个接口都能在 `tests` 目录下找到对应测试入口。
## 目录职责
- `test.go`
- 测试公共初始化入口
- 负责装载配置、初始化数据库、验证器、Gin Router
- `ping_test.go`
- 根路由 `GET /ping`
- `admin_test/`
- 管理端接口测试目录
- 按资源域拆分,同一资源的读写测试放在同一个文件里
## admin_test 文件分工
- `public_routes_test.go`
- `/admin/v1/demo`
- `/admin/v1/file/:uuid`
- `auth_routes_test.go`
- `/admin/v1/auth/captcha`
- `/admin/v1/auth/login`
- `/admin/v1/auth/check-token`
- `/admin/v1/auth/logout`
- `common_routes_test.go`
- `/admin/v1/common/upload`
- `admin_user_test.go`
- `GET /admin/v1/admin-user/*`
- `POST /admin/v1/admin-user/update-profile`
- `POST /admin/v1/admin-user/update-password`
- `POST /admin/v1/admin-user/create`
- `POST /admin/v1/admin-user/update`
- `POST /admin/v1/admin-user/delete`
- `POST /admin/v1/admin-user/bind-role`
- `permission_routes_test.go`
- `POST /admin/v1/permission/update`
- `GET /admin/v1/permission/list`
- `menu_test.go`
- `GET /admin/v1/menu/*`
- `POST /admin/v1/menu/create`
- `POST /admin/v1/menu/update`
- `POST /admin/v1/menu/delete`
- `role_test.go`
- `GET /admin/v1/role/*`
- `POST /admin/v1/role/create`
- `POST /admin/v1/role/update`
- `POST /admin/v1/role/delete`
- `POST /admin/v1/role/menu-access`
- `department_test.go`
- `GET /admin/v1/department/*`
- `POST /admin/v1/department/create`
- `POST /admin/v1/department/update`
- `POST /admin/v1/department/delete`
- `POST /admin/v1/department/user-access`
- `log_routes_test.go`
- `GET /admin/v1/log/request/list`
- `GET /admin/v1/log/request/detail`
- `GET /admin/v1/log/login/list`
- `GET /admin/v1/log/login/detail`
## 辅助文件
- `admin_test/admin_test.go`
- `TestMain`
- 请求发送、鉴权头注入、统一响应解析
- `admin_test/test_helpers_test.go`
- 环境判断、测试资源命名、查找、清理、兜底数据创建
## 维护规则
- 新增接口时,必须在 `tests` 目录补对应测试。
- 管理端新接口默认放到 `tests/admin_test`,并按资源域归类。
- 同一资源的读写测试优先合并在同一个 `*_test.go` 文件内。
================================================
FILE: tests/admin_test/README.md
================================================
# tests/admin_test 索引
这份索引用于解决两个问题:
1. 当前后台接口哪些已经在 `tests/admin_test` 里被覆盖到了
2. 某个接口该去哪个测试文件里找
## 当前结论
- `internal/routers/admin_router.go` 中共定义 **68 个接口**
- 所有 68 个接口都至少有一处测试引用
- 但覆盖深度并不完全一致
- 64 个接口有成功路径测试(含完整 CRUD 流程)
- 4 个接口仅有未登录拦截 / 参数校验测试
结论:
- **接口覆盖有了**
- **覆盖深度不均匀**
## 目录规则
当前目录按"领域"拆分,同一资源的读写测试放在同一个文件里:
- `public_routes_test.go` — 公开接口
- `auth_routes_test.go` — 登录、验证码、token 校验、登出
- `common_routes_test.go` — 通用接口(上传等)
- `admin_user_test.go` — 管理员相关接口(含读测试、鉴权测试、写流程测试)
- `permission_routes_test.go` — API 权限接口
- `menu_test.go` — 菜单相关接口(含读测试、鉴权测试、写流程测试)
- `role_test.go` — 角色相关接口(含读测试、鉴权测试、写流程测试)
- `department_test.go` — 部门相关接口(含读测试、鉴权测试、写流程测试)
- `system_routes_test.go` — 系统参数 / 字典接口
- `log_routes_test.go` — 请求日志 / 登录日志接口
- `task_routes_test.go` — 任务中心组接口
- `test_helpers_test.go` — 测试辅助函数(环境判断、测试资源命名/查找/清理/兜底数据创建)
## 接口到文件映射
### 公开接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/demo` | `public_routes_test.go` | 成功路径 |
| `GET /admin/v1/file/:uuid` | `public_routes_test.go` | Not-found |
| `POST /admin/v1/login` | `auth_routes_test.go` | 参数校验(验证码错误) |
| `GET /admin/v1/login-captcha` | `auth_routes_test.go` | 成功路径 |
### 通用 / 认证接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `POST /admin/v1/common/upload` | `common_routes_test.go` | 未登录拦截 |
| `POST /admin/v1/auth/logout` | `auth_routes_test.go` | 成功路径(DB token 撤销)+ 未登录拦截 |
| `GET /admin/v1/auth/check-token` | `auth_routes_test.go` | 成功路径 |
### 管理员接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/admin-user/get` | `admin_user_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/admin-user/user-menu-info` | `admin_user_test.go` | 成功路径 |
| `POST /admin/v1/admin-user/update-profile` | `admin_user_test.go` | 参数校验 |
| `GET /admin/v1/admin-user/list` | `admin_user_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/admin-user/detail` | `admin_user_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/admin-user/get-full-phone` | `admin_user_test.go` | 未登录拦截 |
| `GET /admin/v1/admin-user/get-full-email` | `admin_user_test.go` | 未登录拦截 |
| `POST /admin/v1/admin-user/create` | `admin_user_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/admin-user/update` | `admin_user_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/admin-user/delete` | `admin_user_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/admin-user/bind-role` | `admin_user_test.go` | 未登录拦截 |
### 权限接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/permission/list` | `permission_routes_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/permission/update` | `permission_routes_test.go` | 未登录拦截 + 参数校验 |
### 菜单接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/menu/list` | `menu_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/menu/detail` | `menu_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/menu/create` | `menu_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/menu/update` | `menu_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/menu/delete` | `menu_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/menu/update-all-menu-permissions` | `menu_test.go` | 成功路径 + 未登录拦截 |
### 角色接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/role/list` | `role_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/role/detail` | `role_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/role/create` | `role_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/role/update` | `role_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/role/delete` | `role_test.go` | 成功路径 + 未登录拦截 |
### 部门接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/department/list` | `department_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/department/detail` | `department_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/department/create` | `department_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/department/update` | `department_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/department/delete` | `department_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/department/bind-role` | `department_test.go` | 成功路径 + 未登录拦截 |
### 系统参数
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/system/config/list` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `GET /admin/v1/system/config/detail` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `GET /admin/v1/system/config/value` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/config/create` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/config/update` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/config/delete` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/config/refresh` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
### 系统字典
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/system/dict/type/list` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `GET /admin/v1/system/dict/type/detail` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/dict/type/create` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/dict/type/update` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/dict/type/delete` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `GET /admin/v1/system/dict/item/list` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/dict/item/create` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/dict/item/update` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `POST /admin/v1/system/dict/item/delete` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
| `GET /admin/v1/system/dict/options` | `system_routes_test.go` | 成功路径 + 未登录拦截(成功路径依赖 `sys_*` 表已迁移) |
### 日志接口
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/log/request/list` | `log_routes_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/log/request/detail` | `log_routes_test.go` | 未登录拦截 |
| `GET /admin/v1/log/request/export` | `log_routes_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/log/request/mask-config` | `log_routes_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/log/request/mask-config` | `log_routes_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/log/login/list` | `log_routes_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/log/login/detail` | `log_routes_test.go` | 未登录拦截 |
### 任务中心
| 接口 | 测试文件 | 当前覆盖 |
| --- | --- | --- |
| `GET /admin/v1/task/list` | `task_routes_test.go` | 成功路径 + 未登录拦截 |
| `POST /admin/v1/task/trigger` | `task_routes_test.go` | 未登录拦截 + 参数校验 |
| `GET /admin/v1/task/run/list` | `task_routes_test.go` | 成功路径 + 未登录拦截 |
| `GET /admin/v1/task/run/detail` | `task_routes_test.go` | Not-found + 未登录拦截 |
| `POST /admin/v1/task/run/retry` | `task_routes_test.go` | 未登录拦截 + 参数校验 |
| `POST /admin/v1/task/run/cancel` | `task_routes_test.go` | 未登录拦截 + 参数校验 |
| `GET /admin/v1/task/cron/state` | `task_routes_test.go` | 成功路径 + 未登录拦截 |
## 覆盖统计
| 覆盖等级 | 接口数 | 说明 |
| --- | --- | --- |
| 成功路径 + CRUD 流程 | 50 | admin-user / menu / role / department / system 等有完整 write flow |
| 成功路径(仅读) | 14 | 列表、详情、导出等只读操作 |
| 未登录拦截(仅有) | 4 | 仅覆盖了鉴权,无成功路径 |
## 当前仍然偏弱的接口
这些接口仅有未登录拦截或参数校验测试,后续增强应优先补成功路径:
**其他模块**
- `POST /admin/v1/common/upload` — 仅有未登录拦截
- `POST /admin/v1/admin-user/bind-role` — 仅有未登录拦截
- `GET /admin/v1/admin-user/get-full-phone` — 仅有未登录拦截
- `GET /admin/v1/admin-user/get-full-email` — 仅有未登录拦截
**覆盖较浅但暂可接受的接口**
- `POST /admin/v1/admin-user/update-profile` — 仅有参数校验(无成功路径)
- `POST /admin/v1/permission/update` — 仅有未登录拦截 + 参数校验
- `GET /admin/v1/log/request/detail` — 仅有未登录拦截
- `GET /admin/v1/log/login/detail` — 仅有未登录拦截
================================================
FILE: tests/admin_test/admin_test.go
================================================
package admin_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
c "github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/internal/global"
"github.com/wannanbigpig/gin-layout/internal/pkg/response"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
"github.com/wannanbigpig/gin-layout/tests"
)
var (
router *gin.Engine
authorization string
mysqlEnabled bool
)
func TestMain(m *testing.M) {
var err error
router, err = tests.SetupRouter()
if err != nil {
_, _ = os.Stderr.WriteString("初始化测试路由失败: " + err.Error() + "\n")
os.Exit(1)
}
now := time.Now().UTC()
expiresAt := now.Add(time.Second * c.Config.Jwt.TTL)
claims := token.AdminCustomClaims{
AdminUserInfo: token.AdminUserInfo{
UserID: 1,
Nickname: "super_admin",
IsSuperAdmin: 1,
},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
Issuer: global.Issuer,
Subject: global.PcAdminSubject,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ID: uuid.NewString(),
},
}
accessToken, err := token.Generate(claims)
if err != nil {
_, _ = os.Stderr.WriteString("创建管理员Token失败: " + err.Error() + "\n")
os.Exit(1)
}
authorization = "Bearer " + accessToken
mysqlEnabled = c.Config.Mysql.Enable
os.Exit(m.Run())
}
func postRequest(route string, body *string) *httptest.ResponseRecorder {
return performRequest(http.MethodPost, route, body, authorization)
}
func anonymousPostRequest(route string, body *string) *httptest.ResponseRecorder {
return performRequest(http.MethodPost, route, body, "")
}
func getRequest(route string, queryParams *url.Values) *httptest.ResponseRecorder {
path := route
if queryParams != nil {
path += "?" + queryParams.Encode()
}
return performRequest(http.MethodGet, path, nil, authorization)
}
func anonymousGetRequest(route string, queryParams *url.Values) *httptest.ResponseRecorder {
path := route
if queryParams != nil {
path += "?" + queryParams.Encode()
}
return performRequest(http.MethodGet, path, nil, "")
}
func performRequest(method, route string, body *string, authHeader string) *httptest.ResponseRecorder {
var reader io.Reader
if body != nil {
reader = bytes.NewBufferString(*body)
}
req := httptest.NewRequest(method, route, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
// decodeResult 解析统一响应结构。
func decodeResult(t *testing.T, recorder *httptest.ResponseRecorder) *response.Result {
t.Helper()
result := new(response.Result)
if err := json.Unmarshal(recorder.Body.Bytes(), result); err != nil {
t.Fatalf("解析响应失败: %v, body=%s", err, recorder.Body.String())
}
return result
}
// requireMySQL 在需要真实数据库链路时跳过测试。
func requireMySQL(t *testing.T) {
t.Helper()
if !mysqlEnabled {
t.Skip("当前测试配置未启用 MySQL,跳过需要真实数据库链路的接口流程测试")
}
}
================================================
FILE: tests/admin_test/admin_user_test.go
================================================
package admin_test
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestGetAdminUserRequiresLogin(t *testing.T) {
queryParams := &url.Values{}
queryParams.Set("id", "1")
resp := anonymousGetRequest("/admin/v1/admin-user/get", queryParams)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestGetCurrentAdminUserWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/admin-user/get", nil)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
data, ok := result.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, float64(1), data["id"])
}
func TestGetUserMenuInfoWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/admin-user/user-menu-info", nil)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
}
func TestUpdateProfileInvalidEmail(t *testing.T) {
requireMySQL(t)
body := `{"email":"invalid-email"}`
resp := postRequest("/admin/v1/admin-user/update-profile", &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.InvalidParameter, result.Code)
}
func TestAdminUserListWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/admin-user/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
}
func TestAdminUserListRequiresLogin(t *testing.T) {
resp := anonymousGetRequest("/admin/v1/admin-user/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestAdminUserProtectedGetRoutesRequireLogin(t *testing.T) {
testCases := []struct {
name string
route string
query *url.Values
}{
{name: "管理员详情需要登录", route: "/admin/v1/admin-user/detail", query: &url.Values{"id": {"1"}}},
{name: "管理员完整手机号需要登录", route: "/admin/v1/admin-user/get-full-phone", query: &url.Values{"id": {"1"}}},
{name: "管理员完整邮箱需要登录", route: "/admin/v1/admin-user/get-full-email", query: &url.Values{"id": {"1"}}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resp := anonymousGetRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
}
func TestAdminUserProtectedPostRoutesRequireLogin(t *testing.T) {
testCases := []struct {
name string
route string
body string
}{
{name: "管理员创建需要登录", route: "/admin/v1/admin-user/create", body: `{}`},
{name: "管理员更新需要登录", route: "/admin/v1/admin-user/update", body: `{}`},
{name: "管理员删除需要登录", route: "/admin/v1/admin-user/delete", body: `{}`},
{name: "管理员绑定角色需要登录", route: "/admin/v1/admin-user/bind-role", body: `{}`},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
}
func TestAdminUserWriteFlow(t *testing.T) {
requireWritableDB(t)
username := fmt.Sprintf("ta%d", time.Now().UnixNano()%1e10)
cleanupAdminUsers(t, "ta")
createBody := map[string]any{
"username": username,
"nickname": "测试管理员",
"password": "12345678",
"email": username + "@example.com",
"dept_ids": []uint{1},
"status": 1,
}
bodyBytes, _ := json.Marshal(createBody)
body := string(bodyBytes)
resp := postRequest("/admin/v1/admin-user/create", &body)
result := decodeResult(t, resp)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, e.SUCCESS, result.Code)
user := findAdminUserByUsername(t, username)
detailResp := getRequest("/admin/v1/admin-user/detail", &url.Values{"id": {strconv.FormatUint(uint64(user.ID), 10)}})
detailResult := decodeResult(t, detailResp)
assert.Equal(t, e.SUCCESS, detailResult.Code)
updateBody := map[string]any{
"id": user.ID,
"nickname": "测试管理员-更新",
"email": username + "-updated@example.com",
"dept_ids": []uint{1},
}
updateBytes, _ := json.Marshal(updateBody)
updatePayload := string(updateBytes)
updateResp := postRequest("/admin/v1/admin-user/update", &updatePayload)
updateResult := decodeResult(t, updateResp)
assert.Equal(t, e.SUCCESS, updateResult.Code)
deleteBytes, _ := json.Marshal(map[string]any{"id": user.ID})
deletePayload := string(deleteBytes)
deleteResp := postRequest("/admin/v1/admin-user/delete", &deletePayload)
deleteResult := decodeResult(t, deleteResp)
assert.Equal(t, e.SUCCESS, deleteResult.Code)
}
================================================
FILE: tests/admin_test/auth_routes_test.go
================================================
package admin_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
c "github.com/wannanbigpig/gin-layout/config"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token"
)
// parseTestToken 解析测试用 token 并返回 claims。
func parseTestToken(accessToken string) (*token.AdminCustomClaims, error) {
claims := new(token.AdminCustomClaims)
secret := []byte(c.GetConfig().Jwt.SecretKey)
parsedToken, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, err
}
if !parsedToken.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func TestLoginCaptcha(t *testing.T) {
captchaResp := anonymousGetRequest("/admin/v1/login-captcha", nil)
assert.Equal(t, http.StatusOK, captchaResp.Code)
captchaResult := decodeResult(t, captchaResp)
assert.Equal(t, e.SUCCESS, captchaResult.Code)
}
func TestLoginInvalidCaptcha(t *testing.T) {
loginData := map[string]any{
"username": "super_admin",
"password": "123456",
"captcha": "wrong",
"captcha_id": "invalid",
}
body, err := json.Marshal(loginData)
assert.Nil(t, err)
bodyStr := string(body)
resp := anonymousPostRequest("/admin/v1/login", &bodyStr)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.CaptchaErr, result.Code)
}
func TestCheckTokenWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/auth/check-token", nil)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
data, ok := result.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, true, data["result"])
}
func TestProtectedAuthRoutesRequireLogin(t *testing.T) {
testCases := []struct {
name string
route string
body string
}{
{name: "退出登录需要登录", route: "/admin/v1/auth/logout", body: `{}`},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
}
// TestLogoutUpdatesDatabase 测试退出登录接口能够正确更新数据库中的 token 撤销状态。
func TestLogoutUpdatesDatabase(t *testing.T) {
requireMySQL(t)
// 先获取验证码
captchaResp := anonymousGetRequest("/admin/v1/login-captcha", nil)
assert.Equal(t, http.StatusOK, captchaResp.Code)
captchaResult := decodeResult(t, captchaResp)
if captchaResult.Code != e.SUCCESS {
t.Skipf("获取验证码失败,跳过测试:%s", captchaResult.Msg)
}
captchaData, ok := captchaResult.Data.(map[string]any)
assert.True(t, ok)
captchaID, _ := captchaData["id"].(string)
// 验证码答案在测试环境下会返回
captchaAnswer, _ := captchaData["answer"].(string)
// 使用验证码登录
loginData := map[string]any{
"username": "super_admin",
"password": "123456",
"captcha": captchaAnswer,
"captcha_id": captchaID,
}
body, err := json.Marshal(loginData)
assert.Nil(t, err)
bodyStr := string(body)
loginResp := anonymousPostRequest("/admin/v1/login", &bodyStr)
assert.Equal(t, http.StatusOK, loginResp.Code)
loginResult := decodeResult(t, loginResp)
if loginResult.Code != e.SUCCESS {
t.Skipf("登录失败,跳过测试:%s", loginResult.Msg)
}
// 提取 token 中的 jwt_id 用于后续验证
data, ok := loginResult.Data.(map[string]any)
assert.True(t, ok)
accessToken, ok := data["access_token"].(string)
assert.True(t, ok)
// 解析 token 获取 jwt_id
claims, err := parseTestToken(accessToken)
assert.Nil(t, err)
jwtID := claims.ID
// 调用退出登录接口(使用登录后返回的 token)
logoutHeader := "Bearer " + accessToken
logoutResp := performRequest(http.MethodPost, "/admin/v1/auth/logout", &bodyStr, logoutHeader)
assert.Equal(t, http.StatusOK, logoutResp.Code)
logoutResult := decodeResult(t, logoutResp)
assert.Equal(t, e.SUCCESS, logoutResult.Code)
// 验证数据库中该 jwt_id 的记录被标记为已撤销
loginLog := model.NewAdminLoginLogs()
db, err := loginLog.GetDB()
assert.Nil(t, err)
err = db.Where("jwt_id = ? AND deleted_at = 0", jwtID).First(loginLog).Error
assert.Nil(t, err)
assert.Equal(t, uint8(1), loginLog.IsRevoked, "退出登录后 token 应被标记为已撤销")
assert.Equal(t, uint8(1), loginLog.RevokedCode, "撤销原因码应为用户主动登出")
}
================================================
FILE: tests/admin_test/common_routes_test.go
================================================
package admin_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestCommonUploadRequiresLogin(t *testing.T) {
body := `{}`
resp := anonymousPostRequest("/admin/v1/common/upload", &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
================================================
FILE: tests/admin_test/department_test.go
================================================
package admin_test
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestDepartmentListWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/department/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
}
func TestDepartmentListRequiresLogin(t *testing.T) {
resp := anonymousGetRequest("/admin/v1/department/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestDepartmentProtectedRoutesRequireLogin(t *testing.T) {
postCases := []struct {
name string
route string
body string
}{
{name: "部门创建需要登录", route: "/admin/v1/department/create", body: `{}`},
{name: "部门更新需要登录", route: "/admin/v1/department/update", body: `{}`},
{name: "部门删除需要登录", route: "/admin/v1/department/delete", body: `{}`},
{name: "部门绑定角色需要登录", route: "/admin/v1/department/bind-role", body: `{}`},
}
for _, tc := range postCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
resp := anonymousGetRequest("/admin/v1/department/detail", &url.Values{"id": {"1"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestDepartmentWriteFlow(t *testing.T) {
requireWritableDB(t)
name := uniqueTestName("dept")
cleanupDepartments(t, testResourcePrefix+"dept")
createBody := map[string]any{
"name": name,
"description": "测试部门",
"sort": 10,
}
createBytes, _ := json.Marshal(createBody)
createPayload := string(createBytes)
createResp := postRequest("/admin/v1/department/create", &createPayload)
createResult := decodeResult(t, createResp)
assert.Equal(t, e.SUCCESS, createResult.Code)
dept := findDepartmentByName(t, name)
detailResp := getRequest("/admin/v1/department/detail", &url.Values{"id": {strconv.FormatUint(uint64(dept.ID), 10)}})
detailResult := decodeResult(t, detailResp)
assert.Equal(t, e.SUCCESS, detailResult.Code)
updateBody := map[string]any{
"id": dept.ID,
"name": name,
"description": "测试部门-更新",
"sort": 20,
}
updateBytes, _ := json.Marshal(updateBody)
updatePayload := string(updateBytes)
updateResp := postRequest("/admin/v1/department/update", &updatePayload)
updateResult := decodeResult(t, updateResp)
assert.Equal(t, e.SUCCESS, updateResult.Code)
bindBytes, _ := json.Marshal(map[string]any{"dept_id": dept.ID, "role_ids": []uint{firstActiveRoleID(t)}})
bindPayload := string(bindBytes)
bindResp := postRequest("/admin/v1/department/bind-role", &bindPayload)
bindResult := decodeResult(t, bindResp)
assert.Equal(t, e.SUCCESS, bindResult.Code)
deleteBytes, _ := json.Marshal(map[string]any{"id": dept.ID})
deletePayload := string(deleteBytes)
deleteResp := postRequest("/admin/v1/department/delete", &deletePayload)
deleteResult := decodeResult(t, deleteResp)
assert.Equal(t, e.SUCCESS, deleteResult.Code)
}
================================================
FILE: tests/admin_test/log_routes_test.go
================================================
package admin_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestLogListRoutesWithAuthorization(t *testing.T) {
requireMySQL(t)
testCases := []struct {
name string
route string
query *url.Values
}{
{
name: "请求日志列表",
route: "/admin/v1/log/request/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{
name: "登录日志列表",
route: "/admin/v1/log/login/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resp := getRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
})
}
}
func TestRequestLogExportRouteWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/log/request/export", &url.Values{"limit": {"10"}})
assert.Equal(t, http.StatusOK, resp.Code)
assert.Contains(t, resp.Header().Get("Content-Type"), "text/csv")
assert.Contains(t, resp.Header().Get("Content-Disposition"), "request_logs_")
}
func TestRequestLogMaskConfigRoutesWithAuthorization(t *testing.T) {
resp := getRequest("/admin/v1/log/request/mask-config", nil)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
body := `{"common":["password","token"],"request_body":["phone"]}`
updateResp := postRequest("/admin/v1/log/request/mask-config", &body)
assert.Equal(t, http.StatusOK, updateResp.Code)
updateResult := decodeResult(t, updateResp)
assert.Equal(t, e.SUCCESS, updateResult.Code)
}
func TestLogRoutesRequireLogin(t *testing.T) {
getCases := []struct {
name string
route string
query *url.Values
}{
{
name: "请求日志列表需要登录",
route: "/admin/v1/log/request/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{
name: "请求日志详情需要登录",
route: "/admin/v1/log/request/detail",
query: &url.Values{"id": {"1"}},
},
{
name: "请求日志导出需要登录",
route: "/admin/v1/log/request/export",
query: &url.Values{"limit": {"10"}},
},
{
name: "请求日志脱敏配置需要登录",
route: "/admin/v1/log/request/mask-config",
query: nil,
},
{
name: "登录日志列表需要登录",
route: "/admin/v1/log/login/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{
name: "登录日志详情需要登录",
route: "/admin/v1/log/login/detail",
query: &url.Values{"id": {"1"}},
},
}
for _, tc := range getCases {
t.Run(tc.name, func(t *testing.T) {
resp := anonymousGetRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
body := `{"common":["password"]}`
resp := anonymousPostRequest("/admin/v1/log/request/mask-config", &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
================================================
FILE: tests/admin_test/menu_test.go
================================================
package admin_test
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestMenuListWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/menu/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
firstMenu := firstMenuNode(result.Data)
if firstMenu != nil {
_, hasTitle := firstMenu["title"]
assert.True(t, hasTitle)
_, hasTitleI18n := firstMenu["title_i18n"]
assert.False(t, hasTitleI18n)
}
}
func TestMenuListRequiresLogin(t *testing.T) {
resp := anonymousGetRequest("/admin/v1/menu/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestMenuProtectedRoutesRequireLogin(t *testing.T) {
postCases := []struct {
name string
route string
body string
}{
{name: "菜单创建需要登录", route: "/admin/v1/menu/create", body: `{}`},
{name: "菜单更新需要登录", route: "/admin/v1/menu/update", body: `{}`},
{name: "菜单删除需要登录", route: "/admin/v1/menu/delete", body: `{}`},
{name: "刷新菜单权限需要登录", route: "/admin/v1/menu/update-all-menu-permissions", body: `{}`},
}
for _, tc := range postCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
resp := anonymousGetRequest("/admin/v1/menu/detail", &url.Values{"id": {"1"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestMenuWriteFlow(t *testing.T) {
requireWritableDB(t)
title := uniqueTestName("menu")
cleanupMenus(t, testResourcePrefix+"menu")
createBody := map[string]any{
"title_i18n": map[string]string{
"zh-CN": title,
},
"name": title,
"path": "/" + title,
"component": "test/component",
"sort": 10,
"type": 2,
"status": 1,
"is_show": 1,
"is_auth": 1,
}
createBytes, _ := json.Marshal(createBody)
createPayload := string(createBytes)
createResp := postRequest("/admin/v1/menu/create", &createPayload)
createResult := decodeResult(t, createResp)
assert.Equal(t, e.SUCCESS, createResult.Code)
menu := findMenuByTitle(t, title)
detailResp := getRequest("/admin/v1/menu/detail", &url.Values{"id": {strconv.FormatUint(uint64(menu.ID), 10)}})
detailResult := decodeResult(t, detailResp)
assert.Equal(t, e.SUCCESS, detailResult.Code)
detailData, ok := detailResult.Data.(map[string]any)
assert.True(t, ok)
_, hasTitle := detailData["title"]
assert.False(t, hasTitle)
titleI18n, ok := detailData["title_i18n"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, title, titleI18n["zh-CN"])
updateBody := map[string]any{
"id": menu.ID,
"title_i18n": map[string]string{
"en-US": title + "-u-en",
},
"name": title + "-u-name",
"path": "/" + title + "-u-path",
"component": "test/component",
"sort": 20,
"type": 2,
"status": 1,
"is_show": 1,
"is_auth": 1,
}
updateBytes, _ := json.Marshal(updateBody)
updatePayload := string(updateBytes)
updateResp := postRequest("/admin/v1/menu/update", &updatePayload)
updateResult := decodeResult(t, updateResp)
assert.Equal(t, e.SUCCESS, updateResult.Code, updateResult.Msg)
updatedDetailResp := getRequest("/admin/v1/menu/detail", &url.Values{"id": {strconv.FormatUint(uint64(menu.ID), 10)}})
updatedDetailResult := decodeResult(t, updatedDetailResp)
assert.Equal(t, e.SUCCESS, updatedDetailResult.Code)
updatedDetailData, ok := updatedDetailResult.Data.(map[string]any)
assert.True(t, ok)
_, hasUpdatedTitle := updatedDetailData["title"]
assert.False(t, hasUpdatedTitle)
updatedTitleI18n, ok := updatedDetailData["title_i18n"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, title, updatedTitleI18n["zh-CN"])
assert.Equal(t, title+"-u-en", updatedTitleI18n["en-US"])
refreshBody := `{}`
refreshResp := postRequest("/admin/v1/menu/update-all-menu-permissions", &refreshBody)
refreshResult := decodeResult(t, refreshResp)
assert.Equal(t, e.SUCCESS, refreshResult.Code, refreshResult.Msg)
deleteBytes, _ := json.Marshal(map[string]any{"id": menu.ID})
deletePayload := string(deleteBytes)
deleteResp := postRequest("/admin/v1/menu/delete", &deletePayload)
deleteResult := decodeResult(t, deleteResp)
assert.Equal(t, e.SUCCESS, deleteResult.Code, deleteResult.Msg)
}
func firstMenuNode(data any) map[string]any {
switch typed := data.(type) {
case []any:
if len(typed) == 0 {
return nil
}
node, _ := typed[0].(map[string]any)
return node
case map[string]any:
rows, ok := typed["data"].([]any)
if !ok || len(rows) == 0 {
return nil
}
node, _ := rows[0].(map[string]any)
return node
default:
return nil
}
}
================================================
FILE: tests/admin_test/permission_routes_test.go
================================================
package admin_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestPermissionEditRequiresLogin(t *testing.T) {
body := `{"id":10,"name":"ping","description":"服务心跳检测接口","method":"GET","route":"/ping","is_auth":0,"sort":100}`
resp := anonymousPostRequest("/admin/v1/permission/update", &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestPermissionListRequiresLogin(t *testing.T) {
queryParams := &url.Values{}
queryParams.Set("page", "1")
queryParams.Set("per_page", "1")
queryParams.Set("name", "ping")
queryParams.Set("method", "GET")
queryParams.Set("route", "/ping")
queryParams.Set("is_auth", "1")
resp := anonymousGetRequest("/admin/v1/permission/list", queryParams)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestPermissionListWithAuthorization(t *testing.T) {
requireMySQL(t)
queryParams := &url.Values{}
queryParams.Set("page", "1")
queryParams.Set("per_page", "5")
queryParams.Set("method", "GET")
resp := getRequest("/admin/v1/permission/list", queryParams)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
}
func TestPermissionUpdateValidationWithAuthorization(t *testing.T) {
requireMySQL(t)
body := `{}`
resp := postRequest("/admin/v1/permission/update", &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.InvalidParameter, result.Code)
}
================================================
FILE: tests/admin_test/public_routes_test.go
================================================
package admin_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestPublicDemoRoute(t *testing.T) {
resp := anonymousGetRequest("/admin/v1/demo", nil)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
}
func TestPublicFileRouteWithoutAuthorization(t *testing.T) {
resp := anonymousGetRequest("/admin/v1/file/not-found-uuid", nil)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.NotEqual(t, e.NotLogin, result.Code)
}
================================================
FILE: tests/admin_test/role_test.go
================================================
package admin_test
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestRoleListWithAuthorization(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/role/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
}
func TestRoleListRequiresLogin(t *testing.T) {
resp := anonymousGetRequest("/admin/v1/role/list", &url.Values{"page": {"1"}, "per_page": {"5"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestRoleProtectedRoutesRequireLogin(t *testing.T) {
postCases := []struct {
name string
route string
body string
}{
{name: "角色创建需要登录", route: "/admin/v1/role/create", body: `{}`},
{name: "角色更新需要登录", route: "/admin/v1/role/update", body: `{}`},
{name: "角色删除需要登录", route: "/admin/v1/role/delete", body: `{}`},
}
for _, tc := range postCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
resp := anonymousGetRequest("/admin/v1/role/detail", &url.Values{"id": {"1"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
}
func TestRoleWriteFlow(t *testing.T) {
requireWritableDB(t)
name := uniqueTestName("role")
cleanupRoles(t, testResourcePrefix+"role")
createBody := map[string]any{
"name": name,
"status": 1,
"sort": 10,
"menu_list": []uint{firstActiveMenuID(t)},
}
createBytes, _ := json.Marshal(createBody)
createPayload := string(createBytes)
createResp := postRequest("/admin/v1/role/create", &createPayload)
createResult := decodeResult(t, createResp)
assert.Equal(t, e.SUCCESS, createResult.Code)
role := findRoleByName(t, name)
detailResp := getRequest("/admin/v1/role/detail", &url.Values{"id": {strconv.FormatUint(uint64(role.ID), 10)}})
detailResult := decodeResult(t, detailResp)
assert.Equal(t, e.SUCCESS, detailResult.Code)
updateBody := map[string]any{
"id": role.ID,
"name": name,
"description": "测试角色-更新",
"status": 1,
"sort": 20,
"menu_list": []uint{firstActiveMenuID(t)},
}
updateBytes, _ := json.Marshal(updateBody)
updatePayload := string(updateBytes)
updateResp := postRequest("/admin/v1/role/update", &updatePayload)
updateResult := decodeResult(t, updateResp)
assert.Equal(t, e.SUCCESS, updateResult.Code)
deleteBytes, _ := json.Marshal(map[string]any{"id": role.ID})
deletePayload := string(deleteBytes)
deleteResp := postRequest("/admin/v1/role/delete", &deletePayload)
deleteResult := decodeResult(t, deleteResp)
assert.Equal(t, e.SUCCESS, deleteResult.Code)
}
================================================
FILE: tests/admin_test/system_routes_test.go
================================================
package admin_test
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/wannanbigpig/gin-layout/internal/model"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestSystemConfigWriteFlow(t *testing.T) {
requireWritableDB(t)
requireSystemModuleTables(t)
configKeyPrefix := uniqueCompactTestName("cfg")
configKey := configKeyPrefix + ".key"
configNameZh := configKeyPrefix + "-zh"
configNameEn := configKeyPrefix + "-en"
updatedConfigValue := configKeyPrefix + "-updated"
cleanupSysConfigsByKeyPrefix(t, configKeyPrefix)
t.Cleanup(func() {
cleanupSysConfigsByKeyPrefix(t, configKeyPrefix)
})
createBody := map[string]any{
"config_key": configKey,
"config_name_i18n": map[string]string{
"zh-CN": configNameZh,
"en-US": configNameEn,
},
"config_value": "init-value",
"value_type": "string",
"group_code": "test",
"status": 1,
"sort": 10,
}
createBytes, _ := json.Marshal(createBody)
createPayload := string(createBytes)
createResp := postRequest("/admin/v1/system/config/create", &createPayload)
createResult := decodeResult(t, createResp)
assert.Equal(t, e.SUCCESS, createResult.Code, createResult.Msg)
config := findSysConfigByKey(t, configKey)
listResp := getRequest("/admin/v1/system/config/list", &url.Values{
"page": {"1"},
"per_page": {"20"},
"config_key": {configKey},
})
listResult := decodeResult(t, listResp)
assert.Equal(t, e.SUCCESS, listResult.Code, listResult.Msg)
assert.True(t, collectionContainsFieldValue(listResult.Data, "config_key", configKey))
detailResp := getRequest("/admin/v1/system/config/detail", &url.Values{
"id": {strconv.FormatUint(uint64(config.ID), 10)},
})
detailResult := decodeResult(t, detailResp)
assert.Equal(t, e.SUCCESS, detailResult.Code, detailResult.Msg)
detailData, ok := detailResult.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, configKey, detailData["config_key"])
assert.NotEmpty(t, detailData["config_name"])
configNameI18n, ok := detailData["config_name_i18n"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, configNameZh, configNameI18n["zh-CN"])
assert.Equal(t, configNameEn, configNameI18n["en-US"])
valueResp := getRequest("/admin/v1/system/config/value", &url.Values{
"config_key": {configKey},
})
valueResult := decodeResult(t, valueResp)
assert.Equal(t, e.SUCCESS, valueResult.Code, valueResult.Msg)
valueData, ok := valueResult.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, configKey, valueData["config_key"])
assert.Equal(t, "init-value", valueData["config_value"])
updateBody := map[string]any{
"id": config.ID,
"config_key": configKey,
"config_name_i18n": map[string]string{
"en-US": configNameEn + "-u",
},
"config_value": updatedConfigValue,
"value_type": "string",
"group_code": "test",
"status": 1,
"sort": 20,
}
updateBytes, _ := json.Marshal(updateBody)
updatePayload := string(updateBytes)
updateResp := postRequest("/admin/v1/system/config/update", &updatePayload)
updateResult := decodeResult(t, updateResp)
assert.Equal(t, e.SUCCESS, updateResult.Code, updateResult.Msg)
refeshBody := `{}`
refreshResp := postRequest("/admin/v1/system/config/refresh", &refeshBody)
refreshResult := decodeResult(t, refreshResp)
assert.Equal(t, e.SUCCESS, refreshResult.Code, refreshResult.Msg)
updatedDetailResp := getRequest("/admin/v1/system/config/detail", &url.Values{
"id": {strconv.FormatUint(uint64(config.ID), 10)},
})
updatedDetailResult := decodeResult(t, updatedDetailResp)
assert.Equal(t, e.SUCCESS, updatedDetailResult.Code, updatedDetailResult.Msg)
updatedDetailData, ok := updatedDetailResult.Data.(map[string]any)
assert.True(t, ok)
assert.Equal(t, updatedConfigValue, updatedDetailData["config_value"])
updatedNameI18n, ok := updatedDetailData["config_name_i18n"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, configNameZh, updatedNameI18n["zh-CN"])
assert.Equal(t, configNameEn+"-u", updatedNameI18n["en-US"])
deleteBytes, _ := json.Marshal(map[string]any{"id": config.ID})
deletePayload := string(deleteBytes)
deleteResp := postRequest("/admin/v1/system/config/delete", &deletePayload)
deleteResult := decodeResult(t, deleteResp)
assert.Equal(t, e.SUCCESS, deleteResult.Code, deleteResult.Msg)
deletedDetailResp := getRequest("/admin/v1/system/config/detail", &url.Values{
"id": {strconv.FormatUint(uint64(config.ID), 10)},
})
deletedDetailResult := decodeResult(t, deletedDetailResp)
assert.Equal(t, e.NotFound, deletedDetailResult.Code)
}
func TestSystemConfigVisibilityTabsAndHiddenSaveFlags(t *testing.T) {
requireWritableDB(t)
requireSystemModuleTables(t)
hiddenKeys := []string{
"storage.active_driver",
"storage.config",
"audit.sensitive_fields",
}
listResp := getRequest("/admin/v1/system/config/list", &url.Values{
"page": {"1"},
"per_page": {"50"},
})
listResult := decodeResult(t, listResp)
assert.Equal(t, e.SUCCESS, listResult.Code, listResult.Msg)
for _, key := range hiddenKeys {
assert.False(t, collectionContainsFieldValue(listResult.Data, "config_key", key))
}
includeHiddenResp := getRequest("/admin/v1/system/config/list", &url.Values{
"page": {"1"},
"per_page": {"50"},
"include_hidden": {"1"},
})
includeHiddenResult := decodeResult(t, includeHiddenResp)
assert.Equal(t, e.SUCCESS, includeHiddenResult.Code, includeHiddenResult.Msg)
for _, key := range hiddenKeys {
assert.True(t, collectionContainsFieldValue(includeHiddenResult.Data, "config_key", key))
}
storageBody := map[string]any{
"active_driver": "local",
"config": map[string]any{
"local": map[string]any{},
"aliyun_oss": map[string]any{
"bucket": "",
"endpoint": "",
"access_key_id": "",
"access_key_secret": "",
"public_url": "",
"private_url": "",
},
"s3": map[string]any{
"bucket": "",
"region": "",
"endpoint": "",
"access_key_id": "",
"secret_access_key": "",
"session_token": "",
"public_url": "",
"private_url": "",
},
"signed_url_ttl_seconds": 300,
"max_file_size_mb": 10,
"allowed_mime_types": []string{},
},
}
storageBytes, _ := json.Marshal(storageBody)
storagePayload := string(storageBytes)
storageResp := postRequest("/admin/v1/system/storage/config", &storagePayload)
storageResult := decodeResult(t, storageResp)
assert.Equal(t, e.SUCCESS, storageResult.Code, storageResult.Msg)
auditBody := map[string]any{
"common": []string{"password"},
"request_header": []string{"authorization"},
"request_body": []string{"secret"},
"response_header": []string{},
"response_body": []string{},
}
auditBytes, _ := json.Marshal(auditBody)
auditPayload := string(auditBytes)
auditResp := postRequest("/admin/v1/log/request/mask-config", &auditPayload)
auditResult := decodeResult(t, auditResp)
assert.Equal(t, e.SUCCESS, auditResult.Code, auditResult.Msg)
storageConfig := findSysConfigByKey(t, "storage.active_driver")
assert.Equal(t, uint8(0), storageConfig.IsVisible)
assert.Equal(t, "storage", storageConfig.ManageTab)
storageJSONConfig := findSysConfigByKey(t, "storage.config")
assert.Equal(t, uint8(0), storageJSONConfig.IsVisible)
assert.Equal(t, "storage", storageJSONConfig.ManageTab)
auditConfig := findSysConfigByKey(t, "audit.sensitive_fields")
assert.Equal(t, uint8(0), auditConfig.IsVisible)
assert.Equal(t, "audit_mask", auditConfig.ManageTab)
}
func TestSystemDictWriteFlow(t *testing.T) {
requireWritableDB(t)
requireSystemModuleTables(t)
typeCodePrefix := uniqueCompactTestName("dict")
typeCode := typeCodePrefix + "_type"
typeNameZh := typeCodePrefix + "-zh"
typeNameEn := typeCodePrefix + "-en"
itemValue := "v1"
itemLabelZh := typeCodePrefix + "-label-zh"
itemLabelEn := typeCodePrefix + "-label-en"
cleanupSysDictByTypeCodePrefix(t, typeCodePrefix)
t.Cleanup(func() {
cleanupSysDictByTypeCodePrefix(t, typeCodePrefix)
})
createTypeBody := map[string]any{
"type_code": typeCode,
"type_name_i18n": map[string]string{
"zh-CN": typeNameZh,
"en-US": typeNameEn,
},
"status": 1,
"sort": 10,
}
createTypeBytes, _ := json.Marshal(createTypeBody)
createTypePayload := string(createTypeBytes)
createTypeResp := postRequest("/admin/v1/system/dict/type/create", &createTypePayload)
createTypeResult := decodeResult(t, createTypeResp)
assert.Equal(t, e.SUCCESS, createTypeResult.Code, createTypeResult.Msg)
dictType := findSysDictTypeByCode(t, typeCode)
typeListResp := getRequest("/admin/v1/system/dict/type/list", &url.Values{
"page": {"1"},
"per_page": {"20"},
"type_code": {typeCode},
})
typeListResult := decodeResult(t, typeListResp)
assert.Equal(t, e.SUCCESS, typeListResult.Code, typeListResult.Msg)
assert.True(t, collectionContainsFieldValue(typeListResult.Data, "type_code", typeCode))
typeDetailResp := getRequest("/admin/v1/system/dict/type/detail", &url.Values{
"id": {strconv.FormatUint(uint64(dictType.ID), 10)},
})
typeDetailResult := decodeResult(t, typeDetailResp)
assert.Equal(t, e.SUCCESS, typeDetailResult.Code, typeDetailResult.Msg)
typeDetailData, ok := typeDetailResult.Data.(map[string]any)
assert.True(t, ok)
typeNameI18n, ok := typeDetailData["type_name_i18n"].(map[string]any)
assert.True(t, ok)
assert.Equal(t, typeNameZh, typeNameI18n["zh-CN"])
assert.Equal(t, typeNameEn, typeNameI18n["en-US"])
updateTypeBody := map[string]any{
"id": dictType.ID,
"type_code": typeCode,
"type_name_i18n": map[string]string{
"en-US": typeNameEn + "-u",
},
"status": 1,
"sort": 20,
}
updateTypeBytes, _ := json.Marshal(updateTypeBody)
updateTypePayload := string(updateTypeBytes)
updateTypeResp := postRequest("/admin/v1/system/dict/type/update", &updateTypePayload)
updateTypeResult := decodeResult(t, updateTypeResp)
assert.Equal(t, e.SUCCESS, updateTypeResult.Code, updateTypeResult.Msg)
createItemBody := map[string]any{
"type_code": typeCode,
"label_i18n": map[string]string{
"zh-CN": itemLabelZh,
"en-US": itemLabelEn,
},
"value": itemValue,
"color": "success",
"tag_type": "success",
"is_default": 1,
"status": 1,
"sort": 10,
}
createItemBytes, _ := json.Marshal(createItemBody)
createItemPayload := string(createItemBytes)
createItemResp := postRequest("/admin/v1/system/dict/item/create", &createItemPayload)
createItemResult := decodeResult(t, createItemResp)
assert.Equal(t, e.SUCCESS, createItemResult.Code, createItemResult.Msg)
item := findSysDictItemByTypeAndValue(t, typeCode, itemValue)
itemListResp := getRequest("/admin/v1/system/dict/item/list", &url.Values{
"page": {"1"},
"per_page": {"20"},
"type_code": {typeCode},
})
itemListResult := decodeResult(t, itemListResp)
assert.Equal(t, e.SUCCESS, itemListResult.Code, itemListResult.Msg)
assert.True(t, collectionContainsFieldValue(itemListResult.Data, "value", itemValue))
optionsResp := getRequest("/admin/v1/system/dict/options", &url.Values{
"type_code": {typeCode},
})
optionsResult := decodeResult(t, optionsResp)
assert.Equal(t, e.SUCCESS, optionsResult.Code, optionsResult.Msg)
assert.True(t, plainListContainsFieldValue(optionsResult.Data, "value", itemValue))
updateItemBody := map[string]any{
"id": item.ID,
"type_code": typeCode,
"label_i18n": map[string]string{
"en-US": itemLabelEn + "-u",
},
"value": itemValue,
"color": "info",
"tag_type": "warning",
"is_default": 1,
"status": 1,
"sort": 15,
}
updateItemBytes, _ := json.Marshal(updateItemBody)
updateItemPayload := string(updateItemBytes)
updateItemResp := postRequest("/admin/v1/system/dict/item/update", &updateItemPayload)
updateItemResult := decodeResult(t, updateItemResp)
assert.Equal(t, e.SUCCESS, updateItemResult.Code, updateItemResult.Msg)
deleteItemBytes, _ := json.Marshal(map[string]any{"id": item.ID})
deleteItemPayload := string(deleteItemBytes)
deleteItemResp := postRequest("/admin/v1/system/dict/item/delete", &deleteItemPayload)
deleteItemResult := decodeResult(t, deleteItemResp)
assert.Equal(t, e.SUCCESS, deleteItemResult.Code, deleteItemResult.Msg)
deleteTypeBytes, _ := json.Marshal(map[string]any{"id": dictType.ID})
deleteTypePayload := string(deleteTypeBytes)
deleteTypeResp := postRequest("/admin/v1/system/dict/type/delete", &deleteTypePayload)
deleteTypeResult := decodeResult(t, deleteTypeResp)
assert.Equal(t, e.SUCCESS, deleteTypeResult.Code, deleteTypeResult.Msg)
deletedTypeDetailResp := getRequest("/admin/v1/system/dict/type/detail", &url.Values{
"id": {strconv.FormatUint(uint64(dictType.ID), 10)},
})
deletedTypeDetailResult := decodeResult(t, deletedTypeDetailResp)
assert.Equal(t, e.NotFound, deletedTypeDetailResult.Code)
}
func TestSystemConfigProtectedRoutesRequireLogin(t *testing.T) {
postCases := []struct {
name string
route string
body string
}{
{name: "系统参数创建需要登录", route: "/admin/v1/system/config/create", body: `{}`},
{name: "系统参数更新需要登录", route: "/admin/v1/system/config/update", body: `{}`},
{name: "系统参数删除需要登录", route: "/admin/v1/system/config/delete", body: `{}`},
{name: "系统参数刷新缓存需要登录", route: "/admin/v1/system/config/refresh", body: `{}`},
}
for _, tc := range postCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
getCases := []struct {
name string
route string
query *url.Values
}{
{name: "系统参数列表需要登录", route: "/admin/v1/system/config/list", query: &url.Values{"page": {"1"}}},
{name: "系统参数详情需要登录", route: "/admin/v1/system/config/detail", query: &url.Values{"id": {"1"}}},
{name: "系统参数值需要登录", route: "/admin/v1/system/config/value", query: &url.Values{"config_key": {"auth.login_lock_enabled"}}},
}
for _, tc := range getCases {
t.Run(tc.name, func(t *testing.T) {
resp := anonymousGetRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
}
func TestSystemDictProtectedRoutesRequireLogin(t *testing.T) {
postCases := []struct {
name string
route string
body string
}{
{name: "字典类型创建需要登录", route: "/admin/v1/system/dict/type/create", body: `{}`},
{name: "字典类型更新需要登录", route: "/admin/v1/system/dict/type/update", body: `{}`},
{name: "字典类型删除需要登录", route: "/admin/v1/system/dict/type/delete", body: `{}`},
{name: "字典项创建需要登录", route: "/admin/v1/system/dict/item/create", body: `{}`},
{name: "字典项更新需要登录", route: "/admin/v1/system/dict/item/update", body: `{}`},
{name: "字典项删除需要登录", route: "/admin/v1/system/dict/item/delete", body: `{}`},
}
for _, tc := range postCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
getCases := []struct {
name string
route string
query *url.Values
}{
{name: "字典类型列表需要登录", route: "/admin/v1/system/dict/type/list", query: &url.Values{"page": {"1"}}},
{name: "字典类型详情需要登录", route: "/admin/v1/system/dict/type/detail", query: &url.Values{"id": {"1"}}},
{name: "字典项列表需要登录", route: "/admin/v1/system/dict/item/list", query: &url.Values{"type_code": {"common_status"}}},
{name: "字典选项需要登录", route: "/admin/v1/system/dict/options", query: &url.Values{"type_code": {"common_status"}}},
}
for _, tc := range getCases {
t.Run(tc.name, func(t *testing.T) {
resp := anonymousGetRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
}
func collectionContainsFieldValue(collectionData any, field string, target any) bool {
collectionMap, ok := collectionData.(map[string]any)
if !ok {
return false
}
rows, ok := collectionMap["data"].([]any)
if !ok {
return false
}
for _, row := range rows {
rowMap, ok := row.(map[string]any)
if !ok {
continue
}
if rowMap[field] == target {
return true
}
}
return false
}
func plainListContainsFieldValue(data any, field string, target any) bool {
rows, ok := data.([]any)
if !ok {
// 全局 response.WithData 对非 object 数据会包装为 data.result。
if wrapper, ok := data.(map[string]any); ok {
if result, exists := wrapper["result"]; exists {
rows, ok = result.([]any)
if !ok {
return false
}
} else {
return false
}
} else {
return false
}
}
for _, row := range rows {
rowMap, ok := row.(map[string]any)
if !ok {
continue
}
if rowMap[field] == target {
return true
}
}
return false
}
// requireSystemModuleTables 确认系统参数/字典相关表已迁移。
func requireSystemModuleTables(t *testing.T) {
t.Helper()
db, err := model.GetDB()
if err != nil {
t.Skipf("数据库不可用,跳过 system 成功路径测试: %v", err)
}
requiredTables := []string{
"sys_config",
"sys_config_i18n",
"sys_dict_type",
"sys_dict_type_i18n",
"sys_dict_item",
"sys_dict_item_i18n",
}
for _, table := range requiredTables {
if !db.Migrator().HasTable(table) {
t.Skipf("测试库缺少表 %s,跳过 system 成功路径测试", table)
}
}
}
================================================
FILE: tests/admin_test/task_routes_test.go
================================================
package admin_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
e "github.com/wannanbigpig/gin-layout/internal/pkg/errors"
)
func TestTaskCenterListRoutesWithAuthorization(t *testing.T) {
requireMySQL(t)
testCases := []struct {
name string
route string
query *url.Values
}{
{
name: "任务定义列表",
route: "/admin/v1/task/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{
name: "任务执行记录列表",
route: "/admin/v1/task/run/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{
name: "定时任务最近状态列表",
route: "/admin/v1/task/cron/state",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resp := getRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.SUCCESS, result.Code)
})
}
}
func TestTaskCenterRunDetailNotFound(t *testing.T) {
requireMySQL(t)
resp := getRequest("/admin/v1/task/run/detail", &url.Values{"id": {"99999999"}})
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotFound, result.Code)
}
func TestTaskCenterRoutesRequireLogin(t *testing.T) {
getCases := []struct {
name string
route string
query *url.Values
}{
{
name: "任务定义列表需要登录",
route: "/admin/v1/task/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{
name: "任务执行记录列表需要登录",
route: "/admin/v1/task/run/list",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
{name: "任务执行记录详情需要登录", route: "/admin/v1/task/run/detail", query: &url.Values{"id": {"1"}}},
{
name: "定时任务最近状态需要登录",
route: "/admin/v1/task/cron/state",
query: &url.Values{"page": {"1"}, "per_page": {"5"}},
},
}
for _, tc := range getCases {
t.Run(tc.name, func(t *testing.T) {
resp := anonymousGetRequest(tc.route, tc.query)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
postCases := []struct {
name string
route string
body string
}{
{name: "手动触发任务需要登录", route: "/admin/v1/task/trigger", body: `{"task_code":"demo:send"}`},
{name: "重试任务需要登录", route: "/admin/v1/task/run/retry", body: `{"run_id":1}`},
{name: "取消任务需要登录", route: "/admin/v1/task/run/cancel", body: `{"run_id":1}`},
}
for _, tc := range postCases {
t.Run(tc.name, func(t *testing.T) {
body := tc.body
resp := anonymousPostRequest(tc.route, &body)
assert.Equal(t, http.StatusOK, resp.Code)
result := decodeResult(t, resp)
assert.Equal(t, e.NotLogin, result.Code)
})
}
}
func TestTaskCenterOperateRouteValidation(t *testing.T) {
requireMySQL(t)
triggerBody := `{}`
triggerResp := postRequest("/admin/v1/task/trigger", &triggerBody)
assert.Equal(t, http.StatusOK, triggerResp.Code)
triggerResult := decodeResult(t, triggerResp)
assert.Equal(t, e.InvalidParameter, triggerResult.Code)
retryBody := `{}`
retryResp := postRequest("/admin/v1/task/run/retry", &retryBody)
assert.Equal(t, http.StatusOK, retryResp.Code)
retryResult := decodeResult(t, retryResp)
assert.Equal(t, e.InvalidParameter, retryResult.Code)
cancelBody := `{}`
cancelResp := postRequest("/admin/v1/task/run/cancel", &cancelBody)
assert.Equal(t, http.StatusOK, cancelResp.Code)
cancelResult := decodeResult(t, cancelResp)
assert.Equal(t, e.InvalidParameter, cancelResult.Code)
}
================================================
FILE: tests/admin_test/test_helpers_test.go
================================================
package admin_test
import (
"fmt"
"strings"
"testing"
"time"
"gorm.io/gorm"
"github.com/wannanbigpig/gin-layout/internal/model"
"github.com/wannanbigpig/gin-layout/internal/pkg/i18n"
)
const testResourcePrefix = "test-auto-"
// requireWritableDB 在需要真实数据库写入时跳过测试。
func requireWritableDB(t *testing.T) {
t.Helper()
requireMySQL(t)
if _, err := model.GetDB(); err != nil {
t.Skip("数据库连接不可用,跳过真实写入测试")
}
}
// uniqueTestName 生成用于测试资源的唯一名称。
func uniqueTestName(kind string) string {
return fmt.Sprintf("%s%s-%d", testResourcePrefix, kind, time.Now().UnixNano())
}
// containsPrefix 判断字符串是否包含测试前缀。
func containsPrefix(s string) bool {
return strings.HasPrefix(s, testResourcePrefix)
}
// uniqueCompactTestName 生成适合表单校验长度限制的测试名称。
func uniqueCompactTestName(kind string) string {
return fmt.Sprintf("ta%s%d", strings.ReplaceAll(kind, "-", ""), time.Now().UnixNano()%1e8)
}
// findAdminUserByUsername 根据用户名查找管理员。
func findAdminUserByUsername(t *testing.T, username string) *model.AdminUser {
t.Helper()
user := model.NewAdminUsers()
db, err := user.GetDB()
if err != nil {
t.Fatalf("查询管理员失败: %v", err)
}
if err := db.Where("username = ?", username).First(user).Error; err != nil {
t.Fatalf("查询管理员失败: %v", err)
}
return user
}
// findRoleByName 根据角色名称查找角色。
func findRoleByName(t *testing.T, name string) *model.Role {
t.Helper()
role := model.NewRole()
db, err := role.GetDB()
if err != nil {
t.Fatalf("查询角色失败: %v", err)
}
if err := db.Where("name = ?", name).First(role).Error; err != nil {
t.Fatalf("查询角色失败: %v", err)
}
return role
}
// findDepartmentByName 根据部门名称查找部门。
func findDepartmentByName(t *testing.T, name string) *model.Department {
t.Helper()
dept := model.NewDepartment()
db, err := dept.GetDB()
if err != nil {
t.Fatalf("查询部门失败: %v", err)
}
if err := db.Where("name = ?", name).First(dept).Error; err != nil {
t.Fatalf("查询部门失败: %v", err)
}
return dept
}
// findMenuByTitle 根据菜单标题查找菜单。
func findMenuByTitle(t *testing.T, title string) *model.Menu {
t.Helper()
db, err := model.GetDB()
if err != nil {
t.Fatalf("查询菜单失败: %v", err)
}
translation := model.NewMenuI18n()
if err := db.Where("locale = ? AND title = ?", i18n.LocaleZhCN, title).First(translation).Error; err != nil {
t.Fatalf("查询菜单翻译失败: %v", err)
}
menu := model.NewMenu()
if err := db.Where("id = ?", translation.MenuID).First(menu).Error; err != nil {
t.Fatalf("查询菜单失败: %v", err)
}
return menu
}
// cleanupAdminUsers 清理指定前缀创建的管理员测试数据。
func cleanupAdminUsers(t *testing.T, usernamePrefix string) {
t.Helper()
db, err := model.GetDB()
if err != nil {
return
}
_ = db.Where("username LIKE ?", usernamePrefix+"%").Delete(&model.AdminUser{}).Error
}
// cleanupRoles 清理指定前缀创建的角色测试数据。
func cleanupRoles(t *testing.T, namePrefix string) {
t.Helper()
db, err := model.GetDB()
if err != nil {
return
}
var roles []model.Role
if err := db.Where("name LIKE ?", namePrefix+"%").Find(&roles).Error; err != nil {
return
}
if len(roles) == 0 {
return
}
ids := make([]uint, 0, len(roles))
for _, role := range roles {
ids = append(ids, role.ID)
}
_ = db.Where("role_id IN ?", ids).Delete(&model.RoleMenuMap{}).Error
_ = db.Where("role_id IN ?", ids).Delete(&model.AdminUserRoleMap{}).Error
_ = db.Where("role_id IN ?", ids).Delete(&model.DeptRoleMap{}).Error
_ = db.Where("id IN ?", ids).Delete(&model.Role{}).Error
}
// cleanupDepartments 清理指定前缀创建的部门测试数据。
func cleanupDepartments(t *testing.T, namePrefix string) {
t.Helper()
db, err := model.GetDB()
if err != nil {
return
}
var depts []model.Department
if err := db.Where("name LIKE ?", namePrefix+"%").Find(&depts).Error; err != nil {
return
}
if len(depts) == 0 {
return
}
ids := make([]uint, 0, len(depts))
for _, dept := range depts {
ids = append(ids, dept.ID)
}
_ = db.Where("dept_id IN ?", ids).Delete(&model.AdminUserDeptMap{}).Error
_ = db.Where("dept_id IN ?", ids).Delete(&model.DeptRoleMap{}).Error
_ = db.Where("id IN ?", ids).Delete(&model.Department{}).Error
}
// cleanupMenus 清理指定前缀创建的菜单测试数据。
func cleanupMenus(t *testing.T, titlePrefix string) {
t.Helper()
db, err := model.GetDB()
if err != nil {
return
}
var ids []uint
if err := db.Model(&model.MenuI18n{}).
Where("locale = ? AND title LIKE ?", i18n.LocaleZhCN, titlePrefix+"%").
Distinct().
Pluck("menu_id", &ids).Error; err != nil {
return
}
if len(ids) == 0 {
return
}
_ = db.Where("menu_id IN ?", ids).Delete(&model.MenuApiMap{}).Error
_ = db.Where("menu_id IN ?", ids).Delete(&model.RoleMenuMap{}).Error
_ = db.Where("menu_id IN ?", ids).Delete(&model.MenuI18n{}).Error
_ = db.Where("id IN ?", ids).Delete(&model.Menu{}).Error
}
// firstActiveRoleID 返回一个可用于绑定的启用角色 ID。
func firstActiveRoleID(t *testing.T) uint {
t.Helper()
role := model.NewRole()
db, err := role.GetDB()
if err != nil {
t.Fatalf("查询启用角色失败: %v", err)
}
if err := db.Where("status = 1").First(role).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return createFallbackRole(t)
}
t.Fatalf("查询启用角色失败: %v", err)
}
return role.ID
}
// firstActiveMenuID 返回一个可用于角色绑定的启用菜单 ID。
func firstActiveMenuID(t *testing.T) uint {
t.Helper()
menu := model.NewMenu()
db, err := menu.GetDB()
if err != nil {
t.Fatalf("查询启用菜单失败: %v", err)
}
if err := db.Where("status = 1").First(menu).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return createFallbackMenu(t)
}
t.Fatalf("查询启用菜单失败: %v", err)
}
return menu.ID
}
// createFallbackRole 创建测试兜底角色。
func createFallbackRole(t *testing.T) uint {
t.Helper()
name := uniqueCompactTestName("role-seed")
role := &model.Role{
Name: name,
Status: 1,
Level: 1,
Pids: "0",
Sort: 1,
}
db, err := role.GetDB()
if err != nil {
t.Fatalf("创建兜底角色失败: %v", err)
}
if err := db.Create(role).Error; err != nil {
t.Fatalf("创建兜底角色失败: %v", err)
}
return role.ID
}
// createFallbackMenu 创建测试兜底菜单。
func createFallbackMenu(t *testing.T) uint {
t.Helper()
name := uniqueCompactTestName("menu")
menu := &model.Menu{
Name: name,
Path: "/" + name,
FullPath: "/" + name,
Component: "test/component",
IsShow: 1,
Sort: 1,
Type: model.MENU,
Level: 1,
Pids: "0",
IsAuth: 1,
Status: 1,
}
db, err := menu.GetDB()
if err != nil {
t.Fatalf("创建兜底菜单失败: %v", err)
}
if err := db.Create(menu).Error; err != nil {
t.Fatalf("创建兜底菜单失败: %v", err)
}
if err := model.NewMenuI18n().UpsertMenuTitles(menu.ID, map[string]string{
i18n.LocaleZhCN: name,
i18n.LocaleEnUS: name,
}, db); err != nil {
t.Fatalf("创建兜底菜单翻译失败: %v", err)
}
return menu.ID
}
// findSysConfigByKey 根据参数键名查找系统参数。
func findSysConfigByKey(t *testing.T, key string) *model.SysConfig {
t.Helper()
config := model.NewSysConfig()
db, err := config.GetDB()
if err != nil {
t.Fatalf("查询系统参数失败: %v", err)
}
if err := db.Where("config_key = ? AND deleted_at = 0", key).First(config).Error; err != nil {
t.Fatalf("查询系统参数失败: %v", err)
}
return config
}
// findSysDictTypeByCode 根据字典类型编码查找字典类型。
func findSysDictTypeByCode(t *testing.T, typeCode string) *model.SysDictType {
t.Helper()
dictType := model.NewSysDictType()
db, err := dictType.GetDB()
if err != nil {
t.Fatalf("查询字典类型失败: %v", err)
}
if err := db.Where("type_code = ? AND deleted_at = 0", typeCode).First(dictType).Error; err != nil {
t.Fatalf("查询字典类型失败: %v", err)
}
return dictType
}
// findSysDictItemByTypeAndValue 根据类型编码与字典值查找字典项。
func findSysDictItemByTypeAndValue(t *testing.T, typeCode, value string) *model.SysDictItem {
t.Helper()
item := model.NewSysDictItem()
db, err := item.GetDB()
if err != nil {
t.Fatalf("查询字典项失败: %v", err)
}
if err := db.Where("type_code = ? AND value = ? AND deleted_at = 0", typeCode, value).First(item).Error; err != nil {
t.Fatalf("查询字典项失败: %v", err)
}
return item
}
// cleanupSysConfigsByKeyPrefix 清理指定参数键前缀的系统参数测试数据。
func cleanupSysConfigsByKeyPrefix(t *testing.T, keyPrefix string) {
t.Helper()
db, err := model.GetDB()
if err != nil {
return
}
var configs []model.SysConfig
if err := db.Unscoped().Where("config_key LIKE ?", keyPrefix+"%").Find(&configs).Error; err != nil {
return
}
if len(configs) == 0 {
return
}
ids := make([]uint, 0, len(configs))
for _, config := range configs {
ids = append(ids, config.ID)
}
_ = db.Unscoped().Where("config_id IN ?", ids).Delete(&model.SysConfigI18n{}).Error
_ = db.Unscoped().Where("id IN ?", ids).Delete(&model.SysConfig{}).Error
}
// cleanupSysDictByTypeCodePrefix 清理指定字典类型编码前缀的测试数据。
func cleanupSysDictByTypeCodePrefix(t *testing.T, typeCodePrefix string) {
t.Helper()
db, err := model.GetDB()
if err != nil {
return
}
var dictTypes []model.SysDictType
if err := db.Unscoped().Where("type_code LIKE ?", typeCodePrefix+"%").Find(&dictTypes).Error; err != nil {
return
}
if len(dictTypes) == 0 {
return
}
typeIDs := make([]uint, 0, len(dictTypes))
typeCodes := make([]string, 0, len(dictTypes))
for _, dictType := range dictTypes {
typeIDs = append(typeIDs, dictType.ID)
typeCodes = append(typeCodes, dictType.TypeCode)
}
var itemIDs []uint
_ = db.Unscoped().
Model(&model.SysDictItem{}).
Where("type_code IN ?", typeCodes).
Pluck("id", &itemIDs).Error
if len(itemIDs) > 0 {
_ = db.Unscoped().Where("dict_item_id IN ?", itemIDs).Delete(&model.SysDictItemI18n{}).Error
}
_ = db.Unscoped().Where("type_code IN ?", typeCodes).Delete(&model.SysDictItem{}).Error
_ = db.Unscoped().Where("dict_type_id IN ?", typeIDs).Delete(&model.SysDictTypeI18n{}).Error
_ = db.Unscoped().Where("id IN ?", typeIDs).Delete(&model.SysDictType{}).Error
}
================================================
FILE: tests/ping_test.go
================================================
package tests
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPingRoute(t *testing.T) {
router, err := SetupRouter()
if err != nil {
t.Fatalf("setup router failed: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected status code: %d", recorder.Code)
}
if body := recorder.Body.String(); body != "pong" {
t.Fatalf("unexpected response body: %s", body)
}
}
================================================
FILE: tests/test.go
================================================
package tests
import (
"bytes"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"time"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"github.com/wannanbigpig/gin-layout/config"
"github.com/wannanbigpig/gin-layout/data"
"github.com/wannanbigpig/gin-layout/internal/pkg/logger"
"github.com/wannanbigpig/gin-layout/internal/routers"
"github.com/wannanbigpig/gin-layout/internal/validator"
)
// SetupRouter 初始化测试用路由。
func SetupRouter() (*gin.Engine, error) {
rootPath, err := projectRootPath()
if err != nil {
return nil, err
}
configPath, err := testConfigPath()
if err != nil {
return nil, err
}
// 1、初始化配置
if err := config.InitConfig(configPath); err != nil {
return nil, err
}
cfg := config.GetConfig()
if cfg != nil {
cfg.BasePath = rootPath
cfg.Mysql.PrintSql = false
}
// 2、初始化zap日志
if err := logger.InitLogger(); err != nil {
return nil, err
}
// 初始化数据库
if err := data.InitData(); err != nil {
return nil, err
}
// 初始化验证器
if err := validator.InitValidatorTrans("zh"); err != nil {
return nil, err
}
engine, err := routers.SetRouters()
if err != nil {
return nil, err
}
return engine, nil
}
// testConfigPath 返回测试运行使用的临时配置文件路径。
func testConfigPath() (string, error) {
projectRoot, err := projectRootPath()
if err != nil {
return "", err
}
projectConfigPath := filepath.Join(projectRoot, "config.yaml")
if fileInfo, err := os.Stat(projectConfigPath); err == nil {
if fileInfo.IsDir() {
return "", fmt.Errorf("项目根目录 config.yaml 是目录,无法作为测试配置文件")
}
if isProjectConfigUsable(projectConfigPath) {
return projectConfigPath, nil
}
}
examplePath := filepath.Join(projectRoot, "config", "config.yaml.example")
content, err := os.ReadFile(examplePath)
if err != nil {
return "", err
}
tempPath := filepath.Join(os.TempDir(), "go-layout-test-config.yaml")
if err := os.WriteFile(tempPath, content, 0o600); err != nil {
return "", err
}
return tempPath, nil
}
// projectRootPath 返回项目根目录路径。
func projectRootPath() (string, error) {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("failed to resolve project root path")
}
return filepath.Dir(filepath.Dir(file)), nil
}
// isProjectConfigUsable 判断根目录配置是否适合当前测试环境直接使用。
func isProjectConfigUsable(configPath string) bool {
content, err := os.ReadFile(configPath)
if err != nil {
return false
}
v := viper.New()
v.SetConfigType("yaml")
if err := v.ReadConfig(bytes.NewReader(content)); err != nil {
return false
}
if v.GetBool("mysql.enable") && !canDial(v.GetString("mysql.host"), v.GetInt("mysql.port")) {
return false
}
if v.GetBool("redis.enable") && !canDial(v.GetString("redis.host"), v.GetInt("redis.port")) {
return false
}
return true
}
// canDial 检查测试环境是否能连接到指定地址。
func canDial(host string, port int) bool {
if host == "" || port == 0 {
return false
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), time.Second)
if err != nil {
return false
}
_ = conn.Close()
return true
}