Compare commits
9 Commits
54135d2be5
...
061dbde8ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061dbde8ce | ||
|
|
43fa79686d | ||
|
|
1d632065fe | ||
|
|
75e56dae43 | ||
|
|
8fdb25e6bd | ||
|
|
5cd8f0f551 | ||
|
|
82b1ad2b5c | ||
|
|
1486bb903f | ||
|
|
8d5f2c65ae |
5
.gitignore
vendored
5
.gitignore
vendored
@ -17,3 +17,8 @@ temp/
|
|||||||
temp.yaml
|
temp.yaml
|
||||||
bin
|
bin
|
||||||
**/config/config.yaml
|
**/config/config.yaml
|
||||||
|
|
||||||
|
# WorkBuddy 本地记忆(不入库,跨账号上下文见 AGENTS.md 与 docs/change-log/)
|
||||||
|
.workbuddy/
|
||||||
|
# server runtime logs
|
||||||
|
log/
|
||||||
|
|||||||
118
AGENTS.md
Normal file
118
AGENTS.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# AGENTS.md — 项目智能体说明书
|
||||||
|
|
||||||
|
> 本文件是给所有 AI 编程助手(CodeBuddy / WorkBuddy / Claude Code / Codex / Cursor 等)看的项目级上下文。
|
||||||
|
> 任何账号 clone 本仓库后,助手都应先读本文件与 `docs/change-log/` 下最近的记录,即可无缝衔接。
|
||||||
|
> 请保持本文件**长期稳定**:只写「架构、规范、约定」,不要写一次性事项。
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
`service.xpcool.com`:个人多客户端(mini / h5 / app)后端服务,GoFrame v2 单体应用。
|
||||||
|
核心业务:用户认证(JWT)、内容、收藏、站内消息;后台管理(RBAC + 操作审计)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 语言 | Go 1.23.0 |
|
||||||
|
| 框架 | github.com/gogf/gf/v2 v2.10.2 |
|
||||||
|
| 数据库 | MySQL(ORM 由 gf 生成 dao/do/entity) |
|
||||||
|
| 认证 | JWT(`internal/library/jwt`),admin 另有 `X-Permission` 校验 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
service.xpcool.com/
|
||||||
|
├── api/ # HTTP 契约与 Swagger 元数据
|
||||||
|
│ ├── user/v1/ # /api/v1 用户端 API(登录公开,其余走 UserAuth)
|
||||||
|
│ ├── admin/v1/ # /admin/v1 管理端 API(AdminAuth + X-Permission)
|
||||||
|
│ └── open/v1/ # /api/open/v1 开放接口(给前端调用,公开无鉴权)
|
||||||
|
│ └── tools/ # 工具子功能契约,每子功能一目录:tools/<name>/<name>.go
|
||||||
|
├── common/ # 公共可复用模块(不依赖 internal,可独立抽取成库)
|
||||||
|
│ └── tools/ # 工具模块,按子功能分包(见下文)
|
||||||
|
├── internal/
|
||||||
|
│ ├── cmd/ # 启动引导(路由分组注册在此)
|
||||||
|
│ ├── consts/ # 错误码与常量
|
||||||
|
│ ├── controller/ # API→service 适配层(不写业务;含 open/ 开放接口实现)
|
||||||
|
│ ├── service/ # 领域用例(业务逻辑直接写这里,不用 logic/)
|
||||||
|
│ ├── dao/ # gf gen dao 生成,禁止手改
|
||||||
|
│ ├── model/ # entity/ do/ dto/ vo/(entity、do 生成,禁止手改)
|
||||||
|
│ ├── middleware/ # 路由中间件
|
||||||
|
│ ├── library/ # jwt / page / response 等内部基础件
|
||||||
|
│ └── table/ # 表列名常量
|
||||||
|
├── manifest/ # config.dev/test/prod.yaml、sql 迁移
|
||||||
|
├── docs/change-log/ # 每次请求与变更的记录(重要!见「上下文记忆」)
|
||||||
|
└── utility/ # (预留)跨切面辅助
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后台管理 API(api/admin/v1,按 base/system/admin 分组)
|
||||||
|
|
||||||
|
- **分组规则**:`api/admin/v1/{base,system,admin}/` 三个子包,路由前缀 `/admin/v1/{base,system,admin}`:
|
||||||
|
- **base**(基础常规):`/base/log/*`(服务器日志监控)
|
||||||
|
- **system**(系统管理):`/system/auth/*`(登录/信息/权限码)、`/system/menu/*`(菜单路由+CRUD)、`/system/role/*`(角色 CRUD)
|
||||||
|
- **admin**(后台管理):`/admin`(管理员账号 CRUD)
|
||||||
|
- 三层路由隔离(`internal/cmd/cmd.go`):
|
||||||
|
- **公开**(无鉴权):`POST /system/auth/login`
|
||||||
|
- **仅登录** `AdminAuthOnly`:`GET /system/auth/info`、`GET /system/auth/codes`、`GET /system/menu/routes`
|
||||||
|
- **接口级鉴权** `AdminAuth`:RBAC 管理、日志等
|
||||||
|
- **接口鉴权机制(重要)**:中间件按「请求方法+路径」从 `admin_menu`(type=2 行,path 存 `"METHOD /路径"`,`{id}` 为动态段)反查所需权限码,再校验用户是否拥有。**前端无需传 X-Permission**;未配置映射的接口一律拒绝。
|
||||||
|
- 权限码(permission)与路由分离:权限码保持 `system:admin:list` 等逻辑标识,路由路径按 base/system/admin 分组。
|
||||||
|
- 新增受保护接口三步:① `api/admin/v1/{分组}/<xxx>.go` 写 Req/Res;② `internal/controller/admin/<xxx>.go` 加方法;③ 在 `admin_menu` 加 type=2 行:`permission` 填权限码、`path` 填 `"METHOD /路径"` 映射。
|
||||||
|
- 迁移脚本:`003_schema_ext.sql`(admin_menu 加列)、`004_seed.sql`(初始账号/角色/菜单)、`005_menu_paths.sql`、`006_menu_paths_v2.sql`(按钮-接口路径映射,006 为分组重构后)。
|
||||||
|
|
||||||
|
## 开放接口(api/open/v1,前端调用)与命名规则
|
||||||
|
|
||||||
|
- **命名决策**:公共接口前缀用 **open**(不用 common)。理由:`common` 语义偏"内部公共代码",`open` 是开放接口业界惯例(支付宝 /open/api 等),更能表达"对外暴露、无鉴权"。同属"公开"语义的备选还有 `public`。
|
||||||
|
- 路由前缀 `/api/open/v1`,**公开、无鉴权**,实现于 `internal/controller/open`。
|
||||||
|
- **tools 子功能目录规则**:`api/open/v1/tools/<子功能名>/<子功能名>.go` 定义该子功能的 Req/Res 契约(目录名=包名=文件名三一致);控制器 `internal/controller/open/<子功能名>.go` 放对应方法(controller 统一 `package open`)。子功能变大后按端点/子领域在目录内**加文件**(如 ocr 目录下 `ocr.go` → `ocr.go + idcard.go + invoice.go`),不要堆在一个文件里。
|
||||||
|
- 当前端点:`GET/POST /tools/*`(uuid、md5、random、time、ip),底层复用 `common/tools` Go 包。
|
||||||
|
- **新增子功能三步**:① `api/open/v1/tools/<name>/<name>.go` 写 Req/Res(g.Meta 带 path/method);② `internal/controller/open/<name>.go` 加方法;③ 路由自动绑定,无需改 cmd.go。
|
||||||
|
|
||||||
|
## 公共工具模块 common/tools
|
||||||
|
|
||||||
|
- 规则:**不依赖 internal/**,只薄封装 GoFrame 内置组件;新工具优先复用内置(gmd5/gaes/gdes/guid/grand/gtime/gconv/gstr/gfile...),避免重复造轮子。
|
||||||
|
- 子功能:`md5`、`cryptox`(AES/DES)、`uuid`、`random`、`timex`、`convertx`、`strx`、`slicex`、`ip`、`filex`。
|
||||||
|
- 新增子功能:在 `common/tools/` 下建子包,更新 `common/tools/doc.go` 的布局清单。
|
||||||
|
|
||||||
|
## 分层与调用规范(必须遵守)
|
||||||
|
|
||||||
|
1. 调用链:`controller → service → dao → model(do)`;controller 不碰 dao。
|
||||||
|
2. DTO/VO 边界:跨层出入参走 `internal/model/dto` 与 `internal/model/vo`,API 类型与 entity 不得越界。
|
||||||
|
3. **数据库操作必须用 DO 对象**(`internal/model/do`),禁止 `g.Map`;未赋值字段保持 nil 自动忽略:
|
||||||
|
```go
|
||||||
|
dao.Users.Ctx(ctx).Where(cols.Id, id).Data(do.User{Uid: uid}).Update()
|
||||||
|
```
|
||||||
|
4. **时间字段自动维护**:`created_at/updated_at/deleted_at` 由 ORM 自动处理,禁止手动赋值;软删除用 `Delete()`,禁止手写 `WhereNull(cols.DeletedAt)`。
|
||||||
|
5. **错误处理一律用 gerror**(保留堆栈);响应统一走 `internal/library/response`。
|
||||||
|
6. 生成代码(dao/do/entity)**禁止手改**,改表后跑 `gf gen dao` 重新生成。
|
||||||
|
7. 声明 ≥3 个相关变量时,用 `var (...)` 块对齐。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行(dev)
|
||||||
|
GF_GCFG_FILE=config.dev.yaml DB_DSN="user:pass@tcp(127.0.0.1:3306)/db?loc=Local" JWT_SECRET=xxx go run main.go
|
||||||
|
# 数据库模型生成(唯一来源)
|
||||||
|
gf gen dao -p internal -g default -gt -c
|
||||||
|
# 构建 / 测试
|
||||||
|
go build ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 上下文记忆(重要)
|
||||||
|
|
||||||
|
三层配合,保证「换个账号/换台机器也能无缝衔接」:
|
||||||
|
|
||||||
|
1. **本文件(AGENTS.md)**:长期稳定的架构与规范。
|
||||||
|
2. **`docs/change-log/YYYY-MM-DD.md`**:每次对话的「请求 + 变更 + 决策」记录,随 git 提交。
|
||||||
|
- 每次完成任务后,若 `docs/change-log/` 已有当日文件则**追加**,否则新建;
|
||||||
|
- 格式固定:`## 请求` / `## 变更`(含文件清单)/ `## 决策与理由` / `## 待办与风险`;
|
||||||
|
- 助手开工前先读最近 1-2 篇,快速恢复上下文。
|
||||||
|
3. **`.workbuddy/memory/`**:WorkBuddy 桌面端本机记忆(每日日志 + MEMORY.md),**已加入 .gitignore,不入库**,仅本机增强。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- ⚠️ **gtime v2.10.2 格式化**:`Time.Format("Y-m-d H:i:s")` 是 PHP 风格;传 Go layout(`2006-01-02`)要用 `Time.Layout(...)`。工具包 `common/tools/timex` 已统一封装。
|
||||||
|
- ⚠️ **gf v2.10.2 包名与旧版不同**:AES/DES 在 `crypto/gaes`、`crypto/gdes`(无 gcrypto);UUID 在 `util/guid`(无 guuid);无 gslicer(用标准库 slices)。
|
||||||
|
- 配置文件按 `GF_GCFG_FILE` 切换;`manifest/config/config.yaml` 不入库。
|
||||||
|
- 生产环境强密码:`JWT_SECRET`、数据库口令。
|
||||||
|
- 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释。
|
||||||
@ -4,11 +4,16 @@
|
|||||||
service.xpcool.com/
|
service.xpcool.com/
|
||||||
├── api/ # HTTP contracts and Swagger metadata
|
├── api/ # HTTP contracts and Swagger metadata
|
||||||
│ ├── user/v1/ # /api/v1 - client-facing API
|
│ ├── user/v1/ # /api/v1 - client-facing API
|
||||||
│ └── admin/v1/ # /admin/v1 - administration API
|
│ ├── admin/v1/ # /admin/v1 - administration API
|
||||||
|
│ └── open/v1/ # /api/open/v1 - open API for frontends (no auth)
|
||||||
|
│ └── tools/ # one dir per sub-feature: tools/<name>/<name>.go
|
||||||
|
├── common/ # public reusable module (no internal/ deps)
|
||||||
|
│ └── tools/ # utility toolbox: md5, cryptox, uuid, random,
|
||||||
|
│ # timex, convertx, strx, slicex, ip, filex
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── cmd/ # application bootstrap and route isolation
|
│ ├── cmd/ # application bootstrap and route isolation
|
||||||
│ ├── consts/ # application error codes and constants
|
│ ├── consts/ # application error codes and constants
|
||||||
│ ├── controller/ # API-to-service adapters only
|
│ ├── controller/ # API-to-service adapters only (incl. open/ open API)
|
||||||
│ ├── service/ # domain use cases and provider interfaces
|
│ ├── service/ # domain use cases and provider interfaces
|
||||||
│ ├── dao/ # generated by gf gen dao; never hand edited
|
│ ├── dao/ # generated by gf gen dao; never hand edited
|
||||||
│ ├── model/
|
│ ├── model/
|
||||||
@ -18,6 +23,7 @@ service.xpcool.com/
|
|||||||
│ │ └── vo/ # API view models
|
│ │ └── vo/ # API view models
|
||||||
│ ├── middleware/ # configurable route-group middleware
|
│ ├── middleware/ # configurable route-group middleware
|
||||||
│ └── library/ # JWT, response, pagination primitives
|
│ └── library/ # JWT, response, pagination primitives
|
||||||
|
├── docs/change-log/ # per-request change records (agent context)
|
||||||
├── manifest/
|
├── manifest/
|
||||||
│ ├── config/ # config.dev/test/prod.yaml
|
│ ├── config/ # config.dev/test/prod.yaml
|
||||||
│ └── sql/ # ordered MySQL migrations
|
│ └── sql/ # ordered MySQL migrations
|
||||||
@ -25,3 +31,5 @@ service.xpcool.com/
|
|||||||
```
|
```
|
||||||
|
|
||||||
`dao`, `model/do` and `model/entity` are generated after migration, so their schema never drifts from MySQL. Controllers do not access DAO; only services do.
|
`dao`, `model/do` and `model/entity` are generated after migration, so their schema never drifts from MySQL. Controllers do not access DAO; only services do.
|
||||||
|
|
||||||
|
See `AGENTS.md` for the agent-facing conventions and the context-memory system.
|
||||||
|
|||||||
74
api/admin/v1/admin/admin.go
Normal file
74
api/admin/v1/admin/admin.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// Package admin 定义后台管理接口(管理员账号管理),路由前缀 /admin/v1/admin。
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// AdminItem is one row of the admin list.
|
||||||
|
type AdminItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
RoleIds []uint64 `json:"roleIds"`
|
||||||
|
RoleNames []string `json:"roleNames"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListReq pages the administrators.
|
||||||
|
type AdminListReq struct {
|
||||||
|
g.Meta `path:"/admin" method:"get" tags:"Admin/Admin" summary:"Admin list"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Keyword string `json:"keyword"` // matches username or nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListRes is the response of AdminListReq.
|
||||||
|
type AdminListRes struct {
|
||||||
|
List []*AdminItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateReq creates an administrator.
|
||||||
|
type AdminCreateReq struct {
|
||||||
|
g.Meta `path:"/admin" method:"post" tags:"Admin/Admin" summary:"Create admin"`
|
||||||
|
Username string `json:"username" v:"required"`
|
||||||
|
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
RoleIds []uint64 `json:"roleIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateRes is the response of AdminCreateReq.
|
||||||
|
type AdminCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateReq updates profile/status/roles of an administrator.
|
||||||
|
type AdminUpdateReq struct {
|
||||||
|
g.Meta `path:"/admin/{id}" method:"put" tags:"Admin/Admin" summary:"Update admin"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
RoleIds []uint64 `json:"roleIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateRes is the response of AdminUpdateReq.
|
||||||
|
type AdminUpdateRes struct{}
|
||||||
|
|
||||||
|
// AdminResetPwdReq resets an administrator's password.
|
||||||
|
type AdminResetPwdReq struct {
|
||||||
|
g.Meta `path:"/admin/{id}/password" method:"put" tags:"Admin/Admin" summary:"Reset admin password"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetPwdRes is the response of AdminResetPwdReq.
|
||||||
|
type AdminResetPwdRes struct{}
|
||||||
|
|
||||||
|
// AdminDeleteReq deletes an administrator (soft delete).
|
||||||
|
type AdminDeleteReq struct {
|
||||||
|
g.Meta `path:"/admin/{id}" method:"delete" tags:"Admin/Admin" summary:"Delete admin"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteRes is the response of AdminDeleteReq.
|
||||||
|
type AdminDeleteRes struct{}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import "github.com/gogf/gf/v2/frame/g"
|
|
||||||
|
|
||||||
type LoginReq struct {
|
|
||||||
g.Meta `path:"/auth/login" method:"post" tags:"Admin/Auth" summary:"Administrator login"`
|
|
||||||
Username string `json:"username" v:"required"`
|
|
||||||
Password string `json:"password" v:"required"`
|
|
||||||
}
|
|
||||||
type LoginRes struct {
|
|
||||||
AccessToken string `json:"accessToken"`
|
|
||||||
RefreshToken string `json:"refreshToken"`
|
|
||||||
ExpiresIn int64 `json:"expiresIn"`
|
|
||||||
AdminID uint64 `json:"adminId"`
|
|
||||||
}
|
|
||||||
36
api/admin/v1/base/log.go
Normal file
36
api/admin/v1/base/log.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Package base 定义基础常规接口(服务器日志监控等),路由前缀 /admin/v1/base。
|
||||||
|
package base
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// LogFile describes one server log file.
|
||||||
|
type LogFile struct {
|
||||||
|
Name string `json:"name"` // file name
|
||||||
|
Path string `json:"path"` // path relative to the log dir
|
||||||
|
Size int64 `json:"size"` // bytes
|
||||||
|
ModTime string `json:"modTime"` // 2006-01-02 15:04:05
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFilesReq lists the server log files.
|
||||||
|
type LogFilesReq struct {
|
||||||
|
g.Meta `path:"/base/log/files" method:"get" tags:"Admin/Base/Log" summary:"List server log files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFilesRes is the response of LogFilesReq.
|
||||||
|
type LogFilesRes struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
Files []*LogFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTailReq reads the tail of a log file with optional keyword filter.
|
||||||
|
type LogTailReq struct {
|
||||||
|
g.Meta `path:"/base/log/tail" method:"get" tags:"Admin/Base/Log" summary:"Tail a server log file"`
|
||||||
|
File string `json:"file" v:"required#file required"` // relative to the log dir; path traversal is rejected
|
||||||
|
Lines int `json:"lines" d:"200" v:"min:1|max:2000"`
|
||||||
|
Keyword string `json:"keyword"` // substring filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTailRes is the response of LogTailReq.
|
||||||
|
type LogTailRes struct {
|
||||||
|
Lines []string `json:"lines"`
|
||||||
|
}
|
||||||
40
api/admin/v1/system/auth.go
Normal file
40
api/admin/v1/system/auth.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Package system 定义系统管理接口(认证、菜单、角色),路由前缀 /admin/v1/system。
|
||||||
|
package system
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
type LoginReq struct {
|
||||||
|
g.Meta `path:"/system/auth/login" method:"post" tags:"Admin/System/Auth" summary:"Administrator login"`
|
||||||
|
Username string `json:"username" v:"required"`
|
||||||
|
Password string `json:"password" v:"required"`
|
||||||
|
}
|
||||||
|
type LoginRes struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
AdminID uint64 `json:"adminId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoReq returns the current administrator profile (vben getUserInfo).
|
||||||
|
type InfoReq struct {
|
||||||
|
g.Meta `path:"/system/auth/info" method:"get" tags:"Admin/System/Auth" summary:"Current admin info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoRes is the response of InfoReq. Roles carries role codes for vben authority.
|
||||||
|
type InfoRes struct {
|
||||||
|
AdminID uint64 `json:"adminId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodesReq returns the button-level permission codes of the current admin
|
||||||
|
// (vben getAccessCodes, backend access mode).
|
||||||
|
type CodesReq struct {
|
||||||
|
g.Meta `path:"/system/auth/codes" method:"get" tags:"Admin/System/Auth" summary:"Current admin access codes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodesRes is the response of CodesReq.
|
||||||
|
type CodesRes struct {
|
||||||
|
Codes []string `json:"codes"`
|
||||||
|
}
|
||||||
33
api/admin/v1/system/menu.go
Normal file
33
api/admin/v1/system/menu.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// RouteMeta mirrors vben admin dynamic route metadata.
|
||||||
|
type RouteMeta struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
Authority []string `json:"authority,omitempty"` // role codes
|
||||||
|
HideInMenu bool `json:"hideInMenu,omitempty"`
|
||||||
|
KeepAlive bool `json:"keepAlive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteItem is one node of the vben route tree.
|
||||||
|
type RouteItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component,omitempty"`
|
||||||
|
Meta RouteMeta `json:"meta"`
|
||||||
|
Children []*RouteItem `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuRoutesReq returns the current admin's menu tree as vben routes
|
||||||
|
// (backend access mode, login-only endpoint).
|
||||||
|
type MenuRoutesReq struct {
|
||||||
|
g.Meta `path:"/system/menu/routes" method:"get" tags:"Admin/System/Menu" summary:"Current admin menu routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuRoutesRes is the response of MenuRoutesReq.
|
||||||
|
type MenuRoutesRes struct {
|
||||||
|
Routes []*RouteItem `json:"routes"`
|
||||||
|
}
|
||||||
77
api/admin/v1/system/menu_manage.go
Normal file
77
api/admin/v1/system/menu_manage.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// MenuItem is one node of the full menu tree (type 1 menu / 2 button).
|
||||||
|
type MenuItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
ParentId uint64 `json:"parentId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type int `json:"type"` // 1 menu, 2 button/api
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
Children []*MenuItem `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuTreeReq returns the full menu tree for management.
|
||||||
|
type MenuTreeReq struct {
|
||||||
|
g.Meta `path:"/system/menu/tree" method:"get" tags:"Admin/System/Menu" summary:"Full menu tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuTreeRes is the response of MenuTreeReq.
|
||||||
|
type MenuTreeRes struct {
|
||||||
|
Tree []*MenuItem `json:"tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuCreateReq creates a menu or button node.
|
||||||
|
type MenuCreateReq struct {
|
||||||
|
g.Meta `path:"/system/menu" method:"post" tags:"Admin/System/Menu" summary:"Create menu"`
|
||||||
|
ParentId uint64 `json:"parentId"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type int `json:"type" v:"in:1,2#type must be 1 or 2"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Permission string `json:"permission" v:"required"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuCreateRes is the response of MenuCreateReq.
|
||||||
|
type MenuCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuUpdateReq updates a menu or button node.
|
||||||
|
type MenuUpdateReq struct {
|
||||||
|
g.Meta `path:"/system/menu/{id}" method:"put" tags:"Admin/System/Menu" summary:"Update menu"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
ParentId uint64 `json:"parentId"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Type int `json:"type" v:"in:1,2#type must be 1 or 2"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Permission string `json:"permission" v:"required"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuUpdateRes is the response of MenuUpdateReq.
|
||||||
|
type MenuUpdateRes struct{}
|
||||||
|
|
||||||
|
// MenuDeleteReq deletes a menu node (soft delete).
|
||||||
|
type MenuDeleteReq struct {
|
||||||
|
g.Meta `path:"/system/menu/{id}" method:"delete" tags:"Admin/System/Menu" summary:"Delete menu"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuDeleteRes is the response of MenuDeleteReq.
|
||||||
|
type MenuDeleteRes struct{}
|
||||||
62
api/admin/v1/system/role.go
Normal file
62
api/admin/v1/system/role.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// RoleItem is one row of the role list.
|
||||||
|
type RoleItem struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
MenuIds []uint64 `json:"menuIds"` // bound menu ids (for editing)
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleListReq pages the roles.
|
||||||
|
type RoleListReq struct {
|
||||||
|
g.Meta `path:"/system/role" method:"get" tags:"Admin/System/Role" summary:"Role list"`
|
||||||
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
|
Keyword string `json:"keyword"` // matches code or name
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleListRes is the response of RoleListReq.
|
||||||
|
type RoleListRes struct {
|
||||||
|
List []*RoleItem `json:"list"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCreateReq creates a role and binds menu ids.
|
||||||
|
type RoleCreateReq struct {
|
||||||
|
g.Meta `path:"/system/role" method:"post" tags:"Admin/System/Role" summary:"Create role"`
|
||||||
|
Code string `json:"code" v:"required"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
MenuIds []uint64 `json:"menuIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCreateRes is the response of RoleCreateReq.
|
||||||
|
type RoleCreateRes struct {
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleUpdateReq updates a role and rebinds menu ids.
|
||||||
|
type RoleUpdateReq struct {
|
||||||
|
g.Meta `path:"/system/role/{id}" method:"put" tags:"Admin/System/Role" summary:"Update role"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
Name string `json:"name" v:"required"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
MenuIds []uint64 `json:"menuIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleUpdateRes is the response of RoleUpdateReq.
|
||||||
|
type RoleUpdateRes struct{}
|
||||||
|
|
||||||
|
// RoleDeleteReq deletes a role (soft delete).
|
||||||
|
type RoleDeleteReq struct {
|
||||||
|
g.Meta `path:"/system/role/{id}" method:"delete" tags:"Admin/System/Role" summary:"Delete role"`
|
||||||
|
Id uint64 `json:"id" in:"path" v:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleDeleteRes is the response of RoleDeleteReq.
|
||||||
|
type RoleDeleteRes struct{}
|
||||||
18
api/open/v1/tools/doc.go
Normal file
18
api/open/v1/tools/doc.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Package tools hosts the open API tool endpoints (GET/POST /tools/*).
|
||||||
|
//
|
||||||
|
// Naming rule: every sub-feature gets its own directory under tools/, named
|
||||||
|
// after the feature (dir name = package name), and the Req/Res contracts live
|
||||||
|
// in a file named after the feature inside that directory, e.g.
|
||||||
|
//
|
||||||
|
// api/open/v1/tools/uuid/uuid.go - GET /tools/uuid
|
||||||
|
// api/open/v1/tools/md5/md5.go - POST /tools/md5
|
||||||
|
// api/open/v1/tools/random/random.go - GET /tools/random
|
||||||
|
// api/open/v1/tools/time/time.go - GET /tools/time
|
||||||
|
// api/open/v1/tools/ip/ip.go - GET /tools/ip
|
||||||
|
//
|
||||||
|
// As a feature grows, add more files inside its directory (e.g. idcard.go,
|
||||||
|
// invoice.go under ocr/) instead of bloating the starting file.
|
||||||
|
//
|
||||||
|
// Add a new feature by creating tools/<name>/<name>.go and a matching method
|
||||||
|
// in internal/controller/open/<name>.go; the router binds it automatically.
|
||||||
|
package tools
|
||||||
15
api/open/v1/tools/ip/ip.go
Normal file
15
api/open/v1/tools/ip/ip.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package ip defines the GET /tools/ip endpoint contract.
|
||||||
|
package ip
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// IPReq returns the caller's IP information.
|
||||||
|
type IPReq struct {
|
||||||
|
g.Meta `path:"/tools/ip" method:"get" tags:"Open/Tools" summary:"Client IP info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPRes is the response of IPReq.
|
||||||
|
type IPRes struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Internal bool `json:"internal"` // whether the IP is a private/internal address
|
||||||
|
}
|
||||||
15
api/open/v1/tools/md5/md5.go
Normal file
15
api/open/v1/tools/md5/md5.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package md5 defines the POST /tools/md5 endpoint contract.
|
||||||
|
package md5
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// MD5Req computes an MD5 digest.
|
||||||
|
type MD5Req struct {
|
||||||
|
g.Meta `path:"/tools/md5" method:"post" tags:"Open/Tools" summary:"Compute MD5 digest"`
|
||||||
|
Text string `json:"text" v:"required#text required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5Res is the response of MD5Req.
|
||||||
|
type MD5Res struct {
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
}
|
||||||
16
api/open/v1/tools/random/random.go
Normal file
16
api/open/v1/tools/random/random.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Package random defines the GET /tools/random endpoint contract.
|
||||||
|
package random
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// RandomReq generates a random string.
|
||||||
|
type RandomReq struct {
|
||||||
|
g.Meta `path:"/tools/random" method:"get" tags:"Open/Tools" summary:"Generate a random string"`
|
||||||
|
Length int `json:"length" d:"16" v:"min:1|max:128#length must be 1-128"`
|
||||||
|
Type string `json:"type" d:"alnum" v:"in:alnum,digits,letters#unsupported type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomRes is the response of RandomReq.
|
||||||
|
type RandomRes struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
16
api/open/v1/tools/time/time.go
Normal file
16
api/open/v1/tools/time/time.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Package time defines the GET /tools/time endpoint contract.
|
||||||
|
package time
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// TimeReq returns the current server time.
|
||||||
|
type TimeReq struct {
|
||||||
|
g.Meta `path:"/tools/time" method:"get" tags:"Open/Tools" summary:"Current server time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeRes is the response of TimeReq.
|
||||||
|
type TimeRes struct {
|
||||||
|
Timestamp int64 `json:"timestamp"` // unix seconds
|
||||||
|
DateTime string `json:"dateTime"` // 2006-01-02 15:04:05
|
||||||
|
Date string `json:"date"` // 2006-01-02
|
||||||
|
}
|
||||||
15
api/open/v1/tools/uuid/uuid.go
Normal file
15
api/open/v1/tools/uuid/uuid.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package uuid defines the GET /tools/uuid endpoint contract.
|
||||||
|
package uuid
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
// UUIDReq generates a unique ID.
|
||||||
|
type UUIDReq struct {
|
||||||
|
g.Meta `path:"/tools/uuid" method:"get" tags:"Open/Tools" summary:"Generate a unique ID"`
|
||||||
|
Short bool `json:"short"` // true: 8-char short code; false: 32-char ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUIDRes is the response of UUIDReq.
|
||||||
|
type UUIDRes struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
}
|
||||||
10
common/doc.go
Normal file
10
common/doc.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Package common is the public, reusable module of service.xpcool.com.
|
||||||
|
//
|
||||||
|
// It contains only code that is independent of internal/ — anything here can
|
||||||
|
// be shared across services inside the repository, or extracted into a
|
||||||
|
// standalone library later without touching business code.
|
||||||
|
//
|
||||||
|
// Current layout:
|
||||||
|
//
|
||||||
|
// common/tools shared utility toolbox (md5, cryptox, uuid, ...)
|
||||||
|
package common
|
||||||
64
common/tools/convertx/convertx.go
Normal file
64
common/tools/convertx/convertx.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Package convertx provides type conversion helpers with default-value
|
||||||
|
// fallback, built on top of gconv.
|
||||||
|
//
|
||||||
|
// Note: gconv silently returns the zero value when conversion fails, so the
|
||||||
|
// helpers here only fall back to the default for nil / blank-string inputs.
|
||||||
|
// Pass pre-validated data when strict conversion is required.
|
||||||
|
package convertx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/util/gconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToInt converts v to int, returning def when v is nil or a blank string.
|
||||||
|
func ToInt(v any, def int) int {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToInt64 converts v to int64, returning def when v is nil or a blank string.
|
||||||
|
func ToInt64(v any, def int64) int64 {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Int64(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToFloat64 converts v to float64, returning def when v is nil or a blank string.
|
||||||
|
func ToFloat64(v any, def float64) float64 {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Float64(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToString converts v to string, returning def when v is nil.
|
||||||
|
func ToString(v any, def string) string {
|
||||||
|
if v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToBool converts v to bool, returning def when v is nil or a blank string.
|
||||||
|
func ToBool(v any, def bool) bool {
|
||||||
|
if isEmpty(v) {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return gconv.Bool(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEmpty reports whether v is nil or a blank string.
|
||||||
|
func isEmpty(v any) bool {
|
||||||
|
if v == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return strings.TrimSpace(s) == ""
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
80
common/tools/cryptox/cryptox.go
Normal file
80
common/tools/cryptox/cryptox.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Package cryptox provides AES/DES encryption helpers with base64 output,
|
||||||
|
// built on top of gaes and gdes.
|
||||||
|
//
|
||||||
|
// Keys: any-length secrets are accepted — they are normalized to the exact
|
||||||
|
// key size internally, so callers do not need to manage key lengths.
|
||||||
|
package cryptox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/crypto/gaes"
|
||||||
|
"github.com/gogf/gf/v2/crypto/gdes"
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
aesKeySize = 16 // AES-128 key size in bytes
|
||||||
|
desKeySize = 8 // DES key size in bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeKey derives a fixed-size key from an arbitrary-length secret.
|
||||||
|
func normalizeKey(secret string, size int) []byte {
|
||||||
|
key := []byte(secret)
|
||||||
|
if len(key) == size {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
sum := md5.Sum([]byte(secret))
|
||||||
|
out := make([]byte, size)
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
out[i] = sum[i%len(sum)]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AesEncrypt encrypts plainText with AES-128-CBC using a key derived from
|
||||||
|
// secret, and returns the ciphertext encoded in base64.
|
||||||
|
func AesEncrypt(plainText, secret string) (string, error) {
|
||||||
|
out, err := gaes.Encrypt([]byte(plainText), normalizeKey(secret, aesKeySize))
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `AesEncrypt failed`)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AesDecrypt decrypts the base64-encoded cipherText produced by AesEncrypt.
|
||||||
|
func AesDecrypt(cipherText, secret string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `base64 decode failed`)
|
||||||
|
}
|
||||||
|
out, err := gaes.Decrypt(data, normalizeKey(secret, aesKeySize))
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `AesDecrypt failed`)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesEncrypt encrypts plainText with DES-ECB (PKCS5 padding) using a key
|
||||||
|
// derived from secret, and returns the ciphertext encoded in base64.
|
||||||
|
func DesEncrypt(plainText, secret string) (string, error) {
|
||||||
|
out, err := gdes.EncryptECB([]byte(plainText), normalizeKey(secret, desKeySize), gdes.PKCS5PADDING)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `DesEncrypt failed`)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DesDecrypt decrypts the base64-encoded cipherText produced by DesEncrypt.
|
||||||
|
func DesDecrypt(cipherText, secret string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `base64 decode failed`)
|
||||||
|
}
|
||||||
|
out, err := gdes.DecryptECB(data, normalizeKey(secret, desKeySize), gdes.PKCS5PADDING)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `DesDecrypt failed`)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
24
common/tools/doc.go
Normal file
24
common/tools/doc.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Package tools is the shared utility toolbox of the project.
|
||||||
|
//
|
||||||
|
// Every sub-package is a thin, documented wrapper around GoFrame built-in
|
||||||
|
// components (gmd5, gaes, gdes, guid, grand, gtime, gconv, gstr, gfile, ...),
|
||||||
|
// so it stays small and consistent with the framework.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
//
|
||||||
|
// common/tools/md5 MD5 digest helpers
|
||||||
|
// common/tools/cryptox AES/DES encryption with base64 output
|
||||||
|
// common/tools/uuid unique ID generation
|
||||||
|
// common/tools/random random numbers and strings
|
||||||
|
// common/tools/timex time formatting and computation
|
||||||
|
// common/tools/convertx type conversion with default fallback
|
||||||
|
// common/tools/strx string / naming / masking helpers
|
||||||
|
// common/tools/slicex generic slice utilities
|
||||||
|
// common/tools/ip IP address helpers
|
||||||
|
// common/tools/filex file system helpers
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - Never depend on internal/ — this module must stay self-contained.
|
||||||
|
// - Prefer reusing GoFrame built-in components over re-implementing.
|
||||||
|
// - Keep each helper small and add a Chinese doc comment.
|
||||||
|
package tools
|
||||||
28
common/tools/filex/filex.go
Normal file
28
common/tools/filex/filex.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// Package filex provides common file system helpers on top of gfile.
|
||||||
|
package filex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/os/gfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exists reports whether the file or directory at path exists.
|
||||||
|
func Exists(path string) bool {
|
||||||
|
return gfile.Exists(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir reports whether path is a directory.
|
||||||
|
func IsDir(path string) bool {
|
||||||
|
return gfile.IsDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadString returns the full content of the file at path as a string.
|
||||||
|
// Returns an empty string when the file does not exist.
|
||||||
|
func ReadString(path string) string {
|
||||||
|
return gfile.GetContents(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteString writes content to the file at path, creating intermediate
|
||||||
|
// directories if needed.
|
||||||
|
func WriteString(path, content string) error {
|
||||||
|
return gfile.PutContents(path, content)
|
||||||
|
}
|
||||||
59
common/tools/ip/ip.go
Normal file
59
common/tools/ip/ip.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Package ip provides IP address helpers.
|
||||||
|
package ip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/net/gipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValid reports whether s is a valid IPv4 address.
|
||||||
|
func IsValid(s string) bool {
|
||||||
|
return gipv4.Validate(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalIP returns the first non-loopback IPv4 address of this host.
|
||||||
|
func LocalIP() (string, error) {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, `net.InterfaceAddrs failed`)
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if ipNet, ok := addr.(*net.IPNet); ok {
|
||||||
|
if ipv4 := ipNet.IP.To4(); ipv4 != nil && !ipv4.IsLoopback() {
|
||||||
|
return ipv4.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInternal reports whether s is a private/internal IPv4 address
|
||||||
|
// (private ranges, loopback or link-local).
|
||||||
|
func IsInternal(s string) bool {
|
||||||
|
parsed := net.ParseIP(s)
|
||||||
|
if parsed == nil || parsed.To4() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToLong converts an IPv4 string to its uint32 representation
|
||||||
|
// (big-endian, same as inet_aton).
|
||||||
|
func ToLong(s string) (uint32, error) {
|
||||||
|
ipv4 := net.ParseIP(s).To4()
|
||||||
|
if ipv4 == nil {
|
||||||
|
return 0, gerror.Newf(`invalid IPv4 address: %s`, s)
|
||||||
|
}
|
||||||
|
return uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToString converts a uint32 IPv4 value to its dotted-decimal string.
|
||||||
|
func ToString(v uint32) string {
|
||||||
|
return strconv.Itoa(int(v>>24)) + "." +
|
||||||
|
strconv.Itoa(int(v>>16&0xFF)) + "." +
|
||||||
|
strconv.Itoa(int(v>>8&0xFF)) + "." +
|
||||||
|
strconv.Itoa(int(v&0xFF))
|
||||||
|
}
|
||||||
23
common/tools/md5/md5.go
Normal file
23
common/tools/md5/md5.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// Package md5 provides MD5 digest helpers.
|
||||||
|
package md5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/crypto/gmd5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Md5Hex returns the MD5 digest of s as a lowercase hex string.
|
||||||
|
// The underlying error is ignored because it never fails for in-memory input.
|
||||||
|
func Md5Hex(s string) string {
|
||||||
|
h, _ := gmd5.EncryptString(s)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Md5Bytes returns the MD5 digest of data as a lowercase hex string.
|
||||||
|
func Md5Bytes(data []byte) (string, error) {
|
||||||
|
return gmd5.Encrypt(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Md5File returns the MD5 digest of the local file at path as a hex string.
|
||||||
|
func Md5File(path string) (string, error) {
|
||||||
|
return gmd5.EncryptFile(path)
|
||||||
|
}
|
||||||
26
common/tools/random/random.go
Normal file
26
common/tools/random/random.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Package random provides random number and string generation helpers.
|
||||||
|
package random
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/util/grand"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Int returns a random integer in [min, max].
|
||||||
|
func Int(min, max int) int {
|
||||||
|
return grand.N(min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a random alphanumeric string of length n.
|
||||||
|
func String(n int) string {
|
||||||
|
return grand.S(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits returns a random numeric-only string of length n, e.g. SMS codes.
|
||||||
|
func Digits(n int) string {
|
||||||
|
return grand.Digits(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters returns a random letter-only string of length n.
|
||||||
|
func Letters(n int) string {
|
||||||
|
return grand.Letters(n)
|
||||||
|
}
|
||||||
64
common/tools/slicex/slicex.go
Normal file
64
common/tools/slicex/slicex.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Package slicex provides generic slice utilities built on the standard
|
||||||
|
// library (Go 1.23+).
|
||||||
|
package slicex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contains reports whether v is present in items.
|
||||||
|
func Contains[T comparable](items []T, v T) bool {
|
||||||
|
return slices.Contains(items, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique returns items with duplicates removed, preserving first-seen order.
|
||||||
|
func Unique[T comparable](items []T) []T {
|
||||||
|
seen := make(map[T]struct{}, len(items))
|
||||||
|
out := make([]T, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
if _, ok := seen[v]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = struct{}{}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk splits items into sub-slices of at most size elements.
|
||||||
|
// Returns nil when size <= 0 or items is empty.
|
||||||
|
func Chunk[T any](items []T, size int) [][]T {
|
||||||
|
if size <= 0 || len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([][]T, 0, (len(items)+size-1)/size)
|
||||||
|
for len(items) > 0 {
|
||||||
|
n := size
|
||||||
|
if len(items) < n {
|
||||||
|
n = len(items)
|
||||||
|
}
|
||||||
|
out = append(out, items[:n])
|
||||||
|
items = items[n:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map applies fn to every element and returns the results.
|
||||||
|
func Map[T, R any](items []T, fn func(T) R) []R {
|
||||||
|
out := make([]R, len(items))
|
||||||
|
for i, v := range items {
|
||||||
|
out[i] = fn(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter returns the elements for which fn returns true, preserving order.
|
||||||
|
func Filter[T any](items []T, fn func(T) bool) []T {
|
||||||
|
out := make([]T, 0, len(items))
|
||||||
|
for _, v := range items {
|
||||||
|
if fn(v) {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
57
common/tools/strx/strx.go
Normal file
57
common/tools/strx/strx.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Package strx provides string helpers on top of gstr, including naming
|
||||||
|
// conversion and sensitive-data masking.
|
||||||
|
package strx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/text/gstr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SnakeCase converts s to snake_case, e.g. "UserName" -> "user_name".
|
||||||
|
func SnakeCase(s string) string {
|
||||||
|
return gstr.CaseSnake(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CamelCase converts s to CamelCase, e.g. "user_name" -> "UserName".
|
||||||
|
func CamelCase(s string) string {
|
||||||
|
return gstr.CaseCamel(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LowerCamelCase converts s to lowerCamelCase, e.g. "user_name" -> "userName".
|
||||||
|
func LowerCamelCase(s string) string {
|
||||||
|
return gstr.CaseCamelLower(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty reports whether s is empty or whitespace-only.
|
||||||
|
func IsEmpty(s string) bool {
|
||||||
|
return strings.TrimSpace(s) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskPhone masks a phone number, keeping the first 3 and last 4 characters.
|
||||||
|
// e.g. "13812345678" -> "138****5678".
|
||||||
|
func MaskPhone(s string) string {
|
||||||
|
if len(s) < 7 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:3] + "****" + s[len(s)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskIDCard masks a Chinese ID card number, keeping the first 6 and last 4
|
||||||
|
// characters. e.g. "110101199003074512" -> "110101********4512".
|
||||||
|
func MaskIDCard(s string) string {
|
||||||
|
if len(s) < 10 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:6] + "********" + s[len(s)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskName masks a Chinese name, keeping only the first character.
|
||||||
|
// e.g. "张三丰" -> "张**".
|
||||||
|
func MaskName(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= 1 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[0]) + strings.Repeat("*", len(r)-1)
|
||||||
|
}
|
||||||
46
common/tools/timex/timex.go
Normal file
46
common/tools/timex/timex.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Package timex provides time formatting and computation helpers on top of gtime.
|
||||||
|
package timex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LayoutDateTime is the conventional datetime layout: 2006-01-02 15:04:05.
|
||||||
|
LayoutDateTime = "2006-01-02 15:04:05"
|
||||||
|
// LayoutDate is the conventional date layout: 2006-01-02.
|
||||||
|
LayoutDate = "2006-01-02"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Now returns the current time.
|
||||||
|
func Now() *gtime.Time {
|
||||||
|
return gtime.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format returns t formatted with the given Go layout.
|
||||||
|
// When layout is empty, LayoutDateTime is used.
|
||||||
|
//
|
||||||
|
// Note: gtime v2.10.2's Format() takes PHP-style format ("Y-m-d H:i:s"),
|
||||||
|
// so this helper uses the Layout() method which accepts Go layouts.
|
||||||
|
func Format(t *gtime.Time, layout ...string) string {
|
||||||
|
ly := LayoutDateTime
|
||||||
|
if len(layout) > 0 && layout[0] != "" {
|
||||||
|
ly = layout[0]
|
||||||
|
}
|
||||||
|
return t.Layout(ly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp returns the current Unix timestamp in seconds.
|
||||||
|
func Timestamp() int64 {
|
||||||
|
return gtime.Now().Timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartOfDay returns the beginning (00:00:00) of the day containing t.
|
||||||
|
func StartOfDay(t *gtime.Time) *gtime.Time {
|
||||||
|
return gtime.NewFromStr(t.Layout(LayoutDate) + " 00:00:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndOfDay returns the end (23:59:59) of the day containing t.
|
||||||
|
func EndOfDay(t *gtime.Time) *gtime.Time {
|
||||||
|
return gtime.NewFromStr(t.Layout(LayoutDate) + " 23:59:59")
|
||||||
|
}
|
||||||
21
common/tools/uuid/uuid.go
Normal file
21
common/tools/uuid/uuid.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// Package uuid provides unique ID generation helpers.
|
||||||
|
package uuid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogf/gf/v2/util/grand"
|
||||||
|
"github.com/gogf/gf/v2/util/guid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New returns a 32-character unique ID without dashes.
|
||||||
|
func New() string {
|
||||||
|
return guid.S()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short returns a random alphanumeric ID of length n (defaults to 8 when n <= 0).
|
||||||
|
// Suitable for short invite codes / trace ids.
|
||||||
|
func Short(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 8
|
||||||
|
}
|
||||||
|
return grand.S(n)
|
||||||
|
}
|
||||||
273
docs/change-log/2026-08-24.md
Normal file
273
docs/change-log/2026-08-24.md
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
# Change Log — 2026-08-24
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
1. 新增一个「公共模块」,模块下存放 `tools` 功能模块,`tools` 下再分各种子功能。
|
||||||
|
2. 建立项目上下文记忆体系:记录每次请求与更改,方案对比后落地;要求后续使用其他 CodeBuddy 账号也能无缝衔接。
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
新增文件:
|
||||||
|
|
||||||
|
- `common/doc.go` — 公共模块说明(不依赖 internal、可独立抽取)
|
||||||
|
- `common/tools/doc.go` — tools 模块布局清单与封装规则
|
||||||
|
- `common/tools/md5/md5.go` — MD5 摘要(Md5Hex / Md5Bytes / Md5File)
|
||||||
|
- `common/tools/cryptox/cryptox.go` — AES-128-CBC / DES-ECB 加解密(base64 输出,密钥任意长度自动规范化)
|
||||||
|
- `common/tools/uuid/uuid.go` — 唯一 ID(New 32 位 / Short 短随机码)
|
||||||
|
- `common/tools/random/random.go` — 随机数/随机串(Int / String / Digits / Letters)
|
||||||
|
- `common/tools/timex/timex.go` — 时间工具(Format / Timestamp / StartOfDay / EndOfDay)
|
||||||
|
- `common/tools/convertx/convertx.go` — 类型转换带默认值(ToInt / ToInt64 / ToFloat64 / ToString / ToBool)
|
||||||
|
- `common/tools/strx/strx.go` — 字符串工具(命名转换 SnakeCase/CamelCase + 脱敏 MaskPhone/MaskIDCard/MaskName)
|
||||||
|
- `common/tools/slicex/slicex.go` — 泛型切片工具(Contains / Unique / Chunk / Map / Filter)
|
||||||
|
- `common/tools/ip/ip.go` — IP 工具(IsValid / LocalIP / IsInternal / ToLong / ToString)
|
||||||
|
- `common/tools/filex/filex.go` — 文件工具(Exists / IsDir / ReadString / WriteString)
|
||||||
|
- `AGENTS.md` — 项目智能体说明书(架构、规范、命令、记忆体系索引)
|
||||||
|
- `docs/change-log/2026-08-24.md` — 本文档
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `.gitignore` — 追加 `.workbuddy/`(本机记忆不入库)
|
||||||
|
- `PROJECT_STRUCTURE.md` — 目录树补充 `common/` 与 `docs/change-log/`(见后续提交)
|
||||||
|
|
||||||
|
验证:`go build ./...` 与 `go vet ./common/...` 全部通过。
|
||||||
|
|
||||||
|
## 决策与理由
|
||||||
|
|
||||||
|
- **公共模块放顶层 `common/` 而非 `internal/common/`**:Go 的 internal 包无法被外部模块引用,放顶层便于未来抽取为独立库/被同仓库其他服务复用。
|
||||||
|
- **tools 一律薄封装 GoFrame 内置组件**:v2.10.2 中 AES/DES 已拆为 `crypto/gaes`、`crypto/gdes`,UUID 为 `util/guid`,无 `gslicer`(用标准库 `slices` 替代);避免重复造轮子,保持与框架一致。
|
||||||
|
- **记忆体系三层方案**(对比见下):
|
||||||
|
1. `AGENTS.md`(根目录)— 长期稳定规范,跨工具标准(Claude Code/CodeBuddy/Codex 等均识别),随 git 走;
|
||||||
|
2. `docs/change-log/YYYY-MM-DD.md` — 每次请求变更的结构化记录,随 git 走,**这是跨账号衔接的关键**;
|
||||||
|
3. `.workbuddy/memory/` — WorkBuddy 本机增强,不入库。
|
||||||
|
- 对比过 `CLAUDE.md`(Claude Code 专属、已建议统一为 AGENTS.md)、`.cursor/rules`(Cursor 专属)、`.codebuddy/`(仅 CodeBuddy 读取)——它们都不是最大公约数,故不采用。
|
||||||
|
|
||||||
|
## 待办与风险
|
||||||
|
|
||||||
|
- 后续每次任务完成后:更新 `docs/change-log/`(当日文件追加)+ 提交 git,确保其他账号 clone 即恢复上下文。
|
||||||
|
- `convertx` 依赖 gconv 的"转换失败返回零值"行为(无法区分"0"与非法输入),需要严格转换的场景应在 service 层先校验。
|
||||||
|
- 本次变更尚未 git 提交,建议尽快 commit。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(17:30):方向纠正 — 「公共模块」实为公共接口
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
用户澄清:要新增的是**给前端调用的公共 HTTP 接口**(此前误建成 Go 工具包),确认应规划到 `api/` 契约层。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
新增文件:
|
||||||
|
|
||||||
|
- `api/common/v1/tools.go` — 公共接口契约:`UUIDReq/Res`、`MD5Req/Res`、`RandomReq/Res`、`TimeReq/Res`、`IPReq/Res`(g.Meta 路由元数据)
|
||||||
|
- `internal/controller/common/tools.go` — 控制器实现,薄适配层,复用 `common/tools/*` Go 包
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `internal/cmd/cmd.go` — 新增公开分组 `s.Group("/api/common/v1", ...)`(Recover+CORS+HandlerResponse,**无鉴权**)
|
||||||
|
- `common/tools/timex/timex.go` — 修复:`Format` 内部改用 `Layout()` 方法(见决策)
|
||||||
|
- `internal/controller/common/tools.go` — `Time` 用 `now.Layout(...)`
|
||||||
|
- `AGENTS.md` — 目录树与新增「公共接口」小节、注意事项补 gtime 与包名差异
|
||||||
|
- `PROJECT_STRUCTURE.md` — 目录树补充 `api/common/v1`
|
||||||
|
|
||||||
|
验证:`go build ./...`、`go vet` 通过;**实际启动服务冒烟测试** 5 个端点全部返回正确(含修复后 time 格式化)。
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **路由前缀 `/api/common/v1`**:与 `/api/v1`(user)、`/admin/v1`(admin) 平级的独立公开前缀,天然不套登录鉴权;前端三个端(mini/h5/app)通用。
|
||||||
|
- **契约层放 `api/common/v1`,实现放 `internal/controller/common`**:与 user/admin 完全同构;公共接口不需要 service 层(纯工具计算),controller 直接复用 `common/tools` 包,避免过度分层。
|
||||||
|
- **🐛 gtime v2.10.2 大坑(已修复)**:`Time.Format()` 参数是 **PHP 风格**(`"Y-m-d H:i:s"`),传 Go layout(`"2006-01-02 15:04:05"`)会原样输出!Go layout 必须用 `Time.Layout()`。`common/tools/timex` 已统一封装为 `Layout` 语义,调用方直接 `timex.Format(t, layout...)` 即可。
|
||||||
|
- **冒烟测试教训**:`go run` 会 spawn 子进程,`kill %1` 只杀包装进程,残留的 `main.exe` 会继续占用 8000 端口导致后续测试打到旧代码——杀进程需 `netstat -ano | grep :8000` 找 PID 后 `Stop-Process`。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 本次追加变更同样未提交 git,与上文合并为一次 commit。
|
||||||
|
- 公共接口已开放无鉴权能力(md5/random/uuid 等),后续新增接口时需评审是否应限流/加签名,避免被滥用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(18:05):开放接口命名决策 + API 目录重构
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
1. 变更 API 目录设计:契约层改为 `tools/<子功能>/index.go` 结构(示例 `/api/common/v1/tools/ocr/index.go`)。
|
||||||
|
2. 同步修改所有涉及处(控制器、路由)。
|
||||||
|
3. 把命名规则记入项目记忆。
|
||||||
|
4. 咨询:公共接口一般用 common 命名吗?有没有更好的?
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
新增文件(契约层按子功能拆目录):
|
||||||
|
|
||||||
|
- `api/open/v1/tools/doc.go` — tools 目录结构规则说明
|
||||||
|
- `api/open/v1/tools/uuid/index.go`、`md5/index.go`、`random/index.go`、`time/index.go`、`ip/index.go` — 每子功能一个目录,index.go 内 `package <子功能名>`,g.Meta tags 改 `Open/Tools`
|
||||||
|
- `internal/controller/open/controller.go` — Controller 结构 + New()
|
||||||
|
- `internal/controller/open/uuid.go`、`md5.go`、`random.go`、`time.go`、`ip.go` — 按子功能拆文件,import 对应契约子包
|
||||||
|
|
||||||
|
删除文件:
|
||||||
|
|
||||||
|
- `api/common/v1/tools.go`、`api/common/` 目录
|
||||||
|
- `internal/controller/common/tools.go`、`internal/controller/common/` 目录
|
||||||
|
|
||||||
|
修改文件:
|
||||||
|
|
||||||
|
- `internal/cmd/cmd.go` — import `commonctl`→`openctl`;路由 `/api/common/v1`→`/api/open/v1`,注释改 Open tools API
|
||||||
|
- `AGENTS.md` — 目录树更新;「公共接口」小节改为「开放接口(api/open/v1)与命名规则」,记录 open 命名决策与 tools 子功能目录规则
|
||||||
|
- `PROJECT_STRUCTURE.md` — 目录树同步
|
||||||
|
|
||||||
|
验证:`go build ./...`、`go vet` 通过;冒烟测试:旧路由 `/api/common/v1/tools/time` 返回 404,新路由 `/api/open/v1/tools/*` 5 端点全部正确。
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **命名 common → open**:用户询问"公共接口一般用 common 吗",对比后选 **open**(业界开放接口惯例,如支付宝 /open/api;语义强调对外暴露、无鉴权)。`public` 为并列备选(更强调"公开"),`common` 偏内部通用语义、弃用。命名规则已记入 AGENTS.md「开放接口」小节。
|
||||||
|
- **契约层目录规则**:`api/open/v1/tools/<子功能名>/index.go` 每子功能一目录(与 GoFrame 惯例「api 下按功能分包」一致,用户示例 ocr 即为后续子功能,如未来加 OCR 识别接口即建 `tools/ocr/index.go`);控制器 `internal/controller/open/<子功能名>.go` 对应拆文件(同 package open)。
|
||||||
|
- **路由绑定不受目录重构影响**:GoFrame 通过 controller 方法参数反射定位 g.Meta,契约包拆成多个子包(uuid/md5/random/time/ip)不影响 `group.Bind(openctl.New())` 自动注册,cmd.go 只需改前缀。
|
||||||
|
- 包名 `time`(api/open/v1/tools/time)与标准库 time 潜在同名,controller 中统一用 import 别名 `timeapi` 规避。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 前端若已联调旧 `/api/common/v1` 路径需同步改 `/api/open/v1`(当前无线上前端,风险低)。
|
||||||
|
- 新增子功能记得更新 `api/open/v1/tools/doc.go` 的示例清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(18:08):契约文件 index.go → <功能名>.go
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
用户咨询"子功能目录内用 index.go 还是 <功能名>.go 更易扩展维护",确认推荐后执行改动。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `git mv` 重命名 5 个契约文件:`tools/{uuid,md5,random,time,ip}/index.go` → `tools/{uuid,md5,random,time,ip}/{uuid,md5,random,time,ip}.go`(包内容不变)
|
||||||
|
- `api/open/v1/tools/doc.go` — 规则说明改为「目录名=包名=文件名三一致」,补充"功能变大后目录内加文件"的扩展指引
|
||||||
|
- `AGENTS.md` / `PROJECT_STRUCTURE.md` — 同步 index.go 引用为 <name>.go
|
||||||
|
|
||||||
|
验证:`go build ./...`、`go vet` 通过;冒烟测试 5 端点全部正常。
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **选 <功能名>.go 而非 index.go**:① 目录名=包名=文件名三一致,导航直观;② index.go 是"入口"语义,功能膨胀后出现 `index.go + idcard.go` 混排会失去入口意义,而 `<name>.go + idcard.go` 自然;③ 符合 Go 生态主流(strings/strings.go)与 GoFrame 官方模板(api/user/v1/user.go)。
|
||||||
|
- **扩展路径已定型**:子功能从 1 个端点到多个端点,只需在目录内加文件(如 ocr 目录 `ocr.go → + idcard.go + invoice.go`),无需重构文件名。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 无新增风险;后续新增子功能统一按 `tools/<name>/<name>.go` 建文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(22:40):后台管理功能开发(后端完成)
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
使用 goframe-v2 开发登录、菜单、按钮级权限等常规后台管理功能(前端 vben5 对接,backend 动态路由模式);登录后台后开发服务器日志管理功能。
|
||||||
|
|
||||||
|
### 变更(commit 8fdb25e,35 文件)
|
||||||
|
|
||||||
|
- `manifest/sql/003_schema_ext.sql`:admin_menu 加 `icon/component/hidden` 列
|
||||||
|
- `manifest/sql/004_seed.sql`:初始 admin/admin123、super_admin 角色、22 条菜单(含按钮权限码)、角色/用户绑定
|
||||||
|
- `manifest/sql/005_menu_paths.sql`:按钮行 path 填 `"METHOD /路径"` 接口映射({id} 动态段)
|
||||||
|
- auth:`/auth/info`、`/auth/codes`;路由拆分公开(NewAuth)/仅登录(NewProfile)/受保护(New)三组;新增 `AdminAuthOnly` 中间件
|
||||||
|
- menu:`/menu/routes` 返回 vben backend 动态路由树(按角色过滤+排序)
|
||||||
|
- RBAC:`/admins`、`/roles`、`/menus/tree` 及 CRUD(含角色绑定、重置密码、菜单授权、删除校验子节点)
|
||||||
|
- log:`/log/files`、`/log/tail`(反向块扫描读尾部+关键词过滤+路径穿越防护)
|
||||||
|
- 安全:接口鉴权改为「方法+路径→权限码」自动映射(`PermissionForPath`+`matchRoute`),**不再信任前端 X-Permission**
|
||||||
|
- 修复:gf v2.10.2 `${ENV}` 不自动替换 → cmd.injectEnv 注入;MySQL driver 需 blank import `contrib/drivers/mysql/v2`;gtime Format(PHP) 与 Layout(Go) 区分再次踩坑(CreatedAt 输出 layout 原样)
|
||||||
|
- `log/` 加入 .gitignore;config.dev.yaml logger.path=log(日志落盘)
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **接口鉴权用路径映射而非 X-Permission**:原实现用户可用自己拥有的任意权限码访问任意受保护接口(越权漏洞);改为后端按 method+path 查 admin_menu 映射,未配置即拒绝。
|
||||||
|
- **受保护接口分三层**:公开 login;AdminAuthOnly(info/codes/routes,登录即可取,vben 登录后立即调用);AdminAuth(RBAC/日志)。
|
||||||
|
- **admin_menu 按钮行 path 存接口映射**:与 type=1 菜单行的路由 path 语义区分开,避免冲突。
|
||||||
|
- **gf gen dao 不可用**:本机 gf CLI 为公司定制版(生成 com.lib.gf.v2 import),与项目官方 gf 不兼容;本次手动补齐 admin_menu 三字段(entity/do/table),后续换官方 CLI 或脚本化处理。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- **前端对接(vben5)**:admin.xpcool.com 需配置 accessMode=backend、登录/信息/权限码/动态路由对接、系统管理三页面+日志监控页面、按钮级 v-access:code。
|
||||||
|
- 冒烟测试已建 opuser/op 测试角色,可清理。
|
||||||
|
- 菜单管理接口的 assignMenu 权限码暂无独立接口(角色授权在 role update 中完成),保留扩展位。
|
||||||
|
- 生产环境 JWT_SECRET/DB_DSN 必须通过环境变量提供(${ENV} 不会自动替换)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(23:05):前端 vben 对接(源码完成,本地启动验证受阻)
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
继续做 vben 前端(admin.xpcool.com,vben 5.7.0 monorepo),对接后端登录/菜单/按钮级权限/RBAC/日志监控。
|
||||||
|
|
||||||
|
### 变更(前端独立仓库 commit 4615e7c)
|
||||||
|
|
||||||
|
- `.env.development`:`VITE_GLOB_API_URL` 置空、关闭 mock
|
||||||
|
- `vite.config.ts`:代理 `/admin`、`/api` → `http://localhost:8000`
|
||||||
|
- `preferences.ts`:`accessMode: 'backend'`(动态路由)、关闭 token 自动刷新
|
||||||
|
- `api/core/auth.ts`:登录/权限码路径改 `/admin/v1/...`,codes 解包 `data.codes`
|
||||||
|
- `api/core/user.ts`:`/admin/v1/auth/info` 字段映射(adminId→userId、nickname→realName、homePath)
|
||||||
|
- `api/core/menu.ts`:`/admin/v1/menu/routes` 解包 `data.routes`
|
||||||
|
- 新增 `api/system.ts`、`api/log.ts`
|
||||||
|
- 新增页面:`views/system/admin|role|menu/index.vue`(CRUD+按钮级权限)、`views/monitor/log/index.vue`(文件列表/tail/关键词过滤/自动刷新)
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **后端 component 值 `system/admin/index` 与 vben 映射**:vben `normalizeViewPath` 会去前缀、补前导 `/`、去 `/views`,最终匹配 `views/**/*.vue`,无需 `.vue` 后缀。
|
||||||
|
- **前端请求路径写完整 `/admin/v1/...` + apiURL 置空**:因 user(`/api/v1`)、open(`/api/open/v1`)、admin(`/admin/v1`) 前缀不同,写全路径最清晰,避免 proxy rewrite 混乱。
|
||||||
|
- **字段映射在后端/前端约定**:后端返回 `adminId/nickname`,前端映射为 vben `UserInfo(userId/realName)`。
|
||||||
|
|
||||||
|
### 待办与风险(本地启动验证受阻)
|
||||||
|
|
||||||
|
- **node 环境**:system node 23.0.0 未编译 `node:sqlite`(`ERR_UNKNOWN_BUILTIN_MODULE`),pnpm 11.16.0 依赖它;已用 managed node 22.22.2 + corepack wrapper 绕过。
|
||||||
|
- **`pnpm install` 卡住**:已配 `.npmrc`(npmmirror 镜像 + `node-linker=hoisted` + `store-dir=E:/.pnpm-store`),但 install 在 "added 27→98" 反复循环,疑似某 native 依赖 postinstall 失败重试。**dev server 未能启动验证**。
|
||||||
|
- 后续步骤:① 定位卡住的依赖(`pnpm install --reporter=append-only` 看具体包);② 或跳过 postinstall(`pnpm install --ignore-scripts` 后手动补 esbuild 等二进制);③ 完成 install 后 `pnpm dev:antd` 启动,浏览器验证登录/菜单/权限/日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(23:40):API 层 base/system/admin 分组重构
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
优化 API 层设计:`/api/admin/v1/base`(基础常规)、`/api/admin/v1/system`(menu/role/auth 系统管理)、`/api/admin/v1/admin`(后台管理),并同步 service/controller 层。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `api/admin/v1/` 重组为三子包:`base/log.go`(日志)、`system/{auth,menu,menu_manage,role}.go`(认证+菜单+角色)、`admin/admin.go`(管理员)
|
||||||
|
- 路由前缀变更:`/auth/*`→`/system/auth/*`;`/menu/routes`→`/system/menu/routes`;`/menus`→`/system/menu`;`/roles`→`/system/role`;`/admins`→`/admin`;`/log/*`→`/base/log/*`
|
||||||
|
- `internal/controller/admin/*.go` 改 import 对应子包(basev1/systemv1/adminv1)
|
||||||
|
- `manifest/sql/006_menu_paths_v2.sql`:按钮-接口 path 映射更新;`system:role:assignMenu` 无独立接口,path 清空
|
||||||
|
- 冒烟测试全通过:新路径 8 接口正常,旧路径 `/admins` 返回 Not Found
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **权限码与路由分离**:permission 保持 `system:admin:list` 等逻辑标识不变,只改物理路由 path。管理员管理路由在 `/admin/v1/admin` 但权限码仍 `system:admin:*`(逻辑归属系统权限体系)。
|
||||||
|
- **service/dao 层不硬拆子包**:service 保持 `internal/service` 单包按领域接口组织(GoFrame 惯例),dao 为生成代码按表组织;api/controller 体现 base/system/admin 分组即可。
|
||||||
|
- `assignMenu` 权限码保留(前端按钮),但无独立后端接口(授权合并进 role update),path 置空不映射。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- 前端 API 路径需同步为 `/admin/v1/{base,system,admin}` 前缀(当前前端代码仍是旧路径)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加(23:55):前端统一 tdesign UI + 清理无用 app
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
1. 前端 vben5 采用 tdesign UI 库;检查整个前端项目,清理用不到的目录。
|
||||||
|
2. (关联)后端 API 层 base/system/admin 分组重构(见上一条 43fa796)。
|
||||||
|
|
||||||
|
### 变更(前端仓库 commit 6725151)
|
||||||
|
|
||||||
|
- 移除 `apps/web-antd|web-ele|web-naive|web-antdv-next` 和 `backend-mock`,仅保留 `web-tdesign`(此前用户已在工作区删除,本次一并提交)
|
||||||
|
- `web-tdesign` 重新对接后端:`.env.development`(apiURL 置空/关 mock)、`vite.config.ts`(代理 /admin,/api→8000)、`preferences.ts`(accessMode=backend/关 token 刷新)
|
||||||
|
- `api/core/{auth,user,menu}.ts` + `api/{system,log}.ts`:路径对齐 base/system/admin 分组
|
||||||
|
- 页面用 tdesign-vue-next 重写:`views/system/{admin,role,menu}/index.vue`、`views/monitor/log/index.vue`
|
||||||
|
|
||||||
|
### 决策与理由
|
||||||
|
|
||||||
|
- **UI 统一 tdesign**:web-tdesign 是 vben 官方 tdesign 应用;页面组件从 ant-design-vue 换成 tdesign-vue-next(t-table/t-dialog/t-tree/MessagePlugin/DialogPlugin)。
|
||||||
|
- **权限码与路由分离(后端)**:permission 保持 `system:admin:list` 逻辑标识,路由改 `/admin/v1/admin`;管理员管理在 admin 分组但权限码仍 system:*(逻辑归属系统权限体系)。
|
||||||
|
|
||||||
|
### 待办与风险
|
||||||
|
|
||||||
|
- `pnpm install` 卡住问题仍未解决(node:sqlite 已绕过,但 install 在 native 依赖 postinstall 反复循环),dev server 未启动验证。
|
||||||
|
- 后端已删测试角色 opuser/op 可清理;前端路径已同步 base/system/admin,待 dev 启动后联调验证。
|
||||||
5
go.mod
5
go.mod
@ -4,6 +4,11 @@ go 1.23.0
|
|||||||
|
|
||||||
require github.com/gogf/gf/v2 v2.10.2
|
require github.com/gogf/gf/v2 v2.10.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -15,6 +15,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2 h1:UdUV+7GhwYLpkwz7VrwIVO/1ZYodyzSL5is25NET24A=
|
||||||
|
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.2/go.mod h1:eKc+0i3Il7efS2BBjmpy7T9wvN9NGRd67ZV94r9behA=
|
||||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
|||||||
@ -6,25 +6,50 @@ import (
|
|||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
"github.com/gogf/gf/v2/os/gcmd"
|
"github.com/gogf/gf/v2/os/gcmd"
|
||||||
|
"github.com/gogf/gf/v2/os/gcfg"
|
||||||
|
"github.com/gogf/gf/v2/os/genv"
|
||||||
|
|
||||||
adminctl "service.xpcool.com/internal/controller/admin"
|
adminctl "service.xpcool.com/internal/controller/admin"
|
||||||
"service.xpcool.com/internal/controller/hello"
|
"service.xpcool.com/internal/controller/hello"
|
||||||
|
openctl "service.xpcool.com/internal/controller/open"
|
||||||
userctl "service.xpcool.com/internal/controller/user"
|
userctl "service.xpcool.com/internal/controller/user"
|
||||||
"service.xpcool.com/internal/library/jwt"
|
"service.xpcool.com/internal/library/jwt"
|
||||||
"service.xpcool.com/internal/middleware"
|
"service.xpcool.com/internal/middleware"
|
||||||
"service.xpcool.com/internal/service"
|
"service.xpcool.com/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// injectEnv 手动把关键环境变量写入配置系统。
|
||||||
|
// 注意:gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入,
|
||||||
|
// 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。
|
||||||
|
func injectEnv(ctx context.Context) {
|
||||||
|
adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v := genv.Get("DB_DSN"); !v.IsEmpty() {
|
||||||
|
_ = adapter.Set("database.default.link", v.String())
|
||||||
|
}
|
||||||
|
if v := genv.Get("JWT_SECRET"); !v.IsEmpty() {
|
||||||
|
_ = adapter.Set("jwt.secret", v.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Main = gcmd.Command{
|
Main = gcmd.Command{
|
||||||
Name: "main",
|
Name: "main",
|
||||||
Usage: "main",
|
Usage: "main",
|
||||||
Brief: "start http server",
|
Brief: "start http server",
|
||||||
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
|
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
|
||||||
|
injectEnv(ctx)
|
||||||
s := g.Server()
|
s := g.Server()
|
||||||
tokens := jwt.New(ctx)
|
tokens := jwt.New(ctx)
|
||||||
service.RegisterUserAuth(service.NewUserAuth(tokens, nil, nil))
|
service.RegisterUserAuth(service.NewUserAuth(tokens, nil, nil))
|
||||||
service.RegisterAdminAuth(service.NewAdminAuth(tokens))
|
service.RegisterAdminAuth(service.NewAdminAuth(tokens))
|
||||||
|
service.RegisterAdminMenu(service.NewAdminMenu())
|
||||||
|
service.RegisterAdminManage(service.NewAdminManage())
|
||||||
|
service.RegisterRoleManage(service.NewRoleManage())
|
||||||
|
service.RegisterMenuManage(service.NewMenuManage())
|
||||||
|
service.RegisterLogManage(service.NewLogManage())
|
||||||
service.RegisterAdminAudit(service.NewAdminAudit())
|
service.RegisterAdminAudit(service.NewAdminAudit())
|
||||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS)
|
group.Middleware(middleware.Recover, middleware.CORS)
|
||||||
@ -33,6 +58,10 @@ var (
|
|||||||
hello.NewV1(),
|
hello.NewV1(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
s.Group("/api/open/v1", func(group *ghttp.RouterGroup) {
|
||||||
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
|
group.Bind(openctl.New()) // Open tools API for frontends, no auth required.
|
||||||
|
})
|
||||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
group.Bind(userctl.New()) // Login and refresh routes are public.
|
group.Bind(userctl.New()) // Login and refresh routes are public.
|
||||||
@ -40,11 +69,19 @@ var (
|
|||||||
})
|
})
|
||||||
s.Group("/admin/v1", func(group *ghttp.RouterGroup) {
|
s.Group("/admin/v1", func(group *ghttp.RouterGroup) {
|
||||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||||
group.Bind(adminctl.New()) // Admin login remains public; protected controllers mount separately.
|
group.Bind(adminctl.NewAuth()) // Public: login only.
|
||||||
|
group.Group("/", func(profile *ghttp.RouterGroup) {
|
||||||
|
// Login-only endpoints: own profile / access codes / menu routes.
|
||||||
|
profile.Middleware(middleware.AdminAuthOnly(tokens))
|
||||||
|
profile.Bind(adminctl.NewProfile())
|
||||||
|
})
|
||||||
group.Group("/", func(protected *ghttp.RouterGroup) {
|
group.Group("/", func(protected *ghttp.RouterGroup) {
|
||||||
protected.Middleware(middleware.AdminAuth(tokens, service.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int) {
|
// Permission-protected endpoints: RBAC management, logs, ...
|
||||||
|
// 权限由后端按「方法+路径」自动匹配,无需前端传 X-Permission。
|
||||||
|
protected.Middleware(middleware.AdminAuth(tokens, service.AdminAuth().PermissionForPath, service.AdminAuth().HasPermission, func(ctx context.Context, id uint64, permission, method, path, ip, param string, duration, status int) {
|
||||||
service.AdminAudit().Record(ctx, service.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status})
|
service.AdminAudit().Record(ctx, service.AuditEvent{AdminID: id, Permission: permission, Method: method, Path: path, IP: ip, Param: param, DurationMS: duration, StatusCode: status})
|
||||||
}))
|
}))
|
||||||
|
protected.Bind(adminctl.New())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
s.Run()
|
s.Run()
|
||||||
|
|||||||
60
internal/controller/admin/admin.go
Normal file
60
internal/controller/admin/admin.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
adminv1 "service.xpcool.com/api/admin/v1/admin"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminList pages the administrators.
|
||||||
|
func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (res *adminv1.AdminListRes, err error) {
|
||||||
|
items, total, err := service.AdminManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*adminv1.AdminItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, &adminv1.AdminItem{
|
||||||
|
Id: it.Id, Username: it.Username, Nickname: it.Nickname, Status: it.Status,
|
||||||
|
RoleIds: it.RoleIds, RoleNames: it.RoleNames, CreatedAt: it.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &adminv1.AdminListRes{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreate creates an administrator.
|
||||||
|
func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) {
|
||||||
|
id, err := service.AdminManage().Create(ctx, dto.AdminCreateInput{
|
||||||
|
Username: req.Username, Password: req.Password, Nickname: req.Nickname, RoleIds: req.RoleIds,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdate updates an administrator.
|
||||||
|
func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateReq) (res *adminv1.AdminUpdateRes, err error) {
|
||||||
|
if err = service.AdminManage().Update(ctx, dto.AdminUpdateInput{Id: req.Id, Nickname: req.Nickname, Status: req.Status, RoleIds: req.RoleIds}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetPwd resets an administrator's password.
|
||||||
|
func (c *Controller) AdminResetPwd(ctx context.Context, req *adminv1.AdminResetPwdReq) (res *adminv1.AdminResetPwdRes, err error) {
|
||||||
|
if err = service.AdminManage().ResetPassword(ctx, req.Id, req.Password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminResetPwdRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDelete deletes an administrator.
|
||||||
|
func (c *Controller) AdminDelete(ctx context.Context, req *adminv1.AdminDeleteReq) (res *adminv1.AdminDeleteRes, err error) {
|
||||||
|
if err = service.AdminManage().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &adminv1.AdminDeleteRes{}, nil
|
||||||
|
}
|
||||||
@ -2,18 +2,23 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
adminv1 "service.xpcool.com/api/admin/v1"
|
|
||||||
|
systemv1 "service.xpcool.com/api/admin/v1/system"
|
||||||
"service.xpcool.com/internal/model/dto"
|
"service.xpcool.com/internal/model/dto"
|
||||||
"service.xpcool.com/internal/service"
|
"service.xpcool.com/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Controller struct{}
|
// AuthController exposes only the public login endpoint.
|
||||||
|
type AuthController struct{}
|
||||||
|
|
||||||
func New() *Controller { return &Controller{} }
|
// NewAuth creates the public admin auth controller (login only).
|
||||||
func (c *Controller) Login(ctx context.Context, req *adminv1.LoginReq) (res *adminv1.LoginRes, err error) {
|
func NewAuth() *AuthController { return &AuthController{} }
|
||||||
|
|
||||||
|
// Login authenticates an administrator and issues a token pair.
|
||||||
|
func (c *AuthController) Login(ctx context.Context, req *systemv1.LoginReq) (res *systemv1.LoginRes, err error) {
|
||||||
p, id, err := service.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: req.Username, Password: req.Password})
|
p, id, err := service.AdminAuth().Login(ctx, dto.AdminLoginInput{Username: req.Username, Password: req.Password})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &adminv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil
|
return &systemv1.LoginRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
21
internal/controller/admin/controller.go
Normal file
21
internal/controller/admin/controller.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller implements the permission-protected /admin/v1 endpoints
|
||||||
|
// (RBAC management, logs, etc.). Bind behind AdminAuth (X-Permission).
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New creates the protected admin controller.
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
|
|
||||||
|
// adminID returns the authenticated admin id stored by the auth middleware.
|
||||||
|
func adminID(ctx context.Context) uint64 {
|
||||||
|
return g.RequestFromCtx(ctx).GetCtxVar(middleware.AdminIDKey).Uint64()
|
||||||
|
}
|
||||||
30
internal/controller/admin/log.go
Normal file
30
internal/controller/admin/log.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
basev1 "service.xpcool.com/api/admin/v1/base"
|
||||||
|
"service.xpcool.com/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogFiles lists the server log files.
|
||||||
|
func (c *Controller) LogFiles(ctx context.Context, req *basev1.LogFilesReq) (res *basev1.LogFilesRes, err error) {
|
||||||
|
dir, files, err := service.LogManage().Files(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*basev1.LogFile, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
list = append(list, &basev1.LogFile{Name: f.Name, Path: f.Path, Size: f.Size, ModTime: f.ModTime})
|
||||||
|
}
|
||||||
|
return &basev1.LogFilesRes{Dir: dir, Files: list}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTail reads the tail of a log file with optional keyword filter.
|
||||||
|
func (c *Controller) LogTail(ctx context.Context, req *basev1.LogTailReq) (res *basev1.LogTailRes, err error) {
|
||||||
|
lines, err := service.LogManage().Tail(ctx, req.File, req.Lines, req.Keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &basev1.LogTailRes{Lines: lines}, nil
|
||||||
|
}
|
||||||
64
internal/controller/admin/menu.go
Normal file
64
internal/controller/admin/menu.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
systemv1 "service.xpcool.com/api/admin/v1/system"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MenuTree returns the full menu tree (menus + button permissions).
|
||||||
|
func (c *Controller) MenuTree(ctx context.Context, req *systemv1.MenuTreeReq) (res *systemv1.MenuTreeRes, err error) {
|
||||||
|
tree, err := service.MenuManage().Tree(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*systemv1.MenuItem, 0, len(tree))
|
||||||
|
for _, n := range tree {
|
||||||
|
out = append(out, menuNodeToV1(n))
|
||||||
|
}
|
||||||
|
return &systemv1.MenuTreeRes{Tree: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuCreate creates a menu or button node.
|
||||||
|
func (c *Controller) MenuCreate(ctx context.Context, req *systemv1.MenuCreateReq) (res *systemv1.MenuCreateRes, err error) {
|
||||||
|
id, err := service.MenuManage().Create(ctx, dto.MenuCreateInput{
|
||||||
|
ParentId: req.ParentId, Name: req.Name, Icon: req.Icon, Type: req.Type, Path: req.Path,
|
||||||
|
Component: req.Component, Permission: req.Permission, Sort: req.Sort, Status: req.Status, Hidden: req.Hidden,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.MenuCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuUpdate updates a menu or button node.
|
||||||
|
func (c *Controller) MenuUpdate(ctx context.Context, req *systemv1.MenuUpdateReq) (res *systemv1.MenuUpdateRes, err error) {
|
||||||
|
if err = service.MenuManage().Update(ctx, dto.MenuUpdateInput{
|
||||||
|
Id: req.Id, ParentId: req.ParentId, Name: req.Name, Icon: req.Icon, Type: req.Type, Path: req.Path,
|
||||||
|
Component: req.Component, Permission: req.Permission, Sort: req.Sort, Status: req.Status, Hidden: req.Hidden,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.MenuUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuDelete deletes a menu node.
|
||||||
|
func (c *Controller) MenuDelete(ctx context.Context, req *systemv1.MenuDeleteReq) (res *systemv1.MenuDeleteRes, err error) {
|
||||||
|
if err = service.MenuManage().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.MenuDeleteRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuNodeToV1(n *dto.MenuNode) *systemv1.MenuItem {
|
||||||
|
item := &systemv1.MenuItem{
|
||||||
|
Id: n.Id, ParentId: n.ParentId, Name: n.Name, Icon: n.Icon, Type: n.Type, Path: n.Path,
|
||||||
|
Component: n.Component, Permission: n.Permission, Sort: n.Sort, Status: n.Status, Hidden: n.Hidden,
|
||||||
|
}
|
||||||
|
for _, c := range n.Children {
|
||||||
|
item.Children = append(item.Children, menuNodeToV1(c))
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
67
internal/controller/admin/profile.go
Normal file
67
internal/controller/admin/profile.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
systemv1 "service.xpcool.com/api/admin/v1/system"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileController exposes the logged-in admin's own profile endpoints
|
||||||
|
// (login-only, no X-Permission required). Bind behind AdminAuthOnly.
|
||||||
|
type ProfileController struct{}
|
||||||
|
|
||||||
|
// NewProfile creates the profile controller.
|
||||||
|
func NewProfile() *ProfileController { return &ProfileController{} }
|
||||||
|
|
||||||
|
// Info returns the current administrator profile.
|
||||||
|
func (c *ProfileController) Info(ctx context.Context, req *systemv1.InfoReq) (res *systemv1.InfoRes, err error) {
|
||||||
|
info, err := service.AdminAuth().Info(ctx, adminID(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.InfoRes{AdminID: info.AdminID, Username: info.Username, Nickname: info.Nickname, Roles: info.Roles}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codes returns the button-level permission codes of the current administrator.
|
||||||
|
func (c *ProfileController) Codes(ctx context.Context, req *systemv1.CodesReq) (res *systemv1.CodesRes, err error) {
|
||||||
|
codes, err := service.AdminAuth().Codes(ctx, adminID(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.CodesRes{Codes: codes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns the current administrator's visible menu tree as vben routes.
|
||||||
|
func (c *ProfileController) Routes(ctx context.Context, req *systemv1.MenuRoutesReq) (res *systemv1.MenuRoutesRes, err error) {
|
||||||
|
routes, err := service.AdminMenu().Routes(ctx, adminID(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*systemv1.RouteItem, 0, len(routes))
|
||||||
|
for _, r := range routes {
|
||||||
|
out = append(out, toV1Route(r))
|
||||||
|
}
|
||||||
|
return &systemv1.MenuRoutesRes{Routes: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toV1Route converts a dto route tree into the v1 API shape.
|
||||||
|
func toV1Route(r *dto.RouteItem) *systemv1.RouteItem {
|
||||||
|
item := &systemv1.RouteItem{
|
||||||
|
Name: r.Name,
|
||||||
|
Path: r.Path,
|
||||||
|
Component: r.Component,
|
||||||
|
Meta: systemv1.RouteMeta{
|
||||||
|
Title: r.Meta.Title,
|
||||||
|
Icon: r.Meta.Icon,
|
||||||
|
Order: r.Meta.Order,
|
||||||
|
Authority: r.Meta.Authority,
|
||||||
|
HideInMenu: r.Meta.HideInMenu,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range r.Children {
|
||||||
|
item.Children = append(item.Children, toV1Route(c))
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
49
internal/controller/admin/role.go
Normal file
49
internal/controller/admin/role.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
systemv1 "service.xpcool.com/api/admin/v1/system"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleList pages the roles.
|
||||||
|
func (c *Controller) RoleList(ctx context.Context, req *systemv1.RoleListReq) (res *systemv1.RoleListRes, err error) {
|
||||||
|
items, total, err := service.RoleManage().List(ctx, dto.PageQuery{Page: req.Page, Size: req.Size, Keyword: req.Keyword})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list := make([]*systemv1.RoleItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, &systemv1.RoleItem{
|
||||||
|
Id: it.Id, Code: it.Code, Name: it.Name, Status: it.Status, MenuIds: it.MenuIds, CreatedAt: it.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &systemv1.RoleListRes{List: list, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCreate creates a role.
|
||||||
|
func (c *Controller) RoleCreate(ctx context.Context, req *systemv1.RoleCreateReq) (res *systemv1.RoleCreateRes, err error) {
|
||||||
|
id, err := service.RoleManage().Create(ctx, dto.RoleCreateInput{Code: req.Code, Name: req.Name, Status: req.Status, MenuIds: req.MenuIds})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.RoleCreateRes{Id: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleUpdate updates a role.
|
||||||
|
func (c *Controller) RoleUpdate(ctx context.Context, req *systemv1.RoleUpdateReq) (res *systemv1.RoleUpdateRes, err error) {
|
||||||
|
if err = service.RoleManage().Update(ctx, dto.RoleUpdateInput{Id: req.Id, Name: req.Name, Status: req.Status, MenuIds: req.MenuIds}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.RoleUpdateRes{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleDelete deletes a role.
|
||||||
|
func (c *Controller) RoleDelete(ctx context.Context, req *systemv1.RoleDeleteReq) (res *systemv1.RoleDeleteRes, err error) {
|
||||||
|
if err = service.RoleManage().Delete(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &systemv1.RoleDeleteRes{}, nil
|
||||||
|
}
|
||||||
11
internal/controller/open/controller.go
Normal file
11
internal/controller/open/controller.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Package open implements the public open API (/api/open/v1).
|
||||||
|
// These controllers are thin adapters over the common/tools Go packages and
|
||||||
|
// require no authentication. Each sub-feature lives in its own file here,
|
||||||
|
// mirroring api/open/v1/tools/<name>/index.go.
|
||||||
|
package open
|
||||||
|
|
||||||
|
// Controller implements the /api/open/v1 endpoints.
|
||||||
|
type Controller struct{}
|
||||||
|
|
||||||
|
// New creates an open API controller.
|
||||||
|
func New() *Controller { return &Controller{} }
|
||||||
16
internal/controller/open/ip.go
Normal file
16
internal/controller/open/ip.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
|
||||||
|
ipapi "service.xpcool.com/api/open/v1/tools/ip"
|
||||||
|
"service.xpcool.com/common/tools/ip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IP returns the caller's IP and whether it is an internal address.
|
||||||
|
func (c *Controller) IP(ctx context.Context, req *ipapi.IPReq) (res *ipapi.IPRes, err error) {
|
||||||
|
clientIP := g.RequestFromCtx(ctx).GetClientIp()
|
||||||
|
return &ipapi.IPRes{IP: clientIP, Internal: ip.IsInternal(clientIP)}, nil
|
||||||
|
}
|
||||||
13
internal/controller/open/md5.go
Normal file
13
internal/controller/open/md5.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
md5api "service.xpcool.com/api/open/v1/tools/md5"
|
||||||
|
"service.xpcool.com/common/tools/md5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MD5 computes the MD5 digest of the given text.
|
||||||
|
func (c *Controller) MD5(ctx context.Context, req *md5api.MD5Req) (res *md5api.MD5Res, err error) {
|
||||||
|
return &md5api.MD5Res{MD5: md5.Md5Hex(req.Text)}, nil
|
||||||
|
}
|
||||||
22
internal/controller/open/random.go
Normal file
22
internal/controller/open/random.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
randomapi "service.xpcool.com/api/open/v1/tools/random"
|
||||||
|
"service.xpcool.com/common/tools/random"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Random generates a random string of the requested type and length.
|
||||||
|
func (c *Controller) Random(ctx context.Context, req *randomapi.RandomReq) (res *randomapi.RandomRes, err error) {
|
||||||
|
var value string
|
||||||
|
switch req.Type {
|
||||||
|
case "digits":
|
||||||
|
value = random.Digits(req.Length)
|
||||||
|
case "letters":
|
||||||
|
value = random.Letters(req.Length)
|
||||||
|
default:
|
||||||
|
value = random.String(req.Length)
|
||||||
|
}
|
||||||
|
return &randomapi.RandomRes{Value: value}, nil
|
||||||
|
}
|
||||||
18
internal/controller/open/time.go
Normal file
18
internal/controller/open/time.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
timeapi "service.xpcool.com/api/open/v1/tools/time"
|
||||||
|
"service.xpcool.com/common/tools/timex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Time returns the current server timestamp and formatted time.
|
||||||
|
func (c *Controller) Time(ctx context.Context, req *timeapi.TimeReq) (res *timeapi.TimeRes, err error) {
|
||||||
|
now := timex.Now()
|
||||||
|
return &timeapi.TimeRes{
|
||||||
|
Timestamp: now.Timestamp(),
|
||||||
|
DateTime: now.Layout(timex.LayoutDateTime),
|
||||||
|
Date: now.Layout(timex.LayoutDate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
16
internal/controller/open/uuid.go
Normal file
16
internal/controller/open/uuid.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package open
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
uuidapi "service.xpcool.com/api/open/v1/tools/uuid"
|
||||||
|
"service.xpcool.com/common/tools/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UUID generates a unique ID (32-char by default, 8-char when short=true).
|
||||||
|
func (c *Controller) UUID(ctx context.Context, req *uuidapi.UUIDReq) (res *uuidapi.UUIDRes, err error) {
|
||||||
|
if req.Short {
|
||||||
|
return &uuidapi.UUIDRes{UUID: uuid.Short(8)}, nil
|
||||||
|
}
|
||||||
|
return &uuidapi.UUIDRes{UUID: uuid.New()}, nil
|
||||||
|
}
|
||||||
@ -42,8 +42,23 @@ func UserAuth(s *jwt.Service) ghttp.HandlerFunc {
|
|||||||
r.Middleware.Next()
|
r.Middleware.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func AdminAuth(s *jwt.Service, permissionCheck func(context.Context, uint64, string) (bool, error), audit func(context.Context, uint64, string, string, string, string, string, int, int)) ghttp.HandlerFunc {
|
func AdminAuthOnly(s *jwt.Service) ghttp.HandlerFunc {
|
||||||
// 管理端在令牌通过后继续校验 X-Permission 对应的 RBAC 权限,并在请求结束后记审计日志。
|
// 仅校验 admin access token(不要求 X-Permission),用于登录后即可访问的
|
||||||
|
// 个人资料/权限码/菜单路由等接口,如 /auth/info、/auth/codes、/menu/routes。
|
||||||
|
return func(r *ghttp.Request) {
|
||||||
|
c, err := s.Parse(bearer(r), "access", "admin")
|
||||||
|
if err != nil {
|
||||||
|
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.SetCtxVar(AdminIDKey, c.Subject)
|
||||||
|
r.Middleware.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, string) (string, error), permissionCheck func(context.Context, uint64, string) (bool, error), audit func(context.Context, uint64, string, string, string, string, string, int, int)) ghttp.HandlerFunc {
|
||||||
|
// 管理端接口鉴权:先解析 admin token,再按「请求方法+路径」反查所需权限码
|
||||||
|
// (admin_menu type=2 行的 path 映射),最后校验该管理员是否拥有该权限码。
|
||||||
|
// 未配置映射的接口一律拒绝,防止用任意已拥有权限码越权访问。
|
||||||
return func(r *ghttp.Request) {
|
return func(r *ghttp.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
c, err := s.Parse(bearer(r), "access", "admin")
|
c, err := s.Parse(bearer(r), "access", "admin")
|
||||||
@ -51,9 +66,9 @@ func AdminAuth(s *jwt.Service, permissionCheck func(context.Context, uint64, str
|
|||||||
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
|
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permission := r.Header.Get("X-Permission")
|
permission, err := permissionLookup(r.Context(), r.Method, r.URL.Path)
|
||||||
if permission == "" {
|
if err != nil || permission == "" {
|
||||||
response.JSON(r, consts.CodeForbidden, "permission identifier required", nil)
|
response.JSON(r, consts.CodeForbidden, "permission mapping not configured", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok, err := permissionCheck(r.Context(), c.Subject, permission)
|
ok, err := permissionCheck(r.Context(), c.Subject, permission)
|
||||||
|
|||||||
@ -15,11 +15,14 @@ type AdminMenu struct {
|
|||||||
Id any //
|
Id any //
|
||||||
ParentId any //
|
ParentId any //
|
||||||
Name any //
|
Name any //
|
||||||
|
Icon any // menu icon (iconify name)
|
||||||
Type any // 1 menu,2 api
|
Type any // 1 menu,2 api
|
||||||
Path any //
|
Path any //
|
||||||
|
Component any // vue component path, empty for top-level dir
|
||||||
Permission any //
|
Permission any //
|
||||||
Sort any //
|
Sort any //
|
||||||
Status any //
|
Status any //
|
||||||
|
Hidden any // 0 show,1 hide in menu
|
||||||
CreatedAt *gtime.Time //
|
CreatedAt *gtime.Time //
|
||||||
UpdatedAt *gtime.Time //
|
UpdatedAt *gtime.Time //
|
||||||
DeletedAt *gtime.Time //
|
DeletedAt *gtime.Time //
|
||||||
|
|||||||
20
internal/model/dto/admin_menu.go
Normal file
20
internal/model/dto/admin_menu.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// Package dto defines service boundary types for the admin RBAC domain.
|
||||||
|
package dto
|
||||||
|
|
||||||
|
// RouteItem mirrors the vben admin dynamic-route shape (backend access mode).
|
||||||
|
type RouteItem struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Component string
|
||||||
|
Meta RouteMeta
|
||||||
|
Children []*RouteItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteMeta is the route metadata consumed by vben.
|
||||||
|
type RouteMeta struct {
|
||||||
|
Title string
|
||||||
|
Icon string
|
||||||
|
Order int
|
||||||
|
Authority []string // role codes
|
||||||
|
HideInMenu bool
|
||||||
|
}
|
||||||
98
internal/model/dto/admin_rbac.go
Normal file
98
internal/model/dto/admin_rbac.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Package dto — RBAC management inputs/outputs.
|
||||||
|
package dto
|
||||||
|
|
||||||
|
type PageQuery struct {
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
Keyword string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminItem struct {
|
||||||
|
Id uint64
|
||||||
|
Username string
|
||||||
|
Nickname string
|
||||||
|
Status int
|
||||||
|
RoleIds []uint64
|
||||||
|
RoleNames []string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminCreateInput struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Nickname string
|
||||||
|
RoleIds []uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUpdateInput struct {
|
||||||
|
Id uint64
|
||||||
|
Nickname string
|
||||||
|
Status int
|
||||||
|
RoleIds []uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleItem struct {
|
||||||
|
Id uint64
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Status int
|
||||||
|
MenuIds []uint64
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleCreateInput struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Status int
|
||||||
|
MenuIds []uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleUpdateInput struct {
|
||||||
|
Id uint64
|
||||||
|
Name string
|
||||||
|
Status int
|
||||||
|
MenuIds []uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuNode is the full menu tree node used by menu management.
|
||||||
|
type MenuNode struct {
|
||||||
|
Id uint64
|
||||||
|
ParentId uint64
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
Type int
|
||||||
|
Path string
|
||||||
|
Component string
|
||||||
|
Permission string
|
||||||
|
Sort int
|
||||||
|
Status int
|
||||||
|
Hidden bool
|
||||||
|
Children []*MenuNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuCreateInput struct {
|
||||||
|
ParentId uint64
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
Type int
|
||||||
|
Path string
|
||||||
|
Component string
|
||||||
|
Permission string
|
||||||
|
Sort int
|
||||||
|
Status int
|
||||||
|
Hidden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuUpdateInput struct {
|
||||||
|
Id uint64
|
||||||
|
ParentId uint64
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
Type int
|
||||||
|
Path string
|
||||||
|
Component string
|
||||||
|
Permission string
|
||||||
|
Sort int
|
||||||
|
Status int
|
||||||
|
Hidden bool
|
||||||
|
}
|
||||||
@ -7,3 +7,9 @@ type TokenPair struct {
|
|||||||
RefreshToken string `json:"refreshToken"`
|
RefreshToken string `json:"refreshToken"`
|
||||||
ExpiresIn int64 `json:"expiresIn"`
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
}
|
}
|
||||||
|
type AdminInfo struct {
|
||||||
|
AdminID uint64
|
||||||
|
Username string
|
||||||
|
Nickname string
|
||||||
|
Roles []string // 角色码,供 vben authority 使用
|
||||||
|
}
|
||||||
|
|||||||
10
internal/model/dto/log.go
Normal file
10
internal/model/dto/log.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Package dto — log monitoring types.
|
||||||
|
package dto
|
||||||
|
|
||||||
|
// LogFile describes one server log file.
|
||||||
|
type LogFile struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
ModTime string
|
||||||
|
}
|
||||||
@ -10,15 +10,18 @@ import (
|
|||||||
|
|
||||||
// AdminMenu is the golang structure for table admin_menu.
|
// AdminMenu is the golang structure for table admin_menu.
|
||||||
type AdminMenu struct {
|
type AdminMenu struct {
|
||||||
Id uint64 `json:"id" orm:"id" description:""` //
|
Id uint64 `json:"id" orm:"id" description:""` //
|
||||||
ParentId uint64 `json:"parentId" orm:"parent_id" description:""` //
|
ParentId uint64 `json:"parentId" orm:"parent_id" description:""` //
|
||||||
Name string `json:"name" orm:"name" description:""` //
|
Name string `json:"name" orm:"name" description:""` //
|
||||||
Type int `json:"type" orm:"type" description:"1 menu,2 api"` // 1 menu,2 api
|
Icon string `json:"icon" orm:"icon" description:"menu icon (iconify name)"` // menu icon (iconify name)
|
||||||
Path string `json:"path" orm:"path" description:""` //
|
Type int `json:"type" orm:"type" description:"1 menu,2 api"` // 1 menu,2 api
|
||||||
Permission string `json:"permission" orm:"permission" description:""` //
|
Path string `json:"path" orm:"path" description:""` //
|
||||||
Sort int `json:"sort" orm:"sort" description:""` //
|
Component string `json:"component" orm:"component" description:"vue component path, empty for top-level dir"` // vue component path, empty for top-level dir
|
||||||
Status int `json:"status" orm:"status" description:""` //
|
Permission string `json:"permission" orm:"permission" description:""` //
|
||||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
|
Sort int `json:"sort" orm:"sort" description:""` //
|
||||||
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
|
Status int `json:"status" orm:"status" description:""` //
|
||||||
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
|
Hidden bool `json:"hidden" orm:"hidden" description:"0 show,1 hide in menu"` // 0 show,1 hide in menu
|
||||||
|
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
|
||||||
|
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
|
||||||
|
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/errors/gerror"
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"service.xpcool.com/internal/consts"
|
"service.xpcool.com/internal/consts"
|
||||||
@ -42,3 +45,85 @@ func (s *adminAuth) HasPermission(ctx context.Context, adminID uint64, permissio
|
|||||||
}
|
}
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
func (s *adminAuth) Info(ctx context.Context, adminID uint64) (*dto.AdminInfo, error) {
|
||||||
|
var a entity.AdminUser
|
||||||
|
if err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: adminID}).Scan(&a); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query admin info")
|
||||||
|
}
|
||||||
|
if a.Id == 0 {
|
||||||
|
return nil, response.Error(consts.CodeAdminNotFound, "administrator not found")
|
||||||
|
}
|
||||||
|
roles, err := s.roleCodes(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto.AdminInfo{AdminID: a.Id, Username: a.Username, Nickname: a.Nickname, Roles: roles}, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) Codes(ctx context.Context, adminID uint64) ([]string, error) {
|
||||||
|
// 权限码 = 该管理员所有启用角色绑定的菜单 permission(含菜单与按钮级),
|
||||||
|
// 同时用作 vben 前端按钮权限码与后端 X-Permission 校验标识。
|
||||||
|
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").LeftJoin("admin_menu m", "rm.menu_id=m.id").Where("ur.admin_user_id", adminID).Where("m.status", 1).WhereGT("m.permission", "").Fields("DISTINCT m.permission").Array()
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query access codes")
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(list))
|
||||||
|
for _, v := range list {
|
||||||
|
codes = append(codes, v.String())
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) roleCodes(ctx context.Context, adminID uint64) ([]string, error) {
|
||||||
|
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").Where("ur.admin_user_id", adminID).Where("r.status", 1).Fields("DISTINCT r.code").Array()
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query admin roles")
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(list))
|
||||||
|
for _, v := range list {
|
||||||
|
codes = append(codes, v.String())
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
func (s *adminAuth) PermissionForPath(ctx context.Context, method, path string) (string, error) {
|
||||||
|
// 从菜单表 type=2(按钮/API)行反查当前请求所需的权限码。
|
||||||
|
// 未配置映射的接口一律拒绝访问(返回空则中间件拦截)。
|
||||||
|
var list []entity.AdminMenu
|
||||||
|
if err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Type: 2, Status: 1}).Scan(&list); err != nil {
|
||||||
|
return "", gerror.Wrap(err, "query permission mappings")
|
||||||
|
}
|
||||||
|
req := method + " " + path
|
||||||
|
for _, m := range list {
|
||||||
|
if matchRoute(m.Path, req) {
|
||||||
|
return m.Permission, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRoute 匹配 "METHOD /path" 模式,{id} 视为动态段。
|
||||||
|
func matchRoute(pattern, req string) bool {
|
||||||
|
if pattern == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteByte('^')
|
||||||
|
for i := 0; i < len(pattern); i++ {
|
||||||
|
c := pattern[i]
|
||||||
|
switch {
|
||||||
|
case c == '{':
|
||||||
|
if j := strings.IndexByte(pattern[i:], '}'); j > 0 {
|
||||||
|
b.WriteString("[^/]+")
|
||||||
|
i += j
|
||||||
|
} else {
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||||
|
}
|
||||||
|
case c == ' ' || c == '/' || c == '-' || c == '_' || c == '.' ||
|
||||||
|
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'):
|
||||||
|
b.WriteByte(c)
|
||||||
|
default:
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('$')
|
||||||
|
ok, err := regexp.MatchString(b.String(), req)
|
||||||
|
return err == nil && ok
|
||||||
|
}
|
||||||
|
|||||||
90
internal/service/admin_menu.go
Normal file
90
internal/service/admin_menu.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/text/gstr"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/dao"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IAdminMenu interface {
|
||||||
|
// Routes returns the visible menu tree of an admin as vben route items.
|
||||||
|
Routes(context.Context, uint64) ([]*dto.RouteItem, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminMenu struct{}
|
||||||
|
|
||||||
|
func NewAdminMenu() IAdminMenu { return &adminMenu{} }
|
||||||
|
|
||||||
|
var localAdminMenu IAdminMenu
|
||||||
|
|
||||||
|
func AdminMenu() IAdminMenu {
|
||||||
|
if localAdminMenu == nil {
|
||||||
|
panic("AdminMenu implementation not registered")
|
||||||
|
}
|
||||||
|
return localAdminMenu
|
||||||
|
}
|
||||||
|
func RegisterAdminMenu(i IAdminMenu) { localAdminMenu = i }
|
||||||
|
|
||||||
|
// routeName converts a permission code like "system:admin" to a unique
|
||||||
|
// vben route name, e.g. "SystemAdmin".
|
||||||
|
func routeName(permission string) string {
|
||||||
|
if permission == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return gstr.CaseCamel(strings.ReplaceAll(permission, ":", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminMenu) Routes(ctx context.Context, adminID uint64) ([]*dto.RouteItem, error) {
|
||||||
|
// 当前管理员所有启用角色可见的菜单(type=1),含按钮行但不作为路由节点。
|
||||||
|
var list []entity.AdminMenu
|
||||||
|
if err := dao.AdminUserRole.Ctx(ctx).As("ur").
|
||||||
|
LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").
|
||||||
|
LeftJoin("admin_menu m", "rm.menu_id=m.id").
|
||||||
|
Where("ur.admin_user_id", adminID).
|
||||||
|
Where("m.type", 1).
|
||||||
|
Where("m.status", 1).
|
||||||
|
Where("m.deleted_at IS NULL").
|
||||||
|
Fields("m.*").
|
||||||
|
Scan(&list); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query admin menu routes")
|
||||||
|
}
|
||||||
|
byID := make(map[uint64]*dto.RouteItem, len(list))
|
||||||
|
for i := range list {
|
||||||
|
m := &list[i]
|
||||||
|
byID[m.Id] = &dto.RouteItem{
|
||||||
|
Name: routeName(m.Permission),
|
||||||
|
Path: m.Path,
|
||||||
|
Component: m.Component,
|
||||||
|
Meta: dto.RouteMeta{Title: m.Name, Icon: m.Icon, Order: m.Sort, HideInMenu: m.Hidden},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var roots []*dto.RouteItem
|
||||||
|
for i := range list {
|
||||||
|
m := &list[i]
|
||||||
|
item := byID[m.Id]
|
||||||
|
if m.ParentId == 0 {
|
||||||
|
roots = append(roots, item)
|
||||||
|
} else if p, ok := byID[m.ParentId]; ok {
|
||||||
|
p.Children = append(p.Children, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(roots, func(i, j int) bool { return roots[i].Meta.Order < roots[j].Meta.Order })
|
||||||
|
for _, r := range roots {
|
||||||
|
sortRouteChildren(r)
|
||||||
|
}
|
||||||
|
return roots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortRouteChildren(r *dto.RouteItem) {
|
||||||
|
sort.Slice(r.Children, func(i, j int) bool { return r.Children[i].Meta.Order < r.Children[j].Meta.Order })
|
||||||
|
for _, c := range r.Children {
|
||||||
|
sortRouteChildren(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
383
internal/service/admin_rbac.go
Normal file
383
internal/service/admin_rbac.go
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/consts"
|
||||||
|
"service.xpcool.com/internal/dao"
|
||||||
|
"service.xpcool.com/internal/library/response"
|
||||||
|
"service.xpcool.com/internal/model/do"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
"service.xpcool.com/internal/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IAdminManage manages administrators.
|
||||||
|
type IAdminManage interface {
|
||||||
|
List(context.Context, dto.PageQuery) ([]*dto.AdminItem, int, error)
|
||||||
|
Create(context.Context, dto.AdminCreateInput) (uint64, error)
|
||||||
|
Update(context.Context, dto.AdminUpdateInput) error
|
||||||
|
ResetPassword(context.Context, uint64, string) error
|
||||||
|
Delete(context.Context, uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRoleManage manages roles and their menu bindings.
|
||||||
|
type IRoleManage interface {
|
||||||
|
List(context.Context, dto.PageQuery) ([]*dto.RoleItem, int, error)
|
||||||
|
Create(context.Context, dto.RoleCreateInput) (uint64, error)
|
||||||
|
Update(context.Context, dto.RoleUpdateInput) error
|
||||||
|
Delete(context.Context, uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMenuManage manages the menu tree (menus + button permissions).
|
||||||
|
type IMenuManage interface {
|
||||||
|
Tree(context.Context) ([]*dto.MenuNode, error)
|
||||||
|
Create(context.Context, dto.MenuCreateInput) (uint64, error)
|
||||||
|
Update(context.Context, dto.MenuUpdateInput) error
|
||||||
|
Delete(context.Context, uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminManage struct{}
|
||||||
|
type roleManage struct{}
|
||||||
|
type menuManage struct{}
|
||||||
|
|
||||||
|
func NewAdminManage() IAdminManage { return &adminManage{} }
|
||||||
|
func NewRoleManage() IRoleManage { return &roleManage{} }
|
||||||
|
func NewMenuManage() IMenuManage { return &menuManage{} }
|
||||||
|
|
||||||
|
var (
|
||||||
|
localAdminManage IAdminManage
|
||||||
|
localRoleManage IRoleManage
|
||||||
|
localMenuManage IMenuManage
|
||||||
|
)
|
||||||
|
|
||||||
|
func AdminManage() IAdminManage {
|
||||||
|
if localAdminManage == nil {
|
||||||
|
panic("AdminManage implementation not registered")
|
||||||
|
}
|
||||||
|
return localAdminManage
|
||||||
|
}
|
||||||
|
func RegisterAdminManage(i IAdminManage) { localAdminManage = i }
|
||||||
|
func RoleManage() IRoleManage {
|
||||||
|
if localRoleManage == nil {
|
||||||
|
panic("RoleManage implementation not registered")
|
||||||
|
}
|
||||||
|
return localRoleManage
|
||||||
|
}
|
||||||
|
func RegisterRoleManage(i IRoleManage) { localRoleManage = i }
|
||||||
|
func MenuManage() IMenuManage {
|
||||||
|
if localMenuManage == nil {
|
||||||
|
panic("MenuManage implementation not registered")
|
||||||
|
}
|
||||||
|
return localMenuManage
|
||||||
|
}
|
||||||
|
func RegisterMenuManage(i IMenuManage) { localMenuManage = i }
|
||||||
|
|
||||||
|
// ---------------------- Admin manage ----------------------
|
||||||
|
|
||||||
|
func (s *adminManage) List(ctx context.Context, q dto.PageQuery) ([]*dto.AdminItem, int, error) {
|
||||||
|
total, err := dao.AdminUser.Ctx(ctx).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "count admins")
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
var list []entity.AdminUser
|
||||||
|
if err = dao.AdminUser.Ctx(ctx).Page(q.Page, q.Size).OrderDesc("id").Scan(&list); err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "query admins")
|
||||||
|
}
|
||||||
|
items := make([]*dto.AdminItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
a := &list[i]
|
||||||
|
roleIds, roleNames, err := s.roles(ctx, a.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, &dto.AdminItem{
|
||||||
|
Id: a.Id, Username: a.Username, Nickname: a.Nickname, Status: a.Status,
|
||||||
|
RoleIds: roleIds, RoleNames: roleNames,
|
||||||
|
CreatedAt: a.CreatedAt.Layout("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminManage) roles(ctx context.Context, adminID uint64) ([]uint64, []string, error) {
|
||||||
|
var rels []entity.AdminUserRole
|
||||||
|
if err := dao.AdminUserRole.Ctx(ctx).Where(do.AdminUserRole{AdminUserId: adminID}).Scan(&rels); err != nil {
|
||||||
|
return nil, nil, gerror.Wrap(err, "query admin roles")
|
||||||
|
}
|
||||||
|
roleIds := make([]uint64, 0, len(rels))
|
||||||
|
for _, r := range rels {
|
||||||
|
roleIds = append(roleIds, r.RoleId)
|
||||||
|
}
|
||||||
|
if len(roleIds) == 0 {
|
||||||
|
return roleIds, nil, nil
|
||||||
|
}
|
||||||
|
var roles []entity.AdminRole
|
||||||
|
if err := dao.AdminRole.Ctx(ctx).WhereIn("id", roleIds).Scan(&roles); err != nil {
|
||||||
|
return nil, nil, gerror.Wrap(err, "query roles")
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(roles))
|
||||||
|
for _, r := range roles {
|
||||||
|
names = append(names, r.Name)
|
||||||
|
}
|
||||||
|
return roleIds, names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminManage) Create(ctx context.Context, in dto.AdminCreateInput) (uint64, error) {
|
||||||
|
count, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Username: in.Username}).Count()
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "check username")
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return 0, response.Error(consts.CodeInvalidParam, "username already exists")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "hash password")
|
||||||
|
}
|
||||||
|
id, err := dao.AdminUser.Ctx(ctx).Data(do.AdminUser{
|
||||||
|
Username: in.Username, PasswordHash: string(hash), Nickname: in.Nickname, Status: 1,
|
||||||
|
}).InsertAndGetId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "insert admin")
|
||||||
|
}
|
||||||
|
if err = bindAdminRoles(ctx, uint64(id), in.RoleIds); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminManage) Update(ctx context.Context, in dto.AdminUpdateInput) error {
|
||||||
|
data := do.AdminUser{Nickname: in.Nickname, Status: in.Status}
|
||||||
|
if _, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: in.Id}).Data(data).Update(); err != nil {
|
||||||
|
return gerror.Wrap(err, "update admin")
|
||||||
|
}
|
||||||
|
return bindAdminRoles(ctx, in.Id, in.RoleIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminManage) ResetPassword(ctx context.Context, id uint64, password string) error {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "hash password")
|
||||||
|
}
|
||||||
|
if _, err = dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: id}).Data(do.AdminUser{PasswordHash: string(hash)}).Update(); err != nil {
|
||||||
|
return gerror.Wrap(err, "reset password")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *adminManage) Delete(ctx context.Context, id uint64) error {
|
||||||
|
if _, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete admin")
|
||||||
|
}
|
||||||
|
// 关联关系物理删除,避免唯一键残留。
|
||||||
|
if _, err := dao.AdminUserRole.Ctx(ctx).Unscoped().Where(do.AdminUserRole{AdminUserId: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete admin role bindings")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindAdminRoles 全量重绑管理员-角色关系。
|
||||||
|
func bindAdminRoles(ctx context.Context, adminID uint64, roleIds []uint64) error {
|
||||||
|
if _, err := dao.AdminUserRole.Ctx(ctx).Unscoped().Where(do.AdminUserRole{AdminUserId: adminID}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "clear admin roles")
|
||||||
|
}
|
||||||
|
for _, rid := range roleIds {
|
||||||
|
if _, err := dao.AdminUserRole.Ctx(ctx).Data(do.AdminUserRole{AdminUserId: adminID, RoleId: rid}).Insert(); err != nil {
|
||||||
|
return gerror.Wrap(err, "bind admin role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------- Role manage ----------------------
|
||||||
|
|
||||||
|
func (s *roleManage) List(ctx context.Context, q dto.PageQuery) ([]*dto.RoleItem, int, error) {
|
||||||
|
total, err := dao.AdminRole.Ctx(ctx).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "count roles")
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
var list []entity.AdminRole
|
||||||
|
if err = dao.AdminRole.Ctx(ctx).Page(q.Page, q.Size).OrderAsc("id").Scan(&list); err != nil {
|
||||||
|
return nil, 0, gerror.Wrap(err, "query roles")
|
||||||
|
}
|
||||||
|
items := make([]*dto.RoleItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
r := &list[i]
|
||||||
|
menuIds, err := roleMenuIDs(ctx, r.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, &dto.RoleItem{
|
||||||
|
Id: r.Id, Code: r.Code, Name: r.Name, Status: r.Status, MenuIds: menuIds,
|
||||||
|
CreatedAt: r.CreatedAt.Layout("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roleManage) Create(ctx context.Context, in dto.RoleCreateInput) (uint64, error) {
|
||||||
|
count, err := dao.AdminRole.Ctx(ctx).Where(do.AdminRole{Code: in.Code}).Count()
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "check role code")
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return 0, response.Error(consts.CodeInvalidParam, "role code already exists")
|
||||||
|
}
|
||||||
|
id, err := dao.AdminRole.Ctx(ctx).Data(do.AdminRole{Code: in.Code, Name: in.Name, Status: in.Status}).InsertAndGetId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "insert role")
|
||||||
|
}
|
||||||
|
if err = bindRoleMenus(ctx, uint64(id), in.MenuIds); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roleManage) Update(ctx context.Context, in dto.RoleUpdateInput) error {
|
||||||
|
if _, err := dao.AdminRole.Ctx(ctx).Where(do.AdminRole{Id: in.Id}).Data(do.AdminRole{Name: in.Name, Status: in.Status}).Update(); err != nil {
|
||||||
|
return gerror.Wrap(err, "update role")
|
||||||
|
}
|
||||||
|
return bindRoleMenus(ctx, in.Id, in.MenuIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roleManage) Delete(ctx context.Context, id uint64) error {
|
||||||
|
if _, err := dao.AdminRole.Ctx(ctx).Where(do.AdminRole{Id: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete role")
|
||||||
|
}
|
||||||
|
if _, err := dao.AdminRoleMenu.Ctx(ctx).Unscoped().Where(do.AdminRoleMenu{RoleId: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete role menu bindings")
|
||||||
|
}
|
||||||
|
if _, err := dao.AdminUserRole.Ctx(ctx).Unscoped().Where(do.AdminUserRole{RoleId: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete user role bindings")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleMenuIDs(ctx context.Context, roleID uint64) ([]uint64, error) {
|
||||||
|
var rels []entity.AdminRoleMenu
|
||||||
|
if err := dao.AdminRoleMenu.Ctx(ctx).Where(do.AdminRoleMenu{RoleId: roleID}).Scan(&rels); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query role menus")
|
||||||
|
}
|
||||||
|
ids := make([]uint64, 0, len(rels))
|
||||||
|
for _, r := range rels {
|
||||||
|
ids = append(ids, r.MenuId)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindRoleMenus 全量重绑角色-菜单关系。
|
||||||
|
func bindRoleMenus(ctx context.Context, roleID uint64, menuIds []uint64) error {
|
||||||
|
if _, err := dao.AdminRoleMenu.Ctx(ctx).Unscoped().Where(do.AdminRoleMenu{RoleId: roleID}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "clear role menus")
|
||||||
|
}
|
||||||
|
for _, mid := range menuIds {
|
||||||
|
if _, err := dao.AdminRoleMenu.Ctx(ctx).Data(do.AdminRoleMenu{RoleId: roleID, MenuId: mid}).Insert(); err != nil {
|
||||||
|
return gerror.Wrap(err, "bind role menu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------- Menu manage ----------------------
|
||||||
|
|
||||||
|
func (s *menuManage) Tree(ctx context.Context) ([]*dto.MenuNode, error) {
|
||||||
|
var list []entity.AdminMenu
|
||||||
|
if err := dao.AdminMenu.Ctx(ctx).OrderAsc("sort").Scan(&list); err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "query menus")
|
||||||
|
}
|
||||||
|
byID := make(map[uint64]*dto.MenuNode, len(list))
|
||||||
|
for i := range list {
|
||||||
|
m := &list[i]
|
||||||
|
byID[m.Id] = &dto.MenuNode{
|
||||||
|
Id: m.Id, ParentId: m.ParentId, Name: m.Name, Icon: m.Icon, Type: m.Type,
|
||||||
|
Path: m.Path, Component: m.Component, Permission: m.Permission,
|
||||||
|
Sort: m.Sort, Status: m.Status, Hidden: m.Hidden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var roots []*dto.MenuNode
|
||||||
|
for i := range list {
|
||||||
|
m := &list[i]
|
||||||
|
node := byID[m.Id]
|
||||||
|
if m.ParentId == 0 {
|
||||||
|
roots = append(roots, node)
|
||||||
|
} else if p, ok := byID[m.ParentId]; ok {
|
||||||
|
p.Children = append(p.Children, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(roots, func(i, j int) bool { return roots[i].Sort < roots[j].Sort })
|
||||||
|
for _, r := range roots {
|
||||||
|
sortMenuChildren(r)
|
||||||
|
}
|
||||||
|
return roots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortMenuChildren(n *dto.MenuNode) {
|
||||||
|
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Sort < n.Children[j].Sort })
|
||||||
|
for _, c := range n.Children {
|
||||||
|
sortMenuChildren(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *menuManage) Create(ctx context.Context, in dto.MenuCreateInput) (uint64, error) {
|
||||||
|
count, err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Permission: in.Permission}).Count()
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "check menu permission")
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return 0, response.Error(consts.CodeInvalidParam, "permission already exists")
|
||||||
|
}
|
||||||
|
id, err := dao.AdminMenu.Ctx(ctx).Data(do.AdminMenu{
|
||||||
|
ParentId: in.ParentId, Name: in.Name, Icon: in.Icon, Type: in.Type, Path: in.Path,
|
||||||
|
Component: in.Component, Permission: in.Permission, Sort: in.Sort, Status: in.Status, Hidden: in.Hidden,
|
||||||
|
}).InsertAndGetId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, gerror.Wrap(err, "insert menu")
|
||||||
|
}
|
||||||
|
return uint64(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *menuManage) Update(ctx context.Context, in dto.MenuUpdateInput) error {
|
||||||
|
count, err := dao.AdminMenu.Ctx(ctx).Where("permission = ? AND id != ?", in.Permission, in.Id).Count()
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "check menu permission")
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return response.Error(consts.CodeInvalidParam, "permission already exists")
|
||||||
|
}
|
||||||
|
_, err = dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Id: in.Id}).Data(do.AdminMenu{
|
||||||
|
ParentId: in.ParentId, Name: in.Name, Icon: in.Icon, Type: in.Type, Path: in.Path,
|
||||||
|
Component: in.Component, Permission: in.Permission, Sort: in.Sort, Status: in.Status, Hidden: in.Hidden,
|
||||||
|
}).Update()
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "update menu")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *menuManage) Delete(ctx context.Context, id uint64) error {
|
||||||
|
// 删除前检查是否存在子节点。
|
||||||
|
cnt, err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{ParentId: id}).Count()
|
||||||
|
if err != nil {
|
||||||
|
return gerror.Wrap(err, "check menu children")
|
||||||
|
}
|
||||||
|
if cnt > 0 {
|
||||||
|
return response.Error(consts.CodeInvalidParam, "delete children first")
|
||||||
|
}
|
||||||
|
if _, err = dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Id: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete menu")
|
||||||
|
}
|
||||||
|
if _, err = dao.AdminRoleMenu.Ctx(ctx).Unscoped().Where(do.AdminRoleMenu{MenuId: id}).Delete(); err != nil {
|
||||||
|
return gerror.Wrap(err, "delete role menu bindings")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -12,6 +12,11 @@ type IUserAuth interface {
|
|||||||
type IAdminAuth interface {
|
type IAdminAuth interface {
|
||||||
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
|
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
|
||||||
HasPermission(context.Context, uint64, string) (bool, error)
|
HasPermission(context.Context, uint64, string) (bool, error)
|
||||||
|
Info(context.Context, uint64) (*dto.AdminInfo, error)
|
||||||
|
Codes(context.Context, uint64) ([]string, error)
|
||||||
|
// PermissionForPath resolves the permission code required by an endpoint
|
||||||
|
// from admin_menu (type=2 rows) by matching "<METHOD> <path>".
|
||||||
|
PermissionForPath(context.Context, string, string) (string, error)
|
||||||
}
|
}
|
||||||
type AuditEvent struct {
|
type AuditEvent struct {
|
||||||
AdminID uint64
|
AdminID uint64
|
||||||
|
|||||||
167
internal/service/log_manage.go
Normal file
167
internal/service/log_manage.go
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
|
"github.com/gogf/gf/v2/os/gfile"
|
||||||
|
|
||||||
|
"service.xpcool.com/internal/consts"
|
||||||
|
"service.xpcool.com/internal/library/response"
|
||||||
|
"service.xpcool.com/internal/model/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ILogManage reads server log files for the monitoring page.
|
||||||
|
type ILogManage interface {
|
||||||
|
Files(context.Context) (string, []*dto.LogFile, error)
|
||||||
|
Tail(context.Context, string, int, string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type logManage struct{}
|
||||||
|
|
||||||
|
func NewLogManage() ILogManage { return &logManage{} }
|
||||||
|
|
||||||
|
var localLogManage ILogManage
|
||||||
|
|
||||||
|
func LogManage() ILogManage {
|
||||||
|
if localLogManage == nil {
|
||||||
|
panic("LogManage implementation not registered")
|
||||||
|
}
|
||||||
|
return localLogManage
|
||||||
|
}
|
||||||
|
func RegisterLogManage(i ILogManage) { localLogManage = i }
|
||||||
|
|
||||||
|
// logDir returns the configured log directory (config logger.path), default "log".
|
||||||
|
func logDir(ctx context.Context) string {
|
||||||
|
dir := g.Cfg().MustGet(ctx, "logger.path").String()
|
||||||
|
if dir == "" {
|
||||||
|
dir = "log"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath 校验 file 为日志目录内文件,防止路径穿越。
|
||||||
|
func resolvePath(ctx context.Context, file string) (string, error) {
|
||||||
|
dir := logDir(ctx)
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", gerror.Wrap(err, "resolve log dir")
|
||||||
|
}
|
||||||
|
full := filepath.Join(absDir, filepath.Clean(file))
|
||||||
|
if !strings.HasPrefix(full, absDir+string(os.PathSeparator)) && full != absDir {
|
||||||
|
return "", response.Error(consts.CodeInvalidParam, "invalid log file path")
|
||||||
|
}
|
||||||
|
if !gfile.Exists(full) || gfile.IsDir(full) {
|
||||||
|
return "", response.Error(consts.CodeInvalidParam, "log file not found")
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *logManage) Files(ctx context.Context) (string, []*dto.LogFile, error) {
|
||||||
|
dir := logDir(ctx)
|
||||||
|
if !gfile.Exists(dir) {
|
||||||
|
return dir, nil, nil
|
||||||
|
}
|
||||||
|
paths, err := gfile.ScanDirFile(dir, "*.log", true)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, gerror.Wrap(err, "scan log files")
|
||||||
|
}
|
||||||
|
files := make([]*dto.LogFile, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, &dto.LogFile{
|
||||||
|
Name: info.Name(),
|
||||||
|
Path: strings.TrimPrefix(filepath.ToSlash(p), filepath.ToSlash(dir)+"/"),
|
||||||
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(files, func(i, j int) bool { return files[i].ModTime > files[j].ModTime })
|
||||||
|
return dir, files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *logManage) Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) {
|
||||||
|
full, err := resolvePath(ctx, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content, err := tailLines(full, lines)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "read log tail")
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
var filtered []string
|
||||||
|
for _, l := range content {
|
||||||
|
if strings.Contains(l, keyword) {
|
||||||
|
filtered = append(filtered, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailLines reads the last n lines of a file efficiently (reverse chunk scan).
|
||||||
|
func tailLines(path string, n int) ([]string, error) {
|
||||||
|
if n <= 0 {
|
||||||
|
n = 200
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
size := info.Size()
|
||||||
|
if size == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
const chunkSize = 8 << 10 // 8KB
|
||||||
|
var (
|
||||||
|
offset int64 = size
|
||||||
|
buf []byte
|
||||||
|
lines []string
|
||||||
|
)
|
||||||
|
for offset > 0 && len(lines) < n {
|
||||||
|
readSize := int64(chunkSize)
|
||||||
|
if offset < readSize {
|
||||||
|
readSize = offset
|
||||||
|
}
|
||||||
|
offset -= readSize
|
||||||
|
chunk := make([]byte, readSize)
|
||||||
|
if _, err = f.ReadAt(chunk, offset); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
combined := append(chunk, buf...)
|
||||||
|
buf = nil
|
||||||
|
idx := len(combined) - 1
|
||||||
|
for idx >= 0 && len(lines) < n {
|
||||||
|
j := bytes.LastIndexByte(combined[:idx+1], '\n')
|
||||||
|
if j < 0 {
|
||||||
|
buf = append([]byte(nil), combined[:idx+1]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, string(combined[j+1:idx+1]))
|
||||||
|
idx = j - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(buf) > 0 && len(lines) < n {
|
||||||
|
lines = append(lines, string(buf))
|
||||||
|
}
|
||||||
|
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
lines[i], lines[j] = lines[j], lines[i]
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
@ -43,8 +43,18 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Extra: "",
|
Extra: "",
|
||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
"type": {
|
"icon": {
|
||||||
Index: 3,
|
Index: 3,
|
||||||
|
Name: "icon",
|
||||||
|
Type: "varchar(64)",
|
||||||
|
Null: false,
|
||||||
|
Key: "",
|
||||||
|
Default: "",
|
||||||
|
Extra: "",
|
||||||
|
Comment: "menu icon (iconify name)",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
Index: 4,
|
||||||
Name: "type",
|
Name: "type",
|
||||||
Type: "tinyint",
|
Type: "tinyint",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -54,7 +64,7 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Comment: "1 menu,2 api",
|
Comment: "1 menu,2 api",
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
Index: 4,
|
Index: 5,
|
||||||
Name: "path",
|
Name: "path",
|
||||||
Type: "varchar(255)",
|
Type: "varchar(255)",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -63,8 +73,18 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Extra: "",
|
Extra: "",
|
||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
|
"component": {
|
||||||
|
Index: 6,
|
||||||
|
Name: "component",
|
||||||
|
Type: "varchar(255)",
|
||||||
|
Null: false,
|
||||||
|
Key: "",
|
||||||
|
Default: "",
|
||||||
|
Extra: "",
|
||||||
|
Comment: "vue component path, empty for top-level dir",
|
||||||
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
Index: 5,
|
Index: 7,
|
||||||
Name: "permission",
|
Name: "permission",
|
||||||
Type: "varchar(128)",
|
Type: "varchar(128)",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -74,7 +94,7 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
Index: 6,
|
Index: 8,
|
||||||
Name: "sort",
|
Name: "sort",
|
||||||
Type: "int",
|
Type: "int",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -84,7 +104,7 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
Index: 7,
|
Index: 9,
|
||||||
Name: "status",
|
Name: "status",
|
||||||
Type: "tinyint",
|
Type: "tinyint",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -93,8 +113,18 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Extra: "",
|
Extra: "",
|
||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
|
"hidden": {
|
||||||
|
Index: 10,
|
||||||
|
Name: "hidden",
|
||||||
|
Type: "tinyint",
|
||||||
|
Null: false,
|
||||||
|
Key: "",
|
||||||
|
Default: "0",
|
||||||
|
Extra: "",
|
||||||
|
Comment: "0 show,1 hide in menu",
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
Index: 8,
|
Index: 11,
|
||||||
Name: "created_at",
|
Name: "created_at",
|
||||||
Type: "datetime",
|
Type: "datetime",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -104,7 +134,7 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
Index: 9,
|
Index: 12,
|
||||||
Name: "updated_at",
|
Name: "updated_at",
|
||||||
Type: "datetime",
|
Type: "datetime",
|
||||||
Null: false,
|
Null: false,
|
||||||
@ -114,7 +144,7 @@ var AdminMenu = map[string]*gdb.TableField{
|
|||||||
Comment: "",
|
Comment: "",
|
||||||
},
|
},
|
||||||
"deleted_at": {
|
"deleted_at": {
|
||||||
Index: 10,
|
Index: 13,
|
||||||
Name: "deleted_at",
|
Name: "deleted_at",
|
||||||
Type: "datetime",
|
Type: "datetime",
|
||||||
Null: true,
|
Null: true,
|
||||||
|
|||||||
2
main.go
2
main.go
@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "github.com/gogf/gf/contrib/drivers/mysql/v2" // MySQL driver, required by gdb
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/os/gctx"
|
"github.com/gogf/gf/v2/os/gctx"
|
||||||
|
|
||||||
"service.xpcool.com/internal/cmd"
|
"service.xpcool.com/internal/cmd"
|
||||||
|
|||||||
@ -2,7 +2,11 @@ server:
|
|||||||
address: ":8000"
|
address: ":8000"
|
||||||
openapiPath: "/api.json"
|
openapiPath: "/api.json"
|
||||||
swaggerPath: "/swagger"
|
swaggerPath: "/swagger"
|
||||||
logger: { level: "all", stdout: true }
|
logger:
|
||||||
|
level: "all"
|
||||||
|
stdout: true
|
||||||
|
# 日志落盘目录(服务器日志管理功能读取此目录)
|
||||||
|
path: "log"
|
||||||
database:
|
database:
|
||||||
default:
|
default:
|
||||||
link: "${DB_DSN}"
|
link: "${DB_DSN}"
|
||||||
|
|||||||
8
manifest/sql/003_schema_ext.sql
Normal file
8
manifest/sql/003_schema_ext.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- 003_schema_ext.sql
|
||||||
|
-- Extend admin_menu for vben admin dynamic routing (backend access mode).
|
||||||
|
-- icon: iconify icon name; component: vue view path (empty for top-level dir);
|
||||||
|
-- hidden: 0 show in menu, 1 hide (route-only page).
|
||||||
|
ALTER TABLE admin_menu
|
||||||
|
ADD COLUMN icon VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'menu icon (iconify name)' AFTER name,
|
||||||
|
ADD COLUMN component VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'vue component path, empty for top-level dir' AFTER path,
|
||||||
|
ADD COLUMN hidden TINYINT NOT NULL DEFAULT 0 COMMENT '0 show,1 hide in menu' AFTER status;
|
||||||
50
manifest/sql/004_seed.sql
Normal file
50
manifest/sql/004_seed.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
-- 004_seed.sql
|
||||||
|
-- Initial seed: super admin, super_admin role, system menu tree (with button
|
||||||
|
-- permission codes) and role/menu bindings. Run after 003_schema_ext.sql.
|
||||||
|
-- Default admin password: admin123
|
||||||
|
|
||||||
|
INSERT INTO admin_user (id, username, password_hash, nickname, status, created_at, updated_at) VALUES
|
||||||
|
(1, 'admin', '$2a$10$TaPfjTcy7nY1kyEcwRJBwOmrvjzoRZ48orMIO5pAdUN8qj.AAbUpG', '超级管理员', 1, NOW(), NOW());
|
||||||
|
|
||||||
|
INSERT INTO admin_role (id, code, name, status, created_at, updated_at) VALUES
|
||||||
|
(1, 'super_admin', '超级管理员', 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- Menu tree. type: 1 menu, 2 button/api. permission doubles as X-Permission
|
||||||
|
-- header value (backend) and vben access code (frontend).
|
||||||
|
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
|
||||||
|
-- 系统管理
|
||||||
|
(1, 0, '系统管理', 'mdi:settings-outline', 1, '/system', '', 'system', 1, 1, 0, NOW(), NOW()),
|
||||||
|
-- 管理员管理
|
||||||
|
(2, 1, '管理员管理', 'mdi:account-group-outline', 1, 'admin', 'system/admin/index', 'system:admin', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(21, 2, '查询', '', 2, '', '', 'system:admin:list', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(22, 2, '新增', '', 2, '', '', 'system:admin:create', 2, 1, 0, NOW(), NOW()),
|
||||||
|
(23, 2, '编辑', '', 2, '', '', 'system:admin:update', 3, 1, 0, NOW(), NOW()),
|
||||||
|
(24, 2, '删除', '', 2, '', '', 'system:admin:delete', 4, 1, 0, NOW(), NOW()),
|
||||||
|
(25, 2, '重置密码', '', 2, '', '', 'system:admin:resetPwd', 5, 1, 0, NOW(), NOW()),
|
||||||
|
-- 角色管理
|
||||||
|
(3, 1, '角色管理', 'mdi:shield-account-outline', 1, 'role', 'system/role/index', 'system:role', 2, 1, 0, NOW(), NOW()),
|
||||||
|
(31, 3, '查询', '', 2, '', '', 'system:role:list', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(32, 3, '新增', '', 2, '', '', 'system:role:create', 2, 1, 0, NOW(), NOW()),
|
||||||
|
(33, 3, '编辑', '', 2, '', '', 'system:role:update', 3, 1, 0, NOW(), NOW()),
|
||||||
|
(34, 3, '删除', '', 2, '', '', 'system:role:delete', 4, 1, 0, NOW(), NOW()),
|
||||||
|
(35, 3, '分配菜单', '', 2, '', '', 'system:role:assignMenu', 5, 1, 0, NOW(), NOW()),
|
||||||
|
-- 菜单管理
|
||||||
|
(4, 1, '菜单管理', 'mdi:menu-outline', 1, 'menu', 'system/menu/index', 'system:menu', 3, 1, 0, NOW(), NOW()),
|
||||||
|
(41, 4, '查询', '', 2, '', '', 'system:menu:list', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(42, 4, '新增', '', 2, '', '', 'system:menu:create', 2, 1, 0, NOW(), NOW()),
|
||||||
|
(43, 4, '编辑', '', 2, '', '', 'system:menu:update', 3, 1, 0, NOW(), NOW()),
|
||||||
|
(44, 4, '删除', '', 2, '', '', 'system:menu:delete', 4, 1, 0, NOW(), NOW()),
|
||||||
|
-- 系统监控
|
||||||
|
(5, 0, '系统监控', 'mdi:monitor-dashboard', 1, '/monitor', '', 'monitor', 2, 1, 0, NOW(), NOW()),
|
||||||
|
-- 服务器日志
|
||||||
|
(6, 5, '服务器日志', 'mdi:file-document-outline', 1, 'log', 'monitor/log/index', 'monitor:log', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(61, 6, '查看', '', 2, '', '', 'monitor:log:view', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(62, 6, '实时监控', '', 2, '', '', 'monitor:log:tail', 2, 1, 0, NOW(), NOW());
|
||||||
|
|
||||||
|
-- super_admin role binds every menu (1,2,3,4,5,6 and all button rows).
|
||||||
|
INSERT INTO admin_role_menu (role_id, menu_id, created_at, updated_at)
|
||||||
|
SELECT 1, id, NOW(), NOW() FROM admin_menu WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- admin user -> super_admin role.
|
||||||
|
INSERT INTO admin_user_role (admin_user_id, role_id, created_at, updated_at) VALUES
|
||||||
|
(1, 1, NOW(), NOW());
|
||||||
21
manifest/sql/005_menu_paths.sql
Normal file
21
manifest/sql/005_menu_paths.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- 005_menu_paths.sql
|
||||||
|
-- Bind protected API endpoints to their required permission codes.
|
||||||
|
-- The AdminAuth middleware resolves the permission by matching
|
||||||
|
-- "<METHOD> <path>" against admin_menu.type=2 rows, then verifies the admin
|
||||||
|
-- owns it. Dynamic path params use {id} placeholders.
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/admins' WHERE id=21; -- system:admin:list
|
||||||
|
UPDATE admin_menu SET path='POST /admin/v1/admins' WHERE id=22; -- system:admin:create
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/admins/{id}' WHERE id=23; -- system:admin:update
|
||||||
|
UPDATE admin_menu SET path='DELETE /admin/v1/admins/{id}' WHERE id=24; -- system:admin:delete
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/admins/{id}/password' WHERE id=25; -- system:admin:resetPwd
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/roles' WHERE id=31; -- system:role:list
|
||||||
|
UPDATE admin_menu SET path='POST /admin/v1/roles' WHERE id=32; -- system:role:create
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/roles/{id}' WHERE id=33; -- system:role:update
|
||||||
|
UPDATE admin_menu SET path='DELETE /admin/v1/roles/{id}' WHERE id=34; -- system:role:delete
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/roles/{id}/menus' WHERE id=35; -- system:role:assignMenu
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/menus/tree' WHERE id=41; -- system:menu:list
|
||||||
|
UPDATE admin_menu SET path='POST /admin/v1/menus' WHERE id=42; -- system:menu:create
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/menus/{id}' WHERE id=43; -- system:menu:update
|
||||||
|
UPDATE admin_menu SET path='DELETE /admin/v1/menus/{id}' WHERE id=44; -- system:menu:delete
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/log/files' WHERE id=61; -- monitor:log:view
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/log/tail' WHERE id=62; -- monitor:log:tail
|
||||||
19
manifest/sql/006_menu_paths_v2.sql
Normal file
19
manifest/sql/006_menu_paths_v2.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- 006_menu_paths_v2.sql
|
||||||
|
-- Re-map protected endpoints after API regrouping into base/system/admin.
|
||||||
|
-- permission codes stay unchanged; only the physical route paths change.
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/admin' WHERE id=21; -- system:admin:list
|
||||||
|
UPDATE admin_menu SET path='POST /admin/v1/admin' WHERE id=22; -- system:admin:create
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/admin/{id}' WHERE id=23; -- system:admin:update
|
||||||
|
UPDATE admin_menu SET path='DELETE /admin/v1/admin/{id}' WHERE id=24; -- system:admin:delete
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/admin/{id}/password' WHERE id=25; -- system:admin:resetPwd
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/system/role' WHERE id=31; -- system:role:list
|
||||||
|
UPDATE admin_menu SET path='POST /admin/v1/system/role' WHERE id=32; -- system:role:create
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/system/role/{id}' WHERE id=33; -- system:role:update
|
||||||
|
UPDATE admin_menu SET path='DELETE /admin/v1/system/role/{id}' WHERE id=34; -- system:role:delete
|
||||||
|
UPDATE admin_menu SET path='' WHERE id=35; -- system:role:assignMenu (no dedicated endpoint)
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/system/menu/tree' WHERE id=41; -- system:menu:list
|
||||||
|
UPDATE admin_menu SET path='POST /admin/v1/system/menu' WHERE id=42; -- system:menu:create
|
||||||
|
UPDATE admin_menu SET path='PUT /admin/v1/system/menu/{id}' WHERE id=43; -- system:menu:update
|
||||||
|
UPDATE admin_menu SET path='DELETE /admin/v1/system/menu/{id}' WHERE id=44; -- system:menu:delete
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/base/log/files' WHERE id=61; -- monitor:log:view
|
||||||
|
UPDATE admin_menu SET path='GET /admin/v1/base/log/tail' WHERE id=62; -- monitor:log:tail
|
||||||
Loading…
Reference in New Issue
Block a user