Compare commits

..

9 Commits

Author SHA1 Message Date
夏犀麟
061dbde8ce docs: 记录前端 tdesign 迁移与清理 2026-08-24 23:46:08 +08:00
夏犀麟
43fa79686d refactor(api): 后台管理 API 按 base/system/admin 分组重构
- api/admin/v1 拆 base/system/admin 三子包,路由前缀 /admin/v1/{base,system,admin}
- system: auth/menu/role;admin: 管理员;base: log
- controller 改 import 子包;006_menu_paths_v2.sql 更新接口路径映射
- 权限码与路由分离,permission 标识不变
- 冒烟测试新路径 8 接口全通过
2026-08-24 23:40:39 +08:00
夏犀麟
1d632065fe docs: 记录前端 vben 对接进度与本地环境待办 2026-08-24 23:15:47 +08:00
夏犀麟
75e56dae43 docs: 后台管理 API 说明与变更记录(AGENTS.md / change-log) 2026-08-24 22:36:25 +08:00
夏犀麟
8fdb25e6bd feat(admin): 后台登录/菜单/按钮级权限/RBAC管理与服务器日志监控
后端:
- 种子数据:超级管理员(admin/admin123)、super_admin 角色、22 条菜单(含按钮权限码)、角色绑定
- admin_menu 扩展 icon/component/hidden 字段(003_schema_ext.sql),手动补齐 entity/do/table
- auth 扩展:/auth/info、/auth/codes(仅登录),路由拆分公开/Profile/受保护三组
- 菜单路由:/menu/routes 返回 vben backend 动态路由树(按角色过滤、排序)
- RBAC 管理:/admins、/roles、/menus CRUD(含角色绑定、重置密码、菜单授权)
- 日志监控:/log/files、/log/tail(尾部读取+关键词过滤+路径穿越防护)
- 安全加固:接口鉴权改为「方法+路径→权限码」自动映射,杜绝任意权限码越权
- 修复:gf v2.10.2  不自动替换→cmd 注入;MySQL driver 需显式引入 contrib
- 全链路冒烟测试通过(含越权拒绝 30003)
2026-08-24 22:35:54 +08:00
夏犀麟
5cd8f0f551 refactor(api): 契约文件 index.go 重命名为 <功能名>.go
- tools/{uuid,md5,random,time,ip}/index.go → 对应 <功能名>.go(git mv 保留历史)
- 目录名=包名=文件名三一致,扩展时目录内按功能加文件(ocr.go → + idcard.go + invoice.go)
- doc.go / AGENTS.md / PROJECT_STRUCTURE.md 同步规则说明
- 冒烟测试 5 端点全部通过
2026-08-24 18:07:33 +08:00
夏犀麟
82b1ad2b5c refactor(api): 公共接口改名 open 并重构契约层为 tools/<子功能>/index.go
- api/common/v1 → api/open/v1,路由前缀 /api/common/v1 → /api/open/v1
- 契约层按子功能拆目录:api/open/v1/tools/{uuid,md5,random,time,ip}/index.go
- 控制器同步拆文件:internal/controller/open/{uuid,md5,random,time,ip}.go
- 命名规则(open 优于 common)与 tools 目录规则记入 AGENTS.md
- 冒烟测试:旧路由 404,新路由 5 端点全部通过
2026-08-24 18:03:30 +08:00
夏犀麟
1486bb903f feat(api): 新增公共接口 api/common/v1(tools 工具端点)并修复 gtime 格式化
- api/common/v1/tools.go 契约 + internal/controller/common 实现:uuid/md5/random/time/ip
- cmd.go 注册 /api/common/v1 公开分组(无鉴权),复用 common/tools Go 包
- 修复 gtime v2.10.2 坑:Format 为 PHP 风格,Go layout 须用 Layout(),timex 封装为 Layout 语义
- 冒烟测试 5 端点全部通过;AGENTS.md / PROJECT_STRUCTURE.md / change-log 同步更新
2026-08-24 17:29:30 +08:00
夏犀麟
8d5f2c65ae feat(common): 新增公共模块 common/tools 及项目上下文记忆体系
- common/tools 工具模块,10 个子功能:md5/cryptox/uuid/random/timex/convertx/strx/slicex/ip/filex(薄封装 GoFrame 内置组件)
- AGENTS.md 项目智能体说明书(架构、规范、命令、记忆体系索引)
- docs/change-log/2026-08-24.md 本次请求与变更记录
- .gitignore 排除 .workbuddy/;PROJECT_STRUCTURE.md 补充目录树
2026-08-24 17:18:10 +08:00
64 changed files with 2815 additions and 50 deletions

