From 21c264fcd7477b6436943736e29a5e6e1c41050e Mon Sep 17 00:00:00 2001 From: yvan <8574526@qq.com> Date: Thu, 13 Aug 2026 21:51:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/kratos/admin/v1/admin.pb.go | 36 +- api/kratos/admin/v1/admin_grpc.pb.go | 2 +- api/kratos/admin/v1/admin_http.pb.go | 4 +- api/kratos/admin/v1/error_reason.pb.go | 6 +- configs/config.yaml | 16 + docs/ADMIN_BACKEND_MIGRATION.md | 55 ++ go.mod | 40 + go.sum | 169 +++++ internal/biz/access.go | 122 +++ internal/biz/audit.go | 80 ++ internal/biz/biz.go | 2 +- internal/biz/data_scope.go | 20 + internal/biz/export.go | 32 + internal/biz/infrastructure.go | 33 + internal/biz/media.go | 230 ++++++ internal/biz/settings.go | 114 +++ internal/biz/system.go | 171 +++++ internal/biz/task.go | 45 ++ internal/biz/version.go | 37 + internal/conf/conf.pb.go | 370 +++++++++- internal/conf/conf.proto | 28 + internal/data/access.go | 941 ++++++++++++++++++++++++ internal/data/audit.go | 430 +++++++++++ internal/data/cache.go | 107 +++ internal/data/data.go | 56 +- internal/data/export.go | 308 ++++++++ internal/data/file_storage.go | 188 +++++ internal/data/media.go | 260 +++++++ internal/data/settings.go | 485 ++++++++++++ internal/data/system.go | 430 +++++++++++ internal/data/task.go | 155 ++++ internal/data/version.go | 201 +++++ internal/server/access_routes.go | 44 ++ internal/server/api_routes.go | 209 ++++++ internal/server/audit_middleware.go | 101 +++ internal/server/audit_routes.go | 246 +++++++ internal/server/authority_routes.go | 134 ++++ internal/server/export_routes.go | 224 ++++++ internal/server/gin.go | 577 +++++++++++++++ internal/server/media_routes.go | 265 +++++++ internal/server/menu_routes.go | 188 +++++ internal/server/organization_routes.go | 237 ++++++ internal/server/policy_routes.go | 97 +++ internal/server/response.go | 50 ++ internal/server/server.go | 2 +- internal/server/settings_routes.go | 437 +++++++++++ internal/server/system_config_routes.go | 115 +++ internal/server/task_routes.go | 181 +++++ internal/server/task_scheduler.go | 155 ++++ internal/server/version_routes.go | 165 +++++ internal/service/access.go | 377 ++++++++++ internal/service/audit.go | 143 ++++ internal/service/export.go | 184 +++++ internal/service/media.go | 104 +++ internal/service/service.go | 2 +- internal/service/settings.go | 274 +++++++ internal/service/system.go | 244 ++++++ internal/service/task.go | 194 +++++ internal/service/version.go | 75 ++ pkg/adminauth/token.go | 46 ++ 60 files changed, 10174 insertions(+), 69 deletions(-) create mode 100644 docs/ADMIN_BACKEND_MIGRATION.md create mode 100644 internal/biz/access.go create mode 100644 internal/biz/audit.go create mode 100644 internal/biz/data_scope.go create mode 100644 internal/biz/export.go create mode 100644 internal/biz/infrastructure.go create mode 100644 internal/biz/media.go create mode 100644 internal/biz/settings.go create mode 100644 internal/biz/system.go create mode 100644 internal/biz/task.go create mode 100644 internal/biz/version.go create mode 100644 internal/data/access.go create mode 100644 internal/data/audit.go create mode 100644 internal/data/cache.go create mode 100644 internal/data/export.go create mode 100644 internal/data/file_storage.go create mode 100644 internal/data/media.go create mode 100644 internal/data/settings.go create mode 100644 internal/data/system.go create mode 100644 internal/data/task.go create mode 100644 internal/data/version.go create mode 100644 internal/server/access_routes.go create mode 100644 internal/server/api_routes.go create mode 100644 internal/server/audit_middleware.go create mode 100644 internal/server/audit_routes.go create mode 100644 internal/server/authority_routes.go create mode 100644 internal/server/export_routes.go create mode 100644 internal/server/gin.go create mode 100644 internal/server/media_routes.go create mode 100644 internal/server/menu_routes.go create mode 100644 internal/server/organization_routes.go create mode 100644 internal/server/policy_routes.go create mode 100644 internal/server/response.go create mode 100644 internal/server/settings_routes.go create mode 100644 internal/server/system_config_routes.go create mode 100644 internal/server/task_routes.go create mode 100644 internal/server/task_scheduler.go create mode 100644 internal/server/version_routes.go create mode 100644 internal/service/access.go create mode 100644 internal/service/audit.go create mode 100644 internal/service/export.go create mode 100644 internal/service/media.go create mode 100644 internal/service/settings.go create mode 100644 internal/service/system.go create mode 100644 internal/service/task.go create mode 100644 internal/service/version.go create mode 100644 pkg/adminauth/token.go diff --git a/api/kratos/admin/v1/admin.pb.go b/api/kratos/admin/v1/admin.pb.go index 1139fd5..e430c12 100644 --- a/api/kratos/admin/v1/admin.pb.go +++ b/api/kratos/admin/v1/admin.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc (unknown) +// protoc v7.35.1 // source: kratos/admin/v1/admin.proto package v1 @@ -590,29 +590,29 @@ const file_kratos_admin_v1_admin_proto_rawDesc = "" + "updateTime\"b\n" + "\bAdminSet\x12.\n" + "\x06admins\x18\x01 \x03(\v2\x16.kratos.admin.v1.AdminR\x06admins\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"q\n" + - "\fLoginRequest\x12\x1f\n" + - "\bpassword\x18\x01 \x01(\tB\x03\xe0A\x02R\bpassword\x12\x1c\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"r\n" + + "\fLoginRequest\x12 \n" + + "\bpassword\x18\x01 \x01(\tB\x04\xe2A\x01\x02R\bpassword\x12\x1c\n" + "\busername\x18\x02 \x01(\tH\x00R\busername\x12\x16\n" + "\x05email\x18\x03 \x01(\tH\x00R\x05emailB\n" + "\n" + - "\bidentity\"&\n" + - "\x0fGetAdminRequest\x12\x13\n" + - "\x02id\x18\x01 \x01(\x03B\x03\xe0A\x02R\x02id\"\x82\x01\n" + + "\bidentity\"'\n" + + "\x0fGetAdminRequest\x12\x14\n" + + "\x02id\x18\x01 \x01(\x03B\x04\xe2A\x01\x02R\x02id\"\x82\x01\n" + "\x11ListAdminsRequest\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12\x16\n" + "\x06filter\x18\x03 \x01(\tR\x06filter\x12\x19\n" + - "\border_by\x18\x04 \x01(\tR\aorderBy\"G\n" + - "\x12CreateAdminRequest\x121\n" + - "\x05admin\x18\x01 \x01(\v2\x16.kratos.admin.v1.AdminB\x03\xe0A\x02R\x05admin\"\x89\x01\n" + - "\x12UpdateAdminRequest\x121\n" + - "\x05admin\x18\x01 \x01(\v2\x16.kratos.admin.v1.AdminB\x03\xe0A\x02R\x05admin\x12@\n" + - "\vupdate_mask\x18\x02 \x01(\v2\x1a.google.protobuf.FieldMaskB\x03\xe0A\x02R\n" + - "updateMask\")\n" + - "\x12DeleteAdminRequest\x12\x13\n" + - "\x02id\x18\x01 \x01(\x03B\x03\xe0A\x02R\x02id2\xa0\x06\n" + + "\border_by\x18\x04 \x01(\tR\aorderBy\"H\n" + + "\x12CreateAdminRequest\x122\n" + + "\x05admin\x18\x01 \x01(\v2\x16.kratos.admin.v1.AdminB\x04\xe2A\x01\x02R\x05admin\"\x8b\x01\n" + + "\x12UpdateAdminRequest\x122\n" + + "\x05admin\x18\x01 \x01(\v2\x16.kratos.admin.v1.AdminB\x04\xe2A\x01\x02R\x05admin\x12A\n" + + "\vupdate_mask\x18\x02 \x01(\v2\x1a.google.protobuf.FieldMaskB\x04\xe2A\x01\x02R\n" + + "updateMask\"*\n" + + "\x12DeleteAdminRequest\x12\x14\n" + + "\x02id\x18\x01 \x01(\x03B\x04\xe2A\x01\x02R\x02id2\xa0\x06\n" + "\fAdminService\x12[\n" + "\x05Login\x12\x1d.kratos.admin.v1.LoginRequest\x1a\x16.kratos.admin.v1.Admin\"\x1b\x82\xd3\xe4\x93\x02\x15:\x01*\"\x10/v1/admins/login\x12V\n" + "\x06Logout\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x1c\x82\xd3\xe4\x93\x02\x16:\x01*\"\x11/v1/admins/logout\x12U\n" + @@ -622,8 +622,8 @@ const file_kratos_admin_v1_admin_proto_rawDesc = "" + "\vCreateAdmin\x12#.kratos.admin.v1.CreateAdminRequest\x1a\x16.kratos.admin.v1.Admin\" \x82\xd3\xe4\x93\x02\x1a:\x05admin\"\x11/v1/admins/create\x12l\n" + "\vUpdateAdmin\x12#.kratos.admin.v1.UpdateAdminRequest\x1a\x16.kratos.admin.v1.Admin\" \x82\xd3\xe4\x93\x02\x1a:\x05admin\x1a\x11/v1/admins/update\x12c\n" + "\vDeleteAdmin\x12#.kratos.admin.v1.DeleteAdminRequest\x1a\x16.google.protobuf.Empty\"\x17\x82\xd3\xe4\x93\x02\x11*\x0f/v1/admins/{id}\x12]\n" + - "\bGetAdmin\x12 .kratos.admin.v1.GetAdminRequest\x1a\x16.kratos.admin.v1.Admin\"\x17\x82\xd3\xe4\x93\x02\x11\x12\x0f/v1/admins/{id}BQ\n" + - "\x13api.kratos.admin.v1P\x01Z8kra/api/kratos/admin/v1;v1b\x06proto3" + "\bGetAdmin\x12 .kratos.admin.v1.GetAdminRequest\x1a\x16.kratos.admin.v1.Admin\"\x17\x82\xd3\xe4\x93\x02\x11\x12\x0f/v1/admins/{id}B3\n" + + "\x13api.kratos.admin.v1P\x01Z\x1akra/api/kratos/admin/v1;v1b\x06proto3" var ( file_kratos_admin_v1_admin_proto_rawDescOnce sync.Once diff --git a/api/kratos/admin/v1/admin_grpc.pb.go b/api/kratos/admin/v1/admin_grpc.pb.go index 9e9ecd1..4b46c28 100644 --- a/api/kratos/admin/v1/admin_grpc.pb.go +++ b/api/kratos/admin/v1/admin_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc (unknown) +// - protoc v7.35.1 // source: kratos/admin/v1/admin.proto package v1 diff --git a/api/kratos/admin/v1/admin_http.pb.go b/api/kratos/admin/v1/admin_http.pb.go index f496ac5..e3a8edc 100644 --- a/api/kratos/admin/v1/admin_http.pb.go +++ b/api/kratos/admin/v1/admin_http.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-http. DO NOT EDIT. // versions: -// - protoc-gen-go-http v2.9.2 -// - protoc (unknown) +// - protoc-gen-go-http v3.0.0 +// - protoc v7.35.1 // source: kratos/admin/v1/admin.proto package v1 diff --git a/api/kratos/admin/v1/error_reason.pb.go b/api/kratos/admin/v1/error_reason.pb.go index 9764807..e81186f 100644 --- a/api/kratos/admin/v1/error_reason.pb.go +++ b/api/kratos/admin/v1/error_reason.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc (unknown) +// protoc v7.35.1 // source: kratos/admin/v1/error_reason.proto package v1 @@ -101,8 +101,8 @@ const file_kratos_admin_v1_error_reason_proto_rawDesc = "" + "\x13INVALID_CREDENTIALS\x10\x02\x12\x19\n" + "\x15INVALID_IDENTITY_TYPE\x10\x03\x12\x13\n" + "\x0fUNAUTHENTICATED\x10\x04\x12\x15\n" + - "\x11PERMISSION_DENIED\x10\x05BQ\n" + - "\x13api.kratos.admin.v1P\x01Z8kra/api/kratos/admin/v1;v1b\x06proto3" + "\x11PERMISSION_DENIED\x10\x05B3\n" + + "\x13api.kratos.admin.v1P\x01Z\x1akra/api/kratos/admin/v1;v1b\x06proto3" var ( file_kratos_admin_v1_error_reason_proto_rawDescOnce sync.Once diff --git a/configs/config.yaml b/configs/config.yaml index 5e5453e..954286b 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -13,3 +13,19 @@ data: addr: 127.0.0.1:6379 read_timeout: 0.2s write_timeout: 0.2s +admin: + router_prefix: "" + jwt: + # Production deployments must override this value with a private secret. + signing_key: change-me-before-production + expires_time: 168h + buffer_time: 24h + issuer: kra + captcha: + key_long: 6 + img_width: 240 + img_height: 80 + store_expiration: 3m + local: + store_path: uploads/file + path_prefix: uploads/file diff --git a/docs/ADMIN_BACKEND_MIGRATION.md b/docs/ADMIN_BACKEND_MIGRATION.md new file mode 100644 index 0000000..8ff6aef --- /dev/null +++ b/docs/ADMIN_BACKEND_MIGRATION.md @@ -0,0 +1,55 @@ +# GVA 管理后端迁移清单 + +## 基线与范围 + +- 参考基线:工作区 `gva/server`,Git `02f37833`。 +- 目标:保留 GVA Vue 管理端使用的 HTTP 路径、方法、请求字段、统一响应结构和核心业务语义,同时按 Kratos 的 `service -> biz -> data` 分层落地。 +- 明确排除:代码生成、代码生成历史、插件打包/安装、AI/LLM、MCP、Skills,以及测试文件。 +- 当前基线没有公告或邮件的 router/model/service,因此没有虚构对应接口。 +- 源端排除 51 个接口名后剩余 167 个接口名,目标端全部覆盖;另增加 `GET /health`。由于不同分组存在 `upload`、`init` 等同名方法,目标实际注册路由总数为 168。 + +## 模块契约矩阵 + +| 模块 | 路由数 | 迁移内容 | +|---|---:|---| +| 初始化、登录、用户、个人中心 | 17 | 初始化、验证码、登录、JWT、用户 CRUD、多角色、个人资料、密码、菜单、注销 | +| 角色 | 9 | 角色树、创建、复制、更新、删除、用户关联、数据范围 | +| 菜单 | 10 | 菜单树、CRUD、角色菜单、菜单角色、菜单按钮 | +| API 与 Casbin | 19 | API CRUD/同步/忽略、角色 API、Casbin 策略、按钮权限 | +| 组织 | 16 | 部门树、岗位、用户关联、自定义数据范围 | +| 字典与参数 | 24 | 字典/字典项 CRUD、树、路径、导入导出、系统参数 | +| 安全、系统配置、API Token | 9 | 安全策略热更新、限流、锁定、密码策略、配置、服务器信息、Token 签发/作废 | +| Excel 导入导出 | 12 | 模板 CRUD、SQL 预览、一次性下载令牌、Excel 导入导出 | +| 版本管理 | 7 | 菜单/API/字典版本打包、记录、下载和导入 | +| 审计与日志 | 20 | 操作、登录、数据访问、错误日志以及安全的文件日志查看 | +| 定时任务与 SSE | 9 | Cron CRUD/启停/触发、HTTP/本地执行器、执行日志、失败告警 SSE | +| 媒体 | 15 | 文件库、附件分类、本地存储枚举、普通上传、秒传与分片续传 | + +## 分层映射 + +| Kratos 层 | 职责 | +|---|---| +| `internal/server` | Gin 路由、鉴权/审计中间件、HTTP 请求绑定与响应写出、Kratos transport 生命周期 | +| `internal/service` | HTTP DTO 与领域数据的适配、用例编排 | +| `internal/biz` | 用户、权限、设置、审计、任务、媒体等领域对象、仓储接口与业务规则 | +| `internal/data` | GORM PO、MySQL 仓储、Redis/内存缓存、本地文件存储 | +| `cmd/kratos-admin` | Wire 依赖注入与 Kratos 应用组装 | + +## 兼容性说明 + +- HTTP 响应保持 `{code, data, msg}`,成功码为 `0`。 +- JWT 继续接受 `x-token` Header 和同名 Cookie;角色切换返回 `new-token`、`new-expires-at`。 +- 初始化会自动迁移表、写入管理员与菜单,并从 Gin 实际路由同步 API 表。 +- 超级管理员角色 `888` 保留全权限旁路;其他角色由 Casbin 策略控制。 +- 代码中的 `*-gva` 字符串仅是原 Vue 图标库的图标标识,为页面显示兼容而保留;Go 文件、类型、变量和方法均使用实际职责命名。 +- 当前首先实现 MySQL、Redis(不可用时退化为进程缓存)和本地文件存储;云 OSS 驱动没有混入领域层。 + +## 验证命令 + +```bash +go generate ./cmd/kratos-admin +go test ./... +go vet ./... +go build ./... +git diff --check +``` diff --git a/go.mod b/go.mod index 0fcb2fd..5bc7898 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.25.7 require ( entgo.io/ent v0.14.6 + github.com/casbin/casbin/v3 v3.10.0 + github.com/gin-gonic/gin v1.10.0 github.com/go-kratos/aip-go/ents v0.0.0-20251213081434-74ffa1fc1588 github.com/go-kratos/kratos/contrib/otel/v3 v3.0.0-20260617100506-4e232a3eff59 github.com/go-kratos/kratos/v3 v3.0.0 @@ -11,12 +13,18 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 + github.com/mojocn/base64Captcha v1.3.8 + github.com/redis/go-redis/v9 v9.7.0 + github.com/robfig/cron/v3 v3.0.1 + github.com/xuri/excelize/v2 v2.9.0 go.einride.tech/aip v0.86.3 go.uber.org/automaxprocs v1.6.0 golang.org/x/crypto v0.53.0 google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 ) replace github.com/go-kratos/kratos/v3 v3.0.0 => github.com/go-kratos/kratos/v3 v3.0.0-20260617100506-4e232a3eff59 @@ -27,23 +35,55 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/casbin/govaluate v1.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/inflect v0.21.6 // indirect github.com/go-playground/form/v4 v4.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/hcl/v2 v2.24.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect + github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect github.com/zclconf/go-cty v1.18.1 // indirect github.com/zclconf/go-cty-yaml v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/image v0.23.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/go.sum b/go.sum index 0deb57d..f833757 100644 --- a/go.sum +++ b/go.sum @@ -14,12 +14,33 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/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/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +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/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.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.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/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/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= @@ -27,6 +48,12 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-kratos/aip-go/ents v0.0.0-20251213081434-74ffa1fc1588 h1:e0dWyNWFeTgGCH7cRMROahTwMaQYtmHce/6fxVmA6yI= github.com/go-kratos/aip-go/ents v0.0.0-20251213081434-74ffa1fc1588/go.mod h1:ifKMm4eJmaQz5WNKKhfClpCng8UxUB3sArof8wjwG78= github.com/go-kratos/kratos/contrib/otel/v3 v3.0.0-20260617100506-4e232a3eff59 h1:FeDujUZF6a2pPS0RWGI52IXWPqVcGW1QP5vHDlAqLE8= @@ -44,16 +71,30 @@ github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lY github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk= github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo= +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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -64,24 +105,80 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg= +github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= +github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM= github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -104,20 +201,85 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +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.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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.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-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.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +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.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc= @@ -133,7 +295,14 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j 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/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/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/biz/access.go b/internal/biz/access.go new file mode 100644 index 0000000..9f90c2c --- /dev/null +++ b/internal/biz/access.go @@ -0,0 +1,122 @@ +package biz + +import ( + "context" + "time" +) + +type API struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Path string + Description string + APIGroup string + Method string +} + +type MenuButton struct { + ID uint + Name string + Description string + MenuID uint +} + +type Department struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + ParentID uint + Ancestors string + Sort int + LeaderID uint + Status bool + Children []*Department +} + +type Position struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + Code string + Sort int + Status bool + Remark string +} + +type AccessRepo interface { + CreateAuthority(context.Context, *Authority) error + CopyAuthority(context.Context, uint, *Authority) error + UpdateAuthority(context.Context, *Authority) error + DeleteAuthority(context.Context, uint) error + ListAuthorities(context.Context) ([]*Authority, error) + SetAuthorityMenus(context.Context, uint, []uint) error + AuthorityMenuIDs(context.Context, uint) ([]uint, error) + SetAuthorityUsers(context.Context, uint, []uint) error + AuthorityUserIDs(context.Context, uint) ([]uint, error) + SetDataScope(context.Context, uint, int, []uint) error + DataScopeDepartmentIDs(context.Context, uint) ([]uint, error) + ResolveDataScope(context.Context, uint, uint) (DataScope, error) + + CreateMenu(context.Context, *Menu) error + UpdateMenu(context.Context, *Menu) error + DeleteMenu(context.Context, uint) error + FindMenu(context.Context, uint) (*Menu, error) + ListMenus(context.Context) ([]*Menu, error) + MenuRoleIDs(context.Context, uint) ([]uint, error) + SetMenuRoles(context.Context, uint, []uint) error + ReplaceMenuButtons(context.Context, uint, []*MenuButton) error + + CreateAPI(context.Context, *API) error + UpdateAPI(context.Context, *API) error + DeleteAPIs(context.Context, []uint) error + FindAPI(context.Context, uint) (*API, error) + ListAPIs(context.Context, int, int, *API) ([]*API, int64, error) + APIRoleIDs(context.Context, uint) ([]uint, error) + SetAPIRoles(context.Context, uint, []uint) error + Authorize(context.Context, uint, string, string) (bool, error) + PolicyPaths(context.Context, uint) ([]*API, error) + SetPolicyPaths(context.Context, uint, []*API) error + IgnoredAPIs(context.Context) ([]*API, error) + SetAPIIgnored(context.Context, string, string, bool) error + ApplyAPISync(context.Context, []*API, []*API) error + + Buttons(context.Context, uint) ([]*MenuButton, error) + SetAuthorityButtons(context.Context, uint, map[uint][]uint) error + AuthorityButtonIDs(context.Context, uint) ([]uint, error) + SelectedButtons(context.Context, uint, uint) ([]uint, error) + SetSelectedButtons(context.Context, uint, uint, []uint) error + CanRemoveButton(context.Context, uint) (bool, error) + + CreateDepartment(context.Context, *Department) error + UpdateDepartment(context.Context, *Department) error + DeleteDepartment(context.Context, uint) error + FindDepartment(context.Context, uint) (*Department, error) + ListDepartments(context.Context, string) ([]*Department, error) + DepartmentUserIDs(context.Context, uint) ([]uint, error) + SetDepartmentUsers(context.Context, uint, []uint) error + SetUserDepartments(context.Context, uint, []uint, uint) error + + CreatePosition(context.Context, *Position) error + UpdatePosition(context.Context, *Position) error + DeletePosition(context.Context, uint) error + FindPosition(context.Context, uint) (*Position, error) + ListPositions(context.Context, int, int, *Position) ([]*Position, int64, error) + PositionUserIDs(context.Context, uint) ([]uint, error) + SetPositionUsers(context.Context, uint, []uint) error + SetUserPositions(context.Context, uint, []uint) error +} + +type AccessUsecase struct{ repo AccessRepo } + +func NewAccessUsecase(repo AccessRepo) *AccessUsecase { return &AccessUsecase{repo: repo} } + +func (uc *AccessUsecase) Repo() AccessRepo { return uc.repo } +func (uc *AccessUsecase) Authorize(ctx context.Context, authorityID uint, path, method string) (bool, error) { + if authorityID == 888 { + return true, nil + } + return uc.repo.Authorize(ctx, authorityID, path, method) +} diff --git a/internal/biz/audit.go b/internal/biz/audit.go new file mode 100644 index 0000000..3821248 --- /dev/null +++ b/internal/biz/audit.go @@ -0,0 +1,80 @@ +package biz + +import ( + "context" + "time" +) + +type OperationRecord struct { + ID uint + CreatedAt time.Time + IP, Method, Path string + Status int + LatencyMS int64 + Agent, ErrorMessage, Body, Response string + UserID uint + RequestID, TraceID, DeviceID string +} +type LoginLog struct { + ID uint + CreatedAt time.Time + Username, IP string + Status bool + ErrorMessage, Agent string + UserID uint +} +type DataAccessLog struct { + ID uint + CreatedAt time.Time + EventType, TargetTable, Operation string + UserID, AuthorityID uint + Scope int + RequestID, Method, Path, Detail string +} +type LogDate struct { + Date string + FileCount int +} +type LogFile struct { + Path, Name string + Size int64 + ModifiedAt time.Time +} +type LogContent struct { + Date, Path, Content string + LineCount int + NextCursor int64 + HasMore, LimitedByBytes bool + Size int64 + ModifiedAt time.Time +} +type ErrorRecord struct { + ID uint + CreatedAt time.Time + Form, Info, Level, RequestID, TraceID, Solution, Status string +} +type AuditRepo interface { + RecordOperation(context.Context, *OperationRecord) error + ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error) + FindOperation(context.Context, uint) (*OperationRecord, error) + DeleteOperations(context.Context, []uint) error + RecordLogin(context.Context, *LoginLog) error + ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error) + FindLogin(context.Context, uint) (*LoginLog, error) + DeleteLogins(context.Context, []uint) error + RecordDataAccess(context.Context, *DataAccessLog) error + ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error) + DeleteDataAccess(context.Context, []uint) error + LogDates(context.Context, string) ([]LogDate, error) + LogFiles(context.Context, string) ([]LogFile, error) + LogContent(context.Context, string, string, *int64) (*LogContent, error) + CreateError(context.Context, *ErrorRecord) error + UpdateError(context.Context, *ErrorRecord) error + DeleteErrors(context.Context, []uint) error + FindError(context.Context, uint) (*ErrorRecord, error) + ListErrors(context.Context, int, int, *ErrorRecord) ([]*ErrorRecord, int64, error) +} +type AuditUsecase struct{ repo AuditRepo } + +func NewAuditUsecase(repo AuditRepo) *AuditUsecase { return &AuditUsecase{repo: repo} } +func (uc *AuditUsecase) Repo() AuditRepo { return uc.repo } diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 9eb395f..c7217b8 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -3,4 +3,4 @@ package biz import "github.com/google/wire" // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(NewAdminUsecase) +var ProviderSet = wire.NewSet(NewAdminUsecase, NewSystemUsecase, NewAccessUsecase, NewSettingsUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewTaskUsecase, NewMediaUsecase) diff --git a/internal/biz/data_scope.go b/internal/biz/data_scope.go new file mode 100644 index 0000000..26b9455 --- /dev/null +++ b/internal/biz/data_scope.go @@ -0,0 +1,20 @@ +package biz + +import "context" + +type DataScope struct { + All bool + OwnerUserID uint + DepartmentIDs []uint +} + +type dataScopeKey struct{} + +func NewDataScopeContext(ctx context.Context, scope DataScope) context.Context { + return context.WithValue(ctx, dataScopeKey{}, scope) +} + +func DataScopeFromContext(ctx context.Context) (DataScope, bool) { + scope, ok := ctx.Value(dataScopeKey{}).(DataScope) + return scope, ok +} diff --git a/internal/biz/export.go b/internal/biz/export.go new file mode 100644 index 0000000..cc0030d --- /dev/null +++ b/internal/biz/export.go @@ -0,0 +1,32 @@ +package biz + +import ( + "context" + "time" +) + +type ExportCondition struct{ From, Column, Operator string } +type ExportJoin struct{ Join, Table, On string } +type ExportTemplate struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + DBName, Name, TableName, TemplateID, TemplateInfo, SQL, ImportSQL string + Limit *int + Order string + Conditions []ExportCondition + Joins []ExportJoin +} +type ExportRepo interface { + CreateExportTemplate(context.Context, *ExportTemplate) error + UpdateExportTemplate(context.Context, *ExportTemplate) error + DeleteExportTemplates(context.Context, []uint) error + FindExportTemplate(context.Context, uint, string) (*ExportTemplate, error) + ListExportTemplates(context.Context, int, int, *ExportTemplate) ([]*ExportTemplate, int64, error) + QueryExport(context.Context, *ExportTemplate, map[string]string) ([]map[string]any, string, error) + ImportExportRows(context.Context, *ExportTemplate, []map[string]any) error +} +type ExportUsecase struct{ repo ExportRepo } + +func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{repo: repo} } +func (uc *ExportUsecase) Repo() ExportRepo { return uc.repo } diff --git a/internal/biz/infrastructure.go b/internal/biz/infrastructure.go new file mode 100644 index 0000000..ac5a72a --- /dev/null +++ b/internal/biz/infrastructure.go @@ -0,0 +1,33 @@ +package biz + +import ( + "context" + "io" + "time" +) + +// Cache is the shared cache seam. The data implementation uses Redis when it +// is reachable and falls back to process memory for local development. +type Cache interface { + Get(context.Context, string) (string, bool, error) + Set(context.Context, string, string, time.Duration) error + Delete(context.Context, string) error + Increment(context.Context, string, time.Duration) (int64, error) +} + +type StoredFile struct { + Name string + Path string + URL string + Size int64 +} + +// FileStorage owns the persistence boundary for uploaded files. +type FileStorage interface { + Put(context.Context, string, io.Reader) (*StoredFile, error) + Open(context.Context, string) (io.ReadCloser, error) + Delete(context.Context, string) error + Compose(context.Context, []string, string) (*StoredFile, string, error) + DeletePrefix(context.Context, string) error + List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) +} diff --git a/internal/biz/media.go b/internal/biz/media.go new file mode 100644 index 0000000..792b1a9 --- /dev/null +++ b/internal/biz/media.go @@ -0,0 +1,230 @@ +package biz + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "github.com/google/uuid" + "io" + "path/filepath" + "sort" + "strings" + "time" +) + +type MediaFile struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + CategoryID int + URL, Tag, Key string + Size int64 + Mime, MD5 string + UserID uint +} +type AttachmentCategory struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + ParentID uint + Children []*AttachmentCategory +} +type UploadSession struct { + ID uint + UserID uint + FileName, FileHash string + FileSize, ChunkSize int64 + ChunkTotal int + Status, StorageKey string + MediaID uint +} +type UploadChunk struct { + Index int + Hash string + Size int64 +} +type MediaRepo interface { + CreateMedia(context.Context, *MediaFile) error + FindMedia(context.Context, uint) (*MediaFile, error) + FindMediaByHash(context.Context, uint, string) (*MediaFile, error) + ListMedia(context.Context, int, int, string, int, string, uint) ([]*MediaFile, int64, error) + UpdateMediaName(context.Context, uint, string) error + DeleteMedia(context.Context, uint) error + MediaKeyReferences(context.Context, string) (int64, error) + CreateMediaBatch(context.Context, []*MediaFile) error + SaveCategory(context.Context, *AttachmentCategory) error + DeleteCategory(context.Context, uint) error + ListCategories(context.Context) ([]*AttachmentCategory, error) + FindUploadingSession(context.Context, uint, string) (*UploadSession, error) + CreateUploadSession(context.Context, *UploadSession) error + FindUploadSession(context.Context, uint) (*UploadSession, error) + CompleteUploadSession(context.Context, uint, string, uint) error + DeleteUploadSession(context.Context, uint) error + UpsertChunk(context.Context, uint, *UploadChunk) error + ListChunks(context.Context, uint) ([]*UploadChunk, error) + DeleteChunks(context.Context, uint) error +} +type MediaUsecase struct { + repo MediaRepo + files FileStorage +} + +func NewMediaUsecase(repo MediaRepo, files FileStorage) *MediaUsecase { + return &MediaUsecase{repo: repo, files: files} +} +func (uc *MediaUsecase) Repo() MediaRepo { return uc.repo } + +var blockedExtensions = map[string]bool{".exe": true, ".dll": true, ".bat": true, ".cmd": true, ".sh": true, ".php": true, ".jsp": true, ".asp": true, ".aspx": true, ".html": true, ".htm": true, ".svg": true} + +func validateMediaName(name string) error { + if name == "" || filepath.Base(name) != name { + return errors.New("文件名不合法") + } + if blockedExtensions[strings.ToLower(filepath.Ext(name))] { + return errors.New("文件类型不允许上传") + } + return nil +} +func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) { + if err := validateMediaName(name); err != nil { + return nil, err + } + ext := strings.ToLower(filepath.Ext(name)) + key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext + hash := md5.New() + stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash)) + if err != nil { + return nil, err + } + media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID} + if save { + if err = uc.repo.CreateMedia(ctx, media); err != nil { + _ = uc.files.Delete(ctx, key) + return nil, err + } + } + return media, nil +} +func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error { + media, err := uc.repo.FindMedia(ctx, id) + if err != nil { + return err + } + count, err := uc.repo.MediaKeyReferences(ctx, media.Key) + if err != nil { + return err + } + if count <= 1 { + if err = uc.files.Delete(ctx, media.Key); err != nil { + return err + } + } + return uc.repo.DeleteMedia(ctx, id) +} +func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (*UploadSession, *MediaFile, []int, error) { + if err := validateMediaName(name); err != nil { + return nil, nil, nil, err + } + if size <= 0 || chunkSize <= 0 || total <= 0 { + return nil, nil, nil, errors.New("上传参数不合法") + } + if media, err := uc.repo.FindMediaByHash(ctx, userID, hash); err == nil { + copy := *media + copy.ID = 0 + copy.Name = name + if err = uc.repo.CreateMedia(ctx, ©); err == nil { + return nil, ©, nil, nil + } + } + session, err := uc.repo.FindUploadingSession(ctx, userID, hash) + if err != nil { + session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"} + if err = uc.repo.CreateUploadSession(ctx, session); err != nil { + return nil, nil, nil, err + } + } + chunks, err := uc.repo.ListChunks(ctx, session.ID) + if err != nil { + return nil, nil, nil, err + } + indices := make([]int, 0, len(chunks)) + for _, v := range chunks { + indices = append(indices, v.Index) + } + sort.Ints(indices) + return session, nil, indices, nil +} +func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error { + session, err := uc.repo.FindUploadSession(ctx, uploadID) + if err != nil || session.UserID != userID { + return errors.New("上传会话不存在或无权操作") + } + if session.Status != "uploading" || index < 0 || index >= session.ChunkTotal { + return errors.New("上传会话状态或分片序号不合法") + } + hash := md5.New() + key := fmt.Sprintf(".chunks/%d/%08d", uploadID, index) + stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash)) + if err != nil { + return err + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actual, expected) { + _ = uc.files.Delete(ctx, key) + return errors.New("分片校验失败") + } + return uc.repo.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size}) +} +func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) { + session, err := uc.repo.FindUploadSession(ctx, uploadID) + if err != nil || session.UserID != userID { + return nil, errors.New("上传会话不存在或无权操作") + } + chunks, err := uc.repo.ListChunks(ctx, uploadID) + if err != nil || len(chunks) != session.ChunkTotal { + return nil, errors.New("分片不完整") + } + sort.Slice(chunks, func(i, j int) bool { return chunks[i].Index < chunks[j].Index }) + names := make([]string, 0, len(chunks)) + for index, chunk := range chunks { + if chunk.Index != index { + return nil, errors.New("分片序号不连续") + } + names = append(names, fmt.Sprintf(".chunks/%d/%08d", uploadID, index)) + } + ext := strings.ToLower(filepath.Ext(session.FileName)) + key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext + stored, hash, err := uc.files.Compose(ctx, names, key) + if err != nil { + return nil, err + } + if !strings.EqualFold(hash, session.FileHash) { + _ = uc.files.Delete(ctx, key) + return nil, errors.New("整文件校验失败") + } + media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID} + if err = uc.repo.CreateMedia(ctx, media); err != nil { + _ = uc.files.Delete(ctx, key) + return nil, err + } + _ = uc.repo.CompleteUploadSession(ctx, uploadID, key, media.ID) + _ = uc.repo.DeleteChunks(ctx, uploadID) + _ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID)) + return media, nil +} +func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error { + session, err := uc.repo.FindUploadSession(ctx, uploadID) + if err != nil || session.UserID != userID { + return errors.New("上传会话不存在或无权操作") + } + _ = uc.repo.DeleteChunks(ctx, uploadID) + _ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID)) + return uc.repo.DeleteUploadSession(ctx, uploadID) +} +func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) { + return uc.files.List(ctx, prefix, cursor, limit) +} diff --git a/internal/biz/settings.go b/internal/biz/settings.go new file mode 100644 index 0000000..3725794 --- /dev/null +++ b/internal/biz/settings.go @@ -0,0 +1,114 @@ +package biz + +import ( + "context" + "time" +) + +type Dictionary struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + Type string + Status bool + Desc string + ParentID *uint + Children []*Dictionary + Details []*DictionaryDetail +} + +type DictionaryDetail struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Label string + Value string + Extend string + Status bool + Sort int + DictionaryID uint + ParentID *uint + Level int + Path string + Children []*DictionaryDetail +} + +type SystemParameter struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + Key string + Value string + Desc string +} + +type APIToken struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + UserID uint + AuthorityID uint + Token string + Status bool + ExpiresAt time.Time + Remark string + User *User +} + +type SecurityConfig struct { + ID uint + CaptchaOpen int + CaptchaTimeout int + KeyLong int + ImgWidth int + ImgHeight int + PwdMinLength int + PwdRequireUpper bool + PwdRequireLower bool + PwdRequireDigit bool + PwdRequireSpecial bool + LimitEnable bool + LimitWindow int + LimitCount int + LockEnable bool + LockThreshold int + LockDuration int + PwdExpireEnable bool + PwdExpireDays int + ForceNewUserChangePassword bool +} + +type SettingsRepo interface { + CreateDictionary(context.Context, *Dictionary) error + UpdateDictionary(context.Context, *Dictionary) error + DeleteDictionary(context.Context, uint) error + FindDictionary(context.Context, uint, string, bool) (*Dictionary, error) + ListDictionaries(context.Context, int, int, string, string, bool) ([]*Dictionary, int64, error) + CreateDictionaryDetail(context.Context, *DictionaryDetail) error + UpdateDictionaryDetail(context.Context, *DictionaryDetail) error + DeleteDictionaryDetail(context.Context, uint) error + FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error) + ListDictionaryDetails(context.Context, int, int, uint, string, string) ([]*DictionaryDetail, int64, error) + DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error) + + CreateParameter(context.Context, *SystemParameter) error + UpdateParameter(context.Context, *SystemParameter) error + DeleteParameters(context.Context, []uint) error + FindParameter(context.Context, uint, string) (*SystemParameter, error) + ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error) + + UserHasAuthority(context.Context, uint, uint) (*User, bool, error) + CreateAPIToken(context.Context, *APIToken) error + ListAPITokens(context.Context, int, int, uint, *bool) ([]*APIToken, int64, error) + DisableAPIToken(context.Context, uint) (string, error) + IsTokenDisabled(context.Context, string) (bool, error) + SecurityConfig(context.Context) (*SecurityConfig, error) + SaveSecurityConfig(context.Context, *SecurityConfig) error +} + +type SettingsUsecase struct{ repo SettingsRepo } + +func NewSettingsUsecase(repo SettingsRepo) *SettingsUsecase { return &SettingsUsecase{repo: repo} } +func (uc *SettingsUsecase) Repo() SettingsRepo { return uc.repo } diff --git a/internal/biz/system.go b/internal/biz/system.go new file mode 100644 index 0000000..b56d70e --- /dev/null +++ b/internal/biz/system.go @@ -0,0 +1,171 @@ +package biz + +import ( + "context" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type User struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + UUID string + Username string + Password string + NickName string + HeaderImg string + AuthorityID uint + Authority Authority + Authorities []Authority + DeptID uint + Department *Department + Departments []Department + Positions []Position + Phone string + Email string + Enable int + OriginSetting map[string]any + MustChangePassword bool + PasswordUpdatedAt *time.Time +} +type UserListFilter struct { + Username, NickName, Phone, Email, OrderKey string + Desc bool +} + +type Authority struct { + AuthorityID uint + AuthorityName string + ParentID *uint + DataScope int + DefaultRouter string +} + +type Menu struct { + ID uint + ParentID uint + Path string + Name string + Hidden bool + Component string + Sort int + ActiveName string + KeepAlive bool + DefaultMenu bool + Title string + Icon string + CloseTab bool + TransitionType string + Children []*Menu + Buttons []*MenuButton +} + +type SystemRepo interface { + IsInitialized(context.Context) (bool, error) + Initialize(context.Context) error + FindUserByUsername(context.Context, string) (*User, error) + FindUserByID(context.Context, uint) (*User, error) + MenusByAuthority(context.Context, uint) ([]*Menu, error) + ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error) + CreateUser(context.Context, *User) (*User, error) + UpdateUser(context.Context, *User) error + DeleteUser(context.Context, uint) error + UpdatePassword(context.Context, uint, string) error + SetUserAuthorities(context.Context, uint, []uint) error + SetUserAuthority(context.Context, uint, uint) error + SetUserSetting(context.Context, uint, map[string]any) error + ListAuthorities(context.Context) ([]*Authority, error) +} + +type SystemUsecase struct { + repo SystemRepo + cache Cache + files FileStorage +} + +func NewSystemUsecase(repo SystemRepo, cache Cache, files FileStorage) *SystemUsecase { + return &SystemUsecase{repo: repo, cache: cache, files: files} +} + +func (uc *SystemUsecase) CacheGet(ctx context.Context, key string) (string, bool, error) { + return uc.cache.Get(ctx, key) +} +func (uc *SystemUsecase) CacheSet(ctx context.Context, key, value string, expiration time.Duration) error { + return uc.cache.Set(ctx, key, value, expiration) +} +func (uc *SystemUsecase) CacheDelete(ctx context.Context, key string) error { + return uc.cache.Delete(ctx, key) +} + +func (uc *SystemUsecase) IsInitialized(ctx context.Context) (bool, error) { + return uc.repo.IsInitialized(ctx) +} +func (uc *SystemUsecase) Initialize(ctx context.Context) error { return uc.repo.Initialize(ctx) } + +func (uc *SystemUsecase) Login(ctx context.Context, username, password string) (*User, error) { + u, err := uc.repo.FindUserByUsername(ctx, username) + if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil { + return nil, ErrInvalidCredentials + } + return u, nil +} + +func (uc *SystemUsecase) User(ctx context.Context, id uint) (*User, error) { + return uc.repo.FindUserByID(ctx, id) +} + +func (uc *SystemUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) { + return uc.repo.MenusByAuthority(ctx, authorityID) +} + +func (uc *SystemUsecase) ListUsers(ctx context.Context, page, pageSize int, filter *UserListFilter) ([]*User, int64, error) { + return uc.repo.ListUsers(ctx, page, pageSize, filter) +} +func (uc *SystemUsecase) CreateUser(ctx context.Context, user *User) (*User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + user.Password = string(hash) + return uc.repo.CreateUser(ctx, user) +} +func (uc *SystemUsecase) UpdateUser(ctx context.Context, user *User) error { + return uc.repo.UpdateUser(ctx, user) +} +func (uc *SystemUsecase) DeleteUser(ctx context.Context, id uint) error { + return uc.repo.DeleteUser(ctx, id) +} +func (uc *SystemUsecase) ResetPassword(ctx context.Context, id uint, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + return uc.repo.UpdatePassword(ctx, id, string(hash)) +} +func (uc *SystemUsecase) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error { + user, err := uc.repo.FindUserByID(ctx, id) + if err != nil { + return err + } + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)) != nil { + return ErrInvalidCredentials + } + return uc.ResetPassword(ctx, id, newPassword) +} +func (uc *SystemUsecase) Authorities(ctx context.Context) ([]*Authority, error) { + return uc.repo.ListAuthorities(ctx) +} +func (uc *SystemUsecase) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { + if len(authorityIDs) == 0 { + return ErrInvalidCredentials + } + return uc.repo.SetUserAuthorities(ctx, id, authorityIDs) +} +func (uc *SystemUsecase) SetUserAuthority(ctx context.Context, id, authorityID uint) error { + return uc.repo.SetUserAuthority(ctx, id, authorityID) +} +func (uc *SystemUsecase) SetUserSetting(ctx context.Context, id uint, setting map[string]any) error { + return uc.repo.SetUserSetting(ctx, id, setting) +} diff --git a/internal/biz/task.go b/internal/biz/task.go new file mode 100644 index 0000000..d108c42 --- /dev/null +++ b/internal/biz/task.go @@ -0,0 +1,45 @@ +package biz + +import ( + "context" + "time" +) + +type TimedTask struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name, Description, Spec string + WithSeconds bool + ExecutorType, MethodName string + Params []byte + HTTPURL, HTTPMethod string + HTTPHeader []byte + HTTPBody string + HTTPAllowPrivate, Enabled bool + EnabledFilter *bool +} +type TimedTaskLog struct { + ID uint + CreatedAt time.Time + TaskID uint + TaskName, TriggerType string + StartedAt, FinishedAt time.Time + DurationMS int64 + Status, ErrorMsg, Output string +} +type TaskRepo interface { + CreateTask(context.Context, *TimedTask) error + UpdateTask(context.Context, *TimedTask) error + DeleteTask(context.Context, uint) error + FindTask(context.Context, uint) (*TimedTask, error) + ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error) + ToggleTask(context.Context, uint, bool) error + RecordTaskLog(context.Context, *TimedTaskLog) error + ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error) + CleanupLogs(context.Context, time.Time) error +} +type TaskUsecase struct{ repo TaskRepo } + +func NewTaskUsecase(repo TaskRepo) *TaskUsecase { return &TaskUsecase{repo: repo} } +func (uc *TaskUsecase) Repo() TaskRepo { return uc.repo } diff --git a/internal/biz/version.go b/internal/biz/version.go new file mode 100644 index 0000000..ad404eb --- /dev/null +++ b/internal/biz/version.go @@ -0,0 +1,37 @@ +package biz + +import ( + "context" + "time" +) + +type Version struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Name string + Code string + Description string + Data string +} +type VersionBundle struct { + Name string + Code string + Description string + ExportTime string + Menus []*Menu + APIs []*API + Dictionaries []*Dictionary +} +type VersionRepo interface { + CreateVersion(context.Context, *Version) error + DeleteVersions(context.Context, []uint) error + FindVersion(context.Context, uint) (*Version, error) + ListVersions(context.Context, int, int, string, string) ([]*Version, int64, error) + BuildVersionBundle(context.Context, []uint, []uint, []uint) (*VersionBundle, error) + ImportVersionBundle(context.Context, *VersionBundle) error +} +type VersionUsecase struct{ repo VersionRepo } + +func NewVersionUsecase(repo VersionRepo) *VersionUsecase { return &VersionUsecase{repo: repo} } +func (uc *VersionUsecase) Repo() VersionRepo { return uc.repo } diff --git a/internal/conf/conf.pb.go b/internal/conf/conf.pb.go index 621e117..a776afc 100644 --- a/internal/conf/conf.pb.go +++ b/internal/conf/conf.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc (unknown) +// protoc v7.35.1 // source: conf/conf.proto package conf @@ -26,6 +26,7 @@ type Bootstrap struct { state protoimpl.MessageState `protogen:"open.v1"` Server *Server `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` Data *Data `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + Admin *AdminBackend `protobuf:"bytes,3,opt,name=admin,proto3" json:"admin,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -74,6 +75,13 @@ func (x *Bootstrap) GetData() *Data { return nil } +func (x *Bootstrap) GetAdmin() *AdminBackend { + if x != nil { + return x.Admin + } + return nil +} + type Server struct { state protoimpl.MessageState `protogen:"open.v1"` Http *Server_HTTP `protobuf:"bytes,1,opt,name=http,proto3" json:"http,omitempty"` @@ -178,6 +186,75 @@ func (x *Data) GetRedis() *Data_Redis { return nil } +// AdminBackend contains settings for the administration HTTP transport. +type AdminBackend struct { + state protoimpl.MessageState `protogen:"open.v1"` + RouterPrefix string `protobuf:"bytes,1,opt,name=router_prefix,json=routerPrefix,proto3" json:"router_prefix,omitempty"` + Jwt *AdminBackend_JWT `protobuf:"bytes,2,opt,name=jwt,proto3" json:"jwt,omitempty"` + Captcha *AdminBackend_Captcha `protobuf:"bytes,3,opt,name=captcha,proto3" json:"captcha,omitempty"` + Local *AdminBackend_Local `protobuf:"bytes,4,opt,name=local,proto3" json:"local,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend) Reset() { + *x = AdminBackend{} + mi := &file_conf_conf_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend) ProtoMessage() {} + +func (x *AdminBackend) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend.ProtoReflect.Descriptor instead. +func (*AdminBackend) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3} +} + +func (x *AdminBackend) GetRouterPrefix() string { + if x != nil { + return x.RouterPrefix + } + return "" +} + +func (x *AdminBackend) GetJwt() *AdminBackend_JWT { + if x != nil { + return x.Jwt + } + return nil +} + +func (x *AdminBackend) GetCaptcha() *AdminBackend_Captcha { + if x != nil { + return x.Captcha + } + return nil +} + +func (x *AdminBackend) GetLocal() *AdminBackend_Local { + if x != nil { + return x.Local + } + return nil +} + type Server_HTTP struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -189,7 +266,7 @@ type Server_HTTP struct { func (x *Server_HTTP) Reset() { *x = Server_HTTP{} - mi := &file_conf_conf_proto_msgTypes[3] + mi := &file_conf_conf_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -201,7 +278,7 @@ func (x *Server_HTTP) String() string { func (*Server_HTTP) ProtoMessage() {} func (x *Server_HTTP) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[3] + mi := &file_conf_conf_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -249,7 +326,7 @@ type Server_GRPC struct { func (x *Server_GRPC) Reset() { *x = Server_GRPC{} - mi := &file_conf_conf_proto_msgTypes[4] + mi := &file_conf_conf_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -261,7 +338,7 @@ func (x *Server_GRPC) String() string { func (*Server_GRPC) ProtoMessage() {} func (x *Server_GRPC) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[4] + mi := &file_conf_conf_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -308,7 +385,7 @@ type Data_Database struct { func (x *Data_Database) Reset() { *x = Data_Database{} - mi := &file_conf_conf_proto_msgTypes[5] + mi := &file_conf_conf_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -320,7 +397,7 @@ func (x *Data_Database) String() string { func (*Data_Database) ProtoMessage() {} func (x *Data_Database) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[5] + mi := &file_conf_conf_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -362,7 +439,7 @@ type Data_Redis struct { func (x *Data_Redis) Reset() { *x = Data_Redis{} - mi := &file_conf_conf_proto_msgTypes[6] + mi := &file_conf_conf_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -374,7 +451,7 @@ func (x *Data_Redis) String() string { func (*Data_Redis) ProtoMessage() {} func (x *Data_Redis) ProtoReflect() protoreflect.Message { - mi := &file_conf_conf_proto_msgTypes[6] + mi := &file_conf_conf_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -418,15 +495,204 @@ func (x *Data_Redis) GetWriteTimeout() *durationpb.Duration { return nil } +type AdminBackend_JWT struct { + state protoimpl.MessageState `protogen:"open.v1"` + SigningKey string `protobuf:"bytes,1,opt,name=signing_key,json=signingKey,proto3" json:"signing_key,omitempty"` + ExpiresTime *durationpb.Duration `protobuf:"bytes,2,opt,name=expires_time,json=expiresTime,proto3" json:"expires_time,omitempty"` + BufferTime *durationpb.Duration `protobuf:"bytes,3,opt,name=buffer_time,json=bufferTime,proto3" json:"buffer_time,omitempty"` + Issuer string `protobuf:"bytes,4,opt,name=issuer,proto3" json:"issuer,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend_JWT) Reset() { + *x = AdminBackend_JWT{} + mi := &file_conf_conf_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend_JWT) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend_JWT) ProtoMessage() {} + +func (x *AdminBackend_JWT) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend_JWT.ProtoReflect.Descriptor instead. +func (*AdminBackend_JWT) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3, 0} +} + +func (x *AdminBackend_JWT) GetSigningKey() string { + if x != nil { + return x.SigningKey + } + return "" +} + +func (x *AdminBackend_JWT) GetExpiresTime() *durationpb.Duration { + if x != nil { + return x.ExpiresTime + } + return nil +} + +func (x *AdminBackend_JWT) GetBufferTime() *durationpb.Duration { + if x != nil { + return x.BufferTime + } + return nil +} + +func (x *AdminBackend_JWT) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +type AdminBackend_Captcha struct { + state protoimpl.MessageState `protogen:"open.v1"` + KeyLong int32 `protobuf:"varint,1,opt,name=key_long,json=keyLong,proto3" json:"key_long,omitempty"` + ImgWidth int32 `protobuf:"varint,2,opt,name=img_width,json=imgWidth,proto3" json:"img_width,omitempty"` + ImgHeight int32 `protobuf:"varint,3,opt,name=img_height,json=imgHeight,proto3" json:"img_height,omitempty"` + StoreExpiration *durationpb.Duration `protobuf:"bytes,4,opt,name=store_expiration,json=storeExpiration,proto3" json:"store_expiration,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend_Captcha) Reset() { + *x = AdminBackend_Captcha{} + mi := &file_conf_conf_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend_Captcha) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend_Captcha) ProtoMessage() {} + +func (x *AdminBackend_Captcha) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend_Captcha.ProtoReflect.Descriptor instead. +func (*AdminBackend_Captcha) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3, 1} +} + +func (x *AdminBackend_Captcha) GetKeyLong() int32 { + if x != nil { + return x.KeyLong + } + return 0 +} + +func (x *AdminBackend_Captcha) GetImgWidth() int32 { + if x != nil { + return x.ImgWidth + } + return 0 +} + +func (x *AdminBackend_Captcha) GetImgHeight() int32 { + if x != nil { + return x.ImgHeight + } + return 0 +} + +func (x *AdminBackend_Captcha) GetStoreExpiration() *durationpb.Duration { + if x != nil { + return x.StoreExpiration + } + return nil +} + +type AdminBackend_Local struct { + state protoimpl.MessageState `protogen:"open.v1"` + StorePath string `protobuf:"bytes,1,opt,name=store_path,json=storePath,proto3" json:"store_path,omitempty"` + PathPrefix string `protobuf:"bytes,2,opt,name=path_prefix,json=pathPrefix,proto3" json:"path_prefix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend_Local) Reset() { + *x = AdminBackend_Local{} + mi := &file_conf_conf_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend_Local) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend_Local) ProtoMessage() {} + +func (x *AdminBackend_Local) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend_Local.ProtoReflect.Descriptor instead. +func (*AdminBackend_Local) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3, 2} +} + +func (x *AdminBackend_Local) GetStorePath() string { + if x != nil { + return x.StorePath + } + return "" +} + +func (x *AdminBackend_Local) GetPathPrefix() string { + if x != nil { + return x.PathPrefix + } + return "" +} + var File_conf_conf_proto protoreflect.FileDescriptor const file_conf_conf_proto_rawDesc = "" + "\n" + "\x0fconf/conf.proto\x12\n" + - "kratos.api\x1a\x1egoogle/protobuf/duration.proto\"]\n" + + "kratos.api\x1a\x1egoogle/protobuf/duration.proto\"\x8d\x01\n" + "\tBootstrap\x12*\n" + "\x06server\x18\x01 \x01(\v2\x12.kratos.api.ServerR\x06server\x12$\n" + - "\x04data\x18\x02 \x01(\v2\x10.kratos.api.DataR\x04data\"\xb8\x02\n" + + "\x04data\x18\x02 \x01(\v2\x10.kratos.api.DataR\x04data\x12.\n" + + "\x05admin\x18\x03 \x01(\v2\x18.kratos.api.AdminBackendR\x05admin\"\xb8\x02\n" + "\x06Server\x12+\n" + "\x04http\x18\x01 \x01(\v2\x17.kratos.api.Server.HTTPR\x04http\x12+\n" + "\x04grpc\x18\x02 \x01(\v2\x17.kratos.api.Server.GRPCR\x04grpc\x1ai\n" + @@ -448,7 +714,30 @@ const file_conf_conf_proto_rawDesc = "" + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" + "\x04addr\x18\x02 \x01(\tR\x04addr\x12<\n" + "\fread_timeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\vreadTimeout\x12>\n" + - "\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeoutB6Z4kra/internal/conf;confb\x06proto3" + "\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\x82\x05\n" + + "\fAdminBackend\x12#\n" + + "\rrouter_prefix\x18\x01 \x01(\tR\frouterPrefix\x12.\n" + + "\x03jwt\x18\x02 \x01(\v2\x1c.kratos.api.AdminBackend.JWTR\x03jwt\x12:\n" + + "\acaptcha\x18\x03 \x01(\v2 .kratos.api.AdminBackend.CaptchaR\acaptcha\x124\n" + + "\x05local\x18\x04 \x01(\v2\x1e.kratos.api.AdminBackend.LocalR\x05local\x1a\xb8\x01\n" + + "\x03JWT\x12\x1f\n" + + "\vsigning_key\x18\x01 \x01(\tR\n" + + "signingKey\x12<\n" + + "\fexpires_time\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\vexpiresTime\x12:\n" + + "\vbuffer_time\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\n" + + "bufferTime\x12\x16\n" + + "\x06issuer\x18\x04 \x01(\tR\x06issuer\x1a\xa6\x01\n" + + "\aCaptcha\x12\x19\n" + + "\bkey_long\x18\x01 \x01(\x05R\akeyLong\x12\x1b\n" + + "\timg_width\x18\x02 \x01(\x05R\bimgWidth\x12\x1d\n" + + "\n" + + "img_height\x18\x03 \x01(\x05R\timgHeight\x12D\n" + + "\x10store_expiration\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\x0fstoreExpiration\x1aG\n" + + "\x05Local\x12\x1d\n" + + "\n" + + "store_path\x18\x01 \x01(\tR\tstorePath\x12\x1f\n" + + "\vpath_prefix\x18\x02 \x01(\tR\n" + + "pathPrefixB\x18Z\x16kra/internal/conf;confb\x06proto3" var ( file_conf_conf_proto_rawDescOnce sync.Once @@ -462,33 +751,44 @@ func file_conf_conf_proto_rawDescGZIP() []byte { return file_conf_conf_proto_rawDescData } -var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_conf_conf_proto_goTypes = []any{ - (*Bootstrap)(nil), // 0: kratos.api.Bootstrap - (*Server)(nil), // 1: kratos.api.Server - (*Data)(nil), // 2: kratos.api.Data - (*Server_HTTP)(nil), // 3: kratos.api.Server.HTTP - (*Server_GRPC)(nil), // 4: kratos.api.Server.GRPC - (*Data_Database)(nil), // 5: kratos.api.Data.Database - (*Data_Redis)(nil), // 6: kratos.api.Data.Redis - (*durationpb.Duration)(nil), // 7: google.protobuf.Duration + (*Bootstrap)(nil), // 0: kratos.api.Bootstrap + (*Server)(nil), // 1: kratos.api.Server + (*Data)(nil), // 2: kratos.api.Data + (*AdminBackend)(nil), // 3: kratos.api.AdminBackend + (*Server_HTTP)(nil), // 4: kratos.api.Server.HTTP + (*Server_GRPC)(nil), // 5: kratos.api.Server.GRPC + (*Data_Database)(nil), // 6: kratos.api.Data.Database + (*Data_Redis)(nil), // 7: kratos.api.Data.Redis + (*AdminBackend_JWT)(nil), // 8: kratos.api.AdminBackend.JWT + (*AdminBackend_Captcha)(nil), // 9: kratos.api.AdminBackend.Captcha + (*AdminBackend_Local)(nil), // 10: kratos.api.AdminBackend.Local + (*durationpb.Duration)(nil), // 11: google.protobuf.Duration } var file_conf_conf_proto_depIdxs = []int32{ 1, // 0: kratos.api.Bootstrap.server:type_name -> kratos.api.Server 2, // 1: kratos.api.Bootstrap.data:type_name -> kratos.api.Data - 3, // 2: kratos.api.Server.http:type_name -> kratos.api.Server.HTTP - 4, // 3: kratos.api.Server.grpc:type_name -> kratos.api.Server.GRPC - 5, // 4: kratos.api.Data.database:type_name -> kratos.api.Data.Database - 6, // 5: kratos.api.Data.redis:type_name -> kratos.api.Data.Redis - 7, // 6: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration - 7, // 7: kratos.api.Server.GRPC.timeout:type_name -> google.protobuf.Duration - 7, // 8: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration - 7, // 9: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration - 10, // [10:10] is the sub-list for method output_type - 10, // [10:10] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 3, // 2: kratos.api.Bootstrap.admin:type_name -> kratos.api.AdminBackend + 4, // 3: kratos.api.Server.http:type_name -> kratos.api.Server.HTTP + 5, // 4: kratos.api.Server.grpc:type_name -> kratos.api.Server.GRPC + 6, // 5: kratos.api.Data.database:type_name -> kratos.api.Data.Database + 7, // 6: kratos.api.Data.redis:type_name -> kratos.api.Data.Redis + 8, // 7: kratos.api.AdminBackend.jwt:type_name -> kratos.api.AdminBackend.JWT + 9, // 8: kratos.api.AdminBackend.captcha:type_name -> kratos.api.AdminBackend.Captcha + 10, // 9: kratos.api.AdminBackend.local:type_name -> kratos.api.AdminBackend.Local + 11, // 10: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration + 11, // 11: kratos.api.Server.GRPC.timeout:type_name -> google.protobuf.Duration + 11, // 12: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration + 11, // 13: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration + 11, // 14: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration + 11, // 15: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration + 11, // 16: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration + 17, // [17:17] is the sub-list for method output_type + 17, // [17:17] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_conf_conf_proto_init() } @@ -502,7 +802,7 @@ func file_conf_conf_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_conf_conf_proto_rawDesc), len(file_conf_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/conf/conf.proto b/internal/conf/conf.proto index 44e0fed..ba567a4 100644 --- a/internal/conf/conf.proto +++ b/internal/conf/conf.proto @@ -8,6 +8,7 @@ import "google/protobuf/duration.proto"; message Bootstrap { Server server = 1; Data data = 2; + AdminBackend admin = 3; } message Server { @@ -39,3 +40,30 @@ message Data { Database database = 1; Redis redis = 2; } + +// AdminBackend contains settings for the administration HTTP transport. +message AdminBackend { + string router_prefix = 1; + JWT jwt = 2; + Captcha captcha = 3; + Local local = 4; + + message JWT { + string signing_key = 1; + google.protobuf.Duration expires_time = 2; + google.protobuf.Duration buffer_time = 3; + string issuer = 4; + } + + message Captcha { + int32 key_long = 1; + int32 img_width = 2; + int32 img_height = 3; + google.protobuf.Duration store_expiration = 4; + } + + message Local { + string store_path = 1; + string path_prefix = 2; + } +} diff --git a/internal/data/access.go b/internal/data/access.go new file mode 100644 index 0000000..318888a --- /dev/null +++ b/internal/data/access.go @@ -0,0 +1,941 @@ +package data + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "kra/internal/biz" + + "github.com/casbin/casbin/v3" + casbinmodel "github.com/casbin/casbin/v3/model" + "gorm.io/gorm" +) + +type apiPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Path string `gorm:"uniqueIndex:idx_api_path_method"` + Description string + APIGroup string `gorm:"column:api_group"` + Method string `gorm:"uniqueIndex:idx_api_path_method"` +} + +func (apiPO) TableName() string { return "sys_apis" } + +type ignoredAPIPO struct { + Path string `gorm:"primaryKey"` + Method string `gorm:"primaryKey"` +} + +func (ignoredAPIPO) TableName() string { return "sys_ignore_apis" } + +type authorityAPIPO struct { + AuthorityID uint `gorm:"primaryKey;column:authority_id"` + APIID uint `gorm:"primaryKey;column:api_id"` +} + +func (authorityAPIPO) TableName() string { return "sys_authority_apis" } + +type menuButtonPO struct { + ID uint `gorm:"primaryKey"` + Name string + Description string `gorm:"column:desc"` + MenuID uint `gorm:"column:sys_base_menu_id"` +} + +func (menuButtonPO) TableName() string { return "sys_base_menu_btns" } + +type authorityButtonPO struct { + AuthorityID uint `gorm:"primaryKey;column:authority_id"` + MenuID uint `gorm:"primaryKey;column:sys_menu_id"` + ButtonID uint `gorm:"primaryKey;column:sys_base_menu_btn_id"` +} + +func (authorityButtonPO) TableName() string { return "sys_authority_btns" } + +type departmentPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"index"` + ParentID uint + Ancestors string + Sort int + LeaderID uint + Status bool `gorm:"default:true"` +} + +func (departmentPO) TableName() string { return "sys_departments" } + +type positionPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"index"` + Code string + Sort int + Status bool `gorm:"default:true"` + Remark string +} + +func (positionPO) TableName() string { return "sys_positions" } + +type userDepartmentPO struct { + UserID uint `gorm:"primaryKey;column:sys_user_id"` + DepartmentID uint `gorm:"primaryKey;column:sys_department_id"` +} + +func (userDepartmentPO) TableName() string { return "sys_user_departments" } + +type userPositionPO struct { + UserID uint `gorm:"primaryKey;column:sys_user_id"` + PositionID uint `gorm:"primaryKey;column:sys_position_id"` +} + +func (userPositionPO) TableName() string { return "sys_user_positions" } + +type authorityDepartmentPO struct { + AuthorityID uint `gorm:"primaryKey;column:authority_id"` + DepartmentID uint `gorm:"primaryKey;column:department_id"` +} + +func (authorityDepartmentPO) TableName() string { return "sys_authority_departments" } + +type accessRepo struct{ data *Data } + +func NewAccessRepo(data *Data) biz.AccessRepo { return &accessRepo{data: data} } + +func (r *accessRepo) CreateAuthority(ctx context.Context, value *biz.Authority) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("存在相同角色id") + } + if value.DefaultRouter == "" { + value.DefaultRouter = "dashboard" + } + if err := tx.Create(&authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}).Error; err != nil { + return err + } + var dashboard menuPO + if err := tx.Where("name = ?", "dashboard").First(&dashboard).Error; err == nil { + if err = tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: value.AuthorityID, SysBaseMenuID: dashboard.ID}).Error; err != nil { + return err + } + } + defaults := []struct{ path, method string }{{"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}} + for _, item := range defaults { + var api apiPO + if err := tx.Where("path = ? AND method = ?", item.path, item.method).First(&api).Error; err == nil { + if err = tx.Create(&authorityAPIPO{AuthorityID: value.AuthorityID, APIID: api.ID}).Error; err != nil { + return err + } + } + } + return nil + }) +} +func (r *accessRepo) CopyAuthority(ctx context.Context, sourceID uint, value *biz.Authority) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { + return err + } + if count != 0 { + return errors.New("存在相同角色id") + } + if err := tx.Create(&authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}).Error; err != nil { + return err + } + copyLinks := func(table string, destination any, columns map[string]any) error { + return tx.Table(table).Where(columns).Find(destination).Error + } + var menus []authorityMenuPO + if err := copyLinks("sys_authority_menus", &menus, map[string]any{"sys_authority_authority_id": sourceID}); err != nil { + return err + } + for i := range menus { + menus[i].SysAuthorityAuthorityID = value.AuthorityID + } + if len(menus) > 0 { + if err := tx.Create(&menus).Error; err != nil { + return err + } + } + var apis []authorityAPIPO + if err := copyLinks("sys_authority_apis", &apis, map[string]any{"authority_id": sourceID}); err != nil { + return err + } + for i := range apis { + apis[i].AuthorityID = value.AuthorityID + } + if len(apis) > 0 { + if err := tx.Create(&apis).Error; err != nil { + return err + } + } + var buttons []authorityButtonPO + if err := copyLinks("sys_authority_btns", &buttons, map[string]any{"authority_id": sourceID}); err != nil { + return err + } + for i := range buttons { + buttons[i].AuthorityID = value.AuthorityID + } + if len(buttons) > 0 { + if err := tx.Create(&buttons).Error; err != nil { + return err + } + } + var departments []authorityDepartmentPO + if err := copyLinks("sys_authority_departments", &departments, map[string]any{"authority_id": sourceID}); err != nil { + return err + } + for i := range departments { + departments[i].AuthorityID = value.AuthorityID + } + if len(departments) > 0 { + return tx.Create(&departments).Error + } + return nil + }) +} +func (r *accessRepo) UpdateAuthority(ctx context.Context, value *biz.Authority) error { + return r.data.gormDB.WithContext(ctx).Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Updates(map[string]any{"authority_name": value.AuthorityName, "parent_id": value.ParentID, "data_scope": value.DataScope, "default_router": value.DefaultRouter}).Error +} +func (r *accessRepo) DeleteAuthority(ctx context.Context, id uint) error { + if id == 888 { + return errors.New("不能删除超级管理员") + } + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var users, children int64 + if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Count(&users).Error; err != nil { + return err + } + if users > 0 { + return errors.New("此角色有用户正在使用禁止删除") + } + if err := tx.Model(&userPO{}).Where("authority_id = ?", id).Count(&users).Error; err != nil { + return err + } + if users > 0 { + return errors.New("此角色有用户正在使用禁止删除") + } + if err := tx.Model(&authorityPO{}).Where("parent_id = ?", id).Count(&children).Error; err != nil { + return err + } + if children > 0 { + return errors.New("此角色存在子角色不允许删除") + } + if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + if err := tx.Where("authority_id = ?", id).Delete(&authorityAPIPO{}).Error; err != nil { + return err + } + if err := tx.Where("authority_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil { + return err + } + if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil { + return err + } + return tx.Delete(&authorityPO{}, "authority_id = ?", id).Error + }) +} +func (r *accessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { + var pos []authorityPO + if err := r.data.gormDB.WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { + return nil, err + } + out := make([]*biz.Authority, 0, len(pos)) + for _, po := range pos { + v := toBizAuthority(po) + out = append(out, &v) + } + return out, nil +} +func (r *accessRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + links := make([]authorityMenuPO, 0, len(ids)) + for _, mid := range ids { + links = append(links, authorityMenuPO{SysAuthorityAuthorityID: id, SysBaseMenuID: mid}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_base_menu_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetAuthorityUsers(ctx context.Context, id uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var oldIDs []uint + if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &oldIDs).Error; err != nil { + return err + } + if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { + return err + } + links := make([]userAuthorityPO, 0, len(ids)) + for _, uid := range ids { + links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id}) + } + if len(links) > 0 { + if err := tx.Create(&links).Error; err != nil { + return err + } + } + selected := map[uint]bool{} + for _, uid := range ids { + selected[uid] = true + } + for _, uid := range oldIDs { + if selected[uid] { + continue + } + var user userPO + if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id { + if err = tx.Model(&user).Update("authority_id", 888).Error; err != nil { + return err + } + fallback := userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: 888} + if err = tx.FirstOrCreate(&fallback, fallback).Error; err != nil { + return err + } + } + } + return nil + }) +} +func (r *accessRepo) AuthorityUserIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetDataScope(ctx context.Context, id uint, scope int, deptIDs []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&authorityPO{}).Where("authority_id = ?", id).Update("data_scope", scope).Error; err != nil { + return err + } + if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil { + return err + } + links := make([]authorityDepartmentPO, 0, len(deptIDs)) + for _, did := range deptIDs { + links = append(links, authorityDepartmentPO{AuthorityID: id, DepartmentID: did}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("authority_id = ?", id).Pluck("department_id", &ids).Error + return ids, err +} +func (r *accessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) { + if authorityID == 888 { + return biz.DataScope{All: true}, nil + } + var authority authorityPO + if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", authorityID).Error; err != nil { + return biz.DataScope{}, err + } + if authority.DataScope == 1 { + return biz.DataScope{All: true}, nil + } + if authority.DataScope == 4 { + return biz.DataScope{OwnerUserID: userID}, nil + } + var ids []uint + if authority.DataScope == 5 { + var err error + ids, err = r.DataScopeDepartmentIDs(ctx, authorityID) + if err != nil { + return biz.DataScope{}, err + } + } else { + if err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_user_id = ?", userID).Pluck("sys_department_id", &ids).Error; err != nil { + return biz.DataScope{}, err + } + if authority.DataScope == 2 && len(ids) > 0 { + var departments []departmentPO + if err := r.data.gormDB.WithContext(ctx).Find(&departments).Error; err != nil { + return biz.DataScope{}, err + } + selected := make(map[uint]bool, len(ids)) + for _, id := range ids { + selected[id] = true + } + for _, department := range departments { + for _, part := range strings.Split(department.Ancestors, ",") { + value, _ := strconv.ParseUint(part, 10, 64) + if selected[uint(value)] { + selected[department.ID] = true + break + } + } + } + ids = ids[:0] + for id := range selected { + ids = append(ids, id) + } + } + } + return biz.DataScope{DepartmentIDs: ids}, nil +} + +func menuFromPO(po menuPO) *biz.Menu { + return &biz.Menu{ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType} +} +func menuToPO(v *biz.Menu) menuPO { + return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType} +} +func (r *accessRepo) CreateMenu(ctx context.Context, v *biz.Menu) error { + po := menuToPO(v) + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil +} +func (r *accessRepo) UpdateMenu(ctx context.Context, v *biz.Menu) error { + po := menuToPO(v) + return r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Where("id = ?", v.ID).Updates(&po).Error +} +func (r *accessRepo) DeleteMenu(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&menuPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("此菜单存在子菜单不可删除") + } + if err := tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + return tx.Delete(&menuPO{}, id).Error + }) +} +func (r *accessRepo) FindMenu(ctx context.Context, id uint) (*biz.Menu, error) { + var po menuPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return menuFromPO(po), nil +} +func (r *accessRepo) ListMenus(ctx context.Context) ([]*biz.Menu, error) { + var pos []menuPO + if err := r.data.gormDB.WithContext(ctx).Order("sort,id").Find(&pos).Error; err != nil { + return nil, err + } + out := make([]*biz.Menu, 0, len(pos)) + for _, po := range pos { + out = append(out, menuFromPO(po)) + } + return out, nil +} +func (r *accessRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetMenuRoles(ctx context.Context, id uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + links := make([]authorityMenuPO, 0, len(ids)) + for _, aid := range ids { + links = append(links, authorityMenuPO{SysAuthorityAuthorityID: aid, SysBaseMenuID: id}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) ReplaceMenuButtons(ctx context.Context, menuID uint, buttons []*biz.MenuButton) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var oldIDs []uint + if err := tx.Model(&menuButtonPO{}).Where("sys_base_menu_id = ?", menuID).Pluck("id", &oldIDs).Error; err != nil { + return err + } + if len(oldIDs) > 0 { + if err := tx.Where("sys_base_menu_btn_id IN ?", oldIDs).Delete(&authorityButtonPO{}).Error; err != nil { + return err + } + } + if err := tx.Where("sys_base_menu_id = ?", menuID).Delete(&menuButtonPO{}).Error; err != nil { + return err + } + pos := make([]menuButtonPO, 0, len(buttons)) + for _, button := range buttons { + pos = append(pos, menuButtonPO{Name: button.Name, Description: button.Description, MenuID: menuID}) + } + if len(pos) > 0 { + return tx.Create(&pos).Error + } + return nil + }) +} + +func apiFromPO(po apiPO) *biz.API { + return &biz.API{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Path: po.Path, Description: po.Description, APIGroup: po.APIGroup, Method: po.Method} +} +func (r *accessRepo) CreateAPI(ctx context.Context, v *biz.API) error { + return r.data.gormDB.WithContext(ctx).Create(&apiPO{Path: v.Path, Description: v.Description, APIGroup: v.APIGroup, Method: strings.ToUpper(v.Method)}).Error +} +func (r *accessRepo) UpdateAPI(ctx context.Context, v *biz.API) error { + return r.data.gormDB.WithContext(ctx).Model(&apiPO{}).Where("id = ?", v.ID).Updates(map[string]any{"path": v.Path, "description": v.Description, "api_group": v.APIGroup, "method": strings.ToUpper(v.Method)}).Error +} +func (r *accessRepo) DeleteAPIs(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("api_id IN ?", ids).Delete(&authorityAPIPO{}).Error; err != nil { + return err + } + return tx.Delete(&apiPO{}, ids).Error + }) +} +func (r *accessRepo) FindAPI(ctx context.Context, id uint) (*biz.API, error) { + var po apiPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return apiFromPO(po), nil +} +func (r *accessRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]*biz.API, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&apiPO{}) + if q != nil { + if q.Path != "" { + db = db.Where("path LIKE ?", "%"+q.Path+"%") + } + if q.APIGroup != "" { + db = db.Where("api_group = ?", q.APIGroup) + } + if q.Method != "" { + db = db.Where("method = ?", strings.ToUpper(q.Method)) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []apiPO + if err := db.Order("api_group,path,method").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.API, 0, len(pos)) + for _, po := range pos { + out = append(out, apiFromPO(po)) + } + return out, total, nil +} +func (r *accessRepo) APIRoleIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityAPIPO{}).Where("api_id = ?", id).Pluck("authority_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetAPIRoles(ctx context.Context, id uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("api_id = ?", id).Delete(&authorityAPIPO{}).Error; err != nil { + return err + } + links := make([]authorityAPIPO, 0, len(ids)) + for _, aid := range ids { + links = append(links, authorityAPIPO{AuthorityID: aid, APIID: id}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) { + policies, err := r.PolicyPaths(ctx, aid) + if err != nil { + return false, err + } + model, 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 && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)`) + if err != nil { + return false, err + } + enforcer, err := casbin.NewEnforcer(model) + if err != nil { + return false, err + } + subject := strconv.FormatUint(uint64(aid), 10) + for _, policy := range policies { + if _, err := enforcer.AddPolicy(subject, policy.Path, strings.ToUpper(policy.Method)); err != nil { + return false, err + } + } + return enforcer.Enforce(subject, path, strings.ToUpper(method)) +} +func (r *accessRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, error) { + var pos []apiPO + err := r.data.gormDB.WithContext(ctx).Table("sys_apis").Joins("JOIN sys_authority_apis ON sys_authority_apis.api_id = sys_apis.id").Where("sys_authority_apis.authority_id = ? AND sys_apis.deleted_at IS NULL", aid).Order("sys_apis.path,sys_apis.method").Find(&pos).Error + if err != nil { + return nil, err + } + out := make([]*biz.API, 0, len(pos)) + for _, po := range pos { + out = append(out, apiFromPO(po)) + } + return out, nil +} +func (r *accessRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil { + return err + } + links := make([]authorityAPIPO, 0, len(paths)) + for _, path := range paths { + var po apiPO + err := tx.Where("path = ? AND method = ?", path.Path, strings.ToUpper(path.Method)).First(&po).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + po = apiPO{Path: path.Path, Method: strings.ToUpper(path.Method), Description: path.Description, APIGroup: path.APIGroup} + if err = tx.Create(&po).Error; err != nil { + return err + } + } else if err != nil { + return err + } + links = append(links, authorityAPIPO{AuthorityID: aid, APIID: po.ID}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} + +func (r *accessRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) { + var pos []ignoredAPIPO + if err := r.data.gormDB.WithContext(ctx).Order("path,method").Find(&pos).Error; err != nil { + return nil, err + } + out := make([]*biz.API, 0, len(pos)) + for _, po := range pos { + out = append(out, &biz.API{Path: po.Path, Method: po.Method}) + } + return out, nil +} +func (r *accessRepo) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error { + po := ignoredAPIPO{Path: path, Method: strings.ToUpper(method)} + if ignored { + return r.data.gormDB.WithContext(ctx).FirstOrCreate(&po, po).Error + } + return r.data.gormDB.WithContext(ctx).Where("path = ? AND method = ?", po.Path, po.Method).Delete(&ignoredAPIPO{}).Error +} +func (r *accessRepo) ApplyAPISync(ctx context.Context, added, deleted []*biz.API) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, item := range added { + po := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), Description: item.Description, APIGroup: item.APIGroup} + if err := tx.Where("path = ? AND method = ?", po.Path, po.Method).FirstOrCreate(&po).Error; err != nil { + return err + } + } + for _, item := range deleted { + var ids []uint + if err := tx.Model(&apiPO{}).Where("path = ? AND method = ?", item.Path, strings.ToUpper(item.Method)).Pluck("id", &ids).Error; err != nil { + return err + } + if len(ids) > 0 { + if err := tx.Where("api_id IN ?", ids).Delete(&authorityAPIPO{}).Error; err != nil { + return err + } + if err := tx.Where("id IN ?", ids).Delete(&apiPO{}).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (r *accessRepo) Buttons(ctx context.Context, menuID uint) ([]*biz.MenuButton, error) { + var pos []menuButtonPO + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menuID).Find(&pos).Error; err != nil { + return nil, err + } + out := make([]*biz.MenuButton, 0, len(pos)) + for _, po := range pos { + out = append(out, &biz.MenuButton{ID: po.ID, Name: po.Name, Description: po.Description, MenuID: po.MenuID}) + } + return out, nil +} +func (r *accessRepo) SetAuthorityButtons(ctx context.Context, aid uint, buttons map[uint][]uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("authority_id = ?", aid).Delete(&authorityButtonPO{}).Error; err != nil { + return err + } + var links []authorityButtonPO + for menuID, ids := range buttons { + for _, id := range ids { + links = append(links, authorityButtonPO{AuthorityID: aid, MenuID: menuID, ButtonID: id}) + } + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Pluck("sys_base_menu_btn_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Delete(&authorityButtonPO{}).Error; err != nil { + return err + } + links := make([]authorityButtonPO, 0, len(ids)) + for _, id := range ids { + links = append(links, authorityButtonPO{AuthorityID: aid, MenuID: menuID, ButtonID: id}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) CanRemoveButton(ctx context.Context, id uint) (bool, error) { + var count int64 + err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("sys_base_menu_btn_id = ?", id).Count(&count).Error + return count == 0, err +} +func (r *accessRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ?", aid).Pluck("sys_base_menu_btn_id", &ids).Error + return ids, err +} + +func deptFromPO(po departmentPO) *biz.Department { + return &biz.Department{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, ParentID: po.ParentID, Ancestors: po.Ancestors, Sort: po.Sort, LeaderID: po.LeaderID, Status: po.Status} +} +func (r *accessRepo) CreateDepartment(ctx context.Context, v *biz.Department) error { + v.Ancestors = "0" + if v.ParentID != 0 { + var parent departmentPO + if err := r.data.gormDB.WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { + return err + } + v.Ancestors = strings.Trim(parent.Ancestors+","+strconv.FormatUint(uint64(parent.ID), 10), ",") + } + return r.data.gormDB.WithContext(ctx).Create(&departmentPO{Name: v.Name, ParentID: v.ParentID, Ancestors: v.Ancestors, Sort: v.Sort, LeaderID: v.LeaderID, Status: v.Status}).Error +} +func (r *accessRepo) UpdateDepartment(ctx context.Context, v *biz.Department) error { + v.Ancestors = "0" + if v.ParentID != 0 { + var parent departmentPO + if err := r.data.gormDB.WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { + return err + } + v.Ancestors = strings.Trim(parent.Ancestors+","+strconv.FormatUint(uint64(parent.ID), 10), ",") + } + return r.data.gormDB.WithContext(ctx).Model(&departmentPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "parent_id": v.ParentID, "ancestors": v.Ancestors, "sort": v.Sort, "leader_id": v.LeaderID, "status": v.Status}).Error +} +func (r *accessRepo) DeleteDepartment(ctx context.Context, id uint) error { + var count int64 + if err := r.data.gormDB.WithContext(ctx).Model(&departmentPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("部门存在子部门") + } + return r.data.gormDB.WithContext(ctx).Delete(&departmentPO{}, id).Error +} +func (r *accessRepo) FindDepartment(ctx context.Context, id uint) (*biz.Department, error) { + var po departmentPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return deptFromPO(po), nil +} +func (r *accessRepo) ListDepartments(ctx context.Context, name string) ([]*biz.Department, error) { + var pos []departmentPO + db := r.data.gormDB.WithContext(ctx).Order("sort,id") + if name != "" { + db = db.Where("name LIKE ?", "%"+name+"%") + } + if err := db.Find(&pos).Error; err != nil { + return nil, err + } + nodes := map[uint]*biz.Department{} + for _, po := range pos { + nodes[po.ID] = deptFromPO(po) + } + var roots []*biz.Department + for _, po := range pos { + n := nodes[po.ID] + if p := nodes[po.ParentID]; p != nil { + p.Children = append(p.Children, n) + } else { + roots = append(roots, n) + } + } + return roots, nil +} +func (r *accessRepo) DepartmentUserIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Pluck("sys_user_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetDepartmentUsers(ctx context.Context, id uint, ids []uint) error { + return r.replaceUserDepartments(ctx, id, ids) +} +func (r *accessRepo) replaceUserDepartments(ctx context.Context, id uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_department_id = ?", id).Delete(&userDepartmentPO{}).Error; err != nil { + return err + } + links := make([]userDepartmentPO, 0, len(ids)) + for _, uid := range ids { + links = append(links, userDepartmentPO{UserID: uid, DepartmentID: id}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) SetUserDepartments(ctx context.Context, uid uint, ids []uint, primary uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_user_id = ?", uid).Delete(&userDepartmentPO{}).Error; err != nil { + return err + } + links := make([]userDepartmentPO, 0, len(ids)) + for _, id := range ids { + links = append(links, userDepartmentPO{UserID: uid, DepartmentID: id}) + } + if len(links) > 0 { + if err := tx.Create(&links).Error; err != nil { + return err + } + } + if primary == 0 && len(ids) > 0 { + primary = ids[0] + } + return tx.Model(&userPO{}).Where("id = ?", uid).Update("dept_id", primary).Error + }) +} + +func posFromPO(po positionPO) *biz.Position { + return &biz.Position{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, Code: po.Code, Sort: po.Sort, Status: po.Status, Remark: po.Remark} +} +func (r *accessRepo) CreatePosition(ctx context.Context, v *biz.Position) error { + return r.data.gormDB.WithContext(ctx).Create(&positionPO{Name: v.Name, Code: v.Code, Sort: v.Sort, Status: v.Status, Remark: v.Remark}).Error +} +func (r *accessRepo) UpdatePosition(ctx context.Context, v *biz.Position) error { + return r.data.gormDB.WithContext(ctx).Model(&positionPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "code": v.Code, "sort": v.Sort, "status": v.Status, "remark": v.Remark}).Error +} +func (r *accessRepo) DeletePosition(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&positionPO{}, id).Error +} +func (r *accessRepo) FindPosition(ctx context.Context, id uint) (*biz.Position, error) { + var po positionPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return posFromPO(po), nil +} +func (r *accessRepo) ListPositions(ctx context.Context, page, size int, q *biz.Position) ([]*biz.Position, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&positionPO{}) + if q != nil { + if q.Name != "" { + db = db.Where("name LIKE ?", "%"+q.Name+"%") + } + if q.Code != "" { + db = db.Where("code LIKE ?", "%"+q.Code+"%") + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []positionPO + if err := db.Order("sort,id").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.Position, 0, len(pos)) + for _, po := range pos { + out = append(out, posFromPO(po)) + } + return out, total, nil +} +func (r *accessRepo) PositionUserIDs(ctx context.Context, id uint) ([]uint, error) { + var ids []uint + err := r.data.gormDB.WithContext(ctx).Model(&userPositionPO{}).Where("sys_position_id = ?", id).Pluck("sys_user_id", &ids).Error + return ids, err +} +func (r *accessRepo) SetPositionUsers(ctx context.Context, id uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_position_id = ?", id).Delete(&userPositionPO{}).Error; err != nil { + return err + } + links := make([]userPositionPO, 0, len(ids)) + for _, uid := range ids { + links = append(links, userPositionPO{UserID: uid, PositionID: id}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} +func (r *accessRepo) SetUserPositions(ctx context.Context, uid uint, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_user_id = ?", uid).Delete(&userPositionPO{}).Error; err != nil { + return err + } + links := make([]userPositionPO, 0, len(ids)) + for _, id := range ids { + links = append(links, userPositionPO{UserID: uid, PositionID: id}) + } + if len(links) > 0 { + return tx.Create(&links).Error + } + return nil + }) +} diff --git a/internal/data/audit.go b/internal/data/audit.go new file mode 100644 index 0000000..0dfd24d --- /dev/null +++ b/internal/data/audit.go @@ -0,0 +1,430 @@ +package data + +import ( + "bytes" + "context" + "errors" + "gorm.io/gorm" + "io/fs" + "kra/internal/biz" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type operationPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + IP, Method, Path string + Status int + LatencyMS int64 `gorm:"column:latency_ms"` + Agent string `gorm:"type:text"` + ErrorMessage string + Body string `gorm:"type:text"` + Response string `gorm:"column:resp;type:text"` + UserID uint + RequestID string `gorm:"index"` + TraceID string `gorm:"index"` + DeviceID string +} + +func (operationPO) TableName() string { return "sys_operation_records" } + +type loginLogPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Username, IP string + Status bool + ErrorMessage, Agent string + UserID uint +} + +func (loginLogPO) TableName() string { return "sys_login_logs" } + +type dataAccessLogPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + EventType, TargetTable, Operation string + UserID, AuthorityID uint + Scope int + RequestID, Method, Path, Detail string +} + +func (dataAccessLogPO) TableName() string { return "sys_data_access_logs" } + +type errorRecordPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Form string `gorm:"type:text"` + Info string `gorm:"type:text"` + Level string + RequestID string `gorm:"index"` + TraceID string `gorm:"index"` + Solution string `gorm:"type:text"` + Status string `gorm:"default:未处理"` +} + +func (errorRecordPO) TableName() string { return "sys_error" } + +type auditRepo struct{ data *Data } + +func NewAuditRepo(data *Data) biz.AuditRepo { return &auditRepo{data: data} } +func (r *auditRepo) RecordOperation(ctx context.Context, v *biz.OperationRecord) error { + return r.data.gormDB.WithContext(ctx).Create(&operationPO{IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID}).Error +} +func opFromPO(v operationPO) *biz.OperationRecord { + return &biz.OperationRecord{ID: v.ID, CreatedAt: v.CreatedAt, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID} +} +func (r *auditRepo) ListOperations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]*biz.OperationRecord, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&operationPO{}) + if q != nil { + if q.Path != "" { + db = db.Where("path LIKE ?", "%"+q.Path+"%") + } + if q.Method != "" { + db = db.Where("method = ?", q.Method) + } + if q.Status != 0 { + db = db.Where("status = ?", q.Status) + } + if q.IP != "" { + db = db.Where("ip LIKE ?", "%"+q.IP+"%") + } + if q.UserID != 0 { + db = db.Where("user_id = ?", q.UserID) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []operationPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.OperationRecord, 0, len(pos)) + for _, po := range pos { + out = append(out, opFromPO(po)) + } + return out, total, nil +} +func (r *auditRepo) FindOperation(ctx context.Context, id uint) (*biz.OperationRecord, error) { + var po operationPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return opFromPO(po), nil +} +func (r *auditRepo) DeleteOperations(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&operationPO{}, ids).Error +} +func (r *auditRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error { + return r.data.gormDB.WithContext(ctx).Create(&loginLogPO{Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}).Error +} +func loginFromPO(v loginLogPO) *biz.LoginLog { + return &biz.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID} +} +func (r *auditRepo) ListLogins(ctx context.Context, page, size int, q *biz.LoginLog) ([]*biz.LoginLog, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&loginLogPO{}) + if q != nil { + if q.Username != "" { + db = db.Where("username LIKE ?", "%"+q.Username+"%") + } + if q.IP != "" { + db = db.Where("ip LIKE ?", "%"+q.IP+"%") + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []loginLogPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.LoginLog, 0, len(pos)) + for _, po := range pos { + out = append(out, loginFromPO(po)) + } + return out, total, nil +} +func (r *auditRepo) FindLogin(ctx context.Context, id uint) (*biz.LoginLog, error) { + var po loginLogPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return loginFromPO(po), nil +} +func (r *auditRepo) DeleteLogins(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&loginLogPO{}, ids).Error +} +func (r *auditRepo) RecordDataAccess(ctx context.Context, v *biz.DataAccessLog) error { + return r.data.gormDB.WithContext(ctx).Create(&dataAccessLogPO{EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}).Error +} +func dataAccessFromPO(v dataAccessLogPO) *biz.DataAccessLog { + return &biz.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} +} +func (r *auditRepo) ListDataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]*biz.DataAccessLog, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&dataAccessLogPO{}) + if q != nil { + if q.EventType != "" { + db = db.Where("event_type = ?", q.EventType) + } + if q.TargetTable != "" { + db = db.Where("target_table LIKE ?", "%"+q.TargetTable+"%") + } + if q.UserID != 0 { + db = db.Where("user_id = ?", q.UserID) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []dataAccessLogPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.DataAccessLog, 0, len(pos)) + for _, po := range pos { + out = append(out, dataAccessFromPO(po)) + } + return out, total, nil +} +func (r *auditRepo) DeleteDataAccess(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&dataAccessLogPO{}, ids).Error +} + +func logRoot() (string, error) { + root, err := filepath.Abs("logs") + if err != nil { + return "", err + } + info, err := os.Stat(root) + if errors.Is(err, fs.ErrNotExist) { + return root, nil + } + if err != nil { + return "", err + } + if !info.IsDir() { + return "", errors.New("日志路径不是目录") + } + return root, nil +} +func (r *auditRepo) LogDates(ctx context.Context, month string) ([]biz.LogDate, error) { + if parsed, err := time.Parse("2006-01", month); err != nil || parsed.Format("2006-01") != month { + return nil, errors.New("日志月份格式不正确") + } + root, err := logRoot() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(root) + if errors.Is(err, fs.ErrNotExist) { + return []biz.LogDate{}, nil + } + if err != nil { + return nil, err + } + out := []biz.LogDate{} + for _, entry := range entries { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !strings.HasPrefix(entry.Name(), month+"-") { + continue + } + if parsed, parseErr := time.Parse("2006-01-02", entry.Name()); parseErr != nil || parsed.Format("2006-01-02") != entry.Name() { + continue + } + count := 0 + _ = filepath.WalkDir(filepath.Join(root, entry.Name()), func(_ string, item fs.DirEntry, walkErr error) error { + if walkErr == nil && !item.IsDir() && strings.EqualFold(filepath.Ext(item.Name()), ".log") { + count++ + } + return nil + }) + if count > 0 { + out = append(out, biz.LogDate{Date: entry.Name(), FileCount: count}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date }) + return out, nil +} +func (r *auditRepo) LogFiles(ctx context.Context, date string) ([]biz.LogFile, error) { + if parsed, err := time.Parse("2006-01-02", date); err != nil || parsed.Format("2006-01-02") != date { + return nil, errors.New("日志日期格式不正确") + } + root, err := logRoot() + if err != nil { + return nil, err + } + dateRoot := filepath.Join(root, date) + out := []biz.LogFile{} + err = filepath.WalkDir(dateRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + if errors.Is(walkErr, fs.ErrNotExist) { + return nil + } + return walkErr + } + if ctx.Err() != nil { + return ctx.Err() + } + if entry.Type()&os.ModeSymlink != 0 { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") { + return nil + } + info, infoErr := entry.Info() + if infoErr != nil { + return infoErr + } + relative, _ := filepath.Rel(dateRoot, path) + out = append(out, biz.LogFile{Path: filepath.ToSlash(relative), Name: entry.Name(), Size: info.Size(), ModifiedAt: info.ModTime()}) + return nil + }) + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out, err +} +func (r *auditRepo) LogContent(ctx context.Context, date, path string, cursor *int64) (*biz.LogContent, error) { + if _, err := time.Parse("2006-01-02", date); err != nil { + return nil, errors.New("日志日期格式不正确") + } + root, err := logRoot() + if err != nil { + return nil, err + } + dateRoot := filepath.Join(root, date) + target := filepath.Clean(filepath.Join(dateRoot, filepath.FromSlash(path))) + if target == dateRoot || !strings.HasPrefix(target, dateRoot+string(os.PathSeparator)) || !strings.EqualFold(filepath.Ext(target), ".log") { + return nil, errors.New("日志文件路径不合法") + } + info, err := os.Lstat(target) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, errors.New("日志文件路径不合法") + } + end := info.Size() + if cursor != nil && *cursor >= 0 && *cursor < end { + end = *cursor + } + start := end - int64(2*1024*1024) + limited := start > 0 + if start < 0 { + start = 0 + } + file, err := os.Open(target) + if err != nil { + return nil, err + } + defer file.Close() + data := make([]byte, end-start) + n, err := file.ReadAt(data, start) + if err != nil && n == 0 { + return nil, err + } + data = data[:n] + lines := bytes.Split(data, []byte("\n")) + if len(lines) > 501 { + drop := len(lines) - 501 + offset := 0 + for _, line := range lines[:drop] { + offset += len(line) + 1 + } + start += int64(offset) + data = data[offset:] + limited = true + } + return &biz.LogContent{Date: date, Path: path, Content: string(data), LineCount: bytes.Count(data, []byte("\n")), NextCursor: start, HasMore: start > 0, LimitedByBytes: limited, Size: info.Size(), ModifiedAt: info.ModTime()}, nil +} +func errorFromPO(v errorRecordPO) *biz.ErrorRecord { + return &biz.ErrorRecord{ID: v.ID, CreatedAt: v.CreatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status} +} +func (r *auditRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error { + if v.Status == "" { + v.Status = "未处理" + } + return r.data.gormDB.WithContext(ctx).Create(&errorRecordPO{Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error +} +func (r *auditRepo) UpdateError(ctx context.Context, v *biz.ErrorRecord) error { + return r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}).Where("id = ?", v.ID).Updates(map[string]any{"form": v.Form, "info": v.Info, "level": v.Level, "solution": v.Solution, "status": v.Status}).Error +} +func (r *auditRepo) DeleteErrors(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&errorRecordPO{}, ids).Error +} +func (r *auditRepo) FindError(ctx context.Context, id uint) (*biz.ErrorRecord, error) { + var po errorRecordPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return errorFromPO(po), nil +} +func (r *auditRepo) ListErrors(ctx context.Context, page, size int, q *biz.ErrorRecord) ([]*biz.ErrorRecord, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}) + if q != nil { + if q.Form != "" { + db = db.Where("form LIKE ?", "%"+q.Form+"%") + } + if q.Level != "" { + db = db.Where("level = ?", q.Level) + } + if q.Status != "" { + db = db.Where("status = ?", q.Status) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []errorRecordPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.ErrorRecord, 0, len(pos)) + for _, po := range pos { + out = append(out, errorFromPO(po)) + } + return out, total, nil +} diff --git a/internal/data/cache.go b/internal/data/cache.go new file mode 100644 index 0000000..50b8ee1 --- /dev/null +++ b/internal/data/cache.go @@ -0,0 +1,107 @@ +package data + +import ( + "context" + "strconv" + "sync" + "time" + + "kra/internal/biz" + + "github.com/redis/go-redis/v9" +) + +type memoryCacheEntry struct { + value string + expiresAt time.Time +} + +type cacheStore struct { + client *redis.Client + mu sync.Mutex + memory map[string]memoryCacheEntry +} + +func NewCache(data *Data) biz.Cache { + return &cacheStore{client: data.redis, memory: make(map[string]memoryCacheEntry)} +} + +func (s *cacheStore) Get(ctx context.Context, key string) (string, bool, error) { + if s.client != nil { + value, err := s.client.Get(ctx, key).Result() + if err == nil { + return value, true, nil + } + if err != redis.Nil { + return "", false, err + } + return "", false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.memory[key] + if !ok { + return "", false, nil + } + if !entry.expiresAt.IsZero() && time.Now().After(entry.expiresAt) { + delete(s.memory, key) + return "", false, nil + } + return entry.value, true, nil +} + +func (s *cacheStore) Set(ctx context.Context, key, value string, expiration time.Duration) error { + if s.client != nil { + return s.client.Set(ctx, key, value, expiration).Err() + } + s.mu.Lock() + defer s.mu.Unlock() + entry := memoryCacheEntry{value: value} + if expiration > 0 { + entry.expiresAt = time.Now().Add(expiration) + } + s.memory[key] = entry + return nil +} + +func (s *cacheStore) Delete(ctx context.Context, key string) error { + if s.client != nil { + return s.client.Del(ctx, key).Err() + } + s.mu.Lock() + delete(s.memory, key) + s.mu.Unlock() + return nil +} + +func (s *cacheStore) Increment(ctx context.Context, key string, expiration time.Duration) (int64, error) { + if s.client != nil { + value, err := s.client.Incr(ctx, key).Result() + if err != nil { + return 0, err + } + if value == 1 && expiration > 0 { + if err := s.client.Expire(ctx, key, expiration).Err(); err != nil { + return 0, err + } + } + return value, nil + } + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.memory[key] + if ok && !entry.expiresAt.IsZero() && time.Now().After(entry.expiresAt) { + ok = false + } + value := int64(0) + if ok { + value, _ = strconv.ParseInt(entry.value, 10, 64) + } + value++ + entry.value = strconv.FormatInt(value, 10) + if !ok && expiration > 0 { + entry.expiresAt = time.Now().Add(expiration) + } + s.memory[key] = entry + return value, nil +} diff --git a/internal/data/data.go b/internal/data/data.go index 4c037a5..5e00bab 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -2,29 +2,66 @@ package data import ( "context" + "fmt" "log" "os" + "time" + _ "github.com/go-sql-driver/mysql" + "github.com/google/wire" + "github.com/redis/go-redis/v9" + "gorm.io/driver/mysql" + "gorm.io/gorm" "kra/internal/conf" "kra/internal/data/ent" "kra/internal/data/ent/migrate" - _ "github.com/go-sql-driver/mysql" - "github.com/google/wire" ) // ProviderSet is data providers. -var ProviderSet = wire.NewSet(NewData, NewAdminRepo) +var ProviderSet = wire.NewSet(NewData, NewAdminRepo, NewSystemRepo, NewAccessRepo, NewSettingsRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewTaskRepo, NewMediaRepo, NewCache, NewFileStorage) // Data is a struct that contains the database client. type Data struct { - db *ent.Client + db *ent.Client + gormDB *gorm.DB + redis *redis.Client } // NewData creates a new Data instance. func NewData(c *conf.Data) (*Data, func(), error) { + if c == nil || c.Database == nil { + return nil, nil, fmt.Errorf("database configuration is required") + } db, err := ent.Open(c.Database.Driver, c.Database.Source) if err != nil { - log.Fatalf("failed opening connection to database: %v", err) + return nil, nil, fmt.Errorf("open ent database: %w", err) + } + gormDB, err := gorm.Open(mysql.Open(c.Database.Source), &gorm.Config{}) + if err != nil { + _ = db.Close() + return nil, nil, err + } + var redisClient *redis.Client + if c.Redis != nil && c.Redis.Addr != "" { + options := &redis.Options{Addr: c.Redis.Addr} + if c.Redis.Network != "" { + options.Network = c.Redis.Network + } + if c.Redis.ReadTimeout != nil { + options.ReadTimeout = c.Redis.ReadTimeout.AsDuration() + } + if c.Redis.WriteTimeout != nil { + options.WriteTimeout = c.Redis.WriteTimeout.AsDuration() + } + candidate := redis.NewClient(options) + pingCtx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + if pingErr := candidate.Ping(pingCtx).Err(); pingErr != nil { + log.Printf("redis unavailable, using in-memory cache: %v", pingErr) + _ = candidate.Close() + } else { + redisClient = candidate + } + cancel() } if os.Getenv("DEPLOY_ENV") == "dev" { // Enable debug mode for detailed logging. @@ -35,9 +72,14 @@ func NewData(c *conf.Data) (*Data, func(), error) { } } cleanup := func() { - db.Close() + _ = db.Close() + if redisClient != nil { + _ = redisClient.Close() + } } return &Data{ - db: db, + db: db, + gormDB: gormDB, + redis: redisClient, }, cleanup, nil } diff --git a/internal/data/export.go b/internal/data/export.go new file mode 100644 index 0000000..b2b5b6a --- /dev/null +++ b/internal/data/export.go @@ -0,0 +1,308 @@ +package data + +import ( + "context" + "errors" + "fmt" + "gorm.io/gorm" + "kra/internal/biz" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +type exportTemplatePO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + DBName string + Name string + DBTableName string `gorm:"column:table_name"` + TemplateID string `gorm:"uniqueIndex"` + TemplateInfo string `gorm:"type:text"` + SQL string `gorm:"type:text"` + ImportSQL string `gorm:"type:text"` + Limit *int + Order string +} + +func (exportTemplatePO) TableName() string { return "sys_export_templates" } + +type exportConditionPO struct { + ID uint `gorm:"primaryKey"` + TemplateID string `gorm:"index"` + From string + Column string + Operator string +} + +func (exportConditionPO) TableName() string { return "sys_export_template_condition" } + +type exportJoinPO struct { + ID uint `gorm:"primaryKey"` + TemplateID string `gorm:"index"` + Join string `gorm:"column:joins"` + Table string + On string `gorm:"column:on"` +} + +func (exportJoinPO) TableName() string { return "sys_export_template_join" } + +type exportRepo struct{ data *Data } + +func NewExportRepo(data *Data) biz.ExportRepo { return &exportRepo{data: data} } +func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *biz.ExportTemplate { + v := &biz.ExportTemplate{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DBName: po.DBName, Name: po.Name, TableName: po.DBTableName, TemplateID: po.TemplateID, TemplateInfo: po.TemplateInfo, SQL: po.SQL, ImportSQL: po.ImportSQL, Limit: po.Limit, Order: po.Order} + for _, x := range conditions { + v.Conditions = append(v.Conditions, biz.ExportCondition{From: x.From, Column: x.Column, Operator: x.Operator}) + } + for _, x := range joins { + v.Joins = append(v.Joins, biz.ExportJoin{Join: x.Join, Table: x.Table, On: x.On}) + } + return v +} +func exportToPO(v *biz.ExportTemplate) exportTemplatePO { + return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order} +} +func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate) error { + if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { + return err + } + if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil { + return err + } + conditions := make([]exportConditionPO, 0, len(v.Conditions)) + for _, x := range v.Conditions { + conditions = append(conditions, exportConditionPO{TemplateID: v.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) + } + if len(conditions) > 0 { + if err := tx.Create(&conditions).Error; err != nil { + return err + } + } + joins := make([]exportJoinPO, 0, len(v.Joins)) + for _, x := range v.Joins { + joins = append(joins, exportJoinPO{TemplateID: v.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) + } + if len(joins) > 0 { + return tx.Create(&joins).Error + } + return nil +} +func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + po := exportToPO(v) + if err := tx.Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return r.saveRelations(tx, v) + }) +} +func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + po := exportToPO(v) + if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil { + return err + } + return r.saveRelations(tx, v) + }) +} +func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var tids []string + if err := tx.Model(&exportTemplatePO{}).Where("id IN ?", ids).Pluck("template_id", &tids).Error; err != nil { + return err + } + if len(tids) > 0 { + if err := tx.Where("template_id IN ?", tids).Delete(&exportConditionPO{}).Error; err != nil { + return err + } + if err := tx.Where("template_id IN ?", tids).Delete(&exportJoinPO{}).Error; err != nil { + return err + } + } + return tx.Delete(&exportTemplatePO{}, ids).Error + }) +} +func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string) (*biz.ExportTemplate, error) { + var po exportTemplatePO + db := r.data.gormDB.WithContext(ctx) + var err error + if id != 0 { + err = db.First(&po, id).Error + } else { + err = db.Where("template_id = ?", tid).First(&po).Error + } + if err != nil { + return nil, err + } + var conditions []exportConditionPO + var joins []exportJoinPO + if err = db.Where("template_id = ?", po.TemplateID).Find(&conditions).Error; err != nil { + return nil, err + } + if err = db.Where("template_id = ?", po.TemplateID).Find(&joins).Error; err != nil { + return nil, err + } + return exportFromPO(po, conditions, joins), nil +} +func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q *biz.ExportTemplate) ([]*biz.ExportTemplate, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&exportTemplatePO{}) + if q != nil { + if q.Name != "" { + db = db.Where("name LIKE ?", "%"+q.Name+"%") + } + if q.TableName != "" { + db = db.Where("table_name = ?", q.TableName) + } + if q.TemplateID != "" { + db = db.Where("template_id = ?", q.TemplateID) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []exportTemplatePO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.ExportTemplate, 0, len(pos)) + for _, po := range pos { + v, err := r.FindExportTemplate(ctx, po.ID, "") + if err != nil { + return nil, 0, err + } + out = append(out, v) + } + return out, total, nil +} + +var safeIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.]*$`) +var allowedOperator = map[string]bool{"=": true, "!=": true, ">": true, ">=": true, "<": true, "<=": true, "LIKE": true, "IN": true, "NOT IN": true, "BETWEEN": true} + +func validateSelectSQL(sql string) error { + normalized := strings.ToLower(strings.TrimSpace(sql)) + if !strings.HasPrefix(normalized, "select ") || strings.Contains(normalized, ";") { + return errors.New("仅允许单条 SELECT 查询") + } + return nil +} +func (r *exportRepo) QueryExport(ctx context.Context, t *biz.ExportTemplate, params map[string]string) ([]map[string]any, string, error) { + db := r.data.gormDB.WithContext(ctx) + var rows []map[string]any + if t.SQL != "" { + if err := validateSelectSQL(t.SQL); err != nil { + return nil, "", err + } + args := map[string]any{} + for k, v := range params { + args[k] = v + } + err := db.Raw(t.SQL, args).Scan(&rows).Error + return rows, t.SQL, err + } + if !safeIdentifier.MatchString(t.TableName) { + return nil, "", errors.New("表名不合法") + } + columns := make([]string, 0) + for key := range parseTemplateColumns(t.TemplateInfo) { + if !safeIdentifier.MatchString(key) { + return nil, "", errors.New("列名不合法") + } + columns = append(columns, key) + } + sort.Strings(columns) + if len(columns) == 0 { + return nil, "", errors.New("模板列为空") + } + query := db.Table(t.TableName).Select(strings.Join(columns, ",")) + for _, j := range t.Joins { + joinType := strings.ToUpper(strings.TrimSpace(j.Join)) + if joinType != "LEFT JOIN" && joinType != "RIGHT JOIN" && joinType != "INNER JOIN" && joinType != "JOIN" { + return nil, "", errors.New("关联类型不合法") + } + if !safeIdentifier.MatchString(j.Table) { + return nil, "", errors.New("关联表不合法") + } + query = query.Joins(joinType + " " + j.Table + " ON " + j.On) + } + for _, condition := range t.Conditions { + operator := strings.ToUpper(strings.TrimSpace(condition.Operator)) + if !allowedOperator[operator] || !safeIdentifier.MatchString(condition.Column) { + return nil, "", errors.New("查询条件不合法") + } + value := params[condition.From] + if value == "" { + continue + } + switch operator { + case "LIKE": + query = query.Where(condition.Column+" LIKE ?", "%"+value+"%") + case "IN", "NOT IN": + query = query.Where(condition.Column+" "+operator+" ?", strings.Split(value, ",")) + case "BETWEEN": + start, end := params["start"+condition.From], params["end"+condition.From] + if start != "" && end != "" { + query = query.Where(condition.Column+" BETWEEN ? AND ?", start, end) + } + default: + query = query.Where(condition.Column+" "+operator+" ?", value) + } + } + limit := 0 + if raw := params["limit"]; raw != "" { + limit, _ = strconv.Atoi(raw) + } else if t.Limit != nil { + limit = *t.Limit + } + if limit > 0 { + query = query.Limit(limit) + } + if t.Order != "" { + parts := strings.Fields(t.Order) + if len(parts) < 1 || len(parts) > 2 || !safeIdentifier.MatchString(parts[0]) || (len(parts) == 2 && strings.ToUpper(parts[1]) != "ASC" && strings.ToUpper(parts[1]) != "DESC") { + return nil, "", errors.New("排序不合法") + } + query = query.Order(t.Order) + } + preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }) + err := query.Find(&rows).Error + return rows, preview, err +} +func parseTemplateColumns(raw string) map[string]string { + out := map[string]string{} + parts := strings.Split(strings.Trim(strings.TrimSpace(raw), "{}"), ",") + for _, part := range parts { + pair := strings.SplitN(part, ":", 2) + if len(pair) == 2 { + out[strings.Trim(strings.TrimSpace(pair[0]), `"`)] = strings.Trim(strings.TrimSpace(pair[1]), `"`) + } + } + return out +} +func (r *exportRepo) ImportExportRows(ctx context.Context, t *biz.ExportTemplate, rows []map[string]any) error { + sql := strings.TrimSpace(t.ImportSQL) + lower := strings.ToLower(sql) + if sql == "" || strings.Contains(sql, ";") || (!strings.HasPrefix(lower, "insert ") && !strings.HasPrefix(lower, "update ")) { + return errors.New("导入 SQL 仅允许单条 INSERT 或 UPDATE") + } + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for index, row := range rows { + if err := tx.Exec(sql, row).Error; err != nil { + return fmt.Errorf("第%d行导入失败: %w", index+2, err) + } + } + return nil + }) +} diff --git a/internal/data/file_storage.go b/internal/data/file_storage.go new file mode 100644 index 0000000..39cf7ee --- /dev/null +++ b/internal/data/file_storage.go @@ -0,0 +1,188 @@ +package data + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "kra/internal/biz" + "kra/internal/conf" +) + +type fileStorage struct { + root string + urlPrefix string +} + +func NewFileStorage(config *conf.AdminBackend) (biz.FileStorage, error) { + root, prefix := "uploads/file", "uploads/file" + if config != nil && config.Local != nil { + if config.Local.StorePath != "" { + root = config.Local.StorePath + } + if config.Local.PathPrefix != "" { + prefix = config.Local.PathPrefix + } + } + root = filepath.Clean(root) + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create upload directory: %w", err) + } + return &fileStorage{root: root, urlPrefix: "/" + strings.Trim(prefix, "/")}, nil +} + +func (s *fileStorage) resolve(name string) (string, error) { + clean := filepath.Clean(strings.TrimPrefix(name, "/")) + if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("invalid storage path") + } + return filepath.Join(s.root, clean), nil +} + +func (s *fileStorage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) { + path, err := s.resolve(name) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return nil, err + } + size, copyErr := io.Copy(file, reader) + closeErr := file.Close() + if copyErr != nil { + return nil, copyErr + } + if closeErr != nil { + return nil, closeErr + } + return &biz.StoredFile{Name: filepath.Base(name), Path: name, URL: s.urlPrefix + "/" + strings.TrimPrefix(filepath.ToSlash(name), "/"), Size: size}, nil +} + +func (s *fileStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) { + path, err := s.resolve(name) + if err != nil { + return nil, err + } + return os.Open(path) +} + +func (s *fileStorage) Delete(ctx context.Context, name string) error { + path, err := s.resolve(name) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (s *fileStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) { + reader, writer := io.Pipe() + hash := md5.New() + errCh := make(chan error, 1) + go func() { + defer writer.Close() + for _, name := range names { + if ctx.Err() != nil { + errCh <- ctx.Err() + return + } + file, err := s.Open(ctx, name) + if err != nil { + errCh <- err + return + } + _, err = io.Copy(io.MultiWriter(writer, hash), file) + _ = file.Close() + if err != nil { + errCh <- err + return + } + } + errCh <- nil + }() + stored, err := s.Put(ctx, destination, reader) + composeErr := <-errCh + if err != nil { + return nil, "", err + } + if composeErr != nil { + _ = s.Delete(ctx, destination) + return nil, "", composeErr + } + return stored, hex.EncodeToString(hash.Sum(nil)), nil +} +func (s *fileStorage) DeletePrefix(ctx context.Context, prefix string) error { + path, err := s.resolve(strings.TrimSuffix(prefix, "/") + "/placeholder") + if err != nil { + return err + } + dir := filepath.Dir(path) + if !strings.HasPrefix(dir, s.root+string(filepath.Separator)) { + return fmt.Errorf("invalid storage path") + } + if err = os.RemoveAll(dir); err != nil { + return err + } + return nil +} +func (s *fileStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { + if limit < 1 || limit > 1000 { + limit = 100 + } + root, err := s.resolve(strings.TrimSuffix(prefix, "/") + "/placeholder") + if err != nil { + return nil, "", false, err + } + walkRoot := filepath.Dir(root) + items := []*biz.StoredFile{} + err = filepath.WalkDir(walkRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if ctx.Err() != nil { + return ctx.Err() + } + if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return nil + } + relative, _ := filepath.Rel(s.root, path) + relative = filepath.ToSlash(relative) + if relative <= cursor { + return nil + } + info, infoErr := entry.Info() + if infoErr != nil { + return infoErr + } + items = append(items, &biz.StoredFile{Name: entry.Name(), Path: relative, URL: s.urlPrefix + "/" + relative, Size: info.Size()}) + return nil + }) + if err != nil { + return nil, "", false, err + } + sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path }) + hasMore := len(items) > limit + if hasMore { + items = items[:limit] + } + next := "" + if len(items) > 0 { + next = items[len(items)-1].Path + } + return items, next, hasMore, nil +} diff --git a/internal/data/media.go b/internal/data/media.go new file mode 100644 index 0000000..584a305 --- /dev/null +++ b/internal/data/media.go @@ -0,0 +1,260 @@ +package data + +import ( + "context" + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz" + "time" +) + +type mediaPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string + CategoryID int `gorm:"column:class_id"` + URL string + Tag string + Key string `gorm:"index"` + Size int64 + Mime string + MD5 string `gorm:"index;column:md5"` + UserID uint `gorm:"index"` +} + +func (mediaPO) TableName() string { return "media_file_upload_and_downloads" } + +type categoryPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string + ParentID uint `gorm:"column:pid"` +} + +func (categoryPO) TableName() string { return "media_attachment_category" } + +type uploadSessionPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + UserID uint `gorm:"index"` + FileName string + FileHash string `gorm:"index"` + FileSize int64 + ChunkSize int64 + ChunkTotal int + Status string + StorageKey string + MediaID uint +} + +func (uploadSessionPO) TableName() string { return "media_uploads" } + +type uploadChunkPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + UploadID uint `gorm:"uniqueIndex:idx_upload_chunk"` + ChunkIndex int `gorm:"uniqueIndex:idx_upload_chunk"` + ChunkHash string + Size int64 +} + +func (uploadChunkPO) TableName() string { return "media_upload_chunks" } + +type mediaRepo struct{ data *Data } + +func NewMediaRepo(data *Data) biz.MediaRepo { return &mediaRepo{data: data} } +func mediaFromPO(v mediaPO) *biz.MediaFile { + return &biz.MediaFile{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, CategoryID: v.CategoryID, URL: v.URL, Tag: v.Tag, Key: v.Key, Size: v.Size, Mime: v.Mime, MD5: v.MD5, UserID: v.UserID} +} +func mediaToPO(v *biz.MediaFile) mediaPO { + return mediaPO{ID: v.ID, Name: v.Name, CategoryID: v.CategoryID, URL: v.URL, Tag: v.Tag, Key: v.Key, Size: v.Size, Mime: v.Mime, MD5: v.MD5, UserID: v.UserID} +} +func (r *mediaRepo) CreateMedia(ctx context.Context, v *biz.MediaFile) error { + po := mediaToPO(v) + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + v.CreatedAt = po.CreatedAt + return nil +} +func (r *mediaRepo) FindMedia(ctx context.Context, id uint) (*biz.MediaFile, error) { + var po mediaPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return mediaFromPO(po), nil +} +func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash string) (*biz.MediaFile, error) { + var po mediaPO + if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND md5 = ?", userID, hash).First(&po).Error; err != nil { + return nil, err + } + return mediaFromPO(po), nil +} +func (r *mediaRepo) ListMedia(ctx context.Context, page, size int, keyword string, categoryID int, tag string, userID uint) ([]*biz.MediaFile, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}) + if keyword != "" { + db = db.Where("name LIKE ?", "%"+keyword+"%") + } + if categoryID > 0 { + db = db.Where("class_id = ?", categoryID) + } + if tag != "" { + db = db.Where("tag = ?", tag) + } + if userID > 0 { + db = db.Where("user_id = ?", userID) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []mediaPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.MediaFile, 0, len(pos)) + for _, po := range pos { + out = append(out, mediaFromPO(po)) + } + return out, total, nil +} +func (r *mediaRepo) UpdateMediaName(ctx context.Context, id uint, name string) error { + return r.data.gormDB.WithContext(ctx).Model(&mediaPO{}).Where("id = ?", id).Update("name", name).Error +} +func (r *mediaRepo) DeleteMedia(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&mediaPO{}, id).Error +} +func (r *mediaRepo) MediaKeyReferences(ctx context.Context, key string) (int64, error) { + var count int64 + err := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}).Where("`key` = ?", key).Count(&count).Error + return count, err +} +func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile) error { + pos := make([]mediaPO, 0, len(items)) + for _, v := range items { + pos = append(pos, mediaToPO(v)) + } + if len(pos) == 0 { + return nil + } + return r.data.gormDB.WithContext(ctx).Create(&pos).Error +} +func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error { + db := r.data.gormDB.WithContext(ctx) + var count int64 + query := db.Model(&categoryPO{}).Where("name = ? AND pid = ?", v.Name, v.ParentID) + if v.ID != 0 { + query = query.Where("id <> ?", v.ID) + } + if err := query.Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("分类名称已存在") + } + if v.ID == 0 { + po := categoryPO{Name: v.Name, ParentID: v.ParentID} + if err := db.Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil + } + return db.Model(&categoryPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "pid": v.ParentID}).Error +} +func (r *mediaRepo) DeleteCategory(ctx context.Context, id uint) error { + var count int64 + if err := r.data.gormDB.WithContext(ctx).Model(&categoryPO{}).Where("pid = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("请先删除子级") + } + return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&categoryPO{}, id).Error +} +func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCategory, error) { + var pos []categoryPO + if err := r.data.gormDB.WithContext(ctx).Order("id").Find(&pos).Error; err != nil { + return nil, err + } + byID := map[uint]*biz.AttachmentCategory{} + for _, po := range pos { + byID[po.ID] = &biz.AttachmentCategory{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, ParentID: po.ParentID, Children: []*biz.AttachmentCategory{}} + } + roots := []*biz.AttachmentCategory{} + for _, po := range pos { + v := byID[po.ID] + if parent := byID[po.ParentID]; parent != nil { + parent.Children = append(parent.Children, v) + } else { + roots = append(roots, v) + } + } + return roots, nil +} +func uploadFromPO(v uploadSessionPO) *biz.UploadSession { + return &biz.UploadSession{ID: v.ID, UserID: v.UserID, FileName: v.FileName, FileHash: v.FileHash, FileSize: v.FileSize, ChunkSize: v.ChunkSize, ChunkTotal: v.ChunkTotal, Status: v.Status, StorageKey: v.StorageKey, MediaID: v.MediaID} +} +func (r *mediaRepo) FindUploadingSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) { + var po uploadSessionPO + if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "uploading").First(&po).Error; err != nil { + return nil, err + } + return uploadFromPO(po), nil +} +func (r *mediaRepo) CreateUploadSession(ctx context.Context, v *biz.UploadSession) error { + po := uploadSessionPO{UserID: v.UserID, FileName: v.FileName, FileHash: v.FileHash, FileSize: v.FileSize, ChunkSize: v.ChunkSize, ChunkTotal: v.ChunkTotal, Status: v.Status} + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil +} +func (r *mediaRepo) FindUploadSession(ctx context.Context, id uint) (*biz.UploadSession, error) { + var po uploadSessionPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return uploadFromPO(po), nil +} +func (r *mediaRepo) CompleteUploadSession(ctx context.Context, id uint, key string, mediaID uint) error { + return r.data.gormDB.WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ?", id).Updates(map[string]any{"status": "completed", "storage_key": key, "media_id": mediaID}).Error +} +func (r *mediaRepo) DeleteUploadSession(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&uploadSessionPO{}, id).Error +} +func (r *mediaRepo) UpsertChunk(ctx context.Context, uploadID uint, v *biz.UploadChunk) error { + po := uploadChunkPO{UploadID: uploadID, ChunkIndex: v.Index, ChunkHash: v.Hash, Size: v.Size} + return r.data.gormDB.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "upload_id"}, {Name: "chunk_index"}}, DoUpdates: clause.AssignmentColumns([]string{"chunk_hash", "size", "updated_at"})}).Create(&po).Error +} +func (r *mediaRepo) ListChunks(ctx context.Context, uploadID uint) ([]*biz.UploadChunk, error) { + var pos []uploadChunkPO + if err := r.data.gormDB.WithContext(ctx).Where("upload_id = ?", uploadID).Order("chunk_index").Find(&pos).Error; err != nil { + return nil, err + } + out := make([]*biz.UploadChunk, 0, len(pos)) + for _, po := range pos { + out = append(out, &biz.UploadChunk{Index: po.ChunkIndex, Hash: po.ChunkHash, Size: po.Size}) + } + return out, nil +} +func (r *mediaRepo) DeleteChunks(ctx context.Context, uploadID uint) error { + return r.data.gormDB.WithContext(ctx).Unscoped().Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error +} diff --git a/internal/data/settings.go b/internal/data/settings.go new file mode 100644 index 0000000..a54c783 --- /dev/null +++ b/internal/data/settings.go @@ -0,0 +1,485 @@ +package data + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "kra/internal/biz" + + "gorm.io/gorm" +) + +type dictionaryPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string + Type string `gorm:"uniqueIndex"` + Status bool + Desc string + ParentID *uint +} + +func (dictionaryPO) TableName() string { return "sys_dictionaries" } + +type dictionaryDetailPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Label string + Value string + Extend string + Status bool + Sort int + DictionaryID uint `gorm:"column:sys_dictionary_id;index"` + ParentID *uint + Level int + Path string +} + +func (dictionaryDetailPO) TableName() string { return "sys_dictionary_details" } + +type parameterPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string + Key string `gorm:"uniqueIndex"` + Value string + Desc string +} + +func (parameterPO) TableName() string { return "sys_params" } + +type apiTokenPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + UserID uint + AuthorityID uint + Token string `gorm:"type:text;uniqueIndex"` + Status bool + ExpiresAt time.Time + Remark string +} + +func (apiTokenPO) TableName() string { return "sys_api_tokens" } + +type securityConfigPO struct { + ID uint `gorm:"primaryKey"` + CaptchaOpen int + CaptchaTimeout int + KeyLong int + ImgWidth int + ImgHeight int + PwdMinLength int + PwdRequireUpper bool + PwdRequireLower bool + PwdRequireDigit bool + PwdRequireSpecial bool + LimitEnable bool + LimitWindow int + LimitCount int + LockEnable bool + LockThreshold int + LockDuration int + PwdExpireEnable bool + PwdExpireDays int + ForceNewUserChangePassword bool +} + +func (securityConfigPO) TableName() string { return "sys_security_config" } + +type settingsRepo struct{ data *Data } + +func NewSettingsRepo(data *Data) biz.SettingsRepo { return &settingsRepo{data: data} } + +func dictionaryFromPO(po dictionaryPO) *biz.Dictionary { + return &biz.Dictionary{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, Type: po.Type, Status: po.Status, Desc: po.Desc, ParentID: po.ParentID} +} +func detailFromPO(po dictionaryDetailPO) *biz.DictionaryDetail { + return &biz.DictionaryDetail{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Label: po.Label, Value: po.Value, Extend: po.Extend, Status: po.Status, Sort: po.Sort, DictionaryID: po.DictionaryID, ParentID: po.ParentID, Level: po.Level, Path: po.Path} +} +func parameterFromPO(po parameterPO) *biz.SystemParameter { + return &biz.SystemParameter{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, Key: po.Key, Value: po.Value, Desc: po.Desc} +} + +func (r *settingsRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary) error { + po := dictionaryPO{Name: v.Name, Type: v.Type, Status: v.Status, Desc: v.Desc, ParentID: v.ParentID} + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil +} +func (r *settingsRepo) UpdateDictionary(ctx context.Context, v *biz.Dictionary) error { + return r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parent_id": v.ParentID}).Error +} +func (r *settingsRepo) DeleteDictionary(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&dictionaryPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("存在子字典不可删除") + } + if err := tx.Where("sys_dictionary_id = ?", id).Delete(&dictionaryDetailPO{}).Error; err != nil { + return err + } + return tx.Delete(&dictionaryPO{}, id).Error + }) +} +func (r *settingsRepo) FindDictionary(ctx context.Context, id uint, typ string, details bool) (*biz.Dictionary, error) { + var po dictionaryPO + db := r.data.gormDB.WithContext(ctx) + var err error + if id != 0 { + err = db.First(&po, id).Error + } else { + err = db.Where("type = ?", typ).First(&po).Error + } + if err != nil { + return nil, err + } + out := dictionaryFromPO(po) + if details { + out.Details, _, err = r.ListDictionaryDetails(ctx, 1, 10000, out.ID, "", "") + } + return out, err +} +func (r *settingsRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}) + if name != "" { + db = db.Where("name LIKE ?", "%"+name+"%") + } + if typ != "" { + db = db.Where("type LIKE ?", "%"+typ+"%") + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []dictionaryPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.Dictionary, 0, len(pos)) + for _, po := range pos { + v := dictionaryFromPO(po) + if details { + v.Details, _, _ = r.ListDictionaryDetails(ctx, 1, 10000, v.ID, "", "") + } + out = append(out, v) + } + return out, total, nil +} + +func (r *settingsRepo) CreateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { + po := dictionaryDetailPO{Label: v.Label, Value: v.Value, Extend: v.Extend, Status: v.Status, Sort: v.Sort, DictionaryID: v.DictionaryID, ParentID: v.ParentID} + po.Level = 0 + po.Path = "" + if v.ParentID != nil && *v.ParentID != 0 { + var parent dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).First(&parent, *v.ParentID).Error; err != nil { + return err + } + po.Level = parent.Level + 1 + po.Path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",") + } + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + v.Level = po.Level + v.Path = po.Path + return nil +} +func (r *settingsRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { + var po dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).First(&po, v.ID).Error; err != nil { + return err + } + level, path := 0, "" + if v.ParentID != nil && *v.ParentID != 0 { + if *v.ParentID == v.ID { + return errors.New("不能将自身设为父级") + } + var parent dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).First(&parent, *v.ParentID).Error; err != nil { + return err + } + if strings.Contains(","+parent.Path+",", fmt.Sprintf(",%d,", v.ID)) { + return errors.New("不能移动到自己的子级") + } + level = parent.Level + 1 + path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",") + } + oldPrefix := strings.Trim(strings.Join([]string{po.Path, fmt.Sprint(po.ID)}, ","), ",") + newPrefix := strings.Trim(strings.Join([]string{path, fmt.Sprint(po.ID)}, ","), ",") + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", v.ID).Updates(map[string]any{"label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sys_dictionary_id": v.DictionaryID, "parent_id": v.ParentID, "level": level, "path": path}).Error; err != nil { + return err + } + return tx.Model(&dictionaryDetailPO{}).Where("path = ? OR path LIKE ?", oldPrefix, oldPrefix+",%").Update("path", gorm.Expr("REPLACE(path, ?, ?)", oldPrefix, newPrefix)).Error + }) +} +func (r *settingsRepo) DeleteDictionaryDetail(ctx context.Context, id uint) error { + var count int64 + if err := r.data.gormDB.WithContext(ctx).Model(&dictionaryDetailPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("存在子级不可删除") + } + return r.data.gormDB.WithContext(ctx).Delete(&dictionaryDetailPO{}, id).Error +} +func (r *settingsRepo) FindDictionaryDetail(ctx context.Context, id uint) (*biz.DictionaryDetail, error) { + var po dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return detailFromPO(po), nil +} +func (r *settingsRepo) ListDictionaryDetails(ctx context.Context, page, size int, dictionaryID uint, label, value string) ([]*biz.DictionaryDetail, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&dictionaryDetailPO{}) + if dictionaryID != 0 { + db = db.Where("sys_dictionary_id = ?", dictionaryID) + } + if label != "" { + db = db.Where("label LIKE ?", "%"+label+"%") + } + if value != "" { + db = db.Where("value LIKE ?", "%"+value+"%") + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []dictionaryDetailPO + if err := db.Order("sort,id").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.DictionaryDetail, 0, len(pos)) + for _, po := range pos { + out = append(out, detailFromPO(po)) + } + return out, total, nil +} +func (r *settingsRepo) DictionaryDetailTree(ctx context.Context, dictionaryID uint, typ string) ([]*biz.DictionaryDetail, error) { + if dictionaryID == 0 { + dictionary, err := r.FindDictionary(ctx, 0, typ, false) + if err != nil { + return nil, err + } + dictionaryID = dictionary.ID + } + items, _, err := r.ListDictionaryDetails(ctx, 1, 100000, dictionaryID, "", "") + if err != nil { + return nil, err + } + byID := map[uint]*biz.DictionaryDetail{} + for _, item := range items { + item.Children = []*biz.DictionaryDetail{} + byID[item.ID] = item + } + roots := []*biz.DictionaryDetail{} + for _, item := range items { + if item.ParentID != nil && byID[*item.ParentID] != nil { + byID[*item.ParentID].Children = append(byID[*item.ParentID].Children, item) + } else { + roots = append(roots, item) + } + } + return roots, nil +} + +func (r *settingsRepo) CreateParameter(ctx context.Context, v *biz.SystemParameter) error { + po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil +} +func (r *settingsRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { + return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc}).Error +} +func (r *settingsRepo) DeleteParameters(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Delete(¶meterPO{}, ids).Error +} +func (r *settingsRepo) FindParameter(ctx context.Context, id uint, key string) (*biz.SystemParameter, error) { + var po parameterPO + db := r.data.gormDB.WithContext(ctx) + var err error + if id != 0 { + err = db.First(&po, id).Error + } else { + err = db.Where("`key` = ?", key).First(&po).Error + } + if err != nil { + return nil, err + } + return parameterFromPO(po), nil +} +func (r *settingsRepo) ListParameters(ctx context.Context, page, size int, q *biz.SystemParameter) ([]*biz.SystemParameter, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(¶meterPO{}) + if q != nil { + if q.Name != "" { + db = db.Where("name LIKE ?", "%"+q.Name+"%") + } + if q.Key != "" { + db = db.Where("`key` LIKE ?", "%"+q.Key+"%") + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []parameterPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.SystemParameter, 0, len(pos)) + for _, po := range pos { + out = append(out, parameterFromPO(po)) + } + return out, total, nil +} + +func (r *settingsRepo) UserHasAuthority(ctx context.Context, userID, authorityID uint) (*biz.User, bool, error) { + var po userPO + if err := r.data.gormDB.WithContext(ctx).First(&po, userID).Error; err != nil { + return nil, false, err + } + var count int64 + err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", userID, authorityID).Count(&count).Error + if err != nil { + return nil, false, err + } + user, err := (&systemRepo{data: r.data}).toBizUser(ctx, &po) + return user, count > 0 || po.AuthorityID == authorityID, err +} +func (r *settingsRepo) CreateAPIToken(ctx context.Context, v *biz.APIToken) error { + po := apiTokenPO{UserID: v.UserID, AuthorityID: v.AuthorityID, Token: v.Token, Status: v.Status, ExpiresAt: v.ExpiresAt, Remark: v.Remark} + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + v.CreatedAt = po.CreatedAt + return nil +} +func (r *settingsRepo) ListAPITokens(ctx context.Context, page, size int, userID uint, status *bool) ([]*biz.APIToken, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&apiTokenPO{}) + if userID != 0 { + db = db.Where("user_id = ?", userID) + } + if status != nil { + db = db.Where("status = ?", *status) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []apiTokenPO + if err := db.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.APIToken, 0, len(pos)) + for _, po := range pos { + v := &biz.APIToken{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UserID: po.UserID, AuthorityID: po.AuthorityID, Token: po.Token, Status: po.Status, ExpiresAt: po.ExpiresAt, Remark: po.Remark} + var userPO userPO + if r.data.gormDB.WithContext(ctx).First(&userPO, po.UserID).Error == nil { + v.User, _ = (&systemRepo{data: r.data}).toBizUser(ctx, &userPO) + } + out = append(out, v) + } + return out, total, nil +} +func (r *settingsRepo) DisableAPIToken(ctx context.Context, id uint) (string, error) { + var po apiTokenPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return "", err + } + return po.Token, r.data.gormDB.WithContext(ctx).Model(&po).Update("status", false).Error +} +func (r *settingsRepo) IsTokenDisabled(ctx context.Context, token string) (bool, error) { + var po apiTokenPO + err := r.data.gormDB.WithContext(ctx).Where("token = ?", token).First(&po).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return !po.Status || time.Now().After(po.ExpiresAt), nil +} + +func defaultSecurityConfig() securityConfigPO { + return securityConfigPO{ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90} +} +func securityFromPO(v securityConfigPO) *biz.SecurityConfig { + return &biz.SecurityConfig{ID: v.ID, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} +} +func securityToPO(v *biz.SecurityConfig) securityConfigPO { + return securityConfigPO{ID: 1, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} +} +func (r *settingsRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) { + var po securityConfigPO + err := r.data.gormDB.WithContext(ctx).First(&po, 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + po = defaultSecurityConfig() + err = r.data.gormDB.WithContext(ctx).Create(&po).Error + } + if err != nil { + return nil, err + } + return securityFromPO(po), nil +} +func (r *settingsRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityConfig) error { + previous, err := r.SecurityConfig(ctx) + if err != nil { + return err + } + po := securityToPO(v) + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(&po).Error; err != nil { + return err + } + if v.PwdExpireEnable && !previous.PwdExpireEnable { + return tx.Model(&userPO{}).Where("password_updated_at IS NULL").Update("password_updated_at", time.Now()).Error + } + return nil + }) +} diff --git a/internal/data/system.go b/internal/data/system.go new file mode 100644 index 0000000..153660f --- /dev/null +++ b/internal/data/system.go @@ -0,0 +1,430 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "time" + + "kra/internal/biz" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type userPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + UUID string `gorm:"type:char(36);uniqueIndex"` + Username string `gorm:"index;uniqueIndex"` + Password string + NickName string `gorm:"column:nick_name"` + HeaderImg string `gorm:"column:header_img"` + AuthorityID uint `gorm:"column:authority_id"` + DeptID uint `gorm:"column:dept_id"` + Phone string + Email string + Enable int `gorm:"default:1"` + OriginSetting string `gorm:"type:text"` + PasswordUpdatedAt *time.Time + MustChangePassword bool +} + +func (userPO) TableName() string { return "sys_users" } + +type authorityPO struct { + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + AuthorityID uint `gorm:"primaryKey;column:authority_id"` + AuthorityName string + ParentID *uint + DataScope int `gorm:"default:1"` + DefaultRouter string `gorm:"default:dashboard"` +} + +func (authorityPO) TableName() string { return "sys_authorities" } + +type menuPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + MenuLevel uint + ParentID uint `gorm:"column:parent_id"` + Path string + Name string + Hidden bool + Component string + Sort int + ActiveName string + KeepAlive bool + DefaultMenu bool + Title string + Icon string + CloseTab bool + TransitionType string +} + +func (menuPO) TableName() string { return "sys_base_menus" } + +type userAuthorityPO struct { + SysUserID uint `gorm:"primaryKey;column:sys_user_id"` + SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"` +} + +func (userAuthorityPO) TableName() string { return "sys_user_authority" } + +type authorityMenuPO struct { + SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"` + SysBaseMenuID uint `gorm:"primaryKey;column:sys_base_menu_id"` +} + +func (authorityMenuPO) TableName() string { return "sys_authority_menus" } + +type systemRepo struct{ data *Data } + +func NewSystemRepo(data *Data) biz.SystemRepo { return &systemRepo{data: data} } + +func (r *systemRepo) IsInitialized(ctx context.Context) (bool, error) { + return r.data.gormDB.WithContext(ctx).Migrator().HasTable(&userPO{}), nil +} + +func (r *systemRepo) Initialize(ctx context.Context) error { + db := r.data.gormDB.WithContext(ctx) + if err := db.AutoMigrate(&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &menuButtonPO{}, &authorityButtonPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &securityConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}); err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", DataScope: 1, DefaultRouter: "dashboard"} + if err := tx.FirstOrCreate(&authority, authorityPO{AuthorityID: 888}).Error; err != nil { + return err + } + menus := defaultMenus() + for i := range menus { + if err := tx.Where("name = ?", menus[i].Name).FirstOrCreate(&menus[i]).Error; err != nil { + return err + } + } + var persisted []menuPO + if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil { + return err + } + nameID := make(map[string]uint, len(persisted)) + for _, menu := range persisted { + nameID[menu.Name] = menu.ID + } + for i := range menus { + if menus[i].ParentID == 0 || menus[i].ActiveName == "" { + continue + } + _ = tx.Model(&menuPO{}).Where("name = ?", menus[i].Name).Updates(map[string]any{"parent_id": nameID[menus[i].ActiveName], "active_name": ""}).Error + } + if err := tx.Where("sys_authority_authority_id = ?", 888).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + links := make([]authorityMenuPO, 0, len(persisted)) + for _, menu := range persisted { + links = append(links, authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: menu.ID}) + } + if len(links) > 0 { + if err := tx.Create(&links).Error; err != nil { + return err + } + } + var count int64 + if err := tx.Model(&userPO{}).Where("username = ?", "admin").Count(&count).Error; err != nil { + return err + } + if count == 0 { + hash, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) + if err != nil { + return err + } + now := time.Now() + user := userPO{UUID: uuid.NewString(), Username: "admin", Password: string(hash), NickName: "超级管理员", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &now} + if err := tx.Create(&user).Error; err != nil { + return err + } + if err := tx.Create(&userAuthorityPO{SysUserID: user.ID, SysAuthorityAuthorityID: 888}).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *systemRepo) FindUserByUsername(ctx context.Context, username string) (*biz.User, error) { + var po userPO + if err := r.data.gormDB.WithContext(ctx).Where("username = ?", username).First(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, biz.ErrAdminNotFound + } + return nil, err + } + return r.toBizUser(ctx, &po) +} + +func (r *systemRepo) FindUserByID(ctx context.Context, id uint) (*biz.User, error) { + var po userPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, biz.ErrAdminNotFound + } + return nil, err + } + return r.toBizUser(ctx, &po) +} + +func (r *systemRepo) toBizUser(ctx context.Context, po *userPO) (*biz.User, error) { + var authority authorityPO + if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil { + return nil, err + } + var authorityPOs []authorityPO + if err := r.data.gormDB.WithContext(ctx).Table("sys_authorities").Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil { + return nil, err + } + authorities := make([]biz.Authority, 0, len(authorityPOs)) + for _, item := range authorityPOs { + authorities = append(authorities, toBizAuthority(item)) + } + setting := map[string]any(nil) + if po.OriginSetting != "" { + _ = json.Unmarshal([]byte(po.OriginSetting), &setting) + } + var departmentPOs []departmentPO + if err := r.data.gormDB.WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil { + return nil, err + } + departments := make([]biz.Department, 0, len(departmentPOs)) + var primary *biz.Department + for _, item := range departmentPOs { + value := deptFromPO(item) + departments = append(departments, *value) + if item.ID == po.DeptID { + copy := *value + primary = © + } + } + var positionPOs []positionPO + if err := r.data.gormDB.WithContext(ctx).Table("sys_positions").Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil { + return nil, err + } + positions := make([]biz.Position, 0, len(positionPOs)) + for _, item := range positionPOs { + positions = append(positions, *posFromPO(item)) + } + return &biz.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, Authority: toBizAuthority(authority), Authorities: authorities, DeptID: po.DeptID, Department: primary, Departments: departments, Positions: positions, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil +} + +func toBizAuthority(po authorityPO) biz.Authority { + return biz.Authority{AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} +} + +func (r *systemRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) { + var pos []menuPO + err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus"). + Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). + Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID). + Order("sys_base_menus.sort asc, sys_base_menus.id asc").Scan(&pos).Error + if err != nil { + return nil, err + } + byID := make(map[uint]*biz.Menu, len(pos)) + for _, po := range pos { + byID[po.ID] = &biz.Menu{ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType, Children: []*biz.Menu{}} + } + roots := make([]*biz.Menu, 0) + for _, po := range pos { + menu := byID[po.ID] + if parent := byID[po.ParentID]; parent != nil { + parent.Children = append(parent.Children, menu) + } else { + roots = append(roots, menu) + } + } + return roots, nil +} + +func (r *systemRepo) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]*biz.User, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&userPO{}) + if scope, ok := biz.DataScopeFromContext(ctx); ok && !scope.All { + if scope.OwnerUserID != 0 { + db = db.Where("sys_users.id = ?", scope.OwnerUserID) + } else if len(scope.DepartmentIDs) > 0 { + db = db.Joins("JOIN sys_user_departments ON sys_user_departments.sys_user_id = sys_users.id").Where("sys_user_departments.sys_department_id IN ?", scope.DepartmentIDs).Distinct("sys_users.id") + } else { + db = db.Where("1 = 0") + } + } + order := "id desc" + if filter != nil { + if filter.Username != "" { + db = db.Where("username LIKE ?", "%"+filter.Username+"%") + } + if filter.NickName != "" { + db = db.Where("nick_name LIKE ?", "%"+filter.NickName+"%") + } + if filter.Phone != "" { + db = db.Where("phone LIKE ?", "%"+filter.Phone+"%") + } + if filter.Email != "" { + db = db.Where("email LIKE ?", "%"+filter.Email+"%") + } + allowed := map[string]bool{"id": true, "username": true, "nick_name": true, "phone": true, "email": true, "created_at": true} + if allowed[filter.OrderKey] { + order = filter.OrderKey + if filter.Desc { + order += " desc" + } + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []userPO + if err := db.Order(order).Offset((page - 1) * pageSize).Limit(pageSize).Find(&pos).Error; err != nil { + return nil, 0, err + } + users := make([]*biz.User, 0, len(pos)) + for i := range pos { + user, err := r.toBizUser(ctx, &pos[i]) + if err != nil { + return nil, 0, err + } + users = append(users, user) + } + return users, total, nil +} + +func (r *systemRepo) CreateUser(ctx context.Context, user *biz.User) (*biz.User, error) { + if user.UUID == "" { + user.UUID = uuid.NewString() + } + if user.Enable == 0 { + user.Enable = 1 + } + if user.AuthorityID == 0 { + user.AuthorityID = 888 + } + if user.NickName == "" { + user.NickName = "系统用户" + } + now := time.Now() + po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword} + if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&po).Error; err != nil { + return err + } + return tx.Create(&userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: po.AuthorityID}).Error + }); err != nil { + return nil, err + } + return r.FindUserByID(ctx, po.ID) +} + +func (r *systemRepo) UpdateUser(ctx context.Context, user *biz.User) error { + updates := map[string]any{"nick_name": user.NickName, "header_img": user.HeaderImg, "phone": user.Phone, "email": user.Email, "enable": user.Enable} + if user.AuthorityID != 0 { + updates["authority_id"] = user.AuthorityID + } + return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error +} +func (r *systemRepo) DeleteUser(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { + return err + } + return tx.Delete(&userPO{}, id).Error + }) +} +func (r *systemRepo) UpdatePassword(ctx context.Context, id uint, password string) error { + now := time.Now() + return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(map[string]any{"password": password, "password_updated_at": now, "must_change_password": false}).Error +} +func (r *systemRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { + var pos []authorityPO + if err := r.data.gormDB.WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { + return nil, err + } + result := make([]*biz.Authority, 0, len(pos)) + for _, po := range pos { + a := toBizAuthority(po) + result = append(result, &a) + } + return result, nil +} + +func (r *systemRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { + return err + } + links := make([]userAuthorityPO, 0, len(authorityIDs)) + for _, authorityID := range authorityIDs { + var count int64 + if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil || count == 0 { + if err != nil { + return err + } + return errors.New("角色不存在") + } + links = append(links, userAuthorityPO{SysUserID: id, SysAuthorityAuthorityID: authorityID}) + } + if err := tx.Create(&links).Error; err != nil { + return err + } + return tx.Model(&userPO{}).Where("id = ?", id).Update("authority_id", authorityIDs[0]).Error + }) +} + +func (r *systemRepo) SetUserAuthority(ctx context.Context, id, authorityID uint) error { + var count int64 + if err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", id, authorityID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return errors.New("该用户无此角色") + } + return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Update("authority_id", authorityID).Error +} + +func (r *systemRepo) SetUserSetting(ctx context.Context, id uint, setting map[string]any) error { + value, err := json.Marshal(setting) + if err != nil { + return err + } + return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Update("origin_setting", string(value)).Error +} + +// ActiveName temporarily carries the parent menu name during seeding. It is +// cleared from responses by the service for these records. +func defaultMenus() []menuPO { + root := func(path, name, title, icon string, sort int) menuPO { + return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort} + } + child := func(parent, path, name, component, title, icon string, sort int) menuPO { + return menuPO{MenuLevel: 1, Path: path, Name: name, Component: component, Title: title, Icon: icon, Sort: sort, ActiveName: parent} + } + return []menuPO{ + {Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Title: "仪表盘", Icon: "odometer", Sort: 1}, + root("permission", "permission", "权限管理", "perm-gva", 2), root("org", "org", "组织管理", "share", 3), root("systemConfig", "systemConfig", "系统设置", "config-gva", 4), root("monitor", "monitor", "运维监控", "monitor-gva", 5), root("media", "media", "媒体管理", "folder-opened", 6), + {Path: "person", Name: "person", Component: "view/person/person.vue", Title: "个人信息", Icon: "postcard", Hidden: true, Sort: 13}, + child("permission", "authority", "authority", "view/superAdmin/authority/authority.vue", "角色管理", "role-gva", 1), child("permission", "menu", "menu", "view/superAdmin/menu/menu.vue", "菜单管理", "tickets", 2), child("permission", "api", "api", "view/superAdmin/api/api.vue", "api管理", "api-gva", 3), child("permission", "apiToken", "apiToken", "view/systemTools/apiToken/index.vue", "API Token", "key", 4), + child("org", "user", "user", "view/superAdmin/user/user.vue", "用户管理", "user", 1), child("org", "department", "department", "view/superAdmin/department/department.vue", "部门管理", "office-building", 2), child("org", "position", "position", "view/superAdmin/position/position.vue", "岗位管理", "postcard", 3), + child("systemConfig", "system", "system", "view/systemTools/system/system.vue", "配置文件", "config-file-gva", 1), child("systemConfig", "dictionary", "dictionary", "view/superAdmin/dictionary/sysDictionary.vue", "字典管理", "notebook", 2), child("systemConfig", "sysParams", "sysParams", "view/superAdmin/params/sysParams.vue", "参数管理", "set-up", 3), child("systemConfig", "security", "security", "view/system/security/index.vue", "安全配置", "security-gva", 4), + child("monitor", "operation", "operation", "view/superAdmin/operation/sysOperationRecord.vue", "操作历史", "document", 1), child("monitor", "loginLog", "loginLog", "view/systemTools/loginLog/index.vue", "登录日志", "clock", 2), child("monitor", "sysError", "sysError", "view/systemTools/sysError/sysError.vue", "错误日志", "error-gva", 3), child("monitor", "sysVersion", "sysVersion", "view/systemTools/version/version.vue", "版本管理", "version-gva", 4), child("monitor", "state", "state", "view/system/state.vue", "服务器状态", "server", 5), child("monitor", "dataAccessLog", "dataAccessLog", "view/superAdmin/dataAccessLog/dataAccessLog.vue", "数据权限审计", "warning", 6), child("monitor", "timedTask", "timedTask", "view/systemTools/timedTask/index.vue", "定时任务", "timer", 7), child("monitor", "logViewer", "logViewer", "view/systemTools/logViewer/index.vue", "文件日志", "document", 8), + child("media", "upload", "upload", "view/media/upload.vue", "媒体库(上传下载)", "upload", 1), child("media", "chunkUpload", "chunkUpload", "view/media/chunkUpload.vue", "大文件上传", "folder-add", 2), + } +} diff --git a/internal/data/task.go b/internal/data/task.go new file mode 100644 index 0000000..780f4bb --- /dev/null +++ b/internal/data/task.go @@ -0,0 +1,155 @@ +package data + +import ( + "context" + "gorm.io/gorm" + "kra/internal/biz" + "time" +) + +type taskPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"index"` + Description, Spec string + WithSeconds bool + ExecutorType, MethodName string + Params []byte `gorm:"type:json"` + HTTPURL string + HTTPMethod string + HTTPHeader []byte `gorm:"type:json"` + HTTPBody string `gorm:"type:text"` + HTTPAllowPrivate, Enabled bool +} + +func (taskPO) TableName() string { return "sys_timed_tasks" } + +type taskLogPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + TaskID uint `gorm:"index"` + TaskName, TriggerType string + StartedAt, FinishedAt time.Time + DurationMS int64 + Status string + ErrorMsg, Output string `gorm:"type:text"` +} + +func (taskLogPO) TableName() string { return "sys_timed_task_logs" } + +type taskRepo struct{ data *Data } + +func NewTaskRepo(data *Data) biz.TaskRepo { return &taskRepo{data: data} } +func taskToPO(v *biz.TimedTask) taskPO { + return taskPO{ID: v.ID, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: v.Params, HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: v.HTTPHeader, HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} +} +func taskFromPO(v taskPO) *biz.TimedTask { + return &biz.TimedTask{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: v.Params, HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: v.HTTPHeader, HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} +} +func (r *taskRepo) CreateTask(ctx context.Context, v *biz.TimedTask) error { + po := taskToPO(v) + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil +} +func (r *taskRepo) UpdateTask(ctx context.Context, v *biz.TimedTask) error { + po := taskToPO(v) + return r.data.gormDB.WithContext(ctx).Model(&taskPO{}).Where("id = ?", v.ID).Select("name", "description", "spec", "with_seconds", "executor_type", "method_name", "params", "http_url", "http_method", "http_header", "http_body", "http_allow_private", "enabled").Updates(&po).Error +} +func (r *taskRepo) DeleteTask(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&taskPO{}, id).Error +} +func (r *taskRepo) FindTask(ctx context.Context, id uint) (*biz.TimedTask, error) { + var po taskPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return taskFromPO(po), nil +} +func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *biz.TimedTask) ([]*biz.TimedTask, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&taskPO{}) + if q != nil { + if q.Name != "" { + db = db.Where("name LIKE ?", "%"+q.Name+"%") + } + if q.ExecutorType != "" { + db = db.Where("executor_type = ?", q.ExecutorType) + } + if q.EnabledFilter != nil { + db = db.Where("enabled = ?", *q.EnabledFilter) + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []taskPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.TimedTask, 0, len(pos)) + for _, po := range pos { + out = append(out, taskFromPO(po)) + } + return out, total, nil +} +func (r *taskRepo) ToggleTask(ctx context.Context, id uint, enabled bool) error { + return r.data.gormDB.WithContext(ctx).Model(&taskPO{}).Where("id = ?", id).Update("enabled", enabled).Error +} +func (r *taskRepo) RecordTaskLog(ctx context.Context, v *biz.TimedTaskLog) error { + return r.data.gormDB.WithContext(ctx).Create(&taskLogPO{TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}).Error +} +func taskLogFromPO(v taskLogPO) *biz.TimedTaskLog { + return &biz.TimedTaskLog{ID: v.ID, CreatedAt: v.CreatedAt, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output} +} +func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint, status string) ([]*biz.TimedTaskLog, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&taskLogPO{}) + if taskID != 0 { + db = db.Where("task_id = ?", taskID) + } + if status != "" { + db = db.Where("status = ?", status) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []taskLogPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.TimedTaskLog, 0, len(pos)) + for _, po := range pos { + out = append(out, taskLogFromPO(po)) + } + return out, total, nil +} +func (r *taskRepo) CleanupLogs(ctx context.Context, before time.Time) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Where("created_at < ?", before).Delete(&operationPO{}).Error; err != nil { + return err + } + if err := tx.Unscoped().Where("created_at < ?", before).Delete(&loginLogPO{}).Error; err != nil { + return err + } + return tx.Unscoped().Where("created_at < ?", before).Delete(&taskLogPO{}).Error + }) +} diff --git a/internal/data/version.go b/internal/data/version.go new file mode 100644 index 0000000..d5a3ec5 --- /dev/null +++ b/internal/data/version.go @@ -0,0 +1,201 @@ +package data + +import ( + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "kra/internal/biz" +) + +type versionPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"column:version_name"` + Code string `gorm:"column:version_code"` + Description string + Data string `gorm:"column:version_data;type:text"` +} + +func (versionPO) TableName() string { return "sys_versions" } + +type versionRepo struct{ data *Data } + +func NewVersionRepo(data *Data) biz.VersionRepo { return &versionRepo{data: data} } +func versionFromPO(v versionPO) *biz.Version { + return &biz.Version{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Code: v.Code, Description: v.Description, Data: v.Data} +} +func (r *versionRepo) CreateVersion(ctx context.Context, v *biz.Version) error { + po := versionPO{Name: v.Name, Code: v.Code, Description: v.Description, Data: v.Data} + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + v.ID = po.ID + return nil +} +func (r *versionRepo) DeleteVersions(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&versionPO{}, ids).Error +} +func (r *versionRepo) FindVersion(ctx context.Context, id uint) (*biz.Version, error) { + var po versionPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return versionFromPO(po), nil +} +func (r *versionRepo) ListVersions(ctx context.Context, page, size int, name, code string) ([]*biz.Version, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + db := r.data.gormDB.WithContext(ctx).Model(&versionPO{}) + if name != "" { + db = db.Where("version_name LIKE ?", "%"+name+"%") + } + if code != "" { + db = db.Where("version_code = ?", code) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var pos []versionPO + if err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&pos).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.Version, 0, len(pos)) + for _, po := range pos { + out = append(out, versionFromPO(po)) + } + return out, total, nil +} +func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, dictIDs []uint) (*biz.VersionBundle, error) { + bundle := &biz.VersionBundle{} + var menus []menuPO + if len(menuIDs) > 0 { + if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", menuIDs).Order("sort,id").Find(&menus).Error; err != nil { + return nil, err + } + } + byID := map[uint]*biz.Menu{} + for _, po := range menus { + v := menuFromPO(po) + v.Children = []*biz.Menu{} + var btns []menuButtonPO + _ = r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", po.ID).Find(&btns).Error + for _, btn := range btns { + v.Buttons = append(v.Buttons, &biz.MenuButton{Name: btn.Name, Description: btn.Description}) + } + byID[po.ID] = v + } + for _, po := range menus { + v := byID[po.ID] + if parent := byID[po.ParentID]; parent != nil { + parent.Children = append(parent.Children, v) + } else { + bundle.Menus = append(bundle.Menus, v) + } + } + var apis []apiPO + if len(apiIDs) > 0 { + if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", apiIDs).Find(&apis).Error; err != nil { + return nil, err + } + } + for _, po := range apis { + v := apiFromPO(po) + v.ID = 0 + bundle.APIs = append(bundle.APIs, v) + } + var dictionaries []dictionaryPO + if len(dictIDs) > 0 { + if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", dictIDs).Find(&dictionaries).Error; err != nil { + return nil, err + } + } + settings := &settingsRepo{data: r.data} + for _, po := range dictionaries { + v := dictionaryFromPO(po) + v.ID = 0 + details, _, err := settings.ListDictionaryDetails(ctx, 1, 100000, po.ID, "", "") + if err != nil { + return nil, err + } + for _, d := range details { + d.ID = 0 + d.DictionaryID = 0 + d.ParentID = nil + d.Path = "" + d.Level = 0 + } + v.Details = details + bundle.Dictionaries = append(bundle.Dictionaries, v) + } + return bundle, nil +} +func (r *versionRepo) ImportVersionBundle(ctx context.Context, b *biz.VersionBundle) error { + return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var addMenus func([]*biz.Menu, uint) error + addMenus = func(items []*biz.Menu, parent uint) error { + for _, item := range items { + var existing menuPO + err := tx.Where("name = ? AND path = ?", item.Name, item.Path).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + po := menuToPO(item) + po.ID = 0 + po.ParentID = parent + if err = tx.Create(&po).Error; err != nil { + return err + } + existing = po + for _, button := range item.Buttons { + if err = tx.Create(&menuButtonPO{Name: button.Name, Description: button.Description, MenuID: po.ID}).Error; err != nil { + return err + } + } + } else if err != nil { + return err + } + if err = addMenus(item.Children, existing.ID); err != nil { + return err + } + } + return nil + } + if err := addMenus(b.Menus, 0); err != nil { + return err + } + for _, item := range b.APIs { + po := apiPO{Path: item.Path, Description: item.Description, APIGroup: item.APIGroup, Method: strings.ToUpper(item.Method)} + if err := tx.Where("path = ? AND method = ?", po.Path, po.Method).FirstOrCreate(&po).Error; err != nil { + return err + } + } + for _, item := range b.Dictionaries { + var count int64 + if err := tx.Model(&dictionaryPO{}).Where("type = ?", item.Type).Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + po := dictionaryPO{Name: item.Name, Type: item.Type, Status: item.Status, Desc: item.Desc} + if err := tx.Create(&po).Error; err != nil { + return err + } + for _, detail := range item.Details { + d := dictionaryDetailPO{Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Status: detail.Status, Sort: detail.Sort, DictionaryID: po.ID} + if err := tx.Create(&d).Error; err != nil { + return err + } + } + } + return nil + }) +} diff --git a/internal/server/access_routes.go b/internal/server/access_routes.go new file mode 100644 index 0000000..3c10b70 --- /dev/null +++ b/internal/server/access_routes.go @@ -0,0 +1,44 @@ +package server + +import ( + "net/http" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAccessRoutes(group *gin.RouterGroup, engine *gin.Engine, svc *service.AccessService) { + registerAuthorityRoutes(group, svc) + registerMenuRoutes(group, svc) + registerAPIRoutes(group, engine, svc) + registerPolicyRoutes(group, svc) + registerDepartmentRoutes(group, svc) + registerPositionRoutes(group, svc) +} + +func accessControl(svc *service.AccessService, audit *service.AuditService) gin.HandlerFunc { + return func(c *gin.Context) { + claims := currentClaims(c) + if claims == nil { + noAuth(c, "未登录或非法访问") + return + } + path := c.Request.URL.Path + allowed, err := svc.Authorize(c.Request.Context(), claims.AuthorityID, path, c.Request.Method) + if err != nil || !allowed { + requestID, _ := c.Get("request_id") + _ = audit.RecordDataAccess(c.Request.Context(), &biz.DataAccessLog{EventType: "blocked_access", Operation: c.Request.Method, UserID: claims.ID, AuthorityID: claims.AuthorityID, RequestID: toString(requestID), Method: c.Request.Method, Path: c.Request.URL.Path, Detail: "Casbin policy denied the request"}) + c.AbortWithStatusJSON(http.StatusForbidden, response{Code: codeError, Data: nil, Msg: "权限不足"}) + return + } + scope, err := svc.ResolveDataScope(c.Request.Context(), claims.AuthorityID, claims.ID) + if err != nil { + fail(c, "数据权限加载失败") + return + } + c.Request = c.Request.WithContext(biz.NewDataScopeContext(c.Request.Context(), scope)) + c.Next() + } +} diff --git a/internal/server/api_routes.go b/internal/server/api_routes.go new file mode 100644 index 0000000..bfac2c1 --- /dev/null +++ b/internal/server/api_routes.go @@ -0,0 +1,209 @@ +package server + +import ( + "strconv" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAPIRoutes(group *gin.RouterGroup, engine *gin.Engine, svc *service.AccessService) { + router := group.Group("/api") + router.POST("/getApiList", func(c *gin.Context) { + var req struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Path string `json:"path"` + Description string `json:"description"` + APIGroup string `json:"apiGroup"` + Method string `json:"method"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + items, total, err := svc.APIs(c.Request.Context(), req.Page, req.PageSize, &biz.API{Path: req.Path, Description: req.Description, APIGroup: req.APIGroup, Method: req.Method}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") + }) + router.POST("/getAllApis", func(c *gin.Context) { + items, _, err := svc.APIs(c.Request.Context(), 1, 10000, nil) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"apis": items}, "获取成功") + }) + type apiRequest struct { + ID uint `json:"ID"` + Path string `json:"path"` + Description string `json:"description"` + APIGroup string `json:"apiGroup"` + Method string `json:"method"` + } + router.POST("/createApi", func(c *gin.Context) { + var req apiRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.CreateAPI(c.Request.Context(), &biz.API{Path: req.Path, Description: req.Description, APIGroup: req.APIGroup, Method: req.Method}); err != nil { + fail(c, "创建失败") + return + } + ok(c) + }) + router.POST("/updateApi", func(c *gin.Context) { + var req apiRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdateAPI(c.Request.Context(), &biz.API{ID: req.ID, Path: req.Path, Description: req.Description, APIGroup: req.APIGroup, Method: req.Method}); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + router.POST("/deleteApi", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteAPIs(c.Request.Context(), []uint{req.ID}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.DELETE("/deleteApisByIds", func(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteAPIs(c.Request.Context(), req.IDs); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.POST("/getApiById", func(c *gin.Context) { + var req struct { + ID uint `json:"id"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + item, err := svc.API(c.Request.Context(), req.ID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"api": item}, "获取成功") + }) + router.GET("/getApiGroups", func(c *gin.Context) { + items, _, err := svc.APIs(c.Request.Context(), 1, 10000, nil) + if err != nil { + fail(c, "获取失败") + return + } + seen := map[string]bool{} + groups := []string{} + for _, item := range items { + value, _ := item["apiGroup"].(string) + if value != "" && !seen[value] { + seen[value] = true + groups = append(groups, value) + } + } + writeResult(c, codeSuccess, gin.H{"groups": groups}, "获取成功") + }) + router.GET("/getApiRoles", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("apiId"), 10, 64) + ids, err := svc.APIRoleIDs(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, ids, "获取成功") + }) + router.POST("/setApiRoles", func(c *gin.Context) { + var req struct { + APIID uint `json:"apiId"` + AuthorityIDs []uint `json:"authorityIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetAPIRoles(c.Request.Context(), req.APIID, req.AuthorityIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) + router.GET("/syncApi", func(c *gin.Context) { + routes := engine.Routes() + items := make([]*biz.API, 0, len(routes)) + for _, route := range routes { + items = append(items, &biz.API{Path: route.Path, Method: route.Method}) + } + result, err := svc.SyncAPIs(c.Request.Context(), items) + if err != nil { + fail(c, "同步检查失败") + return + } + writeResult(c, codeSuccess, result, "获取成功") + }) + router.POST("/ignoreApi", func(c *gin.Context) { + var req struct { + Path string `json:"path"` + Method string `json:"method"` + Flag bool `json:"flag"` + } + if c.ShouldBindJSON(&req) != nil || req.Path == "" || req.Method == "" { + fail(c, "参数错误") + return + } + if err := svc.SetAPIIgnored(c.Request.Context(), req.Path, req.Method, req.Flag); err != nil { + fail(c, "忽略设置失败") + return + } + ok(c) + }) + router.POST("/enterSyncApi", func(c *gin.Context) { + var req struct { + NewAPIs []apiRequest `json:"newApis"` + DeleteAPIs []apiRequest `json:"deleteApis"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + toDomain := func(values []apiRequest) []*biz.API { + items := make([]*biz.API, 0, len(values)) + for _, value := range values { + items = append(items, &biz.API{ID: value.ID, Path: value.Path, Description: value.Description, APIGroup: value.APIGroup, Method: value.Method}) + } + return items + } + if err := svc.ApplyAPISync(c.Request.Context(), toDomain(req.NewAPIs), toDomain(req.DeleteAPIs)); err != nil { + fail(c, "同步失败") + return + } + ok(c) + }) + router.GET("/freshCasbin", func(c *gin.Context) { ok(c) }) +} diff --git a/internal/server/audit_middleware.go b/internal/server/audit_middleware.go new file mode 100644 index 0000000..25969ec --- /dev/null +++ b/internal/server/audit_middleware.go @@ -0,0 +1,101 @@ +package server + +import ( + "bytes" + "encoding/json" + "github.com/gin-gonic/gin" + "io" + "kra/internal/biz" + "kra/internal/service" + "strings" + "time" +) + +type captureWriter struct { + gin.ResponseWriter + body bytes.Buffer +} + +func (w *captureWriter) Write(data []byte) (int, error) { + if w.body.Len() < 32768 { + remaining := 32768 - w.body.Len() + if len(data) > remaining { + _, _ = w.body.Write(data[:remaining]) + } else { + _, _ = w.body.Write(data) + } + } + return w.ResponseWriter.Write(data) +} +func redactJSON(raw []byte) string { + if len(raw) == 0 { + return "" + } + if len(raw) > 32768 { + raw = raw[:32768] + } + var value any + if json.Unmarshal(raw, &value) != nil { + return string(raw) + } + var clean func(any) + clean = func(current any) { + switch v := current.(type) { + case map[string]any: + for key, item := range v { + lower := strings.ToLower(key) + if strings.Contains(lower, "password") || strings.Contains(lower, "token") || strings.Contains(lower, "secret") { + v[key] = "******" + } else { + clean(item) + } + } + case []any: + for _, item := range v { + clean(item) + } + } + } + clean(value) + encoded, _ := json.Marshal(value) + return string(encoded) +} +func operationAudit(svc *service.AuditService) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" { + c.Next() + return + } + path := c.Request.URL.Path + if strings.Contains(path, "sysOperationRecord") || strings.Contains(path, "sysLoginLog") || strings.Contains(path, "dataAccessLog") { + c.Next() + return + } + var requestBody []byte + if c.Request.Body != nil { + requestBody, _ = io.ReadAll(io.LimitReader(c.Request.Body, 32769)) + c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) + } + writer := &captureWriter{ResponseWriter: c.Writer} + c.Writer = writer + started := time.Now() + c.Next() + userID := uint(0) + if claims := currentClaims(c); claims != nil { + userID = claims.ID + } + requestID, _ := c.Get("request_id") + status := c.Writer.Status() + errorMessage := "" + if status >= 400 { + errorMessage = writer.body.String() + } + _ = svc.RecordOperation(c.Request.Context(), &biz.OperationRecord{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: redactJSON(requestBody), Response: redactJSON(writer.body.Bytes()), UserID: userID, RequestID: toString(requestID), TraceID: c.GetHeader("traceparent"), DeviceID: c.GetHeader("X-Device-Id")}) + } +} +func toString(value any) string { + if text, ok := value.(string); ok { + return text + } + return "" +} diff --git a/internal/server/audit_routes.go b/internal/server/audit_routes.go new file mode 100644 index 0000000..3bd0a8c --- /dev/null +++ b/internal/server/audit_routes.go @@ -0,0 +1,246 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "kra/internal/biz" + "kra/internal/service" + "strconv" +) + +func idsFromQuery(c *gin.Context) []uint { + values := c.QueryArray("IDs[]") + if len(values) == 0 { + values = c.QueryArray("ids[]") + } + ids := make([]uint, 0, len(values)) + for _, v := range values { + id, _ := strconv.ParseUint(v, 10, 64) + if id > 0 { + ids = append(ids, uint(id)) + } + } + return ids +} +func registerAuditRoutes(group, public *gin.RouterGroup, svc *service.AuditService) { + operations := group.Group("/sysOperationRecord") + operations.GET("/getSysOperationRecordList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + status, _ := strconv.Atoi(c.Query("status")) + userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64) + items, total, err := svc.Operations(c.Request.Context(), page, size, &biz.OperationRecord{Path: c.Query("path"), Method: c.Query("method"), Status: status, IP: c.Query("ip"), UserID: uint(userID)}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + operations.GET("/findSysOperationRecord", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Operation(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"resysOperationRecord": item}, "查询成功") + }) + operations.DELETE("/deleteSysOperationRecord", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + if err := svc.DeleteOperations(c.Request.Context(), []uint{uint(id)}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + operations.DELETE("/deleteSysOperationRecordByIds", func(c *gin.Context) { + if err := svc.DeleteOperations(c.Request.Context(), idsFromQuery(c)); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + logins := group.Group("/sysLoginLog") + logins.GET("/getLoginLogList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := svc.Logins(c.Request.Context(), page, size, &biz.LoginLog{Username: c.Query("username"), IP: c.Query("ip")}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + logins.GET("/findLoginLog", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Login(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, item, "查询成功") + }) + logins.DELETE("/deleteLoginLog", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + if err := svc.DeleteLogins(c.Request.Context(), []uint{uint(id)}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + logins.DELETE("/deleteLoginLogByIds", func(c *gin.Context) { + if err := svc.DeleteLogins(c.Request.Context(), idsFromQuery(c)); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + access := group.Group("/dataAccessLog") + access.POST("/getDataAccessLogList", func(c *gin.Context) { + var req struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + EventType string `json:"eventType"` + TargetTable string `json:"targetTable"` + UserID uint `json:"userId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + items, total, err := svc.DataAccess(c.Request.Context(), req.Page, req.PageSize, &biz.DataAccessLog{EventType: req.EventType, TargetTable: req.TargetTable, UserID: req.UserID}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") + }) + access.DELETE("/deleteDataAccessLogByIds", func(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteDataAccess(c.Request.Context(), req.IDs); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + viewer := group.Group("/logViewer") + viewer.GET("/dates", func(c *gin.Context) { + data, err := svc.LogDates(c.Request.Context(), c.Query("month")) + if err != nil { + fail(c, err.Error()) + return + } + writeResult(c, codeSuccess, data, "获取成功") + }) + viewer.GET("/files", func(c *gin.Context) { + data, err := svc.LogFiles(c.Request.Context(), c.Query("date")) + if err != nil { + fail(c, err.Error()) + return + } + writeResult(c, codeSuccess, data, "获取成功") + }) + viewer.GET("/content", func(c *gin.Context) { + var cursor *int64 + if raw := c.Query("cursor"); raw != "" { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value < 0 { + fail(c, "参数错误") + return + } + cursor = &value + } + data, err := svc.LogContent(c.Request.Context(), c.Query("date"), c.Query("path"), cursor) + if err != nil { + fail(c, err.Error()) + return + } + writeResult(c, codeSuccess, data, "获取成功") + }) + errorsRoute := group.Group("/sysError") + errorsRoute.DELETE("/deleteSysError", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + if err := svc.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + errorsRoute.DELETE("/deleteSysErrorByIds", func(c *gin.Context) { + if err := svc.DeleteErrors(c.Request.Context(), idsFromQuery(c)); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + errorsRoute.PUT("/updateSysError", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + Form string `json:"form"` + Info string `json:"info"` + Level string `json:"level"` + Solution string `json:"solution"` + Status string `json:"status"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdateError(c.Request.Context(), &biz.ErrorRecord{ID: req.ID, Form: req.Form, Info: req.Info, Level: req.Level, Solution: req.Solution, Status: req.Status}); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + errorsRoute.GET("/findSysError", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Error(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"resysError": item}, "查询成功") + }) + errorsRoute.GET("/getSysErrorList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := svc.Errors(c.Request.Context(), page, size, &biz.ErrorRecord{Form: c.Query("form"), Level: c.Query("level"), Status: c.Query("status")}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + errorsRoute.GET("/getSysErrorSolution", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Error(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"solution": item["solution"]}, "获取成功") + }) + public.Group("/sysError").POST("/createSysError", func(c *gin.Context) { + var req struct { + Form string `json:"form"` + Info string `json:"info"` + Level string `json:"level"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + } + if c.ShouldBindJSON(&req) != nil || req.Form == "" { + fail(c, "参数错误") + return + } + if err := svc.CreateError(c.Request.Context(), &biz.ErrorRecord{Form: req.Form, Info: req.Info, Level: req.Level, RequestID: req.RequestID, TraceID: req.TraceID}); err != nil { + fail(c, "创建失败") + return + } + ok(c) + }) +} diff --git a/internal/server/authority_routes.go b/internal/server/authority_routes.go new file mode 100644 index 0000000..31edf98 --- /dev/null +++ b/internal/server/authority_routes.go @@ -0,0 +1,134 @@ +package server + +import ( + "strconv" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAuthorityRoutes(group *gin.RouterGroup, svc *service.AccessService) { + router := group.Group("/authority") + router.POST("/getAuthorityList", func(c *gin.Context) { + items, err := svc.Authorities(c.Request.Context()) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, items, "获取成功") + }) + type authorityRequest struct { + AuthorityID uint `json:"authorityId"` + AuthorityName string `json:"authorityName"` + ParentID *uint `json:"parentId"` + DataScope int `json:"dataScope"` + DefaultRouter string `json:"defaultRouter"` + } + router.POST("/createAuthority", func(c *gin.Context) { + var req authorityRequest + if c.ShouldBindJSON(&req) != nil || req.AuthorityID == 0 { + fail(c, "参数错误") + return + } + value := &biz.Authority{AuthorityID: req.AuthorityID, AuthorityName: req.AuthorityName, ParentID: req.ParentID, DataScope: req.DataScope, DefaultRouter: req.DefaultRouter} + if err := svc.CreateAuthority(c.Request.Context(), value); err != nil { + fail(c, "创建失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{"authority": gin.H{"authorityId": value.AuthorityID, "authorityName": value.AuthorityName, "parentId": value.ParentID, "dataScope": value.DataScope, "defaultRouter": value.DefaultRouter}}, "创建成功") + }) + router.POST("/copyAuthority", func(c *gin.Context) { + var req struct { + Authority authorityRequest `json:"authority"` + OldAuthorityID uint `json:"oldAuthorityId"` + } + if c.ShouldBindJSON(&req) != nil || req.Authority.AuthorityID == 0 || req.OldAuthorityID == 0 { + fail(c, "参数错误") + return + } + value := &biz.Authority{AuthorityID: req.Authority.AuthorityID, AuthorityName: req.Authority.AuthorityName, ParentID: req.Authority.ParentID, DataScope: req.Authority.DataScope, DefaultRouter: req.Authority.DefaultRouter} + if err := svc.CopyAuthority(c.Request.Context(), req.OldAuthorityID, value); err != nil { + fail(c, "拷贝失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{"authority": gin.H{"authorityId": value.AuthorityID, "authorityName": value.AuthorityName, "parentId": value.ParentID, "dataScope": value.DataScope, "defaultRouter": value.DefaultRouter}}, "拷贝成功") + }) + router.PUT("/updateAuthority", func(c *gin.Context) { + var req authorityRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + value := &biz.Authority{AuthorityID: req.AuthorityID, AuthorityName: req.AuthorityName, ParentID: req.ParentID, DataScope: req.DataScope, DefaultRouter: req.DefaultRouter} + if err := svc.UpdateAuthority(c.Request.Context(), value); err != nil { + fail(c, "更新失败") + return + } + writeResult(c, codeSuccess, gin.H{"authority": gin.H{"authorityId": value.AuthorityID, "authorityName": value.AuthorityName, "parentId": value.ParentID, "dataScope": value.DataScope, "defaultRouter": value.DefaultRouter}}, "更新成功") + }) + router.POST("/deleteAuthority", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteAuthority(c.Request.Context(), req.AuthorityID); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + router.POST("/setRoleUsers", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + UserIDs []uint `json:"userIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetAuthorityUsers(c.Request.Context(), req.AuthorityID, req.UserIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) + router.GET("/getUsersByAuthority", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("authorityId"), 10, 64) + ids, err := svc.AuthorityUserIDs(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, ids, "获取成功") + }) + router.POST("/setDataScope", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + DataScope int `json:"dataScope"` + DepartmentIDs []uint `json:"deptIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetDataScope(c.Request.Context(), req.AuthorityID, req.DataScope, req.DepartmentIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) + router.GET("/getDataScopeDepts", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("authorityId"), 10, 64) + ids, err := svc.DataScopeDepartmentIDs(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, ids, "获取成功") + }) +} diff --git a/internal/server/export_routes.go b/internal/server/export_routes.go new file mode 100644 index 0000000..6c21f74 --- /dev/null +++ b/internal/server/export_routes.go @@ -0,0 +1,224 @@ +package server + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "kra/internal/biz" + "kra/internal/service" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type exportTemplateRequest struct { + ID uint `json:"ID"` + DBName string `json:"dbName"` + Name string `json:"name"` + TableName string `json:"tableName"` + TemplateID string `json:"templateID"` + TemplateInfo string `json:"templateInfo"` + SQL string `json:"sql"` + ImportSQL string `json:"importSql"` + Limit *int `json:"limit"` + Order string `json:"order"` + Conditions []struct { + From string `json:"from"` + Column string `json:"column"` + Operator string `json:"operator"` + } `json:"conditions"` + Joins []struct { + Join string `json:"joins"` + Table string `json:"table"` + On string `json:"on"` + } `json:"joinTemplate"` +} + +func exportTemplateDomain(v exportTemplateRequest) *biz.ExportTemplate { + out := &biz.ExportTemplate{ID: v.ID, DBName: v.DBName, Name: v.Name, TableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order} + for _, x := range v.Conditions { + out.Conditions = append(out.Conditions, biz.ExportCondition{From: x.From, Column: x.Column, Operator: x.Operator}) + } + for _, x := range v.Joins { + out.Joins = append(out.Joins, biz.ExportJoin{Join: x.Join, Table: x.Table, On: x.On}) + } + return out +} +func exportParams(values url.Values) map[string]string { + out := map[string]string{} + nested, _ := url.ParseQuery(values.Get("params")) + for key, items := range nested { + if len(items) > 0 { + out[key] = items[0] + } + } + for key, items := range values { + if key != "params" && len(items) > 0 { + out[key] = items[0] + } + } + return out +} + +type exportToken struct { + TemplateID string `json:"templateID"` + Params map[string]string `json:"params"` + Blank bool `json:"blank"` +} + +func registerExportRoutes(group, public *gin.RouterGroup, system *service.SystemService, svc *service.ExportService) { + router := group.Group("/sysExportTemplate") + router.POST("/createSysExportTemplate", func(c *gin.Context) { + var req exportTemplateRequest + if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.TemplateID == "" { + fail(c, "参数错误") + return + } + if err := svc.Create(c.Request.Context(), exportTemplateDomain(req)); err != nil { + fail(c, "创建失败:"+err.Error()) + return + } + ok(c) + }) + router.PUT("/updateSysExportTemplate", func(c *gin.Context) { + var req exportTemplateRequest + if c.ShouldBindJSON(&req) != nil || req.ID == 0 { + fail(c, "参数错误") + return + } + if err := svc.Update(c.Request.Context(), exportTemplateDomain(req)); err != nil { + fail(c, "更新失败:"+err.Error()) + return + } + ok(c) + }) + router.DELETE("/deleteSysExportTemplate", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Delete(c.Request.Context(), []uint{req.ID}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.DELETE("/deleteSysExportTemplateByIds", func(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Delete(c.Request.Context(), req.IDs); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.GET("/findSysExportTemplate", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Template(c.Request.Context(), uint(id), "") + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"resysExportTemplate": item}, "查询成功") + }) + router.GET("/getSysExportTemplateList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := svc.Templates(c.Request.Context(), page, size, &biz.ExportTemplate{Name: c.Query("name"), TableName: c.Query("tableName"), TemplateID: c.Query("templateID")}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + router.GET("/previewSQL", func(c *gin.Context) { + sql, err := svc.Preview(c.Request.Context(), c.Query("templateID"), exportParams(c.Request.URL.Query())) + if err != nil { + fail(c, "预览失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{"sql": sql}, "获取成功") + }) + issue := func(blank bool) gin.HandlerFunc { + return func(c *gin.Context) { + tid := c.Query("templateID") + if tid == "" { + fail(c, "模板ID不能为空") + return + } + token := strings.ReplaceAll(uuid.NewString(), "-", "") + raw, _ := json.Marshal(exportToken{TemplateID: tid, Params: exportParams(c.Request.URL.Query()), Blank: blank}) + if err := system.CacheSet(c.Request.Context(), "export:"+token, string(raw), 5*time.Minute); err != nil { + fail(c, "导出令牌创建失败") + return + } + writeResult(c, codeSuccess, gin.H{"token": token}, "获取成功") + } + } + router.GET("/exportExcel", issue(false)) + router.GET("/exportTemplate", issue(true)) + router.POST("/importExcel", func(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + fail(c, "请选择导入文件") + return + } + opened, err := file.Open() + if err != nil { + fail(c, "读取文件失败") + return + } + defer opened.Close() + tid := c.PostForm("templateID") + if tid == "" { + tid = c.Query("templateID") + } + if err = svc.Import(c.Request.Context(), tid, opened); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + pub := public.Group("/sysExportTemplate") + download := func(expectBlank bool) gin.HandlerFunc { + return func(c *gin.Context) { + token := c.Query("token") + raw, okValue, err := system.CacheGet(c.Request.Context(), "export:"+token) + if err != nil || !okValue { + fail(c, "导出令牌无效或已过期") + return + } + _ = system.CacheDelete(c.Request.Context(), "export:"+token) + var value exportToken + if json.Unmarshal([]byte(raw), &value) != nil || value.Blank != expectBlank { + fail(c, "导出令牌无效") + return + } + var data []byte + var name string + if expectBlank { + data, name, err = svc.ExportBlankTemplate(c.Request.Context(), value.TemplateID) + } else { + data, name, err = svc.Export(c.Request.Context(), value.TemplateID, value.Params) + } + if err != nil { + fail(c, "导出失败:"+err.Error()) + return + } + c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(name)) + c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) + } + } + pub.GET("/exportExcelByToken", download(false)) + pub.GET("/exportTemplateByToken", download(true)) +} diff --git a/internal/server/gin.go b/internal/server/gin.go new file mode 100644 index 0000000..6c50e10 --- /dev/null +++ b/internal/server/gin.go @@ -0,0 +1,577 @@ +package server + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "kra/internal/biz" + "kra/internal/conf" + "kra/internal/service" + "kra/pkg/adminauth" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mojocn/base64Captcha" +) + +// GinServer adapts Gin to the Kratos transport.Server lifecycle. +type GinServer struct { + network string + address string + server *http.Server + listener net.Listener + logger *slog.Logger +} + +func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.SystemService, access *service.AccessService, settings *service.SettingsService, versions *service.VersionService, exports *service.ExportService, audit *service.AuditService, tasks *service.TaskService, media *service.MediaService, scheduler *TaskScheduler, logger *slog.Logger) *GinServer { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use(ginRequestMeta(), gin.Recovery(), securityRateLimit(svc, settings), operationAudit(audit)) + if config != nil && config.Local != nil && config.Local.StorePath != "" { + pathPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/") + if pathPrefix != "/" { + engine.StaticFS(pathPrefix, filesOnly{FileSystem: http.Dir(config.Local.StorePath)}) + } + } + + prefix := "" + if config != nil { + prefix = strings.TrimSuffix(config.RouterPrefix, "/") + } + public := engine.Group(prefix) + public.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + registerPublicRoutes(public, engine, config, svc, settings, access, audit) + private := engine.Group(prefix) + private.Use(jwtAuth(config, settings), accessControl(access, audit)) + registerPrivateRoutes(private, svc) + registerAccessRoutes(private, engine, access) + registerSettingsRoutes(private, public, settings) + registerSystemConfigRoutes(private, config, settings) + registerVersionRoutes(private, versions) + registerExportRoutes(private, public, svc, exports) + registerAuditRoutes(private, public, audit) + registerTaskRoutes(private, tasks, scheduler) + registerMediaRoutes(private, media) + + engine.NoRoute(func(c *gin.Context) { + fail(c, "请求的接口不存在") + }) + + network := "tcp" + address := ":8000" + if c != nil && c.Http != nil { + if c.Http.Network != "" { + network = c.Http.Network + } + if c.Http.Addr != "" { + address = c.Http.Addr + } + } + + return &GinServer{ + network: network, + address: address, + server: &http.Server{Addr: address, Handler: engine}, + logger: logger, + } +} + +func jwtAuth(config *conf.AdminBackend, settings *service.SettingsService) gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("x-token") + if token == "" { + token, _ = c.Cookie("x-token") + } + secret := "" + if config != nil && config.Jwt != nil { + secret = config.Jwt.SigningKey + } + claims, err := adminauth.Parse(token, secret) + if err != nil { + noAuth(c, "未登录或非法访问") + return + } + if disabled, checkErr := settings.IsTokenDisabled(c.Request.Context(), token); checkErr != nil || disabled { + noAuth(c, "登录状态已失效") + return + } + c.Set("admin_claims", claims) + c.Next() + } +} + +func currentClaims(c *gin.Context) *adminauth.Claims { + claims, _ := c.Get("admin_claims") + result, _ := claims.(*adminauth.Claims) + return result +} + +func registerPublicRoutes(group *gin.RouterGroup, engine *gin.Engine, config *conf.AdminBackend, svc *service.SystemService, settings *service.SettingsService, access *service.AccessService, audit *service.AuditService) { + expiration := 3 * time.Minute + keyLong, width, height := 6, 240, 80 + if config != nil && config.Captcha != nil { + if config.Captcha.StoreExpiration != nil { + expiration = config.Captcha.StoreExpiration.AsDuration() + } + if config.Captcha.KeyLong > 0 { + keyLong = int(config.Captcha.KeyLong) + } + if config.Captcha.ImgWidth > 0 { + width = int(config.Captcha.ImgWidth) + } + if config.Captcha.ImgHeight > 0 { + height = int(config.Captcha.ImgHeight) + } + } + store := &captchaStore{service: svc, expiration: expiration} + base := group.Group("/base") + base.POST("/captcha", func(c *gin.Context) { + security, _ := settings.CurrentSecurity(c.Request.Context()) + openCaptcha := true + if security != nil { + keyLong, width, height = security.KeyLong, security.ImgWidth, security.ImgHeight + if security.CaptchaOpen > 0 { + failures, _ := cachedInt(c.Request.Context(), svc, "login:fail:"+c.ClientIP()) + openCaptcha = failures >= security.CaptchaOpen + } + } + if !openCaptcha { + writeResult(c, codeSuccess, gin.H{"captchaId": "", "picPath": "", "captchaLength": keyLong, "openCaptcha": false}, "验证码获取成功") + return + } + driver := base64Captcha.NewDriverDigit(height, width, keyLong, 0.7, 80) + id, picture, _, err := base64Captcha.NewCaptcha(driver, store).Generate() + if err != nil { + fail(c, "验证码获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"captchaId": id, "picPath": picture, "captchaLength": keyLong, "openCaptcha": openCaptcha}, "验证码获取成功") + }) + base.POST("/login", func(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Captcha string `json:"captcha"` + CaptchaID string `json:"captchaId"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" { + fail(c, "请输入用户名和密码") + return + } + security, _ := settings.CurrentSecurity(c.Request.Context()) + if security != nil && security.LockEnable { + if _, locked, _ := svc.CacheGet(c.Request.Context(), "login:lock:"+req.Username); locked { + fail(c, "账户已锁定,请稍后重试") + return + } + } + requireCaptcha := security == nil || security.CaptchaOpen == 0 + if security != nil && security.CaptchaOpen > 0 { + failures, _ := cachedInt(c.Request.Context(), svc, "login:fail:"+c.ClientIP()) + requireCaptcha = failures >= security.CaptchaOpen + } + if requireCaptcha && (req.CaptchaID == "" || req.Captcha == "" || !store.Verify(req.CaptchaID, req.Captcha, true)) { + fail(c, "验证码错误") + return + } + result, err := svc.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + _ = audit.RecordLogin(c.Request.Context(), &biz.LoginLog{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户名不存在或者密码错误", Agent: c.Request.UserAgent()}) + ttl := time.Hour + if security != nil && security.CaptchaTimeout > 0 { + ttl = time.Duration(security.CaptchaTimeout) * time.Second + } + failures, _ := incrementCached(c.Request.Context(), svc, "login:fail:"+c.ClientIP(), ttl) + if security != nil && security.LockEnable && failures >= security.LockThreshold { + _ = svc.CacheSet(c.Request.Context(), "login:lock:"+req.Username, "1", time.Duration(security.LockDuration)*time.Minute) + } + fail(c, "用户名不存在或者密码错误") + return + } + userID, _ := result.User["ID"].(uint) + _ = audit.RecordLogin(c.Request.Context(), &biz.LoginLog{Username: req.Username, IP: c.ClientIP(), Status: true, Agent: c.Request.UserAgent(), UserID: userID}) + _ = svc.CacheDelete(c.Request.Context(), "login:fail:"+c.ClientIP()) + _ = svc.CacheDelete(c.Request.Context(), "login:lock:"+req.Username) + maxAge := int(time.Until(time.UnixMilli(result.ExpiresAt)).Seconds()) + http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: result.Token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + writeResult(c, codeSuccess, result, "登录成功") + }) + initGroup := group.Group("/init") + initGroup.POST("/checkdb", func(c *gin.Context) { + initialized, err := svc.IsInitialized(c.Request.Context()) + if err != nil { + fail(c, "数据库状态检查失败") + return + } + message := "数据库无需初始化" + if !initialized { + message = "前往初始化数据库" + } + writeResult(c, codeSuccess, gin.H{"needInit": !initialized}, message) + }) + initGroup.POST("/initdb", func(c *gin.Context) { + if err := svc.Initialize(c.Request.Context()); err != nil { + fail(c, "自动创建数据库失败") + return + } + routes := engine.Routes() + apis := make([]*biz.API, 0, len(routes)) + for _, route := range routes { + apis = append(apis, &biz.API{Path: route.Path, Method: route.Method, APIGroup: routeGroup(route.Path)}) + } + if err := access.ApplyAPISync(c.Request.Context(), apis, nil); err != nil { + fail(c, "数据库已初始化,但 API 权限数据写入失败") + return + } + writeResult(c, codeSuccess, gin.H{}, "自动创建数据库成功") + }) +} + +func routeGroup(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "base" +} + +func cachedInt(ctx context.Context, svc *service.SystemService, key string) (int, error) { + value, ok, err := svc.CacheGet(ctx, key) + if err != nil || !ok { + return 0, err + } + result, err := strconv.Atoi(value) + return result, err +} +func incrementCached(ctx context.Context, svc *service.SystemService, key string, ttl time.Duration) (int, error) { + value, _ := cachedInt(ctx, svc, key) + value++ + return value, svc.CacheSet(ctx, key, strconv.Itoa(value), ttl) +} +func securityRateLimit(svc *service.SystemService, settings *service.SettingsService) gin.HandlerFunc { + return func(c *gin.Context) { + cfg, err := settings.CurrentSecurity(c.Request.Context()) + if err != nil || cfg == nil || !cfg.LimitEnable { + c.Next() + return + } + window := cfg.LimitWindow + if window < 1 { + window = 60 + } + bucket := time.Now().Unix() / int64(window) + key := "rate:" + c.ClientIP() + ":" + strconv.FormatInt(bucket, 10) + count, cacheErr := incrementCached(c.Request.Context(), svc, key, time.Duration(window+1)*time.Second) + if cacheErr == nil && count > cfg.LimitCount { + c.AbortWithStatusJSON(http.StatusTooManyRequests, response{Code: codeError, Data: nil, Msg: "请求过于频繁"}) + return + } + c.Next() + } +} + +type captchaStore struct { + service *service.SystemService + expiration time.Duration +} + +func (s *captchaStore) key(id string) string { return "captcha:" + id } +func (s *captchaStore) Set(id, value string) error { + return s.service.CacheSet(context.Background(), s.key(id), value, s.expiration) +} +func (s *captchaStore) Get(id string, clear bool) string { + value, ok, err := s.service.CacheGet(context.Background(), s.key(id)) + if err != nil || !ok { + return "" + } + if clear { + _ = s.service.CacheDelete(context.Background(), s.key(id)) + } + return value +} +func (s *captchaStore) Verify(id, answer string, clear bool) bool { + if id == "" || answer == "" { + return false + } + return strings.EqualFold(s.Get(id, clear), answer) +} + +type filesOnly struct{ http.FileSystem } + +func (f filesOnly) Open(name string) (http.File, error) { + file, err := f.FileSystem.Open(name) + if err != nil { + return nil, err + } + info, statErr := file.Stat() + if statErr != nil { + _ = file.Close() + return nil, statErr + } + if info.IsDir() { + _ = file.Close() + return nil, os.ErrPermission + } + return file, nil +} + +func registerPrivateRoutes(group *gin.RouterGroup, svc *service.SystemService) { + user := group.Group("/user") + user.POST("/getUserList", func(c *gin.Context) { + var req struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Username string `json:"username"` + NickName string `json:"nickName"` + Phone string `json:"phone"` + Email string `json:"email"` + OrderKey string `json:"orderKey"` + Desc bool `json:"desc"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + list, total, err := svc.ListUsers(c.Request.Context(), req.Page, req.PageSize, &biz.UserListFilter{Username: req.Username, NickName: req.NickName, Phone: req.Phone, Email: req.Email, OrderKey: req.OrderKey, Desc: req.Desc}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") + }) + user.POST("/admin_register", func(c *gin.Context) { + var req struct { + Username string `json:"userName"` + Password string `json:"passWord"` + NickName string `json:"nickName"` + HeaderImg string `json:"headerImg"` + AuthorityID uint `json:"authorityId"` + AuthorityIDs []uint `json:"authorityIds"` + Enable int `json:"enable"` + Phone string `json:"phone"` + Email string `json:"email"` + } + if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" { + fail(c, "参数错误") + return + } + result, err := svc.CreateUser(c.Request.Context(), service.UserInput{Username: req.Username, Password: req.Password, NickName: req.NickName, HeaderImg: req.HeaderImg, AuthorityID: req.AuthorityID, AuthorityIDs: req.AuthorityIDs, Enable: req.Enable, Phone: req.Phone, Email: req.Email}) + if err != nil { + fail(c, "注册失败") + return + } + writeResult(c, codeSuccess, gin.H{"user": result}, "注册成功") + }) + user.PUT("/setUserInfo", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + NickName string `json:"nickName"` + HeaderImg string `json:"headerImg"` + AuthorityID uint `json:"authorityId"` + AuthorityIDs []uint `json:"authorityIds"` + Enable int `json:"enable"` + Phone string `json:"phone"` + Email string `json:"email"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdateUser(c.Request.Context(), service.UserInput{ID: req.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, AuthorityID: req.AuthorityID, AuthorityIDs: req.AuthorityIDs, Enable: req.Enable, Phone: req.Phone, Email: req.Email}); err != nil { + fail(c, "修改失败") + return + } + ok(c) + }) + user.PUT("/setSelfInfo", func(c *gin.Context) { + claims := currentClaims(c) + if claims == nil { + noAuth(c, "未登录或非法访问") + return + } + var req struct { + NickName string `json:"nickName"` + HeaderImg string `json:"headerImg"` + Phone string `json:"phone"` + Email string `json:"email"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdateUser(c.Request.Context(), service.UserInput{ID: claims.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, Phone: req.Phone, Email: req.Email, Enable: 1}); err != nil { + fail(c, "修改失败") + return + } + ok(c) + }) + user.DELETE("/deleteUser", func(c *gin.Context) { + var req struct { + ID uint `form:"id" json:"id"` + } + _ = c.ShouldBind(&req) + if req.ID == 0 { + var body struct { + ID uint `json:"ID"` + } + _ = c.ShouldBindJSON(&body) + req.ID = body.ID + } + if err := svc.DeleteUser(c.Request.Context(), req.ID); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + user.POST("/resetPassword", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + Password string `json:"password"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.ResetPassword(c.Request.Context(), req.ID, req.Password); err != nil { + fail(c, "重置失败") + return + } + ok(c) + }) + user.POST("/changePassword", func(c *gin.Context) { + claims := currentClaims(c) + var req struct { + Password string `json:"password"` + NewPassword string `json:"newPassword"` + } + if claims == nil || c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.ChangePassword(c.Request.Context(), claims.ID, req.Password, req.NewPassword); err != nil { + fail(c, "修改失败,原密码与当前账户不符") + return + } + ok(c) + }) + user.PUT("/setSelfSetting", func(c *gin.Context) { + claims := currentClaims(c) + var setting map[string]any + if claims == nil || c.ShouldBindJSON(&setting) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetUserSetting(c.Request.Context(), claims.ID, setting); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) + user.POST("/setUserAuthorities", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + AuthorityIDs []uint `json:"authorityIds"` + } + if c.ShouldBindJSON(&req) != nil || req.ID == 0 || len(req.AuthorityIDs) == 0 { + fail(c, "参数错误") + return + } + if err := svc.SetUserAuthorities(c.Request.Context(), req.ID, req.AuthorityIDs); err != nil { + fail(c, "修改失败") + return + } + ok(c) + }) + user.POST("/setUserAuthority", func(c *gin.Context) { + claims := currentClaims(c) + var req struct { + AuthorityID uint `json:"authorityId"` + } + if claims == nil || c.ShouldBindJSON(&req) != nil || req.AuthorityID == 0 { + fail(c, "参数错误") + return + } + login, err := svc.SwitchAuthority(c.Request.Context(), claims.ID, req.AuthorityID) + if err != nil { + fail(c, err.Error()) + return + } + c.Header("new-token", login.Token) + c.Header("new-expires-at", strconv.FormatInt(login.ExpiresAt/1000, 10)) + maxAge := int(time.Until(time.UnixMilli(login.ExpiresAt)).Seconds()) + http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: login.Token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + ok(c) + }) + user.GET("/getUserInfo", func(c *gin.Context) { + claims := currentClaims(c) + if claims == nil { + noAuth(c, "未登录或非法访问") + return + } + result, err := svc.User(c.Request.Context(), claims.ID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"userInfo": result}, "获取成功") + }) + menu := group.Group("/menu") + menu.POST("/getMenu", func(c *gin.Context) { + claims := currentClaims(c) + if claims == nil { + noAuth(c, "未登录或非法访问") + return + } + menus, err := svc.Menus(c.Request.Context(), claims.AuthorityID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"menus": menus}, "获取成功") + }) + jwt := group.Group("/jwt") + jwt.POST("/jsonInBlacklist", func(c *gin.Context) { + http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) + ok(c) + }) +} + +func (s *GinServer) Start(ctx context.Context) error { + listener, err := net.Listen(s.network, s.address) + if err != nil { + return err + } + s.listener = listener + s.server.BaseContext = func(net.Listener) context.Context { return ctx } + s.logger.InfoContext(ctx, "Gin HTTP server listening", "addr", listener.Addr().String()) + if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (s *GinServer) Stop(ctx context.Context) error { + s.logger.InfoContext(ctx, "Gin HTTP server stopping") + return s.server.Shutdown(ctx) +} + +func ginRequestMeta() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-Id") + if requestID == "" { + requestID = uuid.NewString() + } + c.Header("X-Request-Id", requestID) + c.Set("request_id", requestID) + c.Next() + } +} diff --git a/internal/server/media_routes.go b/internal/server/media_routes.go new file mode 100644 index 0000000..8ca8eef --- /dev/null +++ b/internal/server/media_routes.go @@ -0,0 +1,265 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "kra/internal/biz" + "kra/internal/service" + "strconv" +) + +func registerMediaRoutes(group *gin.RouterGroup, svc *service.MediaService) { + files := group.Group("/fileUploadAndDownload") + files.POST("/upload", func(c *gin.Context) { + claims := currentClaims(c) + header, err := c.FormFile("file") + if claims == nil || err != nil { + fail(c, "接收文件失败") + return + } + opened, err := header.Open() + if err != nil { + fail(c, "读取文件失败") + return + } + defer opened.Close() + category, _ := strconv.Atoi(c.DefaultPostForm("classId", "0")) + save := c.DefaultQuery("noSave", "0") == "0" + item, err := svc.Upload(c.Request.Context(), claims.ID, header.Filename, header.Header.Get("Content-Type"), category, opened, save) + if err != nil { + fail(c, "上传失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{"file": item}, "上传成功") + }) + files.POST("/getFileList", func(c *gin.Context) { + var req struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Keyword string `json:"keyword"` + ClassID int `json:"classId"` + Tag string `json:"tag"` + UserID uint `json:"userId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + items, total, err := svc.MediaList(c.Request.Context(), req.Page, req.PageSize, req.Keyword, req.ClassID, req.Tag, req.UserID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") + }) + files.POST("/deleteFile", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Delete(c.Request.Context(), req.ID); err != nil { + fail(c, "删除失败:"+err.Error()) + return + } + ok(c) + }) + files.POST("/deleteFiles", func(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + failed := []uint{} + for _, id := range req.IDs { + if svc.Delete(c.Request.Context(), id) != nil { + failed = append(failed, id) + } + } + if len(failed) > 0 { + writeResult(c, codeSuccess, gin.H{"failedIds": failed}, "部分文件删除失败") + return + } + ok(c) + }) + files.GET("/findFile", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("id"), 10, 64) + item, err := svc.Media(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, item, "获取成功") + }) + files.POST("/editFileName", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + Name string `json:"name"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Rename(c.Request.Context(), req.ID, req.Name); err != nil { + fail(c, "编辑失败") + return + } + ok(c) + }) + files.POST("/importURL", func(c *gin.Context) { + var req []struct { + Name string `json:"name"` + ClassID int `json:"classId"` + URL string `json:"url"` + Tag string `json:"tag"` + Key string `json:"key"` + Size int64 `json:"size"` + Mime string `json:"mime"` + MD5 string `json:"md5"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + claims := currentClaims(c) + items := make([]*biz.MediaFile, 0, len(req)) + for _, v := range req { + items = append(items, &biz.MediaFile{Name: v.Name, CategoryID: v.ClassID, URL: v.URL, Tag: v.Tag, Key: v.Key, Size: v.Size, Mime: v.Mime, MD5: v.MD5, UserID: claims.ID}) + } + if err := svc.ImportURLs(c.Request.Context(), items); err != nil { + fail(c, "导入失败") + return + } + ok(c) + }) + files.POST("/listOssFiles", func(c *gin.Context) { + var req struct { + Prefix string `json:"prefix"` + Cursor string `json:"cursor"` + Limit int `json:"limit"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + items, next, more, err := svc.Storage(c.Request.Context(), req.Prefix, req.Cursor, req.Limit) + if err != nil { + fail(c, "列举失败") + return + } + writeResult(c, codeSuccess, gin.H{"list": items, "nextCursor": next, "hasMore": more}, "获取成功") + }) + categories := group.Group("/attachmentCategory") + categories.GET("/getCategoryList", func(c *gin.Context) { + items, err := svc.Categories(c.Request.Context()) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, items, "获取成功") + }) + categories.POST("/addCategory", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + Name string `json:"name"` + PID uint `json:"pid"` + } + if c.ShouldBindJSON(&req) != nil || req.Name == "" { + fail(c, "参数错误") + return + } + if err := svc.SaveCategory(c.Request.Context(), &biz.AttachmentCategory{ID: req.ID, Name: req.Name, ParentID: req.PID}); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + categories.POST("/deleteCategory", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteCategory(c.Request.Context(), req.ID); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + upload := group.Group("/mediaUpload") + upload.POST("/init", func(c *gin.Context) { + claims := currentClaims(c) + var req struct { + FileName string `json:"fileName"` + FileHash string `json:"fileHash"` + FileSize int64 `json:"fileSize"` + ChunkSize int64 `json:"chunkSize"` + ChunkTotal int `json:"chunkTotal"` + } + if claims == nil || c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + result, err := svc.InitUpload(c.Request.Context(), claims.ID, req.FileName, req.FileHash, req.FileSize, req.ChunkSize, req.ChunkTotal) + if err != nil { + fail(c, err.Error()) + return + } + writeResult(c, codeSuccess, result, "获取成功") + }) + upload.POST("/chunk", func(c *gin.Context) { + claims := currentClaims(c) + uploadID, _ := strconv.ParseUint(c.PostForm("uploadId"), 10, 64) + index, _ := strconv.Atoi(c.PostForm("chunkIndex")) + header, err := c.FormFile("chunk") + if claims == nil || err != nil { + fail(c, "接收分片失败") + return + } + opened, err := header.Open() + if err != nil { + fail(c, "读取分片失败") + return + } + defer opened.Close() + if err = svc.SaveChunk(c.Request.Context(), claims.ID, uint(uploadID), index, c.PostForm("chunkHash"), opened); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + upload.POST("/complete", func(c *gin.Context) { + claims := currentClaims(c) + var req struct { + UploadID uint `json:"uploadId"` + } + if claims == nil || c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + item, err := svc.CompleteUpload(c.Request.Context(), claims.ID, req.UploadID) + if err != nil { + fail(c, err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{"media": item}, "上传成功") + }) + upload.DELETE("/:uploadId", func(c *gin.Context) { + claims := currentClaims(c) + id, _ := strconv.ParseUint(c.Param("uploadId"), 10, 64) + if claims == nil { + fail(c, "未登录") + return + } + if err := svc.CancelUpload(c.Request.Context(), claims.ID, uint(id)); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) +} diff --git a/internal/server/menu_routes.go b/internal/server/menu_routes.go new file mode 100644 index 0000000..4c1dcac --- /dev/null +++ b/internal/server/menu_routes.go @@ -0,0 +1,188 @@ +package server + +import ( + "strconv" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +type menuRequest struct { + ID uint `json:"ID"` + ParentID uint `json:"parentId"` + Path string `json:"path"` + Name string `json:"name"` + Hidden bool `json:"hidden"` + Component string `json:"component"` + Sort int `json:"sort"` + Meta struct { + ActiveName string `json:"activeName"` + KeepAlive bool `json:"keepAlive"` + DefaultMenu bool `json:"defaultMenu"` + Title string `json:"title"` + Icon string `json:"icon"` + CloseTab bool `json:"closeTab"` + TransitionType string `json:"transitionType"` + } `json:"meta"` + MenuButtons []struct { + ID uint `json:"ID"` + Name string `json:"name"` + Description string `json:"desc"` + } `json:"menuBtn"` +} + +func menuDomain(v menuRequest) *biz.Menu { + return &biz.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} +} + +func menuButtons(v menuRequest, menuID uint) []*biz.MenuButton { + buttons := make([]*biz.MenuButton, 0, len(v.MenuButtons)) + for _, button := range v.MenuButtons { + buttons = append(buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: menuID}) + } + return buttons +} + +func registerMenuRoutes(group *gin.RouterGroup, svc *service.AccessService) { + router := group.Group("/menu") + router.POST("/getMenuList", func(c *gin.Context) { + items, err := svc.Menus(c.Request.Context()) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: int64(len(items)), Page: 1, PageSize: 999}, "获取成功") + }) + router.POST("/getBaseMenuTree", func(c *gin.Context) { + items, err := svc.MenuTree(c.Request.Context()) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"menus": items}, "获取成功") + }) + router.POST("/addBaseMenu", func(c *gin.Context) { + var req menuRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + menu := menuDomain(req) + if err := svc.CreateMenu(c.Request.Context(), menu); err != nil { + fail(c, "新增失败") + return + } + if err := svc.ReplaceMenuButtons(c.Request.Context(), menu.ID, menuButtons(req, menu.ID)); err != nil { + fail(c, "按钮保存失败") + return + } + ok(c) + }) + router.POST("/updateBaseMenu", func(c *gin.Context) { + var req menuRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdateMenu(c.Request.Context(), menuDomain(req)); err != nil { + fail(c, "更新失败") + return + } + if err := svc.ReplaceMenuButtons(c.Request.Context(), req.ID, menuButtons(req, req.ID)); err != nil { + fail(c, "按钮保存失败") + return + } + ok(c) + }) + router.POST("/deleteBaseMenu", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteMenu(c.Request.Context(), req.ID); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + router.POST("/getBaseMenuById", func(c *gin.Context) { + var req struct { + ID uint `json:"id"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + item, err := svc.Menu(c.Request.Context(), req.ID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"menu": item}, "获取成功") + }) + router.POST("/addMenuAuthority", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + Menus []struct { + ID uint `json:"ID"` + } `json:"menus"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + ids := make([]uint, 0, len(req.Menus)) + for _, m := range req.Menus { + ids = append(ids, m.ID) + } + if err := svc.SetAuthorityMenus(c.Request.Context(), req.AuthorityID, ids); err != nil { + fail(c, "添加失败") + return + } + ok(c) + }) + router.POST("/getMenuAuthority", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + menus, err := svc.AuthorityMenus(c.Request.Context(), req.AuthorityID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"menus": menus}, "获取成功") + }) + router.GET("/getMenuRoles", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("menuId"), 10, 64) + ids, err := svc.MenuRoleIDs(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, ids, "获取成功") + }) + router.POST("/setMenuRoles", func(c *gin.Context) { + var req struct { + MenuID uint `json:"menuId"` + AuthorityIDs []uint `json:"authorityIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetMenuRoles(c.Request.Context(), req.MenuID, req.AuthorityIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) +} diff --git a/internal/server/organization_routes.go b/internal/server/organization_routes.go new file mode 100644 index 0000000..4896998 --- /dev/null +++ b/internal/server/organization_routes.go @@ -0,0 +1,237 @@ +package server + +import ( + "strconv" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerDepartmentRoutes(group *gin.RouterGroup, svc *service.AccessService) { + router := group.Group("/department") + router.POST("/getDepartmentList", func(c *gin.Context) { + var req struct { + Name string `json:"name"` + } + _ = c.ShouldBindJSON(&req) + items, err := svc.Departments(c.Request.Context(), req.Name) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, items, "获取成功") + }) + type departmentRequest struct { + ID uint `json:"ID"` + Name string `json:"name"` + ParentID uint `json:"parentId"` + Sort int `json:"sort"` + LeaderID uint `json:"leaderId"` + Status bool `json:"status"` + } + router.POST("/createDepartment", func(c *gin.Context) { + var req departmentRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.CreateDepartment(c.Request.Context(), &biz.Department{Name: req.Name, ParentID: req.ParentID, Sort: req.Sort, LeaderID: req.LeaderID, Status: req.Status}); err != nil { + fail(c, "创建失败") + return + } + ok(c) + }) + router.PUT("/updateDepartment", func(c *gin.Context) { + var req departmentRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdateDepartment(c.Request.Context(), &biz.Department{ID: req.ID, Name: req.Name, ParentID: req.ParentID, Sort: req.Sort, LeaderID: req.LeaderID, Status: req.Status}); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + router.DELETE("/deleteDepartment", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteDepartment(c.Request.Context(), req.ID); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + router.GET("/findDepartment", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("id"), 10, 64) + item, err := svc.Department(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, item, "获取成功") + }) + router.GET("/getDepartmentUsers", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("deptId"), 10, 64) + ids, err := svc.DepartmentUserIDs(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, ids, "获取成功") + }) + router.POST("/setDepartmentUsers", func(c *gin.Context) { + var req struct { + DepartmentID uint `json:"deptId"` + UserIDs []uint `json:"userIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetDepartmentUsers(c.Request.Context(), req.DepartmentID, req.UserIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) + group.POST("/user/setUserDepartments", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + DepartmentIDs []uint `json:"deptIds"` + Primary uint `json:"primaryDeptId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetUserDepartments(c.Request.Context(), req.ID, req.DepartmentIDs, req.Primary); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) +} + +func registerPositionRoutes(group *gin.RouterGroup, svc *service.AccessService) { + router := group.Group("/position") + router.POST("/getPositionList", func(c *gin.Context) { + var req struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Name string `json:"name"` + Code string `json:"code"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + items, total, err := svc.Positions(c.Request.Context(), req.Page, req.PageSize, &biz.Position{Name: req.Name, Code: req.Code}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") + }) + type positionRequest struct { + ID uint `json:"ID"` + Name string `json:"name"` + Code string `json:"code"` + Sort int `json:"sort"` + Status bool `json:"status"` + Remark string `json:"remark"` + } + router.POST("/createPosition", func(c *gin.Context) { + var req positionRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.CreatePosition(c.Request.Context(), &biz.Position{Name: req.Name, Code: req.Code, Sort: req.Sort, Status: req.Status, Remark: req.Remark}); err != nil { + fail(c, "创建失败") + return + } + ok(c) + }) + router.PUT("/updatePosition", func(c *gin.Context) { + var req positionRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.UpdatePosition(c.Request.Context(), &biz.Position{ID: req.ID, Name: req.Name, Code: req.Code, Sort: req.Sort, Status: req.Status, Remark: req.Remark}); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + router.DELETE("/deletePosition", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeletePosition(c.Request.Context(), req.ID); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.GET("/findPosition", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("id"), 10, 64) + item, err := svc.Position(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, item, "获取成功") + }) + router.GET("/getPositionUsers", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("positionId"), 10, 64) + ids, err := svc.PositionUserIDs(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, ids, "获取成功") + }) + router.POST("/setPositionUsers", func(c *gin.Context) { + var req struct { + PositionID uint `json:"positionId"` + UserIDs []uint `json:"userIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetPositionUsers(c.Request.Context(), req.PositionID, req.UserIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) + group.POST("/user/setUserPositions", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + PositionIDs []uint `json:"positionIds"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetUserPositions(c.Request.Context(), req.ID, req.PositionIDs); err != nil { + fail(c, "设置失败") + return + } + ok(c) + }) +} diff --git a/internal/server/policy_routes.go b/internal/server/policy_routes.go new file mode 100644 index 0000000..2293346 --- /dev/null +++ b/internal/server/policy_routes.go @@ -0,0 +1,97 @@ +package server + +import ( + "strconv" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerPolicyRoutes(group *gin.RouterGroup, svc *service.AccessService) { + casbin := group.Group("/casbin") + casbin.POST("/updateCasbin", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + Infos []struct { + Path string `json:"path"` + Method string `json:"method"` + } `json:"casbinInfos"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + paths := make([]*biz.API, 0, len(req.Infos)) + for _, v := range req.Infos { + paths = append(paths, &biz.API{Path: v.Path, Method: v.Method}) + } + if err := svc.SetPolicyPaths(c.Request.Context(), req.AuthorityID, paths); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + casbin.POST("/getPolicyPathByAuthorityId", func(c *gin.Context) { + var req struct { + AuthorityID uint `json:"authorityId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + paths, err := svc.PolicyPaths(c.Request.Context(), req.AuthorityID) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"paths": paths}, "获取成功") + }) + buttons := group.Group("/authorityBtn") + buttons.POST("/getAuthorityBtn", func(c *gin.Context) { + var req struct { + MenuID uint `json:"menuID"` + AuthorityID uint `json:"authorityId"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + selected, err := svc.SelectedButtons(c.Request.Context(), req.AuthorityID, req.MenuID) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"selected": selected}, "查询成功") + }) + buttons.POST("/setAuthorityBtn", func(c *gin.Context) { + var req struct { + MenuID uint `json:"menuID"` + AuthorityID uint `json:"authorityId"` + Selected []uint `json:"selected"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.SetSelectedButtons(c.Request.Context(), req.AuthorityID, req.MenuID, req.Selected); err != nil { + fail(c, "分配失败") + return + } + ok(c) + }) + buttons.POST("/canRemoveAuthorityBtn", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("id"), 10, 64) + allowed, err := svc.CanRemoveButton(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "检查失败") + return + } + if !allowed { + fail(c, "此按钮正在被使用无法删除") + return + } + ok(c) + }) +} diff --git a/internal/server/response.go b/internal/server/response.go new file mode 100644 index 0000000..2664482 --- /dev/null +++ b/internal/server/response.go @@ -0,0 +1,50 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const ( + codeSuccess = 0 + codeError = 7 + codePasswordChangeRequired = 10001 +) + +type response struct { + Code int `json:"code"` + Data any `json:"data"` + Msg string `json:"msg"` +} + +type pageResult struct { + List any `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +func writeResult(c *gin.Context, code int, data any, message string) { + c.JSON(http.StatusOK, response{Code: code, Data: data, Msg: message}) +} + +func ok(c *gin.Context) { + writeResult(c, codeSuccess, gin.H{}, "操作成功") +} + +func okWithData(c *gin.Context, data any) { + writeResult(c, codeSuccess, data, "成功") +} + +func fail(c *gin.Context, message string) { + writeResult(c, codeError, gin.H{}, message) +} + +func noAuth(c *gin.Context, message string) { + c.AbortWithStatusJSON(http.StatusUnauthorized, response{ + Code: codeError, + Data: nil, + Msg: message, + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index f389425..2c4b845 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,4 +5,4 @@ import ( ) // ProviderSet is server providers. -var ProviderSet = wire.NewSet(NewGRPCServer, NewHTTPServer) +var ProviderSet = wire.NewSet(NewGRPCServer, NewGinServer, NewTaskScheduler) diff --git a/internal/server/settings_routes.go b/internal/server/settings_routes.go new file mode 100644 index 0000000..1f7a6af --- /dev/null +++ b/internal/server/settings_routes.go @@ -0,0 +1,437 @@ +package server + +import ( + "encoding/json" + "strconv" + + "kra/internal/biz" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +type dictionaryRequest struct { + ID uint `json:"ID" form:"ID"` + Name string `json:"name" form:"name"` + Type string `json:"type" form:"type"` + Status *bool `json:"status" form:"status"` + Desc string `json:"desc" form:"desc"` + ParentID *uint `json:"parentID" form:"parentID"` +} + +func dictionaryDomain(v dictionaryRequest) *biz.Dictionary { + status := true + if v.Status != nil { + status = *v.Status + } + return &biz.Dictionary{ID: v.ID, Name: v.Name, Type: v.Type, Status: status, Desc: v.Desc, ParentID: v.ParentID} +} + +type detailRequest struct { + ID uint `json:"ID" form:"ID"` + Label string `json:"label" form:"label"` + Value string `json:"value" form:"value"` + Extend string `json:"extend" form:"extend"` + Status *bool `json:"status" form:"status"` + Sort int `json:"sort" form:"sort"` + DictionaryID uint `json:"sysDictionaryID" form:"sysDictionaryID"` + ParentID *uint `json:"parentID" form:"parentID"` + Level int `json:"level"` + Path string `json:"path"` +} + +func detailDomain(v detailRequest) *biz.DictionaryDetail { + status := true + if v.Status != nil { + status = *v.Status + } + return &biz.DictionaryDetail{ID: v.ID, Label: v.Label, Value: v.Value, Extend: v.Extend, Status: status, Sort: v.Sort, DictionaryID: v.DictionaryID, ParentID: v.ParentID, Level: v.Level, Path: v.Path} +} + +func registerSettingsRoutes(group *gin.RouterGroup, public *gin.RouterGroup, svc *service.SettingsService) { + registerDictionaryRoutes(group, svc) + registerParameterRoutes(group, svc) + registerAPITokenRoutes(group, svc) +} + +func registerDictionaryRoutes(group *gin.RouterGroup, svc *service.SettingsService) { + dictionaries := group.Group("/sysDictionary") + dictionaries.POST("/createSysDictionary", func(c *gin.Context) { + var req dictionaryRequest + if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.Type == "" { + fail(c, "参数错误") + return + } + if err := svc.CreateDictionary(c.Request.Context(), dictionaryDomain(req)); err != nil { + fail(c, "创建失败:"+err.Error()) + return + } + ok(c) + }) + dictionaries.PUT("/updateSysDictionary", func(c *gin.Context) { + var req dictionaryRequest + if c.ShouldBindJSON(&req) != nil || req.ID == 0 { + fail(c, "参数错误") + return + } + if err := svc.UpdateDictionary(c.Request.Context(), dictionaryDomain(req)); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + dictionaries.DELETE("/deleteSysDictionary", func(c *gin.Context) { + var req dictionaryRequest + if c.ShouldBindJSON(&req) != nil || req.ID == 0 { + fail(c, "参数错误") + return + } + if err := svc.DeleteDictionary(c.Request.Context(), req.ID); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + dictionaries.GET("/findSysDictionary", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Dictionary(c.Request.Context(), uint(id), c.Query("type"), true) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"reSysDictionary": item}, "查询成功") + }) + listHandler := func(details bool) gin.HandlerFunc { + return func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := svc.Dictionaries(c.Request.Context(), page, size, c.Query("name"), c.Query("type"), details) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + } + } + dictionaries.GET("/getSysDictionaryList", listHandler(false)) + dictionaries.GET("/getSysDictionaryListWithDetails", listHandler(true)) + dictionaries.GET("/exportSysDictionary", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Dictionary(c.Request.Context(), uint(id), "", true) + if err != nil { + fail(c, "导出失败") + return + } + delete(item, "ID") + delete(item, "CreatedAt") + delete(item, "UpdatedAt") + delete(item, "DeletedAt") + writeResult(c, codeSuccess, item, "导出成功") + }) + dictionaries.POST("/importSysDictionary", func(c *gin.Context) { + var req struct { + JSON string `json:"json"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + var payload struct { + Name string `json:"name"` + Type string `json:"type"` + Status *bool `json:"status"` + Desc string `json:"desc"` + Details []detailRequest `json:"sysDictionaryDetails"` + } + if json.Unmarshal([]byte(req.JSON), &payload) != nil || payload.Name == "" || payload.Type == "" { + fail(c, "JSON 格式错误") + return + } + dictionary := dictionaryDomain(dictionaryRequest{Name: payload.Name, Type: payload.Type, Status: payload.Status, Desc: payload.Desc}) + if err := svc.CreateDictionary(c.Request.Context(), dictionary); err != nil { + fail(c, "导入失败:"+err.Error()) + return + } + mapping := map[uint]uint{} + pending := append([]detailRequest(nil), payload.Details...) + for len(pending) > 0 { + progress := false + next := pending[:0] + for _, item := range pending { + oldID := item.ID + if item.ParentID != nil && *item.ParentID != 0 { + parent, exists := mapping[*item.ParentID] + if !exists { + next = append(next, item) + continue + } + item.ParentID = &parent + } + value := detailDomain(item) + value.ID = 0 + value.DictionaryID = dictionary.ID + if err := svc.CreateDictionaryDetail(c.Request.Context(), value); err != nil { + fail(c, "导入详情失败") + return + } + if oldID != 0 { + mapping[oldID] = value.ID + } + progress = true + } + if !progress { + fail(c, "字典层级关系无效") + return + } + pending = next + } + ok(c) + }) + + details := group.Group("/sysDictionaryDetail") + details.POST("/createSysDictionaryDetail", func(c *gin.Context) { + var req detailRequest + if c.ShouldBindJSON(&req) != nil || req.DictionaryID == 0 { + fail(c, "参数错误") + return + } + if err := svc.CreateDictionaryDetail(c.Request.Context(), detailDomain(req)); err != nil { + fail(c, "创建失败") + return + } + ok(c) + }) + details.PUT("/updateSysDictionaryDetail", func(c *gin.Context) { + var req detailRequest + if c.ShouldBindJSON(&req) != nil || req.ID == 0 { + fail(c, "参数错误") + return + } + if err := svc.UpdateDictionaryDetail(c.Request.Context(), detailDomain(req)); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + details.DELETE("/deleteSysDictionaryDetail", func(c *gin.Context) { + var req detailRequest + if c.ShouldBindJSON(&req) != nil || req.ID == 0 { + fail(c, "参数错误") + return + } + if err := svc.DeleteDictionaryDetail(c.Request.Context(), req.ID); err != nil { + fail(c, err.Error()) + return + } + ok(c) + }) + details.GET("/findSysDictionaryDetail", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.DictionaryDetail(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"reSysDictionaryDetail": item}, "查询成功") + }) + details.GET("/getSysDictionaryDetailList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + did, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64) + items, total, err := svc.DictionaryDetails(c.Request.Context(), page, size, uint(did), c.Query("label"), c.Query("value")) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + treeHandler := func(byType bool) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64) + typ := "" + if byType { + typ = c.Query("type") + } + items, err := svc.DictionaryTree(c.Request.Context(), uint(id), typ) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, items, "获取成功") + } + } + details.GET("/getDictionaryTreeList", treeHandler(false)) + details.GET("/getDictionaryTreeListByType", treeHandler(true)) + details.GET("/getDictionaryDetailsByParent", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("parentID"), 10, 64) + did, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64) + items, _, err := svc.DictionaryDetails(c.Request.Context(), 1, 10000, uint(did), "", "") + if err != nil { + fail(c, "获取失败") + return + } + out := []map[string]any{} + for _, item := range items { + parent, _ := item["parentID"].(*uint) + if (id == 0 && parent == nil) || (parent != nil && uint64(*parent) == id) { + out = append(out, item) + } + } + writeResult(c, codeSuccess, out, "获取成功") + }) + details.GET("/getDictionaryPath", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.DictionaryDetail(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, gin.H{"path": item["path"]}, "获取成功") + }) +} + +type parameterRequest struct { + ID uint `json:"ID" form:"ID"` + Name string `json:"name" form:"name"` + Key string `json:"key" form:"key"` + Value string `json:"value" form:"value"` + Desc string `json:"desc" form:"desc"` +} + +func parameterDomain(v parameterRequest) *biz.SystemParameter { + return &biz.SystemParameter{ID: v.ID, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} +} +func registerParameterRoutes(group *gin.RouterGroup, svc *service.SettingsService) { + router := group.Group("/sysParams") + router.POST("/createSysParams", func(c *gin.Context) { + var req parameterRequest + if c.ShouldBindJSON(&req) != nil || req.Key == "" { + fail(c, "参数错误") + return + } + if err := svc.CreateParameter(c.Request.Context(), parameterDomain(req)); err != nil { + fail(c, "创建失败") + return + } + ok(c) + }) + router.PUT("/updateSysParams", func(c *gin.Context) { + var req parameterRequest + if c.ShouldBindJSON(&req) != nil || req.ID == 0 { + fail(c, "参数错误") + return + } + if err := svc.UpdateParameter(c.Request.Context(), parameterDomain(req)); err != nil { + fail(c, "更新失败") + return + } + ok(c) + }) + router.DELETE("/deleteSysParams", func(c *gin.Context) { + var req parameterRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteParameters(c.Request.Context(), []uint{req.ID}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.DELETE("/deleteSysParamsByIds", func(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DeleteParameters(c.Request.Context(), req.IDs); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.GET("/findSysParams", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Parameter(c.Request.Context(), uint(id), "") + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"resysParams": item}, "查询成功") + }) + router.GET("/getSysParam", func(c *gin.Context) { + item, err := svc.Parameter(c.Request.Context(), 0, c.Query("key")) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, gin.H{"data": item}, "查询成功") + }) + router.GET("/getSysParamsList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := svc.Parameters(c.Request.Context(), page, size, &biz.SystemParameter{Name: c.Query("name"), Key: c.Query("key")}) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) +} + +func registerAPITokenRoutes(group *gin.RouterGroup, svc *service.SettingsService) { + router := group.Group("/sysApiToken") + router.POST("/createApiToken", func(c *gin.Context) { + var req struct { + UserID uint `json:"userId"` + AuthorityID uint `json:"authorityId"` + Days int `json:"days"` + Remark string `json:"remark"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + token, err := svc.CreateAPIToken(c.Request.Context(), req.UserID, req.AuthorityID, req.Days, req.Remark) + if err != nil { + fail(c, "签发失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{"token": token}, "签发成功") + }) + router.POST("/getApiTokenList", func(c *gin.Context) { + var req struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + UserID uint `json:"userId"` + Status *bool `json:"status"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + items, total, err := svc.APITokens(c.Request.Context(), req.Page, req.PageSize, req.UserID, req.Status) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") + }) + router.POST("/deleteApiToken", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.DisableAPIToken(c.Request.Context(), req.ID); err != nil { + fail(c, "作废失败") + return + } + ok(c) + }) +} diff --git a/internal/server/system_config_routes.go b/internal/server/system_config_routes.go new file mode 100644 index 0000000..a9acb75 --- /dev/null +++ b/internal/server/system_config_routes.go @@ -0,0 +1,115 @@ +package server + +import ( + "runtime" + "syscall" + + "kra/internal/biz" + "kra/internal/conf" + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBackend, settings *service.SettingsService) { + security := group.Group("/securityConfig") + security.GET("/getSecurityConfig", func(c *gin.Context) { + value, err := settings.Security(c.Request.Context()) + if err != nil { + fail(c, "获取安全配置失败") + return + } + writeResult(c, codeSuccess, value, "获取成功") + }) + security.POST("/setSecurityConfig", func(c *gin.Context) { + var value biz.SecurityConfig + if c.ShouldBindJSON(&value) != nil { + fail(c, "参数错误") + return + } + result, err := settings.SaveSecurity(c.Request.Context(), &value) + if err != nil { + fail(c, err.Error()) + return + } + writeResult(c, codeSuccess, result, "设置成功") + }) + + system := group.Group("/system") + system.POST("/getSystemConfig", func(c *gin.Context) { + admin := gin.H{"routerPrefix": ""} + if config != nil { + admin["routerPrefix"] = config.RouterPrefix + if config.Jwt != nil { + admin["jwt"] = gin.H{"signingKey": "******", "expiresTime": config.Jwt.ExpiresTime.AsDuration().String(), "bufferTime": config.Jwt.BufferTime.AsDuration().String(), "issuer": config.Jwt.Issuer} + } + if config.Captcha != nil { + admin["captcha"] = gin.H{"keyLong": config.Captcha.KeyLong, "imgWidth": config.Captcha.ImgWidth, "imgHeight": config.Captcha.ImgHeight, "storeExpiration": config.Captcha.StoreExpiration.AsDuration().String()} + } + if config.Local != nil { + admin["local"] = gin.H{"storePath": config.Local.StorePath, "pathPrefix": config.Local.PathPrefix} + } + } + writeResult(c, codeSuccess, gin.H{"config": gin.H{"admin": admin}}, "获取成功") + }) + system.POST("/setSystemConfig", func(c *gin.Context) { + var req struct { + Config struct { + Admin struct { + RouterPrefix string `json:"routerPrefix"` + JWT struct { + SigningKey string `json:"signingKey"` + Issuer string `json:"issuer"` + } `json:"jwt"` + Local struct { + StorePath string `json:"storePath"` + PathPrefix string `json:"pathPrefix"` + } `json:"local"` + } `json:"admin"` + } `json:"config"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if config != nil { + config.RouterPrefix = req.Config.Admin.RouterPrefix + if config.Jwt != nil { + if req.Config.Admin.JWT.SigningKey != "" && req.Config.Admin.JWT.SigningKey != "******" { + config.Jwt.SigningKey = req.Config.Admin.JWT.SigningKey + } + if req.Config.Admin.JWT.Issuer != "" { + config.Jwt.Issuer = req.Config.Admin.JWT.Issuer + } + } + if config.Local != nil { + if req.Config.Admin.Local.StorePath != "" { + config.Local.StorePath = req.Config.Admin.Local.StorePath + } + if req.Config.Admin.Local.PathPrefix != "" { + config.Local.PathPrefix = req.Config.Admin.Local.PathPrefix + } + } + } + ok(c) + }) + system.POST("/reloadSystem", func(c *gin.Context) { writeResult(c, codeSuccess, gin.H{"reload": true}, "配置已热更新") }) + system.POST("/getServerInfo", func(c *gin.Context) { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + disk := []gin.H{} + var stat syscall.Statfs_t + if syscall.Statfs(".", &stat) == nil { + total := uint64(stat.Blocks) * uint64(stat.Bsize) + free := uint64(stat.Bavail) * uint64(stat.Bsize) + used := total - free + percent := 0 + if total > 0 { + percent = int(used * 100 / total) + } + disk = append(disk, gin.H{"mountPoint": ".", "usedMb": used / 1024 / 1024, "usedGb": used / 1024 / 1024 / 1024, "totalMb": total / 1024 / 1024, "totalGb": total / 1024 / 1024 / 1024, "usedPercent": percent}) + } + server := gin.H{"os": gin.H{"goos": runtime.GOOS, "numCpu": runtime.NumCPU(), "compiler": runtime.Compiler, "goVersion": runtime.Version(), "numGoroutine": runtime.NumGoroutine()}, "cpu": gin.H{"cpus": []float64{}, "cores": runtime.NumCPU()}, "ram": gin.H{"usedMb": mem.Alloc / 1024 / 1024, "totalMb": mem.Sys / 1024 / 1024, "usedPercent": 0}, "disk": disk} + writeResult(c, codeSuccess, gin.H{"server": server}, "获取成功") + }) +} diff --git a/internal/server/task_routes.go b/internal/server/task_routes.go new file mode 100644 index 0000000..d2e32c1 --- /dev/null +++ b/internal/server/task_routes.go @@ -0,0 +1,181 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "kra/internal/biz" + "kra/internal/service" +) + +type taskRequest struct { + ID uint `json:"ID"` + Name string `json:"name"` + Description string `json:"description"` + Spec string `json:"spec"` + WithSeconds bool `json:"withSeconds"` + ExecutorType string `json:"executorType"` + MethodName string `json:"methodName"` + Params json.RawMessage `json:"params"` + HTTPURL string `json:"httpUrl"` + HTTPMethod string `json:"httpMethod"` + HTTPHeader json.RawMessage `json:"httpHeader"` + HTTPBody string `json:"httpBody"` + HTTPAllowPrivate bool `json:"httpAllowPrivate"` + Enabled bool `json:"enabled"` +} + +func taskDomain(v taskRequest) *biz.TimedTask { + return &biz.TimedTask{ID: v.ID, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: v.Params, HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: v.HTTPHeader, HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} +} +func registerTaskRoutes(group *gin.RouterGroup, svc *service.TaskService, scheduler *TaskScheduler) { + router := group.Group("/timedTask") + router.POST("/createTimedTask", func(c *gin.Context) { + var req taskRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + task := taskDomain(req) + if err := svc.Create(c.Request.Context(), task); err != nil { + fail(c, "创建失败:"+err.Error()) + return + } + if err := scheduler.Schedule(task); err != nil { + fail(c, "调度失败:"+err.Error()) + return + } + ok(c) + }) + router.PUT("/updateTimedTask", func(c *gin.Context) { + var req taskRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + task := taskDomain(req) + if err := svc.Update(c.Request.Context(), task); err != nil { + fail(c, "更新失败:"+err.Error()) + return + } + if err := scheduler.Schedule(task); err != nil { + fail(c, "调度失败:"+err.Error()) + return + } + ok(c) + }) + router.DELETE("/deleteTimedTask", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + scheduler.Remove(uint(id)) + if err := svc.Delete(c.Request.Context(), uint(id)); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.POST("/toggleTimedTask", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + Enabled bool `json:"enabled"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Toggle(c.Request.Context(), req.ID, req.Enabled); err != nil { + fail(c, "设置失败") + return + } + task, err := svc.Task(c.Request.Context(), req.ID) + if err != nil { + fail(c, "任务不存在") + return + } + if err = scheduler.Schedule(task); err != nil { + fail(c, "调度失败") + return + } + ok(c) + }) + router.POST("/triggerTimedTask", func(c *gin.Context) { + var req struct { + ID uint `json:"ID"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + task, err := svc.Task(c.Request.Context(), req.ID) + if err != nil { + fail(c, "任务不存在") + return + } + scheduler.Trigger(task) + ok(c) + }) + router.GET("/getTimedTaskList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + var enabled *bool + if raw := c.Query("enabled"); raw != "" { + value, _ := strconv.ParseBool(raw) + enabled = &value + } + items, total, err := svc.Tasks(c.Request.Context(), page, size, &biz.TimedTask{Name: c.Query("name"), ExecutorType: c.Query("executorType"), EnabledFilter: enabled}, scheduler.NextRuns()) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + router.GET("/getTimedTaskLogList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + taskID, _ := strconv.ParseUint(c.Query("taskId"), 10, 64) + items, total, err := svc.Logs(c.Request.Context(), page, size, uint(taskID), c.Query("status")) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + router.GET("/getRegisteredMethods", func(c *gin.Context) { + writeResult(c, codeSuccess, gin.H{"methods": svc.RegisteredMethods()}, "获取成功") + }) + router.GET("/alertStream", func(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + flusher, okFlush := c.Writer.(http.Flusher) + if !okFlush { + fail(c, "SSE 不受支持") + return + } + events := scheduler.Subscribe() + defer scheduler.Unsubscribe(events) + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + _, _ = fmt.Fprint(c.Writer, "event: connected\ndata: {}\n\n") + flusher.Flush() + for { + select { + case event, open := <-events: + if !open { + return + } + _, _ = fmt.Fprintf(c.Writer, "event: alert\ndata: %s\n\n", event) + flusher.Flush() + case <-ticker.C: + _, _ = fmt.Fprint(c.Writer, ": keepalive\n\n") + flusher.Flush() + case <-c.Request.Context().Done(): + return + } + } + }) +} diff --git a/internal/server/task_scheduler.go b/internal/server/task_scheduler.go new file mode 100644 index 0000000..ac28384 --- /dev/null +++ b/internal/server/task_scheduler.go @@ -0,0 +1,155 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "github.com/robfig/cron/v3" + "kra/internal/biz" + "kra/internal/service" + "log/slog" + "sync" + "time" +) + +type TaskScheduler struct { + service *service.TaskService + logger *slog.Logger + standard *cron.Cron + seconds *cron.Cron + mu sync.Mutex + entries map[uint]scheduledEntry + subMu sync.RWMutex + subscribers map[chan []byte]struct{} +} +type scheduledEntry struct { + seconds bool + entry cron.EntryID +} + +func NewTaskScheduler(service *service.TaskService, logger *slog.Logger) *TaskScheduler { + return &TaskScheduler{service: service, logger: logger, standard: cron.New(), seconds: cron.New(cron.WithSeconds()), entries: map[uint]scheduledEntry{}, subscribers: map[chan []byte]struct{}{}} +} +func (s *TaskScheduler) Start(ctx context.Context) error { + s.standard.Start() + s.seconds.Start() + items, _, err := s.service.Tasks(ctx, 1, 100000, nil, nil) + if err == nil { + for _, raw := range items { + id, _ := raw["ID"].(uint) + if task, findErr := s.service.Task(ctx, id); findErr == nil && task.Enabled { + if scheduleErr := s.Schedule(task); scheduleErr != nil { + s.logger.ErrorContext(ctx, "restore timed task failed", "id", id, "error", scheduleErr) + } + } + } + } else { + s.logger.WarnContext(ctx, "timed task table is not ready", "error", err) + } + <-ctx.Done() + return nil +} +func (s *TaskScheduler) Stop(ctx context.Context) error { + standardDone := s.standard.Stop().Done() + secondsDone := s.seconds.Stop().Done() + select { + case <-standardDone: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-secondsDone: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (s *TaskScheduler) Remove(id uint) { + s.mu.Lock() + defer s.mu.Unlock() + if old, ok := s.entries[id]; ok { + if old.seconds { + s.seconds.Remove(old.entry) + } else { + s.standard.Remove(old.entry) + } + delete(s.entries, id) + } +} +func (s *TaskScheduler) Schedule(task *biz.TimedTask) error { + s.Remove(task.ID) + if !task.Enabled { + return nil + } + copy := *task + run := func() { + log := s.service.Run(context.Background(), ©, "auto") + if log.Status != "success" { + s.Broadcast(map[string]any{"taskId": copy.ID, "taskName": copy.Name, "status": log.Status, "errorMsg": log.ErrorMsg, "time": time.Now()}) + } + } + var id cron.EntryID + var err error + if task.WithSeconds { + id, err = s.seconds.AddFunc(task.Spec, run) + } else { + id, err = s.standard.AddFunc(task.Spec, run) + } + if err != nil { + return err + } + s.mu.Lock() + s.entries[task.ID] = scheduledEntry{seconds: task.WithSeconds, entry: id} + s.mu.Unlock() + return nil +} +func (s *TaskScheduler) NextRuns() map[uint]time.Time { + s.mu.Lock() + defer s.mu.Unlock() + out := map[uint]time.Time{} + for id, item := range s.entries { + if item.seconds { + out[id] = s.seconds.Entry(item.entry).Next + } else { + out[id] = s.standard.Entry(item.entry).Next + } + } + return out +} +func (s *TaskScheduler) Trigger(task *biz.TimedTask) { + go func() { + log := s.service.Run(context.Background(), task, "manual") + if log.Status != "success" { + s.Broadcast(map[string]any{"taskId": task.ID, "taskName": task.Name, "status": log.Status, "errorMsg": log.ErrorMsg, "time": time.Now()}) + } + }() +} +func (s *TaskScheduler) Subscribe() chan []byte { + ch := make(chan []byte, 16) + s.subMu.Lock() + s.subscribers[ch] = struct{}{} + s.subMu.Unlock() + return ch +} +func (s *TaskScheduler) Unsubscribe(ch chan []byte) { + s.subMu.Lock() + if _, ok := s.subscribers[ch]; ok { + delete(s.subscribers, ch) + close(ch) + } + s.subMu.Unlock() +} +func (s *TaskScheduler) Broadcast(value any) { + raw, err := json.Marshal(value) + if err != nil { + raw = []byte(fmt.Sprint(value)) + } + s.subMu.RLock() + defer s.subMu.RUnlock() + for ch := range s.subscribers { + select { + case ch <- raw: + default: + } + } +} diff --git a/internal/server/version_routes.go b/internal/server/version_routes.go new file mode 100644 index 0000000..90f4499 --- /dev/null +++ b/internal/server/version_routes.go @@ -0,0 +1,165 @@ +package server + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "kra/internal/biz" + "kra/internal/service" +) + +type versionMenuRequest struct { + Path string `json:"path"` + Name string `json:"name"` + Hidden bool `json:"hidden"` + Component string `json:"component"` + Sort int `json:"sort"` + Meta struct { + ActiveName string `json:"activeName"` + KeepAlive bool `json:"keepAlive"` + DefaultMenu bool `json:"defaultMenu"` + Title string `json:"title"` + Icon string `json:"icon"` + CloseTab bool `json:"closeTab"` + TransitionType string `json:"transitionType"` + } `json:"meta"` + MenuButtons []struct { + Name string `json:"name"` + Desc string `json:"desc"` + } `json:"menuBtn"` + Children []versionMenuRequest `json:"children"` +} + +func versionMenuDomain(v versionMenuRequest) *biz.Menu { + m := &biz.Menu{Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} + for _, b := range v.MenuButtons { + m.Buttons = append(m.Buttons, &biz.MenuButton{Name: b.Name, Description: b.Desc}) + } + for _, child := range v.Children { + m.Children = append(m.Children, versionMenuDomain(child)) + } + return m +} +func registerVersionRoutes(group *gin.RouterGroup, svc *service.VersionService) { + router := group.Group("/sysVersion") + router.DELETE("/deleteSysVersion", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + if err := svc.DeleteVersions(c.Request.Context(), []uint{uint(id)}); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.DELETE("/deleteSysVersionByIds", func(c *gin.Context) { + raw := c.QueryArray("IDs[]") + ids := make([]uint, 0, len(raw)) + for _, v := range raw { + id, _ := strconv.ParseUint(v, 10, 64) + ids = append(ids, uint(id)) + } + if err := svc.DeleteVersions(c.Request.Context(), ids); err != nil { + fail(c, "删除失败") + return + } + ok(c) + }) + router.GET("/findSysVersion", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Version(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败") + return + } + writeResult(c, codeSuccess, item, "查询成功") + }) + router.GET("/getSysVersionList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := svc.Versions(c.Request.Context(), page, size, c.Query("versionName"), c.Query("versionCode")) + if err != nil { + fail(c, "获取失败") + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + }) + router.POST("/exportVersion", func(c *gin.Context) { + var req struct { + VersionName string `json:"versionName"` + VersionCode string `json:"versionCode"` + Description string `json:"description"` + MenuIDs []uint `json:"menuIds"` + APIIDs []uint `json:"apiIds"` + DictIDs []uint `json:"dictIds"` + } + if c.ShouldBindJSON(&req) != nil || req.VersionName == "" || req.VersionCode == "" { + fail(c, "参数错误") + return + } + if err := svc.Export(c.Request.Context(), req.VersionName, req.VersionCode, req.Description, req.MenuIDs, req.APIIDs, req.DictIDs); err != nil { + fail(c, "创建发版失败:"+err.Error()) + return + } + ok(c) + }) + router.GET("/downloadVersionJson", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + raw, code, err := svc.VersionData(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "下载失败") + return + } + name := fmt.Sprintf("version_%s_%s.json", code, time.Now().Format("20060102150405")) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name)) + c.Data(http.StatusOK, "application/json; charset=utf-8", []byte(raw)) + }) + router.POST("/importVersion", func(c *gin.Context) { + var req struct { + Version struct { + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + ExportTime string `json:"exportTime"` + } `json:"version"` + Menus []versionMenuRequest `json:"menus"` + APIs []struct { + Path string `json:"path"` + Description string `json:"description"` + APIGroup string `json:"apiGroup"` + Method string `json:"method"` + } `json:"apis"` + Dictionaries []struct { + Name string `json:"name"` + Type string `json:"type"` + Status bool `json:"status"` + Desc string `json:"desc"` + Details []detailRequest `json:"sysDictionaryDetails"` + } `json:"dictionaries"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + bundle := &biz.VersionBundle{Name: req.Version.Name, Code: req.Version.Code, Description: req.Version.Description, ExportTime: req.Version.ExportTime} + for _, m := range req.Menus { + bundle.Menus = append(bundle.Menus, versionMenuDomain(m)) + } + for _, a := range req.APIs { + bundle.APIs = append(bundle.APIs, &biz.API{Path: a.Path, Description: a.Description, APIGroup: a.APIGroup, Method: a.Method}) + } + for _, d := range req.Dictionaries { + dictionary := &biz.Dictionary{Name: d.Name, Type: d.Type, Status: d.Status, Desc: d.Desc} + for _, detail := range d.Details { + dictionary.Details = append(dictionary.Details, detailDomain(detail)) + } + bundle.Dictionaries = append(bundle.Dictionaries, dictionary) + } + if err := svc.Import(c.Request.Context(), bundle); err != nil { + fail(c, "导入失败:"+err.Error()) + return + } + ok(c) + }) +} diff --git a/internal/service/access.go b/internal/service/access.go new file mode 100644 index 0000000..659434e --- /dev/null +++ b/internal/service/access.go @@ -0,0 +1,377 @@ +package service + +import ( + "context" + "strings" + + "kra/internal/biz" +) + +type AccessService struct{ uc *biz.AccessUsecase } + +func NewAccessService(uc *biz.AccessUsecase) *AccessService { return &AccessService{uc: uc} } +func (s *AccessService) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) { + return s.uc.Authorize(ctx, aid, path, method) +} + +func authorityDTO(v *biz.Authority) map[string]any { return convertAuthority(*v) } +func apiDTO(v *biz.API) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "path": v.Path, "description": v.Description, "apiGroup": v.APIGroup, "method": v.Method} +} +func departmentDTO(v *biz.Department) map[string]any { + children := make([]map[string]any, 0, len(v.Children)) + for _, c := range v.Children { + children = append(children, departmentDTO(c)) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "parentId": v.ParentID, "ancestors": v.Ancestors, "sort": v.Sort, "leaderId": v.LeaderID, "leader": nil, "status": v.Status, "children": children, "namePath": ""} +} +func positionDTO(v *biz.Position) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "code": v.Code, "sort": v.Sort, "status": v.Status, "remark": v.Remark} +} + +func (s *AccessService) Authorities(ctx context.Context) ([]map[string]any, error) { + items, err := s.uc.Repo().ListAuthorities(ctx) + if err != nil { + return nil, err + } + byID := make(map[uint]map[string]any, len(items)) + for _, v := range items { + byID[v.AuthorityID] = authorityDTO(v) + } + roots := make([]map[string]any, 0) + for _, v := range items { + item := byID[v.AuthorityID] + if v.ParentID != nil && *v.ParentID != 0 && byID[*v.ParentID] != nil { + parent := byID[*v.ParentID] + children, _ := parent["children"].([]map[string]any) + parent["children"] = append(children, item) + } else { + roots = append(roots, item) + } + } + return roots, nil +} +func (s *AccessService) CreateAuthority(ctx context.Context, v *biz.Authority) error { + return s.uc.Repo().CreateAuthority(ctx, v) +} +func (s *AccessService) CopyAuthority(ctx context.Context, sourceID uint, v *biz.Authority) error { + return s.uc.Repo().CopyAuthority(ctx, sourceID, v) +} +func (s *AccessService) UpdateAuthority(ctx context.Context, v *biz.Authority) error { + return s.uc.Repo().UpdateAuthority(ctx, v) +} +func (s *AccessService) DeleteAuthority(ctx context.Context, id uint) error { + return s.uc.Repo().DeleteAuthority(ctx, id) +} +func (s *AccessService) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetAuthorityMenus(ctx, id, ids) +} +func (s *AccessService) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().AuthorityMenuIDs(ctx, id) +} +func (s *AccessService) AuthorityMenus(ctx context.Context, id uint) ([]map[string]any, error) { + ids, err := s.uc.Repo().AuthorityMenuIDs(ctx, id) + if err != nil { + return nil, err + } + selected := make(map[uint]bool, len(ids)) + for _, value := range ids { + selected[value] = true + } + menus, err := s.uc.Repo().ListMenus(ctx) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(ids)) + for _, menu := range menus { + if selected[menu.ID] { + out = append(out, map[string]any{"menuId": menu.ID, "authorityId": id, "sysBaseMenu": convertMenu(menu), "parentId": menu.ParentID}) + } + } + return out, nil +} +func (s *AccessService) SetAuthorityUsers(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetAuthorityUsers(ctx, id, ids) +} +func (s *AccessService) AuthorityUserIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().AuthorityUserIDs(ctx, id) +} +func (s *AccessService) SetDataScope(ctx context.Context, id uint, scope int, ids []uint) error { + return s.uc.Repo().SetDataScope(ctx, id, scope, ids) +} +func (s *AccessService) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().DataScopeDepartmentIDs(ctx, id) +} +func (s *AccessService) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) { + return s.uc.Repo().ResolveDataScope(ctx, authorityID, userID) +} + +func (s *AccessService) Menus(ctx context.Context) ([]map[string]any, error) { + items, err := s.uc.Repo().ListMenus(ctx) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + v.Buttons, err = s.uc.Repo().Buttons(ctx, v.ID) + if err != nil { + return nil, err + } + out = append(out, convertMenu(v)) + } + return out, nil +} +func (s *AccessService) MenuTree(ctx context.Context) ([]map[string]any, error) { + items, err := s.uc.Repo().ListMenus(ctx) + if err != nil { + return nil, err + } + byID := make(map[uint]*biz.Menu, len(items)) + for _, item := range items { + item.Buttons, err = s.uc.Repo().Buttons(ctx, item.ID) + if err != nil { + return nil, err + } + item.Children = []*biz.Menu{} + byID[item.ID] = item + } + roots := make([]*biz.Menu, 0) + for _, item := range items { + if parent := byID[item.ParentID]; parent != nil { + parent.Children = append(parent.Children, item) + } else { + roots = append(roots, item) + } + } + out := make([]map[string]any, 0, len(roots)) + for _, item := range roots { + out = append(out, convertMenu(item)) + } + return out, nil +} +func (s *AccessService) CreateMenu(ctx context.Context, v *biz.Menu) error { + return s.uc.Repo().CreateMenu(ctx, v) +} +func (s *AccessService) UpdateMenu(ctx context.Context, v *biz.Menu) error { + return s.uc.Repo().UpdateMenu(ctx, v) +} +func (s *AccessService) DeleteMenu(ctx context.Context, id uint) error { + return s.uc.Repo().DeleteMenu(ctx, id) +} +func (s *AccessService) Menu(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindMenu(ctx, id) + if err != nil { + return nil, err + } + v.Buttons, err = s.uc.Repo().Buttons(ctx, id) + if err != nil { + return nil, err + } + return convertMenu(v), nil +} +func (s *AccessService) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().MenuRoleIDs(ctx, id) +} +func (s *AccessService) SetMenuRoles(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetMenuRoles(ctx, id, ids) +} +func (s *AccessService) ReplaceMenuButtons(ctx context.Context, menuID uint, buttons []*biz.MenuButton) error { + return s.uc.Repo().ReplaceMenuButtons(ctx, menuID, buttons) +} + +func (s *AccessService) APIs(ctx context.Context, page, size int, q *biz.API) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListAPIs(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, apiDTO(v)) + } + return out, total, nil +} +func (s *AccessService) CreateAPI(ctx context.Context, v *biz.API) error { + return s.uc.Repo().CreateAPI(ctx, v) +} +func (s *AccessService) UpdateAPI(ctx context.Context, v *biz.API) error { + return s.uc.Repo().UpdateAPI(ctx, v) +} +func (s *AccessService) DeleteAPIs(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteAPIs(ctx, ids) +} +func (s *AccessService) API(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindAPI(ctx, id) + if err != nil { + return nil, err + } + return apiDTO(v), nil +} +func (s *AccessService) APIRoleIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().APIRoleIDs(ctx, id) +} +func (s *AccessService) SetAPIRoles(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetAPIRoles(ctx, id, ids) +} +func (s *AccessService) PolicyPaths(ctx context.Context, id uint) ([]map[string]any, error) { + items, err := s.uc.Repo().PolicyPaths(ctx, id) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"path": v.Path, "method": v.Method}) + } + return out, nil +} +func (s *AccessService) SetPolicyPaths(ctx context.Context, id uint, paths []*biz.API) error { + return s.uc.Repo().SetPolicyPaths(ctx, id, paths) +} +func (s *AccessService) SyncAPIs(ctx context.Context, routes []*biz.API) (map[string]any, error) { + stored, _, err := s.uc.Repo().ListAPIs(ctx, 1, 100000, nil) + if err != nil { + return nil, err + } + ignored, err := s.uc.Repo().IgnoredAPIs(ctx) + if err != nil { + return nil, err + } + key := func(v *biz.API) string { return strings.ToUpper(v.Method) + " " + v.Path } + ignoreSet := make(map[string]bool, len(ignored)) + for _, item := range ignored { + ignoreSet[key(item)] = true + } + routeSet := make(map[string]*biz.API, len(routes)) + for _, item := range routes { + if !ignoreSet[key(item)] { + routeSet[key(item)] = item + } + } + storedSet := make(map[string]*biz.API, len(stored)) + for _, item := range stored { + storedSet[key(item)] = item + } + added := make([]map[string]any, 0) + deleted := make([]map[string]any, 0) + ignoredDTO := make([]map[string]any, 0, len(ignored)) + for k, item := range routeSet { + if storedSet[k] == nil { + added = append(added, apiDTO(item)) + } + } + for k, item := range storedSet { + if routeSet[k] == nil && !ignoreSet[k] { + deleted = append(deleted, apiDTO(item)) + } + } + for _, item := range ignored { + ignoredDTO = append(ignoredDTO, apiDTO(item)) + } + return map[string]any{"newApis": added, "deleteApis": deleted, "ignoreApis": ignoredDTO}, nil +} +func (s *AccessService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error { + return s.uc.Repo().SetAPIIgnored(ctx, path, method, ignored) +} +func (s *AccessService) ApplyAPISync(ctx context.Context, added, deleted []*biz.API) error { + return s.uc.Repo().ApplyAPISync(ctx, added, deleted) +} + +func (s *AccessService) Buttons(ctx context.Context, id uint) ([]map[string]any, error) { + items, err := s.uc.Repo().Buttons(ctx, id) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"ID": v.ID, "name": v.Name, "desc": v.Description, "sysBaseMenuID": v.MenuID}) + } + return out, nil +} +func (s *AccessService) SetAuthorityButtons(ctx context.Context, id uint, v map[uint][]uint) error { + return s.uc.Repo().SetAuthorityButtons(ctx, id, v) +} +func (s *AccessService) AuthorityButtonIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().AuthorityButtonIDs(ctx, id) +} +func (s *AccessService) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) { + return s.uc.Repo().SelectedButtons(ctx, aid, menuID) +} +func (s *AccessService) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error { + return s.uc.Repo().SetSelectedButtons(ctx, aid, menuID, ids) +} +func (s *AccessService) CanRemoveButton(ctx context.Context, id uint) (bool, error) { + return s.uc.Repo().CanRemoveButton(ctx, id) +} + +func (s *AccessService) Departments(ctx context.Context, name string) ([]map[string]any, error) { + items, err := s.uc.Repo().ListDepartments(ctx, name) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, departmentDTO(v)) + } + return out, nil +} +func (s *AccessService) CreateDepartment(ctx context.Context, v *biz.Department) error { + return s.uc.Repo().CreateDepartment(ctx, v) +} +func (s *AccessService) UpdateDepartment(ctx context.Context, v *biz.Department) error { + return s.uc.Repo().UpdateDepartment(ctx, v) +} +func (s *AccessService) DeleteDepartment(ctx context.Context, id uint) error { + return s.uc.Repo().DeleteDepartment(ctx, id) +} +func (s *AccessService) Department(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindDepartment(ctx, id) + if err != nil { + return nil, err + } + return departmentDTO(v), nil +} +func (s *AccessService) DepartmentUserIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().DepartmentUserIDs(ctx, id) +} +func (s *AccessService) SetDepartmentUsers(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetDepartmentUsers(ctx, id, ids) +} +func (s *AccessService) SetUserDepartments(ctx context.Context, id uint, ids []uint, primary uint) error { + return s.uc.Repo().SetUserDepartments(ctx, id, ids, primary) +} + +func (s *AccessService) Positions(ctx context.Context, page, size int, q *biz.Position) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListPositions(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, positionDTO(v)) + } + return out, total, nil +} +func (s *AccessService) CreatePosition(ctx context.Context, v *biz.Position) error { + return s.uc.Repo().CreatePosition(ctx, v) +} +func (s *AccessService) UpdatePosition(ctx context.Context, v *biz.Position) error { + return s.uc.Repo().UpdatePosition(ctx, v) +} +func (s *AccessService) DeletePosition(ctx context.Context, id uint) error { + return s.uc.Repo().DeletePosition(ctx, id) +} +func (s *AccessService) Position(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindPosition(ctx, id) + if err != nil { + return nil, err + } + return positionDTO(v), nil +} +func (s *AccessService) PositionUserIDs(ctx context.Context, id uint) ([]uint, error) { + return s.uc.Repo().PositionUserIDs(ctx, id) +} +func (s *AccessService) SetPositionUsers(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetPositionUsers(ctx, id, ids) +} +func (s *AccessService) SetUserPositions(ctx context.Context, id uint, ids []uint) error { + return s.uc.Repo().SetUserPositions(ctx, id, ids) +} diff --git a/internal/service/audit.go b/internal/service/audit.go new file mode 100644 index 0000000..478d4b0 --- /dev/null +++ b/internal/service/audit.go @@ -0,0 +1,143 @@ +package service + +import ( + "context" + "kra/internal/biz" +) + +type AuditService struct{ uc *biz.AuditUsecase } + +func NewAuditService(uc *biz.AuditUsecase) *AuditService { return &AuditService{uc: uc} } +func (s *AuditService) RecordOperation(ctx context.Context, v *biz.OperationRecord) error { + return s.uc.Repo().RecordOperation(ctx, v) +} +func (s *AuditService) RecordLogin(ctx context.Context, v *biz.LoginLog) error { + return s.uc.Repo().RecordLogin(ctx, v) +} +func (s *AuditService) RecordDataAccess(ctx context.Context, v *biz.DataAccessLog) error { + return s.uc.Repo().RecordDataAccess(ctx, v) +} +func opDTO(v *biz.OperationRecord) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "ip": v.IP, "method": v.Method, "path": v.Path, "status": v.Status, "latency_ms": v.LatencyMS, "agent": v.Agent, "error_message": v.ErrorMessage, "body": v.Body, "resp": v.Response, "user_id": v.UserID, "request_id": v.RequestID, "trace_id": v.TraceID, "device_id": v.DeviceID, "user": nil} +} +func loginDTO(v *biz.LoginLog) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "username": v.Username, "ip": v.IP, "status": v.Status, "errorMessage": v.ErrorMessage, "agent": v.Agent, "userId": v.UserID, "user": nil} +} +func dataAccessDTO(v *biz.DataAccessLog) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "eventType": v.EventType, "targetTable": v.TargetTable, "operation": v.Operation, "userId": v.UserID, "authorityId": v.AuthorityID, "scope": v.Scope, "requestId": v.RequestID, "method": v.Method, "path": v.Path, "detail": v.Detail} +} +func (s *AuditService) Operations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListOperations(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, opDTO(v)) + } + return out, total, nil +} +func (s *AuditService) Operation(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindOperation(ctx, id) + if err != nil { + return nil, err + } + return opDTO(v), nil +} +func (s *AuditService) DeleteOperations(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteOperations(ctx, ids) +} +func (s *AuditService) Logins(ctx context.Context, page, size int, q *biz.LoginLog) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListLogins(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, loginDTO(v)) + } + return out, total, nil +} +func (s *AuditService) Login(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindLogin(ctx, id) + if err != nil { + return nil, err + } + return loginDTO(v), nil +} +func (s *AuditService) DeleteLogins(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteLogins(ctx, ids) +} +func (s *AuditService) DataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListDataAccess(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, dataAccessDTO(v)) + } + return out, total, nil +} +func (s *AuditService) DeleteDataAccess(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteDataAccess(ctx, ids) +} +func (s *AuditService) LogDates(ctx context.Context, month string) (map[string]any, error) { + items, err := s.uc.Repo().LogDates(ctx, month) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"date": v.Date, "fileCount": v.FileCount}) + } + return map[string]any{"month": month, "dates": out}, nil +} +func (s *AuditService) LogFiles(ctx context.Context, date string) (map[string]any, error) { + items, err := s.uc.Repo().LogFiles(ctx, date) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"path": v.Path, "name": v.Name, "size": v.Size, "modifiedAt": v.ModifiedAt}) + } + return map[string]any{"date": date, "files": out}, nil +} +func (s *AuditService) LogContent(ctx context.Context, date, path string, cursor *int64) (map[string]any, error) { + v, err := s.uc.Repo().LogContent(ctx, date, path, cursor) + if err != nil { + return nil, err + } + return map[string]any{"date": v.Date, "path": v.Path, "content": v.Content, "lineCount": v.LineCount, "nextCursor": v.NextCursor, "hasMore": v.HasMore, "limitedByBytes": v.LimitedByBytes, "size": v.Size, "modifiedAt": v.ModifiedAt}, nil +} +func errorDTO(v *biz.ErrorRecord) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "form": v.Form, "info": v.Info, "level": v.Level, "request_id": v.RequestID, "trace_id": v.TraceID, "solution": v.Solution, "status": v.Status} +} +func (s *AuditService) CreateError(ctx context.Context, v *biz.ErrorRecord) error { + return s.uc.Repo().CreateError(ctx, v) +} +func (s *AuditService) UpdateError(ctx context.Context, v *biz.ErrorRecord) error { + return s.uc.Repo().UpdateError(ctx, v) +} +func (s *AuditService) DeleteErrors(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteErrors(ctx, ids) +} +func (s *AuditService) Error(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindError(ctx, id) + if err != nil { + return nil, err + } + return errorDTO(v), nil +} +func (s *AuditService) Errors(ctx context.Context, page, size int, q *biz.ErrorRecord) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListErrors(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, errorDTO(v)) + } + return out, total, nil +} diff --git a/internal/service/export.go b/internal/service/export.go new file mode 100644 index 0000000..2c13e03 --- /dev/null +++ b/internal/service/export.go @@ -0,0 +1,184 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/xuri/excelize/v2" + "io" + "kra/internal/biz" + "sort" +) + +type ExportService struct{ uc *biz.ExportUsecase } + +func NewExportService(uc *biz.ExportUsecase) *ExportService { return &ExportService{uc: uc} } +func exportDTO(v *biz.ExportTemplate) map[string]any { + conditions := make([]map[string]any, 0, len(v.Conditions)) + for _, x := range v.Conditions { + conditions = append(conditions, map[string]any{"from": x.From, "column": x.Column, "operator": x.Operator}) + } + joins := make([]map[string]any, 0, len(v.Joins)) + for _, x := range v.Joins { + joins = append(joins, map[string]any{"joins": x.Join, "table": x.Table, "on": x.On}) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "dbName": v.DBName, "name": v.Name, "tableName": v.TableName, "templateID": v.TemplateID, "templateInfo": v.TemplateInfo, "sql": v.SQL, "importSql": v.ImportSQL, "limit": v.Limit, "order": v.Order, "conditions": conditions, "joinTemplate": joins} +} +func (s *ExportService) Create(ctx context.Context, v *biz.ExportTemplate) error { + return s.uc.Repo().CreateExportTemplate(ctx, v) +} +func (s *ExportService) Update(ctx context.Context, v *biz.ExportTemplate) error { + return s.uc.Repo().UpdateExportTemplate(ctx, v) +} +func (s *ExportService) Delete(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteExportTemplates(ctx, ids) +} +func (s *ExportService) Template(ctx context.Context, id uint, tid string) (map[string]any, error) { + v, err := s.uc.Repo().FindExportTemplate(ctx, id, tid) + if err != nil { + return nil, err + } + return exportDTO(v), nil +} +func (s *ExportService) Templates(ctx context.Context, page, size int, q *biz.ExportTemplate) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListExportTemplates(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, exportDTO(v)) + } + return out, total, nil +} +func templateColumns(raw string) ([]string, map[string]string, error) { + labels := map[string]string{} + if err := json.Unmarshal([]byte(raw), &labels); err != nil { + return nil, nil, err + } + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + return keys, labels, nil +} +func (s *ExportService) Export(ctx context.Context, tid string, params map[string]string) ([]byte, string, error) { + template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) + if err != nil { + return nil, "", err + } + rows, _, err := s.uc.Repo().QueryExport(ctx, template, params) + if err != nil { + return nil, "", err + } + keys, labels, err := templateColumns(template.TemplateInfo) + if err != nil { + return nil, "", err + } + file := excelize.NewFile() + sheet := file.GetSheetName(0) + for column, key := range keys { + cell, _ := excelize.CoordinatesToCellName(column+1, 1) + _ = file.SetCellValue(sheet, cell, labels[key]) + } + for rowIndex, row := range rows { + for column, key := range keys { + cell, _ := excelize.CoordinatesToCellName(column+1, rowIndex+2) + _ = file.SetCellValue(sheet, cell, row[key]) + } + } + buffer, err := file.WriteToBuffer() + _ = file.Close() + if err != nil { + return nil, "", err + } + return buffer.Bytes(), template.Name + ".xlsx", nil +} +func (s *ExportService) ExportBlankTemplate(ctx context.Context, tid string) ([]byte, string, error) { + template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) + if err != nil { + return nil, "", err + } + keys, labels, err := templateColumns(template.TemplateInfo) + if err != nil { + return nil, "", err + } + file := excelize.NewFile() + sheet := file.GetSheetName(0) + for column, key := range keys { + cell, _ := excelize.CoordinatesToCellName(column+1, 1) + _ = file.SetCellValue(sheet, cell, labels[key]) + } + buffer, err := file.WriteToBuffer() + _ = file.Close() + if err != nil { + return nil, "", err + } + return buffer.Bytes(), template.Name + "_template.xlsx", nil +} +func (s *ExportService) Preview(ctx context.Context, tid string, params map[string]string) (string, error) { + template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) + if err != nil { + return "", err + } + _, sql, err := s.uc.Repo().QueryExport(ctx, template, params) + return sql, err +} +func (s *ExportService) Import(ctx context.Context, tid string, reader io.Reader) error { + template, err := s.uc.Repo().FindExportTemplate(ctx, 0, tid) + if err != nil { + return err + } + file, err := excelize.OpenReader(reader) + if err != nil { + return err + } + defer file.Close() + sheets := file.GetSheetList() + if len(sheets) == 0 { + return errors.New("工作簿没有工作表") + } + rows, err := file.GetRows(sheets[0]) + if err != nil { + return err + } + if len(rows) < 2 { + return nil + } + keys, labels, err := templateColumns(template.TemplateInfo) + if err != nil { + return err + } + reverse := map[string]string{} + for _, key := range keys { + reverse[labels[key]] = key + } + headers := rows[0] + items := make([]map[string]any, 0, len(rows)-1) + for rowIndex, row := range rows[1:] { + item := map[string]any{} + empty := true + for column, value := range row { + if column >= len(headers) { + continue + } + key := reverse[headers[column]] + if key != "" { + item[key] = value + if value != "" { + empty = false + } + } + } + if !empty { + items = append(items, item) + } + _ = rowIndex + } + if err = s.uc.Repo().ImportExportRows(ctx, template, items); err != nil { + return fmt.Errorf("导入失败: %w", err) + } + return nil +} diff --git a/internal/service/media.go b/internal/service/media.go new file mode 100644 index 0000000..1f0138e --- /dev/null +++ b/internal/service/media.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + "io" + "kra/internal/biz" +) + +type MediaService struct{ uc *biz.MediaUsecase } + +func NewMediaService(uc *biz.MediaUsecase) *MediaService { return &MediaService{uc: uc} } +func mediaDTO(v *biz.MediaFile) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "classId": v.CategoryID, "url": v.URL, "tag": v.Tag, "key": v.Key, "size": v.Size, "mime": v.Mime, "md5": v.MD5, "userId": v.UserID} +} +func categoryDTO(v *biz.AttachmentCategory) map[string]any { + children := make([]map[string]any, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, categoryDTO(x)) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "pid": v.ParentID, "children": children} +} +func (s *MediaService) Upload(ctx context.Context, userID uint, name, mime string, categoryID int, reader io.Reader, save bool) (map[string]any, error) { + v, err := s.uc.Upload(ctx, userID, name, mime, categoryID, reader, save) + if err != nil { + return nil, err + } + return mediaDTO(v), nil +} +func (s *MediaService) Media(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindMedia(ctx, id) + if err != nil { + return nil, err + } + return mediaDTO(v), nil +} +func (s *MediaService) MediaList(ctx context.Context, page, size int, keyword string, categoryID int, tag string, userID uint) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListMedia(ctx, page, size, keyword, categoryID, tag, userID) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, mediaDTO(v)) + } + return out, total, nil +} +func (s *MediaService) Delete(ctx context.Context, id uint) error { return s.uc.Delete(ctx, id) } +func (s *MediaService) Rename(ctx context.Context, id uint, name string) error { + return s.uc.Repo().UpdateMediaName(ctx, id, name) +} +func (s *MediaService) ImportURLs(ctx context.Context, items []*biz.MediaFile) error { + return s.uc.Repo().CreateMediaBatch(ctx, items) +} +func (s *MediaService) Storage(ctx context.Context, prefix, cursor string, limit int) ([]map[string]any, string, bool, error) { + items, next, more, err := s.uc.ListStorage(ctx, prefix, cursor, limit) + if err != nil { + return nil, "", false, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"name": v.Name, "key": v.Path, "url": v.URL, "size": v.Size}) + } + return out, next, more, nil +} +func (s *MediaService) Categories(ctx context.Context) ([]map[string]any, error) { + items, err := s.uc.Repo().ListCategories(ctx) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, categoryDTO(v)) + } + return out, nil +} +func (s *MediaService) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error { + return s.uc.Repo().SaveCategory(ctx, v) +} +func (s *MediaService) DeleteCategory(ctx context.Context, id uint) error { + return s.uc.Repo().DeleteCategory(ctx, id) +} +func (s *MediaService) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (map[string]any, error) { + session, media, chunks, err := s.uc.InitUpload(ctx, userID, name, hash, size, chunkSize, total) + if err != nil { + return nil, err + } + if media != nil { + return map[string]any{"instant": true, "media": mediaDTO(media), "uploadedChunks": []int{}}, nil + } + return map[string]any{"instant": false, "uploadId": session.ID, "uploadedChunks": chunks, "media": nil}, nil +} +func (s *MediaService) SaveChunk(ctx context.Context, userID, uploadID uint, index int, hash string, reader io.Reader) error { + return s.uc.SaveChunk(ctx, userID, uploadID, index, hash, reader) +} +func (s *MediaService) CompleteUpload(ctx context.Context, userID, uploadID uint) (map[string]any, error) { + v, err := s.uc.CompleteUpload(ctx, userID, uploadID, "") + if err != nil { + return nil, err + } + return mediaDTO(v), nil +} +func (s *MediaService) CancelUpload(ctx context.Context, userID, uploadID uint) error { + return s.uc.CancelUpload(ctx, userID, uploadID) +} diff --git a/internal/service/service.go b/internal/service/service.go index d4313fb..9ff21fa 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -3,4 +3,4 @@ package service import "github.com/google/wire" // ProviderSet is service providers. -var ProviderSet = wire.NewSet(NewAdminService) +var ProviderSet = wire.NewSet(NewAdminService, NewSystemService, NewAccessService, NewSettingsService, NewVersionService, NewExportService, NewAuditService, NewTaskService, NewMediaService) diff --git a/internal/service/settings.go b/internal/service/settings.go new file mode 100644 index 0000000..96680b1 --- /dev/null +++ b/internal/service/settings.go @@ -0,0 +1,274 @@ +package service + +import ( + "context" + "errors" + "sync" + "time" + + "kra/internal/biz" + "kra/internal/conf" + "kra/pkg/adminauth" +) + +type SettingsService struct { + uc *biz.SettingsUsecase + config *conf.AdminBackend + securityMu sync.RWMutex + securityCache *biz.SecurityConfig +} + +func NewSettingsService(uc *biz.SettingsUsecase, config *conf.AdminBackend) *SettingsService { + return &SettingsService{uc: uc, config: config} +} +func (s *SettingsService) Repo() biz.SettingsRepo { return s.uc.Repo() } + +func dictionaryDTO(v *biz.Dictionary) map[string]any { + children := make([]map[string]any, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, dictionaryDTO(x)) + } + details := make([]map[string]any, 0, len(v.Details)) + for _, x := range v.Details { + details = append(details, detailDTO(x)) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parentID": v.ParentID, "children": children, "sysDictionaryDetails": details} +} +func detailDTO(v *biz.DictionaryDetail) map[string]any { + children := make([]map[string]any, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, detailDTO(x)) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sysDictionaryID": v.DictionaryID, "parentID": v.ParentID, "level": v.Level, "path": v.Path, "disabled": !v.Status, "children": children} +} +func parameterDTO(v *biz.SystemParameter) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc} +} +func tokenDTO(v *biz.APIToken) map[string]any { + var user any = nil + if v.User != nil { + user = convertUser(v.User) + } + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "userId": v.UserID, "authorityId": v.AuthorityID, "token": v.Token, "status": v.Status, "expiresAt": v.ExpiresAt, "remark": v.Remark, "user": user} +} + +func (s *SettingsService) Dictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]map[string]any, int64, error) { + items, total, err := s.Repo().ListDictionaries(ctx, page, size, name, typ, details) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, dictionaryDTO(v)) + } + return out, total, nil +} +func (s *SettingsService) Dictionary(ctx context.Context, id uint, typ string, details bool) (map[string]any, error) { + v, err := s.Repo().FindDictionary(ctx, id, typ, details) + if err != nil { + return nil, err + } + return dictionaryDTO(v), nil +} +func (s *SettingsService) CreateDictionary(ctx context.Context, v *biz.Dictionary) error { + return s.Repo().CreateDictionary(ctx, v) +} +func (s *SettingsService) UpdateDictionary(ctx context.Context, v *biz.Dictionary) error { + return s.Repo().UpdateDictionary(ctx, v) +} +func (s *SettingsService) DeleteDictionary(ctx context.Context, id uint) error { + return s.Repo().DeleteDictionary(ctx, id) +} +func (s *SettingsService) DictionaryDetails(ctx context.Context, page, size int, dictionaryID uint, label, value string) ([]map[string]any, int64, error) { + items, total, err := s.Repo().ListDictionaryDetails(ctx, page, size, dictionaryID, label, value) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, detailDTO(v)) + } + return out, total, nil +} +func (s *SettingsService) DictionaryDetail(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.Repo().FindDictionaryDetail(ctx, id) + if err != nil { + return nil, err + } + return detailDTO(v), nil +} +func (s *SettingsService) DictionaryTree(ctx context.Context, id uint, typ string) ([]map[string]any, error) { + items, err := s.Repo().DictionaryDetailTree(ctx, id, typ) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, detailDTO(v)) + } + return out, nil +} +func (s *SettingsService) CreateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { + return s.Repo().CreateDictionaryDetail(ctx, v) +} +func (s *SettingsService) UpdateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { + return s.Repo().UpdateDictionaryDetail(ctx, v) +} +func (s *SettingsService) DeleteDictionaryDetail(ctx context.Context, id uint) error { + return s.Repo().DeleteDictionaryDetail(ctx, id) +} + +func (s *SettingsService) Parameters(ctx context.Context, page, size int, q *biz.SystemParameter) ([]map[string]any, int64, error) { + items, total, err := s.Repo().ListParameters(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, parameterDTO(v)) + } + return out, total, nil +} +func (s *SettingsService) Parameter(ctx context.Context, id uint, key string) (map[string]any, error) { + v, err := s.Repo().FindParameter(ctx, id, key) + if err != nil { + return nil, err + } + return parameterDTO(v), nil +} +func (s *SettingsService) CreateParameter(ctx context.Context, v *biz.SystemParameter) error { + return s.Repo().CreateParameter(ctx, v) +} +func (s *SettingsService) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { + return s.Repo().UpdateParameter(ctx, v) +} +func (s *SettingsService) DeleteParameters(ctx context.Context, ids []uint) error { + return s.Repo().DeleteParameters(ctx, ids) +} + +func (s *SettingsService) CreateAPIToken(ctx context.Context, userID, authorityID uint, days int, remark string) (string, error) { + user, allowed, err := s.Repo().UserHasAuthority(ctx, userID, authorityID) + if err != nil { + return "", err + } + if !allowed { + return "", errors.New("用户不具备该角色权限") + } + duration := time.Duration(days) * 24 * time.Hour + if days == -1 { + duration = 100 * 365 * 24 * time.Hour + } + if duration <= 0 { + return "", errors.New("有效天数必须大于0或为-1") + } + secret, issuer := "", "kra" + if s.config != nil && s.config.Jwt != nil { + secret = s.config.Jwt.SigningKey + if s.config.Jwt.Issuer != "" { + issuer = s.config.Jwt.Issuer + } + } + token, _, err := adminauth.Generate(secret, issuer, duration, user.ID, authorityID, user.UUID, user.Username, user.NickName, false) + if err != nil { + return "", err + } + record := &biz.APIToken{UserID: userID, AuthorityID: authorityID, Token: token, Status: true, ExpiresAt: time.Now().Add(duration), Remark: remark} + if err = s.Repo().CreateAPIToken(ctx, record); err != nil { + return "", err + } + return token, nil +} +func (s *SettingsService) APITokens(ctx context.Context, page, size int, userID uint, status *bool) ([]map[string]any, int64, error) { + items, total, err := s.Repo().ListAPITokens(ctx, page, size, userID, status) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, tokenDTO(v)) + } + return out, total, nil +} +func (s *SettingsService) DisableAPIToken(ctx context.Context, id uint) error { + _, err := s.Repo().DisableAPIToken(ctx, id) + return err +} +func (s *SettingsService) IsTokenDisabled(ctx context.Context, token string) (bool, error) { + return s.Repo().IsTokenDisabled(ctx, token) +} + +func securityDTO(v *biz.SecurityConfig) map[string]any { + return map[string]any{"ID": v.ID, "captchaOpen": v.CaptchaOpen, "captchaTimeout": v.CaptchaTimeout, "keyLong": v.KeyLong, "imgWidth": v.ImgWidth, "imgHeight": v.ImgHeight, "pwdMinLength": v.PwdMinLength, "pwdRequireUpper": v.PwdRequireUpper, "pwdRequireLower": v.PwdRequireLower, "pwdRequireDigit": v.PwdRequireDigit, "pwdRequireSpecial": v.PwdRequireSpecial, "limitEnable": v.LimitEnable, "limitWindow": v.LimitWindow, "limitCount": v.LimitCount, "lockEnable": v.LockEnable, "lockThreshold": v.LockThreshold, "lockDuration": v.LockDuration, "pwdExpireEnable": v.PwdExpireEnable, "pwdExpireDays": v.PwdExpireDays, "forceNewUserChangePassword": v.ForceNewUserChangePassword} +} +func (s *SettingsService) CurrentSecurity(ctx context.Context) (*biz.SecurityConfig, error) { + s.securityMu.RLock() + if s.securityCache != nil { + copy := *s.securityCache + s.securityMu.RUnlock() + return ©, nil + } + s.securityMu.RUnlock() + value, err := s.Repo().SecurityConfig(ctx) + if err != nil { + return nil, err + } + s.securityMu.Lock() + s.securityCache = value + copy := *value + s.securityMu.Unlock() + return ©, nil +} +func (s *SettingsService) Security(ctx context.Context) (map[string]any, error) { + value, err := s.CurrentSecurity(ctx) + if err != nil { + return nil, err + } + return securityDTO(value), nil +} +func (s *SettingsService) SaveSecurity(ctx context.Context, value *biz.SecurityConfig) (map[string]any, error) { + if value.KeyLong < 1 || value.ImgWidth < 1 || value.ImgHeight < 1 || value.PwdMinLength < 1 || value.LimitWindow < 1 || value.LimitCount < 1 || value.LockThreshold < 1 || value.LockDuration < 1 || value.PwdExpireDays < 1 { + return nil, errors.New("安全配置数值必须大于0") + } + if err := s.Repo().SaveSecurityConfig(ctx, value); err != nil { + return nil, err + } + s.securityMu.Lock() + s.securityCache = value + s.securityMu.Unlock() + return securityDTO(value), nil +} +func (s *SettingsService) ValidatePassword(ctx context.Context, password string) error { + cfg, err := s.CurrentSecurity(ctx) + if err != nil { + return err + } + if len([]rune(password)) < cfg.PwdMinLength { + return errors.New("密码长度不足") + } + hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false + for _, ch := range password { + switch { + case ch >= 'A' && ch <= 'Z': + hasUpper = true + case ch >= 'a' && ch <= 'z': + hasLower = true + case ch >= '0' && ch <= '9': + hasDigit = true + default: + hasSpecial = true + } + } + if cfg.PwdRequireUpper && !hasUpper { + return errors.New("密码必须包含大写字母") + } + if cfg.PwdRequireLower && !hasLower { + return errors.New("密码必须包含小写字母") + } + if cfg.PwdRequireDigit && !hasDigit { + return errors.New("密码必须包含数字") + } + if cfg.PwdRequireSpecial && !hasSpecial { + return errors.New("密码必须包含特殊字符") + } + return nil +} diff --git a/internal/service/system.go b/internal/service/system.go new file mode 100644 index 0000000..0c707b4 --- /dev/null +++ b/internal/service/system.go @@ -0,0 +1,244 @@ +package service + +import ( + "context" + "time" + + "kra/internal/biz" + "kra/internal/conf" + "kra/pkg/adminauth" +) + +type SystemService struct { + uc *biz.SystemUsecase + config *conf.AdminBackend + settings *SettingsService +} + +type LoginResult struct { + User map[string]any `json:"user"` + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` + NeedChangePassword bool `json:"needChangePassword"` +} + +func NewSystemService(uc *biz.SystemUsecase, config *conf.AdminBackend, settings *SettingsService) *SystemService { + return &SystemService{uc: uc, config: config, settings: settings} +} + +func (s *SystemService) IsInitialized(ctx context.Context) (bool, error) { + return s.uc.IsInitialized(ctx) +} +func (s *SystemService) Initialize(ctx context.Context) error { return s.uc.Initialize(ctx) } + +func (s *SystemService) CacheGet(ctx context.Context, key string) (string, bool, error) { + return s.uc.CacheGet(ctx, key) +} +func (s *SystemService) CacheSet(ctx context.Context, key, value string, expiration time.Duration) error { + return s.uc.CacheSet(ctx, key, value, expiration) +} +func (s *SystemService) CacheDelete(ctx context.Context, key string) error { + return s.uc.CacheDelete(ctx, key) +} + +func (s *SystemService) Login(ctx context.Context, username, password string) (*LoginResult, error) { + u, err := s.uc.Login(ctx, username, password) + if err != nil { + return nil, err + } + if u.Enable != 1 { + return nil, biz.ErrInvalidCredentials + } + if security, securityErr := s.settings.CurrentSecurity(ctx); securityErr == nil && security.PwdExpireEnable && u.PasswordUpdatedAt != nil && time.Since(*u.PasswordUpdatedAt) > time.Duration(security.PwdExpireDays)*24*time.Hour { + u.MustChangePassword = true + } + expires := 7 * 24 * time.Hour + secret, issuer := "", "kra" + if s.config != nil && s.config.Jwt != nil { + secret, issuer = s.config.Jwt.SigningKey, s.config.Jwt.Issuer + if s.config.Jwt.ExpiresTime != nil { + expires = s.config.Jwt.ExpiresTime.AsDuration() + } + } + token, claims, err := adminauth.Generate(secret, issuer, expires, u.ID, u.AuthorityID, u.UUID, u.Username, u.NickName, u.MustChangePassword) + if err != nil { + return nil, err + } + return &LoginResult{User: convertUser(u), Token: token, ExpiresAt: claims.ExpiresAt.UnixMilli(), NeedChangePassword: u.MustChangePassword}, nil +} + +func (s *SystemService) User(ctx context.Context, id uint) (map[string]any, error) { + u, err := s.uc.User(ctx, id) + if err != nil { + return nil, err + } + return convertUser(u), nil +} + +func (s *SystemService) Menus(ctx context.Context, authorityID uint) ([]map[string]any, error) { + menus, err := s.uc.Menus(ctx, authorityID) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(menus)) + for _, menu := range menus { + result = append(result, convertMenu(menu)) + } + return result, nil +} + +type UserInput struct { + ID uint + Username, Password, NickName, HeaderImg, Phone, Email string + AuthorityID uint + Enable int + AuthorityIDs []uint +} + +func (s *SystemService) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]map[string]any, int64, error) { + users, total, err := s.uc.ListUsers(ctx, page, pageSize, filter) + if err != nil { + return nil, 0, err + } + result := make([]map[string]any, 0, len(users)) + for _, u := range users { + result = append(result, convertUser(u)) + } + return result, total, nil +} +func (s *SystemService) CreateUser(ctx context.Context, in UserInput) (map[string]any, error) { + if err := s.settings.ValidatePassword(ctx, in.Password); err != nil { + return nil, err + } + security, _ := s.settings.CurrentSecurity(ctx) + mustChange := security != nil && security.ForceNewUserChangePassword + if in.AuthorityID == 0 && len(in.AuthorityIDs) > 0 { + in.AuthorityID = in.AuthorityIDs[0] + } + in.AuthorityIDs = includeAuthority(in.AuthorityIDs, in.AuthorityID) + u, err := s.uc.CreateUser(ctx, &biz.User{Username: in.Username, Password: in.Password, NickName: in.NickName, HeaderImg: in.HeaderImg, AuthorityID: in.AuthorityID, Phone: in.Phone, Email: in.Email, Enable: in.Enable, MustChangePassword: mustChange}) + if err != nil { + return nil, err + } + if len(in.AuthorityIDs) > 0 { + if err = s.uc.SetUserAuthorities(ctx, u.ID, in.AuthorityIDs); err != nil { + return nil, err + } + u, err = s.uc.User(ctx, u.ID) + if err != nil { + return nil, err + } + } + return convertUser(u), nil +} +func (s *SystemService) UpdateUser(ctx context.Context, in UserInput) error { + in.AuthorityIDs = includeAuthority(in.AuthorityIDs, in.AuthorityID) + if err := s.uc.UpdateUser(ctx, &biz.User{ID: in.ID, NickName: in.NickName, HeaderImg: in.HeaderImg, AuthorityID: in.AuthorityID, Phone: in.Phone, Email: in.Email, Enable: in.Enable}); err != nil { + return err + } + if len(in.AuthorityIDs) > 0 { + return s.uc.SetUserAuthorities(ctx, in.ID, in.AuthorityIDs) + } + return nil +} +func includeAuthority(ids []uint, primary uint) []uint { + if primary == 0 { + return ids + } + for _, id := range ids { + if id == primary { + return ids + } + } + return append(ids, primary) +} +func (s *SystemService) DeleteUser(ctx context.Context, id uint) error { + return s.uc.DeleteUser(ctx, id) +} +func (s *SystemService) ResetPassword(ctx context.Context, id uint, password string) error { + if err := s.settings.ValidatePassword(ctx, password); err != nil { + return err + } + return s.uc.ResetPassword(ctx, id, password) +} +func (s *SystemService) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error { + if err := s.settings.ValidatePassword(ctx, newPassword); err != nil { + return err + } + return s.uc.ChangePassword(ctx, id, oldPassword, newPassword) +} +func (s *SystemService) Authorities(ctx context.Context) ([]map[string]any, error) { + authorities, err := s.uc.Authorities(ctx) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(authorities)) + for _, a := range authorities { + result = append(result, convertAuthority(*a)) + } + return result, nil +} +func (s *SystemService) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { + return s.uc.SetUserAuthorities(ctx, id, authorityIDs) +} +func (s *SystemService) SwitchAuthority(ctx context.Context, id, authorityID uint) (*LoginResult, error) { + if err := s.uc.SetUserAuthority(ctx, id, authorityID); err != nil { + return nil, err + } + u, err := s.uc.User(ctx, id) + if err != nil { + return nil, err + } + expires := 7 * 24 * time.Hour + secret, issuer := "", "kra" + if s.config != nil && s.config.Jwt != nil { + secret, issuer = s.config.Jwt.SigningKey, s.config.Jwt.Issuer + if s.config.Jwt.ExpiresTime != nil { + expires = s.config.Jwt.ExpiresTime.AsDuration() + } + } + token, claims, err := adminauth.Generate(secret, issuer, expires, u.ID, authorityID, u.UUID, u.Username, u.NickName, u.MustChangePassword) + if err != nil { + return nil, err + } + return &LoginResult{User: convertUser(u), Token: token, ExpiresAt: claims.ExpiresAt.UnixMilli(), NeedChangePassword: u.MustChangePassword}, nil +} +func (s *SystemService) SetUserSetting(ctx context.Context, id uint, setting map[string]any) error { + return s.uc.SetUserSetting(ctx, id, setting) +} + +func convertAuthority(a biz.Authority) map[string]any { + return map[string]any{"authorityId": a.AuthorityID, "authorityName": a.AuthorityName, "parentId": a.ParentID, "children": []map[string]any{}, "menus": []any{}, "dataScope": a.DataScope, "defaultRouter": a.DefaultRouter} +} + +func convertUser(u *biz.User) map[string]any { + authorities := make([]map[string]any, 0, len(u.Authorities)) + for _, a := range u.Authorities { + authorities = append(authorities, convertAuthority(a)) + } + departments := make([]map[string]any, 0, len(u.Departments)) + for i := range u.Departments { + departments = append(departments, departmentDTO(&u.Departments[i])) + } + positions := make([]map[string]any, 0, len(u.Positions)) + for i := range u.Positions { + positions = append(positions, positionDTO(&u.Positions[i])) + } + dept := map[string]any{} + if u.Department != nil { + dept = departmentDTO(u.Department) + } + return map[string]any{"ID": u.ID, "CreatedAt": u.CreatedAt, "UpdatedAt": u.UpdatedAt, "DeletedAt": nil, "uuid": u.UUID, "userName": u.Username, "nickName": u.NickName, "headerImg": u.HeaderImg, "authorityId": u.AuthorityID, "authority": convertAuthority(u.Authority), "authorities": authorities, "deptId": u.DeptID, "dept": dept, "departments": departments, "positions": positions, "phone": u.Phone, "email": u.Email, "enable": u.Enable, "originSetting": u.OriginSetting} +} + +func convertMenu(menu *biz.Menu) map[string]any { + children := make([]map[string]any, 0, len(menu.Children)) + for _, child := range menu.Children { + children = append(children, convertMenu(child)) + } + buttons := make([]map[string]any, 0, len(menu.Buttons)) + for _, button := range menu.Buttons { + buttons = append(buttons, map[string]any{"ID": button.ID, "name": button.Name, "desc": button.Description, "sysBaseMenuID": button.MenuID}) + } + return map[string]any{"ID": menu.ID, "parentId": menu.ParentID, "path": menu.Path, "name": menu.Name, "hidden": menu.Hidden, "component": menu.Component, "sort": menu.Sort, "meta": map[string]any{"activeName": menu.ActiveName, "keepAlive": menu.KeepAlive, "defaultMenu": menu.DefaultMenu, "title": menu.Title, "icon": menu.Icon, "closeTab": menu.CloseTab, "transitionType": menu.TransitionType}, "children": children, "parameters": []any{}, "menuBtn": buttons} +} diff --git a/internal/service/task.go b/internal/service/task.go new file mode 100644 index 0000000..bdc0eb7 --- /dev/null +++ b/internal/service/task.go @@ -0,0 +1,194 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/robfig/cron/v3" + "io" + "kra/internal/biz" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +type TaskService struct{ uc *biz.TaskUsecase } + +func NewTaskService(uc *biz.TaskUsecase) *TaskService { return &TaskService{uc: uc} } +func (s *TaskService) Validate(v *biz.TimedTask) error { + if v.Name == "" { + return errors.New("任务名不能为空") + } + var err error + if v.WithSeconds { + _, err = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(v.Spec) + } else { + _, err = cron.ParseStandard(v.Spec) + } + if err != nil { + return fmt.Errorf("cron 表达式非法: %w", err) + } + switch v.ExecutorType { + case "method": + if v.MethodName != "ClearDB" { + return errors.New("方法未注册") + } + if len(v.Params) > 0 && !json.Valid(v.Params) { + return errors.New("params 必须是合法 JSON") + } + case "http": + parsed, parseErr := url.Parse(v.HTTPURL) + if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil { + return errors.New("httpUrl 必须是合法的 http/https 地址") + } + if len(v.HTTPHeader) > 0 { + headers := map[string]string{} + if json.Unmarshal(v.HTTPHeader, &headers) != nil { + return errors.New("httpHeader 必须是 JSON 对象") + } + } + default: + return errors.New("executorType 必须为 method 或 http") + } + return nil +} +func (s *TaskService) Create(ctx context.Context, v *biz.TimedTask) error { + if err := s.Validate(v); err != nil { + return err + } + return s.uc.Repo().CreateTask(ctx, v) +} +func (s *TaskService) Update(ctx context.Context, v *biz.TimedTask) error { + if v.ID == 0 { + return errors.New("缺少任务 ID") + } + if err := s.Validate(v); err != nil { + return err + } + return s.uc.Repo().UpdateTask(ctx, v) +} +func (s *TaskService) Delete(ctx context.Context, id uint) error { + return s.uc.Repo().DeleteTask(ctx, id) +} +func (s *TaskService) Toggle(ctx context.Context, id uint, enabled bool) error { + return s.uc.Repo().ToggleTask(ctx, id, enabled) +} +func (s *TaskService) Task(ctx context.Context, id uint) (*biz.TimedTask, error) { + return s.uc.Repo().FindTask(ctx, id) +} +func taskDTO(v *biz.TimedTask, next *time.Time) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "name": v.Name, "description": v.Description, "spec": v.Spec, "withSeconds": v.WithSeconds, "executorType": v.ExecutorType, "methodName": v.MethodName, "params": json.RawMessage(v.Params), "httpUrl": v.HTTPURL, "httpMethod": v.HTTPMethod, "httpHeader": json.RawMessage(v.HTTPHeader), "httpBody": v.HTTPBody, "httpAllowPrivate": v.HTTPAllowPrivate, "enabled": v.Enabled, "nextRunAt": next} +} +func (s *TaskService) Tasks(ctx context.Context, page, size int, q *biz.TimedTask, next map[uint]time.Time) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListTasks(ctx, page, size, q) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + var ptr *time.Time + if value, ok := next[v.ID]; ok { + copy := value + ptr = © + } + out = append(out, taskDTO(v, ptr)) + } + return out, total, nil +} +func (s *TaskService) Logs(ctx context.Context, page, size int, taskID uint, status string) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListTaskLogs(ctx, page, size, taskID, status) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "DeletedAt": nil, "taskId": v.TaskID, "taskName": v.TaskName, "triggerType": v.TriggerType, "startedAt": v.StartedAt, "finishedAt": v.FinishedAt, "durationMs": v.DurationMS, "status": v.Status, "errorMsg": v.ErrorMsg, "output": v.Output}) + } + return out, total, nil +} +func privateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() +} +func (s *TaskService) runHTTP(ctx context.Context, v *biz.TimedTask) (string, error) { + parsed, _ := url.Parse(v.HTTPURL) + if !v.HTTPAllowPrivate { + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", parsed.Hostname()) + if err != nil { + return "", err + } + for _, ip := range ips { + if privateIP(ip) { + return "", errors.New("禁止访问内网或环回地址") + } + } + } + method := strings.ToUpper(v.HTTPMethod) + if method == "" { + method = http.MethodGet + } + request, err := http.NewRequestWithContext(ctx, method, v.HTTPURL, bytes.NewBufferString(v.HTTPBody)) + if err != nil { + return "", err + } + headers := map[string]string{} + _ = json.Unmarshal(v.HTTPHeader, &headers) + for key, value := range headers { + request.Header.Set(key, value) + } + response, err := (&http.Client{Timeout: 30 * time.Second}).Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + output := fmt.Sprintf("HTTP %d: %s", response.StatusCode, string(body)) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return output, fmt.Errorf("HTTP 状态码 %d", response.StatusCode) + } + return output, nil +} +func (s *TaskService) Run(ctx context.Context, v *biz.TimedTask, trigger string) (log *biz.TimedTaskLog) { + started := time.Now() + log = &biz.TimedTaskLog{TaskID: v.ID, TaskName: v.Name, TriggerType: trigger, StartedAt: started, Status: "success"} + defer func() { + if recovered := recover(); recovered != nil { + log.Status = "fail" + log.ErrorMsg = fmt.Sprint(recovered) + } + log.FinishedAt = time.Now() + log.DurationMS = log.FinishedAt.Sub(started).Milliseconds() + _ = s.uc.Repo().RecordTaskLog(context.Background(), log) + }() + runCtx, cancel := context.WithTimeout(ctx, 35*time.Second) + defer cancel() + var err error + switch v.ExecutorType { + case "method": + if v.MethodName == "ClearDB" { + days := 30 + var params struct { + Days int `json:"days"` + } + _ = json.Unmarshal(v.Params, ¶ms) + if params.Days > 0 { + days = params.Days + } + err = s.uc.Repo().CleanupLogs(runCtx, time.Now().AddDate(0, 0, -days)) + log.Output = "过期日志清理完成" + } + case "http": + log.Output, err = s.runHTTP(runCtx, v) + } + if err != nil { + log.Status = "fail" + log.ErrorMsg = err.Error() + } + return log +} +func (s *TaskService) RegisteredMethods() []map[string]any { + return []map[string]any{{"name": "ClearDB", "description": "清理数据库过期日志(操作记录、登录日志、定时任务日志)"}} +} diff --git a/internal/service/version.go b/internal/service/version.go new file mode 100644 index 0000000..e90850e --- /dev/null +++ b/internal/service/version.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "encoding/json" + "time" + + "kra/internal/biz" +) + +type VersionService struct{ uc *biz.VersionUsecase } + +func NewVersionService(uc *biz.VersionUsecase) *VersionService { return &VersionService{uc: uc} } +func versionDTO(v *biz.Version) map[string]any { + return map[string]any{"ID": v.ID, "CreatedAt": v.CreatedAt, "UpdatedAt": v.UpdatedAt, "DeletedAt": nil, "versionName": v.Name, "versionCode": v.Code, "description": v.Description, "versionData": v.Data} +} +func (s *VersionService) Versions(ctx context.Context, page, size int, name, code string) ([]map[string]any, int64, error) { + items, total, err := s.uc.Repo().ListVersions(ctx, page, size, name, code) + if err != nil { + return nil, 0, err + } + out := make([]map[string]any, 0, len(items)) + for _, v := range items { + out = append(out, versionDTO(v)) + } + return out, total, nil +} +func (s *VersionService) Version(ctx context.Context, id uint) (map[string]any, error) { + v, err := s.uc.Repo().FindVersion(ctx, id) + if err != nil { + return nil, err + } + return versionDTO(v), nil +} +func (s *VersionService) VersionData(ctx context.Context, id uint) (string, string, error) { + v, err := s.uc.Repo().FindVersion(ctx, id) + if err != nil { + return "", "", err + } + return v.Data, v.Code, nil +} +func (s *VersionService) DeleteVersions(ctx context.Context, ids []uint) error { + return s.uc.Repo().DeleteVersions(ctx, ids) +} +func (s *VersionService) Export(ctx context.Context, name, code, description string, menuIDs, apiIDs, dictIDs []uint) error { + bundle, err := s.uc.Repo().BuildVersionBundle(ctx, menuIDs, apiIDs, dictIDs) + if err != nil { + return err + } + bundle.Name = name + bundle.Code = code + bundle.Description = description + bundle.ExportTime = time.Now().Format("2006-01-02 15:04:05") + menus := make([]map[string]any, 0, len(bundle.Menus)) + for _, v := range bundle.Menus { + menus = append(menus, convertMenu(v)) + } + apis := make([]map[string]any, 0, len(bundle.APIs)) + for _, v := range bundle.APIs { + apis = append(apis, apiDTO(v)) + } + dicts := make([]map[string]any, 0, len(bundle.Dictionaries)) + for _, v := range bundle.Dictionaries { + dicts = append(dicts, dictionaryDTO(v)) + } + payload := map[string]any{"version": map[string]any{"name": name, "code": code, "description": description, "exportTime": bundle.ExportTime}, "menus": menus, "apis": apis, "dictionaries": dicts} + raw, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + return s.uc.Repo().CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}) +} +func (s *VersionService) Import(ctx context.Context, bundle *biz.VersionBundle) error { + return s.uc.Repo().ImportVersionBundle(ctx, bundle) +} diff --git a/pkg/adminauth/token.go b/pkg/adminauth/token.go new file mode 100644 index 0000000..6efe25b --- /dev/null +++ b/pkg/adminauth/token.go @@ -0,0 +1,46 @@ +package adminauth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UUID string + ID uint + Username string + NickName string + AuthorityID uint `json:"AuthorityId"` + BufferTime int64 + MustChangePwd bool `json:"mustChangePwd"` + jwt.RegisteredClaims +} + +func Generate(secret, issuer string, expires time.Duration, userID, authorityID uint, uuid, username, nickname string, mustChange bool) (string, *Claims, error) { + if secret == "" { + return "", nil, errors.New("empty JWT signing key") + } + now := time.Now() + claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Issuer: issuer, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-time.Second)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + return token, claims, err +} + +func Parse(tokenString, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method") + } + return []byte(secret), nil + }) + if err != nil || !token.Valid { + return nil, errors.New("token is invalid") + } + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, errors.New("token claims are invalid") + } + return claims, nil +}