5
.gitignore vendored
View File

@ -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
View 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 |
| 数据库 | MySQLORM 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 管理端 APIAdminAuth + X-Permission
open/v1/ # /api/open/v1 开放接口给前端调用公开无鉴权
tools/ # 工具子功能契约每子功能一目录tools/<name>/<name>.go
common/ # 公共可复用模块不依赖 internal可独立抽取成库
tools/ # 工具模块按子功能分包见下文
internal/
cmd/ # 启动引导路由分组注册在此
consts/ # 错误码与常量
controller/ # APIservice 适配层不写业务 open/ 开放接口实现
service/ # 领域用例业务逻辑直接写这里不用 logic/
dao/ # gf gen dao 生成禁止手改
model/ # entity/ do/ dto/ vo/entitydo 生成禁止手改
middleware/ # 路由中间件
library/ # jwt / page / response 等内部基础件
table/ # 表列名常量
manifest/ # config.dev/test/prod.yamlsql 迁移
docs/change-log/ # 每次请求与变更的记录重要上下文记忆
utility/ # 预留跨切面辅助
```
## 后台管理 APIapi/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/*`uuidmd5randomtimeip底层复用 `common/tools` Go
- **新增子功能三步** `api/open/v1/tools/<name>/<name>.go` Req/Resg.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` gcryptoUUID `util/guid` guuid gslicer用标准库 slices
- 配置文件按 `GF_GCFG_FILE` 切换`manifest/config/config.yaml` 不入库
- 生产环境强密码`JWT_SECRET`数据库口令
- 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释

View File

@ -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.

View 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{}

View File

@ -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
View 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"`
}

View 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"`
}

View 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"`
}

View 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{}

View 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
View 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

View 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
}

View 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"`
}

View 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"`
}

View 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
}

View 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
View 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

View 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
}

View 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
View 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

View 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
View 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
View 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)
}

View 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)
}

View 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
View 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)
}

View 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
View 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)
}

View 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` 唯一 IDNew 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 8fdb25e35 文件
- `manifest/sql/003_schema_ext.sql`admin_menu `icon/component/hidden`
- `manifest/sql/004_seed.sql`初始 admin/admin123super_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/` 加入 .gitignoreconfig.dev.yaml logger.path=log日志落盘
### 决策与理由
- **接口鉴权用路径映射而非 X-Permission**原实现用户可用自己拥有的任意权限码访问任意受保护接口越权漏洞改为后端按 method+path admin_menu 映射未配置即拒绝
- **受保护接口分三层**公开 loginAdminAuthOnlyinfo/codes/routes登录即可取vben 登录后立即调用AdminAuthRBAC/日志
- **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.comvben 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` 字段映射adminIduserIdnicknamerealNamehomePath
- `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:40API 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 updatepath 置空不映射
### 待办与风险
- 前端 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,/api8000`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-nextt-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
View File

@ -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
View File

@ -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=

View File

@ -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()

View 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
}

View File

@ -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
} }

View 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()
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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{} }

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@ -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)

View File

@ -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 //

View 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
}

View 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
}

View File

@ -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
View 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
}

View File

@ -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:""` //
} }

View File

@ -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
}

View 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)
}
}

View 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
}

View File

@ -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

View 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
}

View File

@ -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,

View File

@ -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"

View File

@ -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}"

View 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
View 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());

View 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

View 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