From 0372d4fff5a6bd24f4b9273a525cab2508f1fba9 Mon Sep 17 00:00:00 2001 From: chengqian Date: Tue, 1 Sep 2026 15:47:10 +0800 Subject: [PATCH 01/24] =?UTF-8?q?feat(role):=E6=96=B0=E5=A2=9E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=9D=83=E9=99=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/admin.go | 10 + api/admin/v1/admin_user.go | 6 + api/admin/v1/auth.go | 23 + api/admin/v1/permission.go | 19 + api/admin/v1/role.go | 161 +++++ api/admin/v1/security.go | 4 + docs/reference/goframe-conventions.md | 32 + internal/controller/admin/admin_v1_auth.go | 3 + internal/controller/admin/admin_v1_role.go | 36 ++ .../internal/sys_admin_role_permissions.go | 87 +++ internal/dao/internal/sys_admin_roles.go | 95 +++ internal/dao/internal/sys_admin_user_roles.go | 87 +++ internal/dao/sys_admin_role_permissions.go | 22 + internal/dao/sys_admin_roles.go | 22 + internal/dao/sys_admin_user_roles.go | 22 + internal/logic/admin/admin_user.go | 79 ++- internal/logic/admin/auth.go | 65 ++ internal/logic/admin/permission.go | 66 +- internal/logic/admin/role.go | 485 +++++++++++++++ internal/logic/admin/role_crud.go | 505 +++++++++++++++ internal/logic/admin/role_test.go | 361 +++++++++++ internal/logic/admin/security.go | 1 + internal/logic/admin/tenant_model.go | 2 +- internal/logic/common/session.go | 14 + internal/middleware/rbac.go | 36 +- internal/middleware/rbac_coverage_test.go | 221 +++++++ .../middleware/request_id_security_test.go | 191 +++--- internal/middleware/request_id_test.go | 9 +- .../model/do/sys_admin_role_permissions.go | 20 + internal/model/do/sys_admin_roles.go | 24 + internal/model/do/sys_admin_user_roles.go | 20 + .../entity/sys_admin_role_permissions.go | 18 + internal/model/entity/sys_admin_roles.go | 22 + internal/model/entity/sys_admin_user_roles.go | 18 + internal/service/admin.go | 44 +- migrations/000020_admin_role_management.sql | 182 ++++++ web/admin/src/router/admin.ts | 98 +-- web/admin/src/router/index.ts | 21 + web/admin/src/stores/auth.ts | 49 ++ web/admin/src/views/AdminLayout.vue | 104 ++-- web/admin/src/views/ForbiddenPage.vue | 61 ++ .../src/views/security/PermissionsPage.vue | 588 ++++++++++++++++-- web/admin/src/views/system/UsersPage.vue | 145 ++++- 43 files changed, 3788 insertions(+), 290 deletions(-) create mode 100644 api/admin/v1/role.go create mode 100644 internal/controller/admin/admin_v1_role.go create mode 100644 internal/dao/internal/sys_admin_role_permissions.go create mode 100644 internal/dao/internal/sys_admin_roles.go create mode 100644 internal/dao/internal/sys_admin_user_roles.go create mode 100644 internal/dao/sys_admin_role_permissions.go create mode 100644 internal/dao/sys_admin_roles.go create mode 100644 internal/dao/sys_admin_user_roles.go create mode 100644 internal/logic/admin/role.go create mode 100644 internal/logic/admin/role_crud.go create mode 100644 internal/logic/admin/role_test.go create mode 100644 internal/middleware/rbac_coverage_test.go create mode 100644 internal/model/do/sys_admin_role_permissions.go create mode 100644 internal/model/do/sys_admin_roles.go create mode 100644 internal/model/do/sys_admin_user_roles.go create mode 100644 internal/model/entity/sys_admin_role_permissions.go create mode 100644 internal/model/entity/sys_admin_roles.go create mode 100644 internal/model/entity/sys_admin_user_roles.go create mode 100644 migrations/000020_admin_role_management.sql create mode 100644 web/admin/src/views/ForbiddenPage.vue diff --git a/api/admin/admin.go b/api/admin/admin.go index 444604c..649b62a 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -38,6 +38,7 @@ type IAdminV1 interface { OperationLogExport(ctx context.Context, req *v1.OperationLogExportReq) (res *v1.OperationLogExportRes, err error) ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (res *v1.ContentFilterLogListRes, err error) AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error) + AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) AdminLogout(ctx context.Context, req *v1.AdminLogoutReq) (res *v1.AdminLogoutRes, err error) AdminRefresh(ctx context.Context, req *v1.AdminRefreshReq) (res *v1.AdminRefreshRes, err error) AdminSessionList(ctx context.Context, req *v1.AdminSessionListReq) (res *v1.AdminSessionListRes, err error) @@ -234,6 +235,15 @@ type IAdminV1 interface { RedemptionDisable(ctx context.Context, req *v1.RedemptionDisableReq) (res *v1.RedemptionDisableRes, err error) RedemptionUsages(ctx context.Context, req *v1.RedemptionUsagesReq) (res *v1.RedemptionUsagesRes, err error) RedemptionExport(ctx context.Context, req *v1.RedemptionExportReq) (res *v1.RedemptionExportRes, err error) + AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error) + AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error) + AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error) + AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error) + AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error) + AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error) + AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error) + AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error) + AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error) Admin2FASetup(ctx context.Context, req *v1.Admin2FASetupReq) (res *v1.Admin2FASetupRes, err error) Admin2FAEnable(ctx context.Context, req *v1.Admin2FAEnableReq) (res *v1.Admin2FAEnableRes, err error) Admin2FADisable(ctx context.Context, req *v1.Admin2FADisableReq) (res *v1.Admin2FADisableRes, err error) diff --git a/api/admin/v1/admin_user.go b/api/admin/v1/admin_user.go index f43a024..5d32fa8 100644 --- a/api/admin/v1/admin_user.go +++ b/api/admin/v1/admin_user.go @@ -30,6 +30,9 @@ type AdminUserItem struct { LastLoginIp string `json:"last_login_ip"` LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` + // Roles 是该账号已分配的角色。role=super_admin 时为空数组(超管权限来自账号属性, + // 不通过角色授予);非超管且为空数组表示「未分配角色」=零权限,界面应醒目提示。 + Roles []AdminRoleBrief `json:"roles"` } // AdminUserCreateReq 创建管理员请求 @@ -39,6 +42,9 @@ type AdminUserCreateReq struct { Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"` Role string `json:"role" d:"admin" v:"in:super_admin,admin#角色只能是 super_admin 或 admin" dc:"角色:super_admin / admin"` + // RoleIDs 在创建时一并分配角色。不传则账号无任何角色(0 权限,只能访问自助接口), + // 这是安全的默认值,但界面应提示「未分配角色」。role=super_admin 时本字段忽略。 + RoleIDs []int64 `json:"role_ids" dc:"分配的角色ID列表"` } type AdminUserCreateRes struct { diff --git a/api/admin/v1/auth.go b/api/admin/v1/auth.go index 6b8e8d6..9e067dc 100644 --- a/api/admin/v1/auth.go +++ b/api/admin/v1/auth.go @@ -23,9 +23,32 @@ type AdminLoginRes struct { DisplayName string `json:"display_name"` Role string `json:"role"` } `json:"user"` + // Permissions 是该账号的有效权限点(角色并集 ∪ 特批)。前端据此渲染菜单与按钮; + // 后端鉴权始终以数据库/缓存为准,这里只影响展示。 + Permissions []string `json:"permissions"` + Roles []AdminRoleBrief `json:"roles"` // 已分配角色(超管为空数组) PendingAgreements []*LoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表 } +// AdminMeReq 当前登录用户信息(权限刷新用) +// +// 权限变更后,已登录用户此前需要重新登录才能拿到新权限。前端在应用启动与 token 刷新后 +// 调用本接口刷新,使菜单与按钮跟上最新权限。 +type AdminMeReq struct { + g.Meta `path:"/auth/me" method:"get" mime:"json" tags:"管理后台-认证" summary:"当前登录用户信息与有效权限"` +} + +// AdminMeRes 当前登录用户信息响应 +type AdminMeRes struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + Roles []AdminRoleBrief `json:"roles"` +} + // LoginPendingAgreement 登录时返回的待接受协议信息 type LoginPendingAgreement struct { Id int64 `json:"id"` diff --git a/api/admin/v1/permission.go b/api/admin/v1/permission.go index ca279c6..64dc26d 100644 --- a/api/admin/v1/permission.go +++ b/api/admin/v1/permission.go @@ -49,6 +49,25 @@ type AdminAllPermissionsReq struct { type AdminAllPermissionsRes struct { Groups []PermissionGroup `json:"groups"` + // Modules 是「模块 × 档位」的配置元数据,角色配置界面据此渲染 19 行单选, + // 而不是把 60 个权限点摊成复选框。Groups 仍保留,供高级模式按权限点勾选。 + Modules []PermissionModuleMeta `json:"modules"` + // Dangerous 列出需要标红并二次确认的高危权限点(权限点 → 风险说明) + Dangerous map[string]string `json:"dangerous"` +} + +// PermissionModuleMeta 单个模块的档位配置元数据 +type PermissionModuleMeta struct { + Module string `json:"module"` // 模块名,与 PermissionGroup.Name 对应 + Label string `json:"label"` // 中文标签 + Tiers []TierOption `json:"tiers"` // 该模块实际存在差异的档位(等价档位已折叠,界面不渲染无意义选项) +} + +// TierOption 档位选项及其展开后的权限点 +type TierOption struct { + Tier string `json:"tier"` // none / read / operate / full + Label string `json:"label"` // 无 / 只读 / 操作 / 完全 + Permissions []string `json:"permissions"` } type PermissionGroup struct { diff --git a/api/admin/v1/role.go b/api/admin/v1/role.go new file mode 100644 index 0000000..9e85350 --- /dev/null +++ b/api/admin/v1/role.go @@ -0,0 +1,161 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ───────────────────────────────────────────────────────────────────────────── +// 角色管理 +// +// 超级管理员不在本接口的管辖范围:它由 sys_admin_users.role 判定并在鉴权时短路, +// 界面上作为一张固定卡片展示「全部权限(不可配置)」。 +// ───────────────────────────────────────────────────────────────────────────── + +// AdminRoleBrief 角色简要信息(登录响应、用户列表等处展示用) +type AdminRoleBrief struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IsEnabled bool `json:"is_enabled"` +} + +// AdminRoleItem 角色列表项 +type AdminRoleItem struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + UserCount int `json:"user_count"` // 关联的管理员账号数(删除前的影响面提示) + PermCount int `json:"perm_count"` // 已授予的权限点数量 + CreatedAt string `json:"created_at"` +} + +// AdminRoleListReq 角色列表请求 +type AdminRoleListReq struct { + g.Meta `path:"/roles" method:"get" mime:"json" tags:"管理后台-角色" summary:"角色列表"` +} + +// AdminRoleListRes 角色列表响应(角色数量少,不分页) +type AdminRoleListRes struct { + List []AdminRoleItem `json:"list"` +} + +// AdminRoleModuleTier 角色在某个模块上的档位选择 +type AdminRoleModuleTier struct { + Module string `json:"module"` // 模块名,如 tenant + Tier string `json:"tier"` // none / read / operate / full;空串表示不匹配任何档位(自定义) +} + +// AdminRoleDetailReq 角色详情请求 +type AdminRoleDetailReq struct { + g.Meta `path:"/roles/{id}" method:"get" mime:"json" tags:"管理后台-角色" summary:"角色详情"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` +} + +// AdminRoleDetailRes 角色详情响应 +// +// 同时给出权限点全集与档位视图:界面默认按档位渲染(19 行单选), +// 高级模式切到权限点级勾选。二者是同一份数据的两个视图。 +type AdminRoleDetailRes struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + UserCount int `json:"user_count"` + Permissions []string `json:"permissions"` // 权限点全集 + ModuleTiers []AdminRoleModuleTier `json:"module_tiers"` // 按模块归纳的档位视图 + CreatedAt string `json:"created_at"` +} + +// AdminRoleCreateReq 新建角色请求 +type AdminRoleCreateReq struct { + g.Meta `path:"/roles" method:"post" mime:"json" tags:"管理后台-角色" summary:"新建角色"` + Code string `json:"code" v:"required#请输入角色标识" dc:"角色标识(小写字母开头,可含数字与下划线,创建后不可修改)"` + Name string `json:"name" v:"required#请输入角色名称" dc:"角色显示名称"` + Description string `json:"description" dc:"角色说明"` + Sort int `json:"sort" dc:"排序权重"` + Permissions []string `json:"permissions" dc:"权限点列表(与 copy_from_role_id 二选一,同时给出时以本字段为准)"` + CopyFromRoleID int64 `json:"copy_from_role_id" dc:"从现有角色复制权限(建「实习运营」就是复制「运营」再收掉几个模块)"` +} + +// AdminRoleCreateRes 新建角色响应 +type AdminRoleCreateRes struct { + ID int64 `json:"id"` +} + +// AdminRoleUpdateReq 更新角色请求(code 不可修改) +type AdminRoleUpdateReq struct { + g.Meta `path:"/roles/{id}" method:"put" mime:"json" tags:"管理后台-角色" summary:"更新角色"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` + Name *string `json:"name" dc:"角色显示名称"` + Description *string `json:"description" dc:"角色说明"` + Sort *int `json:"sort" dc:"排序权重"` + Permissions *[]string `json:"permissions" dc:"权限点列表(全量覆盖,传 null 表示不改动权限)"` +} + +// AdminRoleUpdateRes 更新角色响应 +type AdminRoleUpdateRes struct{} + +// AdminRoleDeleteReq 删除角色请求 +type AdminRoleDeleteReq struct { + g.Meta `path:"/roles/{id}" method:"delete" mime:"json" tags:"管理后台-角色" summary:"删除角色"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` +} + +// AdminRoleDeleteRes 删除角色响应 +// +// 预置角色同样可删 —— is_builtin 只标识来源,不构成删除保护。 +// 不存在把自己锁死的风险:超级管理员由 sys_admin_users.role 判定,不依赖任何角色记录。 +type AdminRoleDeleteRes struct { + AffectedUsers int `json:"affected_users"` // 因此次删除而失去该角色权限的账号数 +} + +// AdminRoleStatusUpdateReq 启用/禁用角色请求 +type AdminRoleStatusUpdateReq struct { + g.Meta `path:"/roles/{id}/status" method:"put" mime:"json" tags:"管理后台-角色" summary:"启用/禁用角色"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` + IsEnabled bool `json:"is_enabled" dc:"是否启用:禁用后该角色权限对全部关联用户立即失效"` +} + +// AdminRoleStatusUpdateRes 启用/禁用角色响应 +type AdminRoleStatusUpdateRes struct{} + +// AdminRoleResetReq 恢复预置角色的默认权限请求 +type AdminRoleResetReq struct { + g.Meta `path:"/roles/{id}/reset" method:"post" mime:"json" tags:"管理后台-角色" summary:"恢复预置角色默认权限"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` +} + +// AdminRoleResetRes 恢复默认权限响应 +type AdminRoleResetRes struct { + Permissions []string `json:"permissions"` +} + +// ───────────────────────────────────────────────────────────────────────────── +// 用户角色分配 +// ───────────────────────────────────────────────────────────────────────────── + +// AdminUserRoleListReq 查询管理员已分配角色 +type AdminUserRoleListReq struct { + g.Meta `path:"/users/{id}/roles" method:"get" mime:"json" tags:"管理后台-角色" summary:"查询用户角色"` + Id int64 `json:"id" in:"path" v:"required" dc:"管理员用户ID"` +} + +// AdminUserRoleListRes 用户角色列表响应 +type AdminUserRoleListRes struct { + List []AdminRoleBrief `json:"list"` +} + +// AdminUserRoleAssignReq 分配管理员角色请求(全量覆盖) +type AdminUserRoleAssignReq struct { + g.Meta `path:"/users/{id}/roles" method:"put" mime:"json" tags:"管理后台-角色" summary:"分配用户角色"` + Id int64 `json:"id" in:"path" v:"required" dc:"管理员用户ID"` + RoleIDs []int64 `json:"role_ids" dc:"角色ID列表(全量覆盖;传空数组表示不分配任何角色,该账号将只剩自助接口权限)"` +} + +// AdminUserRoleAssignRes 分配用户角色响应 +type AdminUserRoleAssignRes struct{} diff --git a/api/admin/v1/security.go b/api/admin/v1/security.go index f21f538..9b6d647 100644 --- a/api/admin/v1/security.go +++ b/api/admin/v1/security.go @@ -66,6 +66,10 @@ type Admin2FAVerifyRes struct { DisplayName string `json:"display_name"` Role string `json:"role"` } `json:"user"` + // 与 AdminLoginRes 保持一致:2FA 是登录的第二段,同样要下发权限, + // 否则开了两步验证的账号前端权限集为空,菜单全不显示。 + Permissions []string `json:"permissions"` + Roles []AdminRoleBrief `json:"roles"` } // ============================================================ diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 7a1498d..d42344a 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -841,3 +841,35 @@ sender := common.NewEmailSender(&common.EmailConfig{ - 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。 排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。 + +### 2026-09-01:测试里用 `gtest.DataPath()` 当 server 名,Windows 下 `Start()` 静默失败 + +**问题**:`internal/middleware` 的 `request_id_test.go` 三个用例在 Windows 上全部失败,断言形如 `EXPECT 0 == 10`、`EXPECT 0 == 100`,或客户端报 `EOF`——看起来像中间件没生成 RequestId,实则请求根本没发出去。 + +**原因**:测试用路径当服务器名: + +```go +s := g.Server(gtest.DataPath("request-id-test")) // 名字是 D:\...\testdata\request-id-test +s.Start() // 返回值被丢弃 +``` + +GoFrame 用**服务器名**拼 session 存储目录:`%TEMP%\gsessions\`。名字里带盘符冒号时路径非法,`os.MkdirAll` 失败,`Start()` 返回 error——但测试没接这个返回值,于是服务器没监听,`GetListenedPort()` 返回 `-1`,客户端请求 `http://127.0.0.1:-1` 直接 EOF。Linux 下 `gtest.DataPath` 返回的路径不含冒号,同样的代码能跑通,所以问题只在 Windows 暴露。 + +同一文件还继承了 `manifest/config/config.yaml` 的 `server.address: :18888`(真实应用端口),即便名字合法也会与本机运行中的服务抢端口。 + +**修复**:服务器名改用普通标识符,并显式绑定空闲端口: + +```go +s := g.Server("mw-request-id-dual") // 纯名字,不是路径 +s.SetAddr("127.0.0.1:0") // 不继承配置文件端口,让系统分配 +s.SetDumpRouterMap(false) +s.Start() +``` + +**正确做法(通用规则)**: + +- **`g.Server(name)` 的参数是「服务器名」不是「路径」**:它会参与 session 目录等文件路径拼接,只能用普通标识符(字母/数字/连字符)。`gtest.DataPath()` 是用来定位测试数据文件的,不要拿来当名字。 +- **测试里起 server 必须 `SetAddr("127.0.0.1:0")`**:`g.Server()` 会读取项目配置,默认继承生产监听端口,在本机跑着服务时必然冲突。 +- **不要丢弃 `s.Start()` 的 error**:Start 失败后测试仍会继续跑,症状表现为断言数值不对或 EOF,与真正原因(服务器没起来)完全无关,极易误导排查方向。 + +排查信号:测试断言"期望 N 实际 0"且伴随 `EOF`;`GetListenedPort()` 返回 `-1`;同一测试在 Linux CI 通过但本地 Windows 失败。 diff --git a/internal/controller/admin/admin_v1_auth.go b/internal/controller/admin/admin_v1_auth.go index 4c415fe..3f44741 100644 --- a/internal/controller/admin/admin_v1_auth.go +++ b/internal/controller/admin/admin_v1_auth.go @@ -28,3 +28,6 @@ func (c *ControllerV1) AdminForceLogout(ctx context.Context, req *v1.AdminForceL func (c *ControllerV1) AdminChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (res *v1.AdminChangePasswordRes, err error) { return service.Admin().ChangePassword(ctx, req) } +func (c *ControllerV1) AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) { + return service.Admin().GetMe(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_role.go b/internal/controller/admin/admin_v1_role.go new file mode 100644 index 0000000..44faabb --- /dev/null +++ b/internal/controller/admin/admin_v1_role.go @@ -0,0 +1,36 @@ +package admin + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error) { + return service.Admin().ListRoles(ctx, req) +} +func (c *ControllerV1) AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error) { + return service.Admin().GetRoleDetail(ctx, req) +} +func (c *ControllerV1) AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error) { + return service.Admin().CreateRole(ctx, req) +} +func (c *ControllerV1) AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error) { + return service.Admin().UpdateRole(ctx, req) +} +func (c *ControllerV1) AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error) { + return service.Admin().DeleteRole(ctx, req) +} +func (c *ControllerV1) AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error) { + return service.Admin().UpdateRoleStatus(ctx, req) +} +func (c *ControllerV1) AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error) { + return service.Admin().ResetRoleDefaults(ctx, req) +} +func (c *ControllerV1) AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error) { + return service.Admin().GetUserRoles(ctx, req) +} +func (c *ControllerV1) AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error) { + return service.Admin().AssignUserRoles(ctx, req) +} diff --git a/internal/dao/internal/sys_admin_role_permissions.go b/internal/dao/internal/sys_admin_role_permissions.go new file mode 100644 index 0000000..79a7b91 --- /dev/null +++ b/internal/dao/internal/sys_admin_role_permissions.go @@ -0,0 +1,87 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysAdminRolePermissionsDao is the data access object for the table sys_admin_role_permissions. +type SysAdminRolePermissionsDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysAdminRolePermissionsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysAdminRolePermissionsColumns defines and stores column names for the table sys_admin_role_permissions. +type SysAdminRolePermissionsColumns struct { + Id string // 主键ID + RoleId string // 角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理) + PermissionPoint string // 权限点标识(如 tenant:create、channel:edit) + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 +} + +// sysAdminRolePermissionsColumns holds the columns for the table sys_admin_role_permissions. +var sysAdminRolePermissionsColumns = SysAdminRolePermissionsColumns{ + Id: "id", + RoleId: "role_id", + PermissionPoint: "permission_point", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// NewSysAdminRolePermissionsDao creates and returns a new DAO object for table data access. +func NewSysAdminRolePermissionsDao(handlers ...gdb.ModelHandler) *SysAdminRolePermissionsDao { + return &SysAdminRolePermissionsDao{ + group: "default", + table: "sys_admin_role_permissions", + columns: sysAdminRolePermissionsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysAdminRolePermissionsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysAdminRolePermissionsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysAdminRolePermissionsDao) Columns() SysAdminRolePermissionsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysAdminRolePermissionsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysAdminRolePermissionsDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysAdminRolePermissionsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_admin_roles.go b/internal/dao/internal/sys_admin_roles.go new file mode 100644 index 0000000..82f3b18 --- /dev/null +++ b/internal/dao/internal/sys_admin_roles.go @@ -0,0 +1,95 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysAdminRolesDao is the data access object for the table sys_admin_roles. +type SysAdminRolesDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysAdminRolesColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysAdminRolesColumns defines and stores column names for the table sys_admin_roles. +type SysAdminRolesColumns struct { + Id string // 主键ID + Code string // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用) + Name string // 角色显示名称(可修改) + Description string // 角色说明,在分配界面展示 + IsBuiltin string // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删) + IsEnabled string // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联 + Sort string // 排序权重(升序) + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 +} + +// sysAdminRolesColumns holds the columns for the table sys_admin_roles. +var sysAdminRolesColumns = SysAdminRolesColumns{ + Id: "id", + Code: "code", + Name: "name", + Description: "description", + IsBuiltin: "is_builtin", + IsEnabled: "is_enabled", + Sort: "sort", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// NewSysAdminRolesDao creates and returns a new DAO object for table data access. +func NewSysAdminRolesDao(handlers ...gdb.ModelHandler) *SysAdminRolesDao { + return &SysAdminRolesDao{ + group: "default", + table: "sys_admin_roles", + columns: sysAdminRolesColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysAdminRolesDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysAdminRolesDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysAdminRolesDao) Columns() SysAdminRolesColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysAdminRolesDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysAdminRolesDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysAdminRolesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_admin_user_roles.go b/internal/dao/internal/sys_admin_user_roles.go new file mode 100644 index 0000000..76bc125 --- /dev/null +++ b/internal/dao/internal/sys_admin_user_roles.go @@ -0,0 +1,87 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// SysAdminUserRolesDao is the data access object for the table sys_admin_user_roles. +type SysAdminUserRolesDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns SysAdminUserRolesColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysAdminUserRolesColumns defines and stores column names for the table sys_admin_user_roles. +type SysAdminUserRolesColumns struct { + Id string // 主键ID + AdminUserId string // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键) + RoleId string // 角色ID(逻辑关联 sys_admin_roles.id,无外键) + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 +} + +// sysAdminUserRolesColumns holds the columns for the table sys_admin_user_roles. +var sysAdminUserRolesColumns = SysAdminUserRolesColumns{ + Id: "id", + AdminUserId: "admin_user_id", + RoleId: "role_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// NewSysAdminUserRolesDao creates and returns a new DAO object for table data access. +func NewSysAdminUserRolesDao(handlers ...gdb.ModelHandler) *SysAdminUserRolesDao { + return &SysAdminUserRolesDao{ + group: "default", + table: "sys_admin_user_roles", + columns: sysAdminUserRolesColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysAdminUserRolesDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysAdminUserRolesDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysAdminUserRolesDao) Columns() SysAdminUserRolesColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysAdminUserRolesDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *SysAdminUserRolesDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *SysAdminUserRolesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/sys_admin_role_permissions.go b/internal/dao/sys_admin_role_permissions.go new file mode 100644 index 0000000..2248bc6 --- /dev/null +++ b/internal/dao/sys_admin_role_permissions.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "github.com/qianfree/team-api/internal/dao/internal" +) + +// sysAdminRolePermissionsDao is the data access object for the table sys_admin_role_permissions. +// You can define custom methods on it to extend its functionality as needed. +type sysAdminRolePermissionsDao struct { + *internal.SysAdminRolePermissionsDao +} + +var ( + // SysAdminRolePermissions is a globally accessible object for table sys_admin_role_permissions operations. + SysAdminRolePermissions = sysAdminRolePermissionsDao{internal.NewSysAdminRolePermissionsDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_admin_roles.go b/internal/dao/sys_admin_roles.go new file mode 100644 index 0000000..2531b00 --- /dev/null +++ b/internal/dao/sys_admin_roles.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "github.com/qianfree/team-api/internal/dao/internal" +) + +// sysAdminRolesDao is the data access object for the table sys_admin_roles. +// You can define custom methods on it to extend its functionality as needed. +type sysAdminRolesDao struct { + *internal.SysAdminRolesDao +} + +var ( + // SysAdminRoles is a globally accessible object for table sys_admin_roles operations. + SysAdminRoles = sysAdminRolesDao{internal.NewSysAdminRolesDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/sys_admin_user_roles.go b/internal/dao/sys_admin_user_roles.go new file mode 100644 index 0000000..2b6784e --- /dev/null +++ b/internal/dao/sys_admin_user_roles.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "github.com/qianfree/team-api/internal/dao/internal" +) + +// sysAdminUserRolesDao is the data access object for the table sys_admin_user_roles. +// You can define custom methods on it to extend its functionality as needed. +type sysAdminUserRolesDao struct { + *internal.SysAdminUserRolesDao +} + +var ( + // SysAdminUserRoles is a globally accessible object for table sys_admin_user_roles operations. + SysAdminUserRoles = sysAdminUserRolesDao{internal.NewSysAdminUserRolesDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go index 99a9d92..da73e0c 100644 --- a/internal/logic/admin/admin_user.go +++ b/internal/logic/admin/admin_user.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/admin/v1" @@ -46,6 +47,16 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A return nil, err } + // 批量取角色,避免逐个账号各查一次(N+1) + userIDs := make([]int64, len(users)) + for i, u := range users { + userIDs[i] = u.Id + } + rolesByUser, err := loadRolesByUsers(ctx, userIDs) + if err != nil { + return nil, err + } + items := make([]v1.AdminUserItem, len(users)) for i, u := range users { lockedUntil := "" @@ -63,6 +74,7 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A LastLoginIp: u.LastLoginIp, LockedUntil: lockedUntil, CreatedAt: u.CreatedAt.String(), + Roles: rolesByUser[u.Id], } } @@ -127,25 +139,43 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v return nil, common.NewBadRequestError(err.Error()) } - result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{ - Username: username, - PasswordHash: passwordHash, - Email: email, - DisplayName: username, - Role: role, - Status: "active", - }).Insert() - if err != nil { - // Race condition: another request inserted a colliding username/email - // between our pre-check and this insert. Translate the DB unique - // violation into a friendly business error. - if common.IsDuplicateKeyError(err) { - return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists) + // 账号与角色关联同事务写入:此前新建的 admin 账号不带任何权限,登录后处处 403, + // 是「系统缺乏角色管理」最直接的体感来源。 + var id int64 + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{ + Username: username, + PasswordHash: passwordHash, + Email: email, + DisplayName: username, + Role: role, + Status: "active", + }).Insert() + if err != nil { + // Race condition: another request inserted a colliding username/email + // between our pre-check and this insert. Translate the DB unique + // violation into a friendly business error. + if common.IsDuplicateKeyError(err) { + return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists) + } + return err } + + id, err = result.LastInsertId() + if err != nil { + return err + } + + // 超级管理员权限来自账号属性而非角色,忽略传入的 role_ids + if role == "super_admin" || len(req.RoleIDs) == 0 { + return nil + } + return replaceUserRoles(ctx, id, req.RoleIDs) + }) + if err != nil { return nil, err } - id, _ := result.LastInsertId() return &v1.AdminUserCreateRes{ID: id}, nil } @@ -228,11 +258,28 @@ func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v // Delete user first, then revoke sessions to avoid leaving the user // in a state where sessions are gone but the user still exists. - _, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete() + // + // 权限相关记录同事务清理:这些表都没有外键,数据库不会代劳, + // 漏删会留下悬空的角色关联与特批权限。 + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete(); err != nil { + return err + } + if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil { + return err + } + if _, err := dao.SysAdminRolePerms.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil { + return err + } + _, err := dao.SysAdminDataScopes.Ctx(ctx).Where("admin_user_id", req.Id).Delete() + return err + }) if err != nil { return nil, err } + InvalidateUserPermCache(ctx, req.Id) + // Revoke all sessions common.RevokeAllSessions(ctx, "admin", req.Id) diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 5fb7c01..9d2b3bc 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -200,6 +200,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog res.User.Username = user.Username res.User.DisplayName = user.DisplayName res.User.Role = user.Role + res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role) // 检查待接受协议 if common.Config().GetBool(ctx, "agreement_enabled") { @@ -447,3 +448,67 @@ func (s *sAdmin) ChangePassword(ctx context.Context, req *v1.AdminChangePassword return nil, nil } + +// loadSessionPermissions 装载登录响应所需的有效权限与角色。 +// +// 权限查询失败不阻断登录:鉴权以服务端为准,前端权限集只影响菜单与按钮渲染。 +// 此时返回空集合,用户会看到一个空菜单,刷新页面(走 /auth/me)即可恢复, +// 好过因为一次查询抖动把人挡在登录页外。 +func loadSessionPermissions(ctx context.Context, userID int64, role string) ([]string, []v1.AdminRoleBrief) { + perms, err := GetEffectivePermissions(ctx, userID, role) + if err != nil { + g.Log().Warningf(ctx, "加载管理员 %d 有效权限失败: %v", userID, err) + perms = []string{} + } + if perms == nil { + perms = []string{} + } + + roles := []v1.AdminRoleBrief{} + // 超级管理员不通过角色获得权限,不查关联表 + if role != "super_admin" { + if briefs, err := GetUserRoleBriefs(ctx, userID); err != nil { + g.Log().Warningf(ctx, "加载管理员 %d 角色失败: %v", userID, err) + } else if briefs != nil { + roles = briefs + } + } + return perms, roles +} + +// GetMe 返回当前登录用户的信息与有效权限。 +// +// 权限此前只在登录响应里下发一次,之后前端从 localStorage 恢复:管理员改了某人的权限, +// 对方不重新登录就一直按旧权限渲染菜单。前端在应用启动与 token 刷新后调用本接口刷新。 +func (s *sAdmin) GetMe(ctx context.Context, _ *v1.AdminMeReq) (*v1.AdminMeRes, error) { + userID := common.GetCtxUserID(ctx) + if userID == 0 { + return nil, common.NewBusinessError(consts.CodeUnauthorized, consts.MsgUnauthorized) + } + + var user *struct { + Id int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role string `json:"role"` + } + err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, common.NewNotFoundError("用户") + } + + perms, roles := loadSessionPermissions(ctx, user.Id, user.Role) + return &v1.AdminMeRes{ + ID: user.Id, + Username: user.Username, + DisplayName: user.DisplayName, + Email: user.Email, + Role: user.Role, + Permissions: perms, + Roles: roles, + }, nil +} diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index 2634ee0..2b3011e 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -152,6 +152,13 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ }, } +// PermissionGroups 返回全部预定义权限点分组。 +// 供 middleware 的权限规则覆盖测试校验「规则引用的权限点确实存在」, +// 避免规则里的拼写错误造成接口对所有非超管角色永久 403。 +func PermissionGroups() []v1.PermissionGroup { + return predefinedPermissionGroups +} + // GetUserPermissions returns permission points and data scopes for an admin user. func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) { // Get permission points @@ -255,6 +262,9 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss return nil, err } + // 特批权限参与有效权限计算,变更后必须让该用户的权限缓存失效 + InvalidateUserPermCache(ctx, req.Id) + return nil, nil } @@ -311,29 +321,67 @@ func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScop return nil, nil } -// GetAllPermissions returns all predefined permission groups. +// tierLabels 是档位的中文标签,随元数据下发给前端,避免前端硬编码一份。 +var tierLabels = map[string]string{ + TierNone: "无", + TierRead: "只读", + TierOperate: "操作", + TierFull: "完全", +} + +// GetAllPermissions returns all predefined permission groups plus tier metadata. +// +// 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用): +// 二者是同一份数据的两个视图,由后端统一给出,避免前端复刻一份档位定义造成漂移。 func (s *sAdmin) GetAllPermissions(ctx context.Context, _ *v1.AdminAllPermissionsReq) (*v1.AdminAllPermissionsRes, error) { + modules := make([]v1.PermissionModuleMeta, 0, len(predefinedPermissionGroups)) + for _, grp := range predefinedPermissionGroups { + tiers := ModuleTiers(grp.Name) + opts := make([]v1.TierOption, 0, len(tiers)) + for _, tier := range tiers { + opts = append(opts, v1.TierOption{ + Tier: tier, + Label: tierLabels[tier], + Permissions: TierPermissions(grp.Name, tier), + }) + } + modules = append(modules, v1.PermissionModuleMeta{ + Module: grp.Name, + Label: grp.Label, + Tiers: opts, + }) + } + return &v1.AdminAllPermissionsRes{ - Groups: predefinedPermissionGroups, + Groups: predefinedPermissionGroups, + Modules: modules, + Dangerous: dangerousPermissions, }, nil } // HasPermission checks if an admin user has a specific permission point. -// super_admin always returns true. +// +// 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限,详见 GetEffectivePermissions。 +// 结果走 300s 双层缓存:鉴权在每个请求上都跑,此前每次一条 COUNT 查询。 +// +// 签名保持不变,middleware/rbac.go 无需改动。 +// super_admin 直接放行,不查表也不进缓存。 func HasPermission(ctx context.Context, userID int64, role string, permission string) bool { if role == "super_admin" { return true } - count, err := dao.SysAdminRolePerms.Ctx(ctx). - Where("admin_user_id", userID). - Where("permission_point", permission). - Count() + perms, err := GetEffectivePermissions(ctx, userID, role) if err != nil { + // 查询失败时 fail-closed:宁可误拒也不误放,鉴权失败不应静默放行 return false } - - return count > 0 + for _, p := range perms { + if p == permission { + return true + } + } + return false } // GetDataScopes returns data scopes for an admin user. diff --git a/internal/logic/admin/role.go b/internal/logic/admin/role.go new file mode 100644 index 0000000..3479e69 --- /dev/null +++ b/internal/logic/admin/role.go @@ -0,0 +1,485 @@ +package admin + +import ( + "context" + "regexp" + "sort" + "strings" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" + lcommon "github.com/qianfree/team-api/internal/logic/common" + "github.com/qianfree/team-api/internal/model/do" +) + +// ───────────────────────────────────────────────────────────────────────────── +// 权限档位模型 +// ───────────────────────────────────────────────────────────────────────────── + +// 权限档位:配置界面按「模块 × 档位」呈现,而不是把 60 个权限点摊成复选框。 +// 档位按【可逆性】而非【读/写/删】划分 —— 典型例子是租户:停用(suspend)是可撤销的 +// 日常运营动作,删除与销户不可逆。若把三者塞进同一档,就会被迫在「运营不能停服」和 +// 「运营能删客户」之间二选一。 +const ( + TierNone = "none" // 无:该模块不可见 + TierRead = "read" // 只读:只能查看 + TierOperate = "operate" // 操作:日常增改与业务动作 + TierFull = "full" // 完全:含删除与高风险动作 +) + +// tierPermissions 是「模块 + 档位 → 权限点集合」的静态映射表。 +// +// 刻意手写而非按 view/create/edit/delete 规则化推导:各模块的权限点语义差异太大 +// (billing:refund 是资金操作、audit:read_sensitive 是隐私、member:model_scope 是配置), +// 规则化只会产生错误的归类。高档位包含低档位的全部权限点。 +var tierPermissions = map[string]map[string][]string{ + "dashboard": { + TierRead: {"dashboard:view"}, + }, + "tenant": { + TierRead: {"tenant:view"}, + TierOperate: {"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend"}, + TierFull: {"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close"}, + }, + "member": { + TierRead: {"member:view"}, + TierOperate: {"member:view", "member:import"}, + TierFull: {"member:view", "member:import", "member:model_scope"}, + }, + "channel": { + TierRead: {"channel:view"}, + TierOperate: {"channel:view", "channel:create", "channel:edit", "channel:test"}, + TierFull: {"channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete"}, + }, + "model": { + TierRead: {"model:view"}, + TierOperate: {"model:view", "model:create", "model:edit"}, + TierFull: {"model:view", "model:create", "model:edit", "model:delete"}, + }, + "billing": { + TierRead: {"billing:view"}, + TierOperate: {"billing:view", "billing:export"}, + TierFull: {"billing:view", "billing:export", "billing:refund"}, + }, + "plan": { + TierRead: {"plan:view"}, + TierOperate: {"plan:view", "plan:create", "plan:edit"}, + TierFull: {"plan:view", "plan:create", "plan:edit", "plan:delete"}, + }, + "order": { + TierRead: {"order:view"}, + TierFull: {"order:view", "order:refund"}, + }, + "promo": { + TierRead: {"promo:view"}, + TierOperate: {"promo:view", "promo:create", "promo:edit"}, + }, + "redemption": { + TierRead: {"redemption:view"}, + TierOperate: {"redemption:view", "redemption:create", "redemption:edit"}, + }, + "invoice": { + TierRead: {"invoice:view"}, + TierOperate: {"invoice:view", "invoice:manage"}, + }, + "operation": { + TierRead: {"operation:view"}, + TierOperate: {"operation:view", "operation:edit"}, + }, + "support": { + TierRead: {"support:view"}, + TierOperate: {"support:view", "support:reply"}, + TierFull: {"support:view", "support:reply", "support:edit"}, + }, + "monitor": { + TierRead: {"monitor:view"}, + TierOperate: {"monitor:view", "monitor:edit"}, + }, + "task": { + TierRead: {"task:view"}, + TierOperate: {"task:view", "task:edit"}, + }, + "file": { + TierRead: {"file:view"}, + TierFull: {"file:view", "file:delete", "file:cleanup"}, + }, + "audit": { + TierRead: {"audit:view"}, + TierOperate: {"audit:view", "audit:export"}, + TierFull: {"audit:view", "audit:export", "audit:read_sensitive"}, + }, + "user": { + TierRead: {"user:view"}, + TierOperate: {"user:view", "user:create", "user:edit"}, + TierFull: {"user:view", "user:create", "user:edit", "user:delete"}, + }, + "system": { + TierRead: {"system:view"}, + TierOperate: {"system:view", "system:edit"}, + TierFull: {"system:view", "system:edit", "system:update", "system:plugin"}, + }, +} + +// dangerousPermissions 是需要在界面上标红并二次确认的高危权限点。 +// 四类:资金、隐私、系统、提权。 +var dangerousPermissions = map[string]string{ + "billing:refund": "可对任意租户钱包退款/调账", + "order:refund": "可对订单发起退款到支付渠道", + "audit:read_sensitive": "可查看敏感数据访问日志", + "system:edit": "可修改支付、汇率、存储、邮件等全站配置", + "system:update": "可触发系统版本更新", + "system:plugin": "可安装/启停插件(等同于部署代码)", + "user:create": "可创建管理员账号", + "user:edit": "可修改管理员账号与其角色(含提权)", + "user:delete": "可删除管理员账号", +} + +// tierOrder 定义档位由低到高的顺序,用于「权限点集合 → 档位」的反向推导。 +var tierOrder = []string{TierNone, TierRead, TierOperate, TierFull} + +// roleCodeRegexp 限制角色标识的取值:小写字母、数字、下划线,2-50 字符。 +// code 参与权限缓存键与审计日志,必须稳定且可读。 +var roleCodeRegexp = regexp.MustCompile(`^[a-z][a-z0-9_]{1,49}$`) + +// reservedRoleCode 是保留字:超级管理员由 sys_admin_users.role 判定并短路鉴权, +// 不允许在角色表里出现同名角色造成语义混淆。 +const reservedRoleCode = "super_admin" + +// TierPermissions 返回指定模块与档位对应的权限点集合。 +// 档位不存在(如 dashboard 没有 operate 档)时返回 nil,调用方应回落到更低档位。 +func TierPermissions(module, tier string) []string { + return tierPermissions[module][tier] +} + +// ModuleTiers 返回某模块实际存在差异的档位列表(含 none,由低到高)。 +// +// 界面只渲染这些档位:dashboard 只有 无/只读,promo 只有 无/只读/操作。 +// 避免让用户在「操作」和「完全」两个完全等价的选项之间纠结。 +func ModuleTiers(module string) []string { + defs, ok := tierPermissions[module] + if !ok { + return []string{TierNone} + } + out := []string{TierNone} + var prev []string + for _, tier := range tierOrder[1:] { + perms, exists := defs[tier] + if !exists || samePermissionSet(perms, prev) { + continue + } + out = append(out, tier) + prev = perms + } + return out +} + +// TierForPermissions 从一组权限点反推它属于某模块的哪个档位。 +// 完全匹配某档位时返回该档位,否则返回空串表示「自定义」(界面切到高级模式展示)。 +func TierForPermissions(module string, perms []string) string { + owned := make([]string, 0, len(perms)) + prefix := module + ":" + for _, p := range perms { + if strings.HasPrefix(p, prefix) { + owned = append(owned, p) + } + } + if len(owned) == 0 { + return TierNone + } + // 由高到低比对,命中即返回(高档位是低档位的超集,先比高档避免误判) + for i := len(tierOrder) - 1; i >= 1; i-- { + if defs, ok := tierPermissions[module][tierOrder[i]]; ok && samePermissionSet(owned, defs) { + return tierOrder[i] + } + } + return "" +} + +// IsDangerousPermission 判断权限点是否属于需要二次确认的高危权限。 +func IsDangerousPermission(perm string) (string, bool) { + reason, ok := dangerousPermissions[perm] + return reason, ok +} + +func samePermissionSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + sa := append([]string(nil), a...) + sb := append([]string(nil), b...) + sort.Strings(sa) + sort.Strings(sb) + for i := range sa { + if sa[i] != sb[i] { + return false + } + } + return true +} + +// ───────────────────────────────────────────────────────────────────────────── +// 有效权限计算与缓存 +// ───────────────────────────────────────────────────────────────────────────── + +// adminPermCache 缓存每个管理员的有效权限集合。 +// +// 鉴权在每个请求上都要跑,此前是一次 COUNT 查询。复用项目的双层缓存 +// (L1 gcache + L2 Redis + 跨实例失效订阅),TTL 与「用户信息 300s」的既定策略一致。 +var adminPermCache = lcommon.NewCache("admin_perm", 300*time.Second) + +// permCacheKey 按用户维度组织缓存键。 +func permCacheKey(userID int64) string { + return g.NewVar(userID).String() +} + +// InvalidateUserPermCache 清除单个用户的权限缓存(角色关联、特批权限变更时调用)。 +func InvalidateUserPermCache(ctx context.Context, userID int64) { + adminPermCache.Delete(ctx, permCacheKey(userID)) +} + +// InvalidateAllPermCache 清空全部管理员的权限缓存。 +// +// 角色维度的变更(改角色权限、启停、删除)会影响该角色下的所有用户。管理员账号在 +// 几十量级且这类操作低频,直接清空整个前缀比精确定位受影响用户更简单也更不容易漏。 +func InvalidateAllPermCache(ctx context.Context) { + adminPermCache.DeleteByPattern(ctx, "*") +} + +// GetEffectivePermissions 返回管理员的有效权限点集合。 +// +// 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限(sys_admin_role_perms) +// +// 只做并集不做否定:带 deny 规则的 RBAC 在排查「这个人为什么没权限」时需要回溯整条 +// 规则链,运维成本远大于收益。要收权限就去掉角色或改角色。 +// +// super_admin 不查表,直接返回全部权限点。 +func GetEffectivePermissions(ctx context.Context, userID int64, role string) ([]string, error) { + if role == "super_admin" { + return AllPermissionPoints(), nil + } + + var cached []string + if adminPermCache.GetJSON(ctx, permCacheKey(userID), &cached) { + return cached, nil + } + + perms, err := loadEffectivePermissions(ctx, userID) + if err != nil { + return nil, err + } + adminPermCache.Set(ctx, permCacheKey(userID), perms) + return perms, nil +} + +// loadEffectivePermissions 从数据库计算有效权限(不走缓存)。 +func loadEffectivePermissions(ctx context.Context, userID int64) ([]string, error) { + set := make(map[string]bool) + + // 1) 角色权限:仅统计已启用的角色。 + // 角色被禁用后其权限立即失效,不需要解除用户关联。 + var rolePerms []struct { + PermissionPoint string `json:"permission_point"` + } + err := dao.SysAdminRolePermissions.Ctx(ctx). + As("rp"). + LeftJoin("sys_admin_roles r", "r.id = rp.role_id"). + LeftJoin("sys_admin_user_roles ur", "ur.role_id = rp.role_id"). + Where("ur.admin_user_id", userID). + Where("r.is_enabled", true). + Fields("rp.permission_point"). + Scan(&rolePerms) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + for _, p := range rolePerms { + set[p.PermissionPoint] = true + } + + // 2) 用户特批权限:独立于角色,角色禁用不影响特批。 + var extraPerms []struct { + PermissionPoint string `json:"permission_point"` + } + err = dao.SysAdminRolePerms.Ctx(ctx). + Where("admin_user_id", userID). + Fields("permission_point"). + Scan(&extraPerms) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + for _, p := range extraPerms { + set[p.PermissionPoint] = true + } + + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// AllPermissionPoints 返回系统定义的全部权限点(超级管理员使用)。 +func AllPermissionPoints() []string { + var out []string + for _, grp := range predefinedPermissionGroups { + out = append(out, grp.Permissions...) + } + sort.Strings(out) + return out +} + +// ───────────────────────────────────────────────────────────────────────────── +// 用户角色关联 +// ───────────────────────────────────────────────────────────────────────────── + +// GetUserRoleBriefs 返回用户已分配角色的简要信息(登录响应与用户列表展示用)。 +func GetUserRoleBriefs(ctx context.Context, userID int64) ([]v1.AdminRoleBrief, error) { + var rows []v1.AdminRoleBrief + err := dao.SysAdminUserRoles.Ctx(ctx). + As("ur"). + LeftJoin("sys_admin_roles r", "r.id = ur.role_id"). + Where("ur.admin_user_id", userID). + Fields("r.id, r.code, r.name, r.is_enabled"). + OrderAsc("r.sort"). + Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + return rows, nil +} + +// replaceUserRoles 全量覆盖用户的角色关联(调用方负责事务与缓存失效)。 +func replaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error { + if _, err := dao.SysAdminUserRoles.Ctx(ctx). + Where("admin_user_id", userID).Delete(); err != nil { + return err + } + if len(roleIDs) == 0 { + return nil + } + + // 去重,避免前端重复提交同一角色触发唯一约束 + seen := make(map[int64]bool, len(roleIDs)) + data := make([]do.SysAdminUserRoles, 0, len(roleIDs)) + for _, id := range roleIDs { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + data = append(data, do.SysAdminUserRoles{AdminUserId: userID, RoleId: id}) + } + if len(data) == 0 { + return nil + } + + // 校验角色存在,避免写入悬空关联(无外键,DB 不会拦) + count, err := dao.SysAdminRoles.Ctx(ctx).WhereIn("id", roleIDs).Count() + if err != nil { + return err + } + if count != len(data) { + return lcommon.NewBadRequestError("包含不存在的角色") + } + + _, err = dao.SysAdminUserRoles.Ctx(ctx).Data(data).Insert() + return err +} + +// AssignUserRoles 为管理员分配角色(全量覆盖)。 +func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) { + user, err := loadAdminUserRole(ctx, req.Id) + if err != nil { + return nil, err + } + if user.Role == "super_admin" { + return nil, lcommon.NewBadRequestError("超级管理员拥有全部权限,无需分配角色") + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return replaceUserRoles(ctx, req.Id, req.RoleIDs) + }) + if err != nil { + return nil, err + } + + InvalidateUserPermCache(ctx, req.Id) + return &v1.AdminUserRoleAssignRes{}, nil +} + +// GetUserRoles 查询管理员已分配的角色。 +func (s *sAdmin) GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) { + if _, err := loadAdminUserRole(ctx, req.Id); err != nil { + return nil, err + } + briefs, err := GetUserRoleBriefs(ctx, req.Id) + if err != nil { + return nil, err + } + if briefs == nil { + briefs = []v1.AdminRoleBrief{} + } + return &v1.AdminUserRoleListRes{List: briefs}, nil +} + +// loadAdminUserRole 读取管理员账号的特权标记,顺带校验账号存在。 +func loadAdminUserRole(ctx context.Context, userID int64) (*struct { + Role string `json:"role"` +}, error) { + var user *struct { + Role string `json:"role"` + } + err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Fields("role").Scan(&user) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, lcommon.NewNotFoundError("用户") + } + return user, nil +} + +// loadRolesByUsers 批量查询多个管理员的角色,返回 userID → 角色列表。 +// +// 用户列表页要展示每个账号的角色,逐个查询会产生 N+1;这里一次 IN 查询后在内存里分组。 +// 结果保证每个入参 userID 都有对应条目(无角色者为空切片),前端可直接据此提示「未分配角色」。 +func loadRolesByUsers(ctx context.Context, userIDs []int64) (map[int64][]v1.AdminRoleBrief, error) { + out := make(map[int64][]v1.AdminRoleBrief, len(userIDs)) + for _, id := range userIDs { + out[id] = []v1.AdminRoleBrief{} + } + if len(userIDs) == 0 { + return out, nil + } + + var rows []struct { + AdminUserId int64 `json:"admin_user_id"` + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IsEnabled bool `json:"is_enabled"` + } + err := dao.SysAdminUserRoles.Ctx(ctx). + As("ur"). + LeftJoin("sys_admin_roles r", "r.id = ur.role_id"). + WhereIn("ur.admin_user_id", userIDs). + Fields("ur.admin_user_id, r.id, r.code, r.name, r.is_enabled"). + OrderAsc("r.sort"). + Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + + for _, r := range rows { + out[r.AdminUserId] = append(out[r.AdminUserId], v1.AdminRoleBrief{ + ID: r.ID, + Code: r.Code, + Name: r.Name, + IsEnabled: r.IsEnabled, + }) + } + return out, nil +} diff --git a/internal/logic/admin/role_crud.go b/internal/logic/admin/role_crud.go new file mode 100644 index 0000000..82d7a5e --- /dev/null +++ b/internal/logic/admin/role_crud.go @@ -0,0 +1,505 @@ +package admin + +import ( + "context" + "sort" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" + lcommon "github.com/qianfree/team-api/internal/logic/common" + "github.com/qianfree/team-api/internal/model/do" +) + +// builtinRoleDefaults 是三个预置角色的默认权限,用于「恢复默认权限」。 +// +// 与 migrations/000020_admin_role_management.sql 的种子数据必须一致, +// 由 TestBuiltinRoleDefaultsMatchMigration 在 CI 阶段比对,防止两处漂移。 +// +// 注意这些只是初始值而非系统契约:用户可以任意修改,甚至删掉预置角色自建一套。 +var builtinRoleDefaults = map[string][]string{ + // 管理员:除 user:*(账号与角色)、system:update/plugin 外的全部权限。 + // 与运营的区别就是「能碰钱(退款)和系统配置」—— 去掉这两项,两个角色就没有分开的必要。 + "admin": { + "dashboard:view", + "channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete", + "model:view", "model:create", "model:edit", "model:delete", + "tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close", + "member:view", "member:import", "member:model_scope", + "plan:view", "plan:create", "plan:edit", "plan:delete", + "billing:view", "billing:export", "billing:refund", + "order:view", "order:refund", + "promo:view", "promo:create", "promo:edit", + "redemption:view", "redemption:create", "redemption:edit", + "invoice:view", "invoice:manage", + "operation:view", "operation:edit", + "support:view", "support:reply", "support:edit", + "monitor:view", "monitor:edit", + "task:view", "task:edit", + "file:view", "file:delete", "file:cleanup", + "audit:view", "audit:export", + "system:view", "system:edit", + }, + // 运营:平台日常运营,渠道与模型的完整权限(配渠道就得填 Key,这是本职工作)。 + // 不给退款、审计、系统设置、账号管理。租户停在「操作」档:能停服,不能删除与销户。 + "operator": { + "dashboard:view", + "channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete", + "model:view", "model:create", "model:edit", "model:delete", + "tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", + "member:view", "member:import", "member:model_scope", + "plan:view", "plan:create", "plan:edit", "plan:delete", + "billing:view", + "order:view", + "promo:view", "promo:create", "promo:edit", + "redemption:view", "redemption:create", "redemption:edit", + "invoice:view", "invoice:manage", + "operation:view", "operation:edit", + "support:view", "support:reply", "support:edit", + "monitor:view", "monitor:edit", + "task:view", "task:edit", + "file:view", "file:delete", "file:cleanup", + }, + // 技术支持:除财务面外全站只读 + 工单完整权限。 + // 排查主力工具不在财务面:请求审计日志走 audit:view,错误日志与渠道错误监控走 monitor:view。 + "support": { + "dashboard:view", + "channel:view", + "model:view", + "tenant:view", + "member:view", + "plan:view", + "operation:view", + "support:view", "support:reply", "support:edit", + "monitor:view", + "task:view", + "file:view", + "audit:view", + }, +} + +// ListRoles 返回角色列表(角色数量少,不分页)。 +func (s *sAdmin) ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) { + var roles []struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + CreatedAt string `json:"created_at"` + } + err := dao.SysAdminRoles.Ctx(ctx).OrderAsc("sort").OrderAsc("id").Scan(&roles) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + + userCounts, err := countByRole(ctx, dao.SysAdminUserRoles.Ctx(ctx)) + if err != nil { + return nil, err + } + permCounts, err := countByRole(ctx, dao.SysAdminRolePermissions.Ctx(ctx)) + if err != nil { + return nil, err + } + + list := make([]v1.AdminRoleItem, len(roles)) + for i, r := range roles { + list[i] = v1.AdminRoleItem{ + ID: r.ID, + Code: r.Code, + Name: r.Name, + Description: r.Description, + IsBuiltin: r.IsBuiltin, + IsEnabled: r.IsEnabled, + Sort: r.Sort, + UserCount: userCounts[r.ID], + PermCount: permCounts[r.ID], + CreatedAt: r.CreatedAt, + } + } + return &v1.AdminRoleListRes{List: list}, nil +} + +// countByRole 按 role_id 聚合计数,避免在角色列表里对每个角色各查一次(N+1)。 +func countByRole(ctx context.Context, model *gdb.Model) (map[int64]int, error) { + var rows []struct { + RoleId int64 `json:"role_id"` + Cnt int `json:"cnt"` + } + err := model.Fields("role_id, COUNT(*) AS cnt").Group("role_id").Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + out := make(map[int64]int, len(rows)) + for _, r := range rows { + out[r.RoleId] = r.Cnt + } + return out, nil +} + +// GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。 +func (s *sAdmin) GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) { + role, err := loadRole(ctx, req.ID) + if err != nil { + return nil, err + } + + perms, err := loadRolePermissions(ctx, req.ID) + if err != nil { + return nil, err + } + + userCount, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Count() + if err != nil { + return nil, err + } + + return &v1.AdminRoleDetailRes{ + ID: role.ID, + Code: role.Code, + Name: role.Name, + Description: role.Description, + IsBuiltin: role.IsBuiltin, + IsEnabled: role.IsEnabled, + Sort: role.Sort, + UserCount: userCount, + Permissions: perms, + ModuleTiers: buildModuleTiers(perms), + CreatedAt: role.CreatedAt, + }, nil +} + +// buildModuleTiers 把权限点集合归纳成每个模块的档位选择,供界面按档位渲染。 +func buildModuleTiers(perms []string) []v1.AdminRoleModuleTier { + modules := make([]string, 0, len(tierPermissions)) + for m := range tierPermissions { + modules = append(modules, m) + } + sort.Strings(modules) + + out := make([]v1.AdminRoleModuleTier, 0, len(modules)) + for _, m := range modules { + out = append(out, v1.AdminRoleModuleTier{Module: m, Tier: TierForPermissions(m, perms)}) + } + return out +} + +// CreateRole 新建角色。权限可直接给出,也可从现有角色复制。 +func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) { + code := strings.ToLower(strings.TrimSpace(req.Code)) + if code == reservedRoleCode { + return nil, lcommon.NewBadRequestError("super_admin 是保留标识,超级管理员由账号属性判定,不能作为角色") + } + if !roleCodeRegexp.MatchString(code) { + return nil, lcommon.NewBadRequestError("角色标识需以小写字母开头,仅含小写字母、数字与下划线,长度 2-50") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, lcommon.NewBadRequestError("请输入角色名称") + } + + exists, err := dao.SysAdminRoles.Ctx(ctx).Where("code", code).Count() + if err != nil { + return nil, err + } + if exists > 0 { + return nil, lcommon.NewBadRequestError("角色标识已存在:" + code) + } + + // 权限来源:显式给出的权限点优先,其次从指定角色复制 + perms := req.Permissions + if len(perms) == 0 && req.CopyFromRoleID > 0 { + if _, err := loadRole(ctx, req.CopyFromRoleID); err != nil { + return nil, err + } + if perms, err = loadRolePermissions(ctx, req.CopyFromRoleID); err != nil { + return nil, err + } + } + + perms, err = sanitizeGrantedPermissions(ctx, perms) + if err != nil { + return nil, err + } + + var newID int64 + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + result, err := dao.SysAdminRoles.Ctx(ctx).Data(do.SysAdminRoles{ + Code: code, + Name: name, + Description: strings.TrimSpace(req.Description), + IsBuiltin: false, + IsEnabled: true, + Sort: req.Sort, + }).Insert() + if err != nil { + if lcommon.IsDuplicateKeyError(err) { + return lcommon.NewBadRequestError("角色标识已存在:" + code) + } + return err + } + newID, err = result.LastInsertId() + if err != nil { + return err + } + return replaceRolePermissions(ctx, newID, perms) + }) + if err != nil { + return nil, err + } + + return &v1.AdminRoleCreateRes{ID: newID}, nil +} + +// UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。 +func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) { + if _, err := loadRole(ctx, req.ID); err != nil { + return nil, err + } + + data := g.Map{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, lcommon.NewBadRequestError("角色名称不能为空") + } + data["name"] = name + } + if req.Description != nil { + data["description"] = strings.TrimSpace(*req.Description) + } + if req.Sort != nil { + data["sort"] = *req.Sort + } + + var perms []string + if req.Permissions != nil { + var err error + if perms, err = sanitizeGrantedPermissions(ctx, *req.Permissions); err != nil { + return nil, err + } + } + + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if len(data) > 0 { + data["updated_at"] = gdb.Raw("now()") + if _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Data(data).Update(); err != nil { + return err + } + } + if req.Permissions != nil { + return replaceRolePermissions(ctx, req.ID, perms) + } + return nil + }) + if err != nil { + return nil, err + } + + // 角色权限变更影响该角色下的全部用户,直接清空前缀 + if req.Permissions != nil { + InvalidateAllPermCache(ctx) + } + return &v1.AdminRoleUpdateRes{}, nil +} + +// UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。 +func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) { + if _, err := loadRole(ctx, req.ID); err != nil { + return nil, err + } + _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Data(g.Map{ + "is_enabled": req.IsEnabled, + "updated_at": gdb.Raw("now()"), + }).Update() + if err != nil { + return nil, err + } + InvalidateAllPermCache(ctx) + return &v1.AdminRoleStatusUpdateRes{}, nil +} + +// DeleteRole 删除角色,并在同一事务内级联清理其权限行与用户关联。 +// +// 无外键,级联必须由业务层完成,否则会留下悬空的权限行与用户关联。 +// 预置角色同样可删:is_builtin 只标识来源,不构成删除保护。 +// +// 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权, +// 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。 +func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) { + if _, err := loadRole(ctx, req.ID); err != nil { + return nil, err + } + + affected, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Count() + if err != nil { + return nil, err + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Delete(); err != nil { + return err + } + if _, err := dao.SysAdminRolePermissions.Ctx(ctx).Where("role_id", req.ID).Delete(); err != nil { + return err + } + _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Delete() + return err + }) + if err != nil { + return nil, err + } + + InvalidateAllPermCache(ctx) + return &v1.AdminRoleDeleteRes{AffectedUsers: affected}, nil +} + +// ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。 +func (s *sAdmin) ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) { + role, err := loadRole(ctx, req.ID) + if err != nil { + return nil, err + } + if !role.IsBuiltin { + return nil, lcommon.NewBadRequestError("只有系统预置角色才有默认权限可恢复") + } + defaults, ok := builtinRoleDefaults[role.Code] + if !ok { + return nil, lcommon.NewBadRequestError("该角色没有登记默认权限") + } + + perms, err := sanitizeGrantedPermissions(ctx, defaults) + if err != nil { + return nil, err + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return replaceRolePermissions(ctx, req.ID, perms) + }) + if err != nil { + return nil, err + } + + InvalidateAllPermCache(ctx) + return &v1.AdminRoleResetRes{Permissions: perms}, nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// 内部辅助 +// ───────────────────────────────────────────────────────────────────────────── + +type roleRecord struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + CreatedAt string `json:"created_at"` +} + +func loadRole(ctx context.Context, id int64) (*roleRecord, error) { + var role *roleRecord + err := dao.SysAdminRoles.Ctx(ctx).Where("id", id).Scan(&role) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if role == nil { + return nil, lcommon.NewNotFoundError("角色") + } + return role, nil +} + +func loadRolePermissions(ctx context.Context, roleID int64) ([]string, error) { + var rows []struct { + PermissionPoint string `json:"permission_point"` + } + err := dao.SysAdminRolePermissions.Ctx(ctx). + Where("role_id", roleID). + Fields("permission_point"). + OrderAsc("permission_point"). + Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.PermissionPoint + } + return out, nil +} + +// replaceRolePermissions 全量覆盖角色权限(调用方负责事务与缓存失效)。 +func replaceRolePermissions(ctx context.Context, roleID int64, perms []string) error { + if _, err := dao.SysAdminRolePermissions.Ctx(ctx). + Where("role_id", roleID).Delete(); err != nil { + return err + } + if len(perms) == 0 { + return nil + } + data := make([]do.SysAdminRolePermissions, len(perms)) + for i, p := range perms { + data[i] = do.SysAdminRolePermissions{RoleId: roleID, PermissionPoint: p} + } + _, err := dao.SysAdminRolePermissions.Ctx(ctx).Data(data).Insert() + return err +} + +// sanitizeGrantedPermissions 校验并规整待授予的权限点: +// 1. 去重、去空、排序,保证同一份权限集合在库里的表示是稳定的 +// 2. 拒绝未定义的权限点 —— 写错一个字(channel:veiw)不会有编译期报错, +// 但会让该模块对该角色永久失效,且排查时毫无线索 +// 3. 防自我提权:非超管不得授予自己当前不具备的权限点 +// +// 第 3 条是兜底。按预置矩阵只有超管拥有 user:edit,但自定义角色可能把 user:* 授予他人, +// 那时若不拦截,一个有 user:edit 的账号就能给自己加满权限,角色体系形同虚设。 +func sanitizeGrantedPermissions(ctx context.Context, perms []string) ([]string, error) { + valid := buildValidPermissionSet() + + seen := make(map[string]bool, len(perms)) + out := make([]string, 0, len(perms)) + for _, p := range perms { + p = strings.TrimSpace(p) + if p == "" || seen[p] { + continue + } + if !valid[p] { + return nil, lcommon.NewBadRequestError("无效的权限点:" + p) + } + seen[p] = true + out = append(out, p) + } + sort.Strings(out) + + operatorRole := lcommon.GetCtxUserRole(ctx) + if operatorRole == "super_admin" { + return out, nil + } + + operatorID := lcommon.GetCtxUserID(ctx) + if operatorID == 0 { + // 没有调用者身份(如后台任务)时不做提权判断,交由上层接口鉴权把关 + return out, nil + } + own, err := GetEffectivePermissions(ctx, operatorID, operatorRole) + if err != nil { + return nil, err + } + ownSet := make(map[string]bool, len(own)) + for _, p := range own { + ownSet[p] = true + } + for _, p := range out { + if !ownSet[p] { + return nil, lcommon.NewBadRequestError("不能授予自己不具备的权限:" + p) + } + } + return out, nil +} diff --git a/internal/logic/admin/role_test.go b/internal/logic/admin/role_test.go new file mode 100644 index 0000000..43c4372 --- /dev/null +++ b/internal/logic/admin/role_test.go @@ -0,0 +1,361 @@ +package admin + +import ( + "os" + "regexp" + "sort" + "strings" + "testing" +) + +// ───────────────────────────────────────────────────────────────────────────── +// 档位模型 +// ───────────────────────────────────────────────────────────────────────────── + +// TestTierIsSuperset 高档位必须包含低档位的全部权限点。 +// 档位是「递进」语义,若高档漏了低档的某个权限,用户从「操作」升到「完全」反而会丢权限。 +func TestTierIsSuperset(t *testing.T) { + for module, defs := range tierPermissions { + read, hasRead := defs[TierRead] + operate, hasOperate := defs[TierOperate] + full, hasFull := defs[TierFull] + + if hasRead && hasOperate { + assertSubset(t, module, TierRead, read, TierOperate, operate) + } + if hasOperate && hasFull { + assertSubset(t, module, TierOperate, operate, TierFull, full) + } + if hasRead && hasFull { + assertSubset(t, module, TierRead, read, TierFull, full) + } + } +} + +func assertSubset(t *testing.T, module, loTier string, lo []string, hiTier string, hi []string) { + t.Helper() + set := make(map[string]bool, len(hi)) + for _, p := range hi { + set[p] = true + } + for _, p := range lo { + if !set[p] { + t.Errorf("模块 %s:%s 档的 %q 未包含在 %s 档中,升档会丢权限", module, loTier, p, hiTier) + } + } +} + +// TestTierPermissionsAreDefined 档位映射表里的权限点必须真实存在。 +// 写错一个字不会有编译期报错,但会让该档位永久失效。 +func TestTierPermissionsAreDefined(t *testing.T) { + valid := buildValidPermissionSet() + for module, defs := range tierPermissions { + for tier, perms := range defs { + for _, p := range perms { + if !valid[p] { + t.Errorf("模块 %s 的 %s 档引用了未定义的权限点 %q", module, tier, p) + } + if !strings.HasPrefix(p, module+":") { + t.Errorf("模块 %s 的 %s 档混入了其他模块的权限点 %q", module, tier, p) + } + } + } + } +} + +// TestEveryPermissionGroupHasTiers 每个权限点分组都要有对应的档位定义, +// 否则该模块在角色配置界面上无法配置(只能走高级模式),等于遗漏。 +func TestEveryPermissionGroupHasTiers(t *testing.T) { + for _, grp := range predefinedPermissionGroups { + if _, ok := tierPermissions[grp.Name]; !ok { + t.Errorf("权限分组 %q 没有档位定义,角色界面将无法配置该模块", grp.Name) + } + } +} + +// TestModuleTiersFoldsEquivalent 等价档位必须折叠,界面不渲染无意义的选项。 +func TestModuleTiersFoldsEquivalent(t *testing.T) { + cases := map[string][]string{ + "dashboard": {TierNone, TierRead}, // 只有 view,无操作/完全之分 + "promo": {TierNone, TierRead, TierOperate}, // 没有删除动作,无「完全」档 + "order": {TierNone, TierRead, TierFull}, // 只读 → 含退款,没有中间档 + "tenant": {TierNone, TierRead, TierOperate, TierFull}, // 三档齐全 + } + for module, want := range cases { + got := ModuleTiers(module) + if !equalStrings(got, want) { + t.Errorf("ModuleTiers(%q) = %v, want %v", module, got, want) + } + } +} + +// TestTierRoundTrip 档位 → 权限点 → 档位 必须能原样还原, +// 否则界面在「保存后重新打开」时会把用户选的档位显示成别的档。 +func TestTierRoundTrip(t *testing.T) { + for module := range tierPermissions { + for _, tier := range ModuleTiers(module) { + perms := TierPermissions(module, tier) + if got := TierForPermissions(module, perms); got != tier { + t.Errorf("模块 %s:档位 %s 的权限集反推为 %q,未能还原", module, tier, got) + } + } + } +} + +// TestTierForPermissionsCustom 不匹配任何档位的权限组合应返回空串(界面显示「自定义」)。 +func TestTierForPermissionsCustom(t *testing.T) { + // 只给 delete 不给 view:不属于任何档位 + if got := TierForPermissions("model", []string{"model:delete"}); got != "" { + t.Errorf("自定义权限组合应返回空串,实际 %q", got) + } + // 无该模块任何权限 = none + if got := TierForPermissions("model", []string{"tenant:view"}); got != TierNone { + t.Errorf("无该模块权限应返回 none,实际 %q", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 预置角色:锁住安全关键的取舍 +// ───────────────────────────────────────────────────────────────────────────── + +// TestBuiltinRoleDefaultsAreValid 预置角色的权限点必须真实存在且无重复。 +func TestBuiltinRoleDefaultsAreValid(t *testing.T) { + valid := buildValidPermissionSet() + for code, perms := range builtinRoleDefaults { + seen := make(map[string]bool, len(perms)) + for _, p := range perms { + if !valid[p] { + t.Errorf("预置角色 %s 引用了未定义的权限点 %q", code, p) + } + if seen[p] { + t.Errorf("预置角色 %s 重复声明权限点 %q", code, p) + } + seen[p] = true + } + } +} + +// TestBuiltinRoleBoundaries 锁定三个角色的分界线。 +// +// 这些不是实现细节而是安全决策:改动它们意味着改变谁能碰钱、谁能改系统、谁能提权。 +// 本测试让这类改动必须是显式的。 +func TestBuiltinRoleBoundaries(t *testing.T) { + has := func(code, perm string) bool { + for _, p := range builtinRoleDefaults[code] { + if p == perm { + return true + } + } + return false + } + + // 地基:能改权限的人只能是超级管理员。 + // 任何预置角色拿到 user:* 都意味着它能给自己加满权限,角色体系随即失效。 + for code := range builtinRoleDefaults { + for _, perm := range []string{"user:view", "user:create", "user:edit", "user:delete"} { + if has(code, perm) { + t.Errorf("预置角色 %s 不应拥有账号管理权限 %s —— 能改权限的人只能是超级管理员", code, perm) + } + } + } + + // 插件是代码执行面、更新是版本变更,仅限超管 + for code := range builtinRoleDefaults { + for _, perm := range []string{"system:plugin", "system:update", "audit:read_sensitive"} { + if has(code, perm) { + t.Errorf("预置角色 %s 不应拥有 %s(仅限超级管理员)", code, perm) + } + } + } + + // 管理员 = 能碰钱与系统配置;这正是它与运营分开存在的理由 + for _, perm := range []string{"billing:refund", "order:refund", "system:edit"} { + if !has("admin", perm) { + t.Errorf("管理员应拥有 %s(去掉退款与系统设置,管理员与运营就没有分开的必要)", perm) + } + } + + // 运营 = 业务配置全权,但碰不了钱 + for _, perm := range []string{"channel:edit", "channel:delete", "model:edit", "plan:edit"} { + if !has("operator", perm) { + t.Errorf("运营应拥有 %s(平台运营的本职工作就是配渠道配模型)", perm) + } + } + for _, perm := range []string{"billing:refund", "order:refund", "system:edit", "system:view", "audit:view"} { + if has("operator", perm) { + t.Errorf("运营不应拥有 %s", perm) + } + } + // 租户停在「操作」档:能停服(可撤销),不能删除与销户(不可逆) + if !has("operator", "tenant:suspend") { + t.Error("运营应拥有 tenant:suspend(欠费/违规停服是日常运营手段)") + } + for _, perm := range []string{"tenant:delete", "tenant:close"} { + if has("operator", perm) { + t.Errorf("运营不应拥有 %s(不可逆操作留给管理员)", perm) + } + } + + // 技术支持 = 除财务面外全站只读,唯一写权限是工单 + for _, perm := range []string{"support:reply", "support:edit", "audit:view", "monitor:view", "channel:view"} { + if !has("support", perm) { + t.Errorf("技术支持应拥有 %s(排查问题所需)", perm) + } + } + for _, perm := range []string{"billing:view", "order:view", "promo:view", "redemption:view", "invoice:view"} { + if has("support", perm) { + t.Errorf("技术支持不应看到财务数据 %s", perm) + } + } + // 除 support:* 外不得有任何写权限 + for _, p := range builtinRoleDefaults["support"] { + if strings.HasPrefix(p, "support:") { + continue + } + if !strings.HasSuffix(p, ":view") { + t.Errorf("技术支持除工单外应全部只读,但拥有写权限 %s", p) + } + } +} + +// TestBuiltinRoleDefaultsMatchMigration 保证 Go 侧的「恢复默认权限」与迁移种子一致。 +// +// 两处各存一份权限清单是必要的(迁移是 SQL、恢复默认是 Go),但漂移后果隐蔽: +// 新装部署拿到迁移的版本,点了「恢复默认」却变成 Go 的版本,同一个角色在不同部署里权限不同。 +func TestBuiltinRoleDefaultsMatchMigration(t *testing.T) { + raw, err := os.ReadFile("../../../migrations/000020_admin_role_management.sql") + if err != nil { + t.Fatalf("读取迁移脚本失败: %v", err) + } + sql := string(raw) + + // 匹配 unnest(ARRAY[ ... ]) ... WHERE r.code = 'xxx' + blockRe := regexp.MustCompile(`(?s)unnest\(ARRAY\[(.*?)\]\).*?r\.code = '([a-z_]+)'`) + permRe := regexp.MustCompile(`'([a-z_]+:[a-z_]+)'`) + + found := make(map[string][]string) + for _, m := range blockRe.FindAllStringSubmatch(sql, -1) { + body, code := m[1], m[2] + var perms []string + for _, pm := range permRe.FindAllStringSubmatch(body, -1) { + perms = append(perms, pm[1]) + } + found[code] = perms + } + + if len(found) != len(builtinRoleDefaults) { + t.Fatalf("迁移脚本里解析到 %d 个角色种子,Go 侧定义了 %d 个", + len(found), len(builtinRoleDefaults)) + } + + for code, want := range builtinRoleDefaults { + got, ok := found[code] + if !ok { + t.Errorf("迁移脚本缺少角色 %s 的权限种子", code) + continue + } + if !equalStringSets(got, want) { + t.Errorf("角色 %s 的权限在迁移脚本与 Go 定义之间不一致:\n 仅迁移有: %v\n 仅 Go 有: %v", + code, difference(got, want), difference(want, got)) + } + } +} + +// TestDangerousPermissionsAreDefined 高危权限清单必须引用真实存在的权限点。 +func TestDangerousPermissionsAreDefined(t *testing.T) { + valid := buildValidPermissionSet() + for p := range dangerousPermissions { + if !valid[p] { + t.Errorf("高危权限清单引用了未定义的权限点 %q", p) + } + } + // 资金与提权类必须在清单内 —— 它们是二次确认的核心对象 + for _, p := range []string{"billing:refund", "order:refund", "user:edit", "system:edit"} { + if _, ok := IsDangerousPermission(p); !ok { + t.Errorf("%s 应被标记为高危权限", p) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 角色标识校验 +// ───────────────────────────────────────────────────────────────────────────── + +func TestRoleCodeValidation(t *testing.T) { + valid := []string{"operator", "sales", "ops_l2", "support2", "ab"} + invalid := []string{ + "", // 空 + "a", // 太短 + "Operator", // 大写 + "2ops", // 数字开头 + "ops-l2", // 连字符 + "ops l2", // 空格 + "客服", // 中文 + strings.Repeat("a", 51), // 超长 + } + for _, c := range valid { + if !roleCodeRegexp.MatchString(c) { + t.Errorf("角色标识 %q 应被接受", c) + } + } + for _, c := range invalid { + if roleCodeRegexp.MatchString(c) { + t.Errorf("角色标识 %q 应被拒绝", c) + } + } + // super_admin 形式上合法,但属于保留字,由业务层单独拦截 + if !roleCodeRegexp.MatchString(reservedRoleCode) { + t.Error("保留字 super_admin 形式上应合法(由业务层按保留字拦截,而非靠正则)") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 辅助 +// ───────────────────────────────────────────────────────────────────────────── + +func TestSamePermissionSet(t *testing.T) { + if !samePermissionSet([]string{"a", "b"}, []string{"b", "a"}) { + t.Error("顺序不同的相同集合应判定为相等") + } + if samePermissionSet([]string{"a"}, []string{"a", "b"}) { + t.Error("长度不同的集合应判定为不等") + } + if samePermissionSet(nil, []string{"a"}) { + t.Error("nil 与非空集合应判定为不等") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalStringSets(a, b []string) bool { + sa := append([]string(nil), a...) + sb := append([]string(nil), b...) + sort.Strings(sa) + sort.Strings(sb) + return equalStrings(sa, sb) +} + +// difference 返回在 a 中但不在 b 中的元素。 +func difference(a, b []string) []string { + set := make(map[string]bool, len(b)) + for _, s := range b { + set[s] = true + } + var out []string + for _, s := range a { + if !set[s] { + out = append(out, s) + } + } + return out +} diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index 5586130..65623ad 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -98,6 +98,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1. res.User.Username = user.Username res.User.DisplayName = user.DisplayName res.User.Role = user.Role + res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role) return res, nil } diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index cfdc60b..26bc783 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -5,7 +5,6 @@ import ( "encoding/json" "github.com/gogf/gf/v2/frame/g" - "github.com/shopspring/decimal" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" @@ -13,6 +12,7 @@ import ( tenantLogic "github.com/qianfree/team-api/internal/logic/common" relay "github.com/qianfree/team-api/internal/logic/relay" do "github.com/qianfree/team-api/internal/model/do" + "github.com/shopspring/decimal" ) // ListTenantModels 列出租户已分配的模型 diff --git a/internal/logic/common/session.go b/internal/logic/common/session.go index 437b15a..08e8b52 100644 --- a/internal/logic/common/session.go +++ b/internal/logic/common/session.go @@ -264,6 +264,20 @@ func GetCtxUserID(ctx context.Context) int64 { return 0 } +// GetCtxUserRole extracts the admin privilege flag ("super_admin" / "admin") from context. +// 注意它是特权标记而非业务角色:业务角色存放在 sys_admin_user_roles, +// 这里只用于判断是否走超级管理员短路。 +func GetCtxUserRole(ctx context.Context) string { + val := ctx.Value("role") + if val == nil { + return "" + } + if role, ok := val.(string); ok { + return role + } + return "" +} + // GetCtxSessionID extracts session ID from context. func GetCtxSessionID(ctx context.Context) int64 { val := ctx.Value("sessionId") diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 357494b..8d4341e 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -79,6 +79,7 @@ var adminPermissionRules = []permissionRule{ {method: "POST", path: "/api/admin/channels", perm: "channel:create"}, {method: "POST", prefix: "/api/admin/channels/", suffix: "/test", perm: "channel:test"}, {method: "POST", prefix: "/api/admin/channels/", suffix: "/clone", perm: "channel:create"}, + {method: "POST", prefix: "/api/admin/channels/", suffix: "/reset-health", perm: "channel:edit"}, {method: "POST", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:edit"}, {method: "DELETE", prefix: "/api/admin/channels/", suffix: "/keys/", perm: "channel:edit"}, {method: "GET", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:view"}, @@ -199,6 +200,7 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/audit/operation-logs/export", perm: "audit:export"}, {method: "GET", path: "/api/admin/audit/request-logs", perm: "audit:view"}, {method: "GET", prefix: "/api/admin/audit/request-logs/", perm: "audit:view"}, + {method: "GET", prefix: "/api/admin/audit/forwarding-trace/", perm: "audit:view"}, {method: "GET", path: "/api/admin/audit/sensitive-logs", perm: "audit:read_sensitive"}, {method: "GET", path: "/api/admin/audit/content-filter-logs", perm: "audit:view"}, @@ -249,7 +251,8 @@ var adminPermissionRules = []permissionRule{ {method: "GET", prefix: "/api/admin/plugins", perm: "system:plugin"}, {method: "POST", prefix: "/api/admin/plugins/", perm: "system:plugin"}, {method: "PUT", prefix: "/api/admin/plugins/", perm: "system:plugin"}, - {method: "GET", prefix: "/api/admin/email/", perm: "system:view"}, + // 邮件发送记录是运营查看通知触达效果的手段,与通知模板同属内容运营,不归系统设置 + {method: "GET", prefix: "/api/admin/email/", perm: "operation:view"}, // ── tenant level config 租户等级配置 ── {method: "GET", path: "/api/admin/tenant-level-configs", perm: "tenant:view"}, @@ -279,6 +282,19 @@ var adminPermissionRules = []permissionRule{ {method: "POST", path: "/api/admin/redemptions", perm: "redemption:create"}, {method: "PUT", prefix: "/api/admin/redemptions/", perm: "redemption:edit"}, + // ── role 角色管理(复用 user 组权限点) ── + // 账号管理与角色管理是同一件事的两面(谁能进后台、能干什么),拆成两组权限没有实际场景: + // 「能建角色但不能分配给人」毫无意义,「能分配角色但不能建角色」用禁用角色即可表达。 + {method: "GET", path: "/api/admin/roles", perm: "user:view"}, + {method: "POST", path: "/api/admin/roles", perm: "user:create"}, + {method: "POST", prefix: "/api/admin/roles/", suffix: "/reset", perm: "user:edit"}, + {method: "GET", prefix: "/api/admin/roles/", perm: "user:view"}, + {method: "PUT", prefix: "/api/admin/roles/", suffix: "/status", perm: "user:edit"}, + {method: "PUT", prefix: "/api/admin/roles/", perm: "user:edit"}, + {method: "DELETE", prefix: "/api/admin/roles/", perm: "user:delete"}, + {method: "GET", prefix: "/api/admin/users/", suffix: "/roles", perm: "user:view"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/roles", perm: "user:edit"}, + // ── permission 权限管理(仅 user 组) ── {method: "GET", path: "/api/admin/permissions", perm: "user:view"}, {method: "GET", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:view"}, @@ -287,6 +303,8 @@ var adminPermissionRules = []permissionRule{ // ── admin session and security ── {method: "POST", path: "/api/admin/auth/logout", perm: "self:access"}, + // 当前用户信息与有效权限:登录即可访问自己的资料,不需要任何业务权限点 + {method: "GET", path: "/api/admin/auth/me", perm: "self:access"}, {method: "GET", path: "/api/admin/auth/sessions", perm: "self:access"}, {method: "DELETE", prefix: "/api/admin/auth/sessions/user/", perm: "user:edit"}, {method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "self:access"}, @@ -328,9 +346,15 @@ type permissionRule struct { } // AdminPermissionGuard enforces RBAC permission checks for admin routes. -// 当前策略:未匹配到权限规则的接口默认放行(仅对已认证 admin)。 -// 已配置规则的接口仍按权限点鉴权;漏配的接口不至于直接 403,降低上线回归风险。 -// 待 adminPermissionRules 全量补齐后,可将下方 perm=="" 分支改回返回 403 以收紧权限。 +// +// 策略:默认拒绝 —— 未匹配到权限规则的接口一律 403。 +// +// 早期为降低上线回归风险曾采用「未匹配即放行」,那在只有 super_admin / admin 两种可信 +// 身份时无害;引入运营、技术支持等低权限角色后,放行等同于越权:任何漏配规则的接口对 +// 所有角色都是敞开的。改为默认拒绝后,遗漏会立刻表现为 403 而不是静默的权限缺口。 +// +// 兜底保证:super_admin 在规则匹配之前短路放行,因此即便规则有遗漏也不会把系统锁死, +// 超管始终可以进入并修复。规则的完整性由 rbac_coverage_test.go 在 CI 阶段保证。 func AdminPermissionGuard(r *ghttp.Request) { if isAdminPublicPath(r.URL.Path) { r.Middleware.Next() @@ -345,8 +369,8 @@ func AdminPermissionGuard(r *ghttp.Request) { perm := matchPermission(r.Method, r.URL.Path) if perm == "" { - // 默认放行:未配置权限规则的接口,对已认证 admin 放行 - r.Middleware.Next() + // 默认拒绝:未配置权限规则的接口不放行(super_admin 已在上方短路) + response.ErrorMsg(r, consts.CodeForbidden, "接口未配置权限规则,请联系管理员") return } diff --git a/internal/middleware/rbac_coverage_test.go b/internal/middleware/rbac_coverage_test.go new file mode 100644 index 0000000..407c09c --- /dev/null +++ b/internal/middleware/rbac_coverage_test.go @@ -0,0 +1,221 @@ +package middleware + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/qianfree/team-api/internal/logic/admin" +) + +// adminAPIDir 是管理后台 API 定义所在目录(相对本测试文件)。 +// 路由由 cmd.go 中 g.Bind(adminController.NewV1()) 从这些 g.Meta 注解注册, +// 前缀统一为 /api/admin。 +const adminAPIDir = "../../api/admin/v1" + +// adminRoutePrefix 与 internal/cmd/cmd.go 的 group.Group("/admin", ...) 保持一致。 +const adminRoutePrefix = "/api/admin" + +// manualAdminRoutes 列出不经 g.Bind(adminController) 注册、但同样挂在 +// AdminPermissionGuard 之下的路由(见 internal/cmd/cmd.go 的独立路由组)。 +// 这些路由没有 g.Meta 注解,AST 扫不到,需手工登记。 +var manualAdminRoutes = []adminRoute{ + {Method: "GET", Path: "/api/admin/files/{id}/serve"}, +} + +type adminRoute struct { + Method string + Path string + Source string // 来源文件:行,用于报错定位 +} + +// collectAdminRoutes 解析 api/admin/v1 下全部 g.Meta 注解,得到已注册的 admin 路由清单。 +// +// 这里刻意用 AST 解析源码而不是 ghttp.Server.GetRoutes():后者需要导入 +// internal/controller/admin,而它反向依赖 internal/middleware,会造成 import cycle。 +// 解析源码同样精确(路由本就由这些注解生成),且不需要构造 server 或任何外部依赖。 +func collectAdminRoutes(t *testing.T) []adminRoute { + t.Helper() + + files, err := filepath.Glob(filepath.Join(adminAPIDir, "*.go")) + if err != nil { + t.Fatalf("扫描 %s 失败: %v", adminAPIDir, err) + } + + fset := token.NewFileSet() + seen := make(map[string]bool) + var out []adminRoute + + add := func(r adminRoute) { + key := r.Method + " " + r.Path + if seen[key] { + return + } + seen[key] = true + out = append(out, r) + } + + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("解析 %s 失败: %v", file, err) + } + + ast.Inspect(f, func(n ast.Node) bool { + st, ok := n.(*ast.StructType) + if !ok || st.Fields == nil { + return true + } + for _, field := range st.Fields.List { + // g.Meta 是嵌入字段(无字段名),带 path/method 标签 + if len(field.Names) != 0 || field.Tag == nil { + continue + } + raw, err := strconv.Unquote(field.Tag.Value) + if err != nil { + continue + } + tag := reflect.StructTag(raw) + path := strings.TrimSpace(tag.Get("path")) + if path == "" { + continue + } + pos := fset.Position(field.Pos()) + for _, m := range strings.Split(tag.Get("method"), ",") { + m = strings.ToUpper(strings.TrimSpace(m)) + if m == "" { + m = "GET" // g.Meta 省略 method 时 GoFrame 默认注册 GET + } + add(adminRoute{ + Method: m, + Path: adminRoutePrefix + path, + Source: filepath.Base(pos.Filename) + ":" + strconv.Itoa(pos.Line), + }) + } + } + return true + }) + } + + for _, r := range manualAdminRoutes { + r.Source = "internal/cmd/cmd.go(手工注册)" + add(r) + } + + if len(out) == 0 { + t.Fatalf("未从 %s 解析到任何路由,测试装置失效", adminAPIDir) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Path != out[j].Path { + return out[i].Path < out[j].Path + } + return out[i].Method < out[j].Method + }) + return out +} + +// concreteFromPattern 把路由模式里的占位段替换成具体值, +// 使其可以喂给 matchPermission(它按具体路径做前缀/后缀匹配,不认识占位符)。 +// +// /api/admin/users/{id}/permissions -> /api/admin/users/1/permissions +// /api/admin/channels/:id -> /api/admin/channels/1 +func concreteFromPattern(pattern string) string { + segs := strings.Split(pattern, "/") + for i, seg := range segs { + if seg == "" { + continue + } + if strings.HasPrefix(seg, "{") || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") { + segs[i] = "1" + } + } + return strings.Join(segs, "/") +} + +// allPermissionPoints 收集 predefinedPermissionGroups 里声明的全部权限点, +// 外加中间件自身消费的伪权限点。 +func allPermissionPoints() map[string]bool { + set := map[string]bool{ + // self:access 不属于任何模块分组:它表示「登录即可访问自己的资源」, + // 在 AdminPermissionGuard 里被直接放行,不参与角色授权。 + "self:access": true, + } + for _, g := range admin.PermissionGroups() { + for _, p := range g.Permissions { + set[p] = true + } + } + return set +} + +// TestEveryAdminRouteHasPermissionRule 保证每个受保护的 admin 接口都能匹配到权限规则。 +// +// AdminPermissionGuard 对未匹配到规则的路由返回 403(默认拒绝)。若新增接口时忘了在 +// adminPermissionRules 登记,低权限角色会拿到一个无法解释的 403 —— 本测试把这类遗漏 +// 在 CI 阶段拦下,而不是等到线上有人点开某个页面才发现。 +func TestEveryAdminRouteHasPermissionRule(t *testing.T) { + var missing []string + + for _, r := range collectAdminRoutes(t) { + path := concreteFromPattern(r.Path) + if isAdminPublicPath(path) { + continue + } + if perm := matchPermission(r.Method, path); perm == "" { + missing = append(missing, r.Method+" "+r.Path+" ("+r.Source+")") + } + } + + if len(missing) > 0 { + t.Fatalf("以下 %d 个 admin 接口未配置权限规则(请在 adminPermissionRules 中补齐):\n %s", + len(missing), strings.Join(missing, "\n ")) + } +} + +// TestPermissionRulesReferenceKnownPoints 保证规则里引用的权限点都是真实存在的。 +// +// 规则里写错一个字(如 channel:veiw)不会有任何编译期报错,但会造成该接口对所有 +// 非超管角色永久 403 —— 因为没有任何角色能被授予一个不存在的权限点。 +func TestPermissionRulesReferenceKnownPoints(t *testing.T) { + known := allPermissionPoints() + + for _, rule := range adminPermissionRules { + if !known[rule.perm] { + target := rule.path + if target == "" { + target = rule.prefix + "*" + rule.suffix + } + t.Errorf("规则 %s %s 引用了未定义的权限点 %q", rule.method, target, rule.perm) + } + } +} + +// TestMatchedPermissionsAreGrantable 从路由侧再验一次: +// 每个受保护接口最终要求的权限点,都必须是可以授予某个角色的。 +func TestMatchedPermissionsAreGrantable(t *testing.T) { + known := allPermissionPoints() + + for _, r := range collectAdminRoutes(t) { + path := concreteFromPattern(r.Path) + if isAdminPublicPath(path) { + continue + } + perm := matchPermission(r.Method, path) + if perm == "" { + continue // 由 TestEveryAdminRouteHasPermissionRule 负责报错 + } + if !known[perm] { + t.Errorf("接口 %s %s 要求权限点 %q,但该权限点未在 predefinedPermissionGroups 中定义", + r.Method, r.Path, perm) + } + } +} diff --git a/internal/middleware/request_id_security_test.go b/internal/middleware/request_id_security_test.go index 9ff0685..6d0c24c 100644 --- a/internal/middleware/request_id_security_test.go +++ b/internal/middleware/request_id_security_test.go @@ -1,50 +1,81 @@ package middleware import ( + "fmt" + "strings" "testing" + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/test/gtest" ) -// TestRequestId_ServerIDGeneration 测试服务端 ID 强制生成 +// startRequestIdServer 起一个只挂 RequestId 中间件的测试服务, +// 回显服务端 ID 与客户端追踪 ID,供本文件的用例复用。 +// +// 注意:RequestId 依赖 ghttp 的响应对象与中间件链,无法脱离 server 直接构造 +// ghttp.Request 来调用(本文件早期版本曾如此尝试,用的 API 并不存在,从未编译通过)。 +func startRequestIdServer(t *gtest.T, name string) (*ghttp.Server, *gclient.Client) { + s := g.Server(name) + s.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(RequestId) + group.ALL("/test", func(r *ghttp.Request) { + r.Response.WriteJson(g.Map{ + "server_id": r.GetCtxVar("RequestId").String(), + "client_id": r.GetCtxVar("ClientTraceId").String(), + }) + }) + }) + s.SetAddr("127.0.0.1:0") + s.SetDumpRouterMap(false) + s.Start() + + client := g.Client() + client.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + return s, client +} + +// TestRequestId_ServerIDGeneration 服务端 ID 必须每次强制生成且互不相同, +// 无论客户端是否传入 X-Request-Id。 func TestRequestId_ServerIDGeneration(t *testing.T) { gtest.C(t, func(t *gtest.T) { - // 模拟请求上下文 - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Response = ghttp.DefaultServer().GetRouterTree() + s, client := startRequestIdServer(t, "request-id-server-gen") + defer s.Shutdown() - // 测试场景1:无客户端 ID - r.Request.Header.Set("X-Request-Id", "") - RequestId(r) + ctx := gctx.New() - serverID := r.GetCtxVar("RequestId").String() - clientID := r.GetCtxVar("ClientTraceId").String() + // 场景 1:无客户端 ID + body1 := client.GetContent(ctx, "/test") + j1 := gjson.New(body1) + serverID1 := j1.Get("server_id").String() + t.AssertNE(serverID1, "") + t.Assert(j1.Get("client_id").String(), "") - t.Assert(serverID != "", true, "服务端 ID 必须生成") - t.Assert(clientID, "", "无客户端 ID 时 ClientTraceId 应为空") + // 场景 2:有效客户端 ID —— 服务端 ID 仍独立生成 + body2 := client.Header(g.MapStrStr{"X-Request-Id": "valid-client-123"}). + GetContent(ctx, "/test") + j2 := gjson.New(body2) + serverID2 := j2.Get("server_id").String() + t.AssertNE(serverID2, "") + t.Assert(j2.Get("client_id").String(), "valid-client-123") - // 测试场景2:有效客户端 ID - r2 := &ghttp.Request{} - r2.Request = gtest.NewRequest("GET", "/test") - r2.Request.Header.Set("X-Request-Id", "valid-client-123") - r2.Response = ghttp.DefaultServer().GetRouterTree() - - RequestId(r2) - - serverID2 := r2.GetCtxVar("RequestId").String() - clientID2 := r2.GetCtxVar("ClientTraceId").String() - - t.Assert(serverID2 != "", true, "服务端 ID 必须生成") - t.Assert(clientID2, "valid-client-123", "有效客户端 ID 应保存") - t.Assert(serverID != serverID2, true, "两次请求的服务端 ID 必须不同") + // 两次请求的服务端 ID 必须不同(幂等键依赖其唯一性) + t.AssertNE(serverID1, serverID2) }) } -// TestRequestId_ClientIDValidation 测试客户端 ID 格式校验 +// TestRequestId_ClientIDValidation 客户端追踪 ID 的格式校验: +// 仅接受字母/数字/连字符且长度 1-64,其余一律丢弃,防止日志注入与不可控的下游传播。 func TestRequestId_ClientIDValidation(t *testing.T) { gtest.C(t, func(t *gtest.T) { + s, client := startRequestIdServer(t, "request-id-client-validation") + defer s.Shutdown() + + ctx := gctx.New() + tests := []struct { name string clientID string @@ -53,106 +84,60 @@ func TestRequestId_ClientIDValidation(t *testing.T) { {"有效ID-字母数字连字符", "client-trace-123", true}, {"有效ID-纯数字", "12345678", true}, {"有效ID-纯字母", "abcdefgh", true}, + {"有效ID-64位边界", strings.Repeat("a", 64), true}, {"非法ID-含空格", "invalid id", false}, - {"非法ID-超长", string(make([]byte, 65)), false}, + {"非法ID-超长65位", strings.Repeat("a", 65), false}, {"非法ID-特殊字符", "id@#$%", false}, {"非法ID-中文", "客户端ID", false}, } for _, tt := range tests { - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Request.Header.Set("X-Request-Id", tt.clientID) - r.Response = ghttp.DefaultServer().GetRouterTree() - - RequestId(r) - - clientID := r.GetCtxVar("ClientTraceId").String() + body := client.Header(g.MapStrStr{"X-Request-Id": tt.clientID}). + GetContent(ctx, "/test") + clientID := gjson.New(body).Get("client_id").String() if tt.shouldAccept { - t.Assert(clientID, tt.clientID, tt.name+" 应被接受") + t.AssertEQ(clientID, tt.clientID) } else { - t.Assert(clientID, "", tt.name+" 应被拒绝") + t.AssertEQ(clientID, "") } } }) } -// TestRequestId_SecurityIsolation 测试安全隔离 -func TestRequestId_SecurityIsolation(t *testing.T) { - gtest.C(t, func(t *gtest.T) { - serverIDs := make(map[string]bool) - maliciousClientID := "malicious-repeated-id" - - // 模拟恶意客户端:10 次请求使用相同的客户端 ID - for i := 0; i < 10; i++ { - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Request.Header.Set("X-Request-Id", maliciousClientID) - r.Response = ghttp.DefaultServer().GetRouterTree() - - RequestId(r) - - serverID := r.GetCtxVar("RequestId").String() - serverIDs[serverID] = true - } - - // 验证:每次请求的服务端 ID 必须不同 - t.Assert(len(serverIDs), 10, "10 次请求应生成 10 个不同的服务端 ID") - }) -} - -// TestRequestId_ResponseHeaders 测试响应头设置 +// TestRequestId_ResponseHeaders 响应头回显规则: +// - X-Teamapi-Request-Id 恒为服务端 ID +// - X-Request-Id 在客户端 ID 有效时回显客户端 ID,否则回填服务端 ID func TestRequestId_ResponseHeaders(t *testing.T) { gtest.C(t, func(t *gtest.T) { + s, client := startRequestIdServer(t, "request-id-response-headers") + defer s.Shutdown() + + ctx := gctx.New() + tests := []struct { - name string - clientID string - expectXTeamapiRequestId bool - expectXRequestIdEcho string + name string + clientID string + echoesSrv bool // X-Request-Id 是否应回显服务端 ID }{ - { - name: "无客户端 ID", - clientID: "", - expectXTeamapiRequestId: true, - expectXRequestIdEcho: "server", // 应回显服务端 ID - }, - { - name: "有效客户端 ID", - clientID: "valid-123", - expectXTeamapiRequestId: true, - expectXRequestIdEcho: "valid-123", // 应回显客户端 ID - }, - { - name: "非法客户端 ID", - clientID: "invalid id with spaces", - expectXTeamapiRequestId: true, - expectXRequestIdEcho: "server", // 应回显服务端 ID - }, + {"无客户端 ID", "", true}, + {"有效客户端 ID", "valid-123", false}, + {"非法客户端 ID", "invalid id with spaces", true}, } for _, tt := range tests { - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Request.Header.Set("X-Request-Id", tt.clientID) - r.Response = ghttp.DefaultServer().GetRouterTree() + resp, err := client.Header(g.MapStrStr{"X-Request-Id": tt.clientID}). + Get(ctx, "/test") + t.AssertNil(err) - RequestId(r) + serverID := gjson.New(resp.ReadAllString()).Get("server_id").String() + resp.Close() - serverID := r.GetCtxVar("RequestId").String() - xTeamapiRequestId := r.Response.Header().Get("X-Teamapi-Request-Id") - xRequestId := r.Response.Header().Get("X-Request-Id") - - // 验证 X-Teamapi-Request-Id - if tt.expectXTeamapiRequestId { - t.Assert(xTeamapiRequestId, serverID, tt.name+": X-Teamapi-Request-Id 应等于服务端 ID") - } - - // 验证 X-Request-Id 回显逻辑 - if tt.expectXRequestIdEcho == "server" { - t.Assert(xRequestId, serverID, tt.name+": X-Request-Id 应回显服务端 ID") + t.AssertEQ(resp.Header.Get("X-Teamapi-Request-Id"), serverID) + if tt.echoesSrv { + t.AssertEQ(resp.Header.Get("X-Request-Id"), serverID) } else { - t.Assert(xRequestId, tt.expectXRequestIdEcho, tt.name+": X-Request-Id 应回显客户端 ID") + t.AssertEQ(resp.Header.Get("X-Request-Id"), tt.clientID) } } }) diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go index 1003d89..0bdc95d 100644 --- a/internal/middleware/request_id_test.go +++ b/internal/middleware/request_id_test.go @@ -16,7 +16,7 @@ import ( // TestRequestId_DualIDMechanism 测试双 ID 机制 func TestRequestId_DualIDMechanism(t *testing.T) { gtest.C(t, func(t *gtest.T) { - s := g.Server(gtest.DataPath("request-id-test")) + s := g.Server("mw-request-id-dual") s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(RequestId) group.ALL("/test", func(r *ghttp.Request) { @@ -28,6 +28,7 @@ func TestRequestId_DualIDMechanism(t *testing.T) { }) }) }) + s.SetAddr("127.0.0.1:0") s.SetDumpRouterMap(false) s.Start() defer s.Shutdown() @@ -98,7 +99,7 @@ func TestRequestId_SecurityIsolation(t *testing.T) { serverIDs := make(map[string]bool) var mu sync.Mutex - s := g.Server(gtest.DataPath("request-id-security")) + s := g.Server("mw-request-id-isolation") s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(RequestId) group.ALL("/test", func(r *ghttp.Request) { @@ -111,6 +112,7 @@ func TestRequestId_SecurityIsolation(t *testing.T) { }) }) }) + s.SetAddr("127.0.0.1:0") s.SetDumpRouterMap(false) s.Start() defer s.Shutdown() @@ -138,7 +140,7 @@ func TestRequestId_IdempotencyKeyUniqueness(t *testing.T) { const concurrency = 100 serverIDs := make(chan string, concurrency) - s := g.Server(gtest.DataPath("request-id-idempotency")) + s := g.Server("mw-request-id-idempotency") s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(RequestId) group.ALL("/test", func(r *ghttp.Request) { @@ -148,6 +150,7 @@ func TestRequestId_IdempotencyKeyUniqueness(t *testing.T) { }) }) }) + s.SetAddr("127.0.0.1:0") s.SetDumpRouterMap(false) s.Start() defer s.Shutdown() diff --git a/internal/model/do/sys_admin_role_permissions.go b/internal/model/do/sys_admin_role_permissions.go new file mode 100644 index 0000000..6147aff --- /dev/null +++ b/internal/model/do/sys_admin_role_permissions.go @@ -0,0 +1,20 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminRolePermissions is the golang structure of table sys_admin_role_permissions for DAO operations like Where/Data. +type SysAdminRolePermissions struct { + g.Meta `orm:"table:sys_admin_role_permissions, do:true"` + Id any // 主键ID + RoleId any // 角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理) + PermissionPoint any // 权限点标识(如 tenant:create、channel:edit) + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/sys_admin_roles.go b/internal/model/do/sys_admin_roles.go new file mode 100644 index 0000000..1f7a29c --- /dev/null +++ b/internal/model/do/sys_admin_roles.go @@ -0,0 +1,24 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminRoles is the golang structure of table sys_admin_roles for DAO operations like Where/Data. +type SysAdminRoles struct { + g.Meta `orm:"table:sys_admin_roles, do:true"` + Id any // 主键ID + Code any // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用) + Name any // 角色显示名称(可修改) + Description any // 角色说明,在分配界面展示 + IsBuiltin any // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删) + IsEnabled any // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联 + Sort any // 排序权重(升序) + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/sys_admin_user_roles.go b/internal/model/do/sys_admin_user_roles.go new file mode 100644 index 0000000..315d375 --- /dev/null +++ b/internal/model/do/sys_admin_user_roles.go @@ -0,0 +1,20 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminUserRoles is the golang structure of table sys_admin_user_roles for DAO operations like Where/Data. +type SysAdminUserRoles struct { + g.Meta `orm:"table:sys_admin_user_roles, do:true"` + Id any // 主键ID + AdminUserId any // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键) + RoleId any // 角色ID(逻辑关联 sys_admin_roles.id,无外键) + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/entity/sys_admin_role_permissions.go b/internal/model/entity/sys_admin_role_permissions.go new file mode 100644 index 0000000..828e33c --- /dev/null +++ b/internal/model/entity/sys_admin_role_permissions.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminRolePermissions is the golang structure for table sys_admin_role_permissions. +type SysAdminRolePermissions struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + RoleId int64 `json:"role_id" orm:"role_id" description:"角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)"` // 角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理) + PermissionPoint string `json:"permission_point" orm:"permission_point" description:"权限点标识(如 tenant:create、channel:edit)"` // 权限点标识(如 tenant:create、channel:edit) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 +} diff --git a/internal/model/entity/sys_admin_roles.go b/internal/model/entity/sys_admin_roles.go new file mode 100644 index 0000000..42481a6 --- /dev/null +++ b/internal/model/entity/sys_admin_roles.go @@ -0,0 +1,22 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminRoles is the golang structure for table sys_admin_roles. +type SysAdminRoles struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + Code string `json:"code" orm:"code" description:"角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)"` // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用) + Name string `json:"name" orm:"name" description:"角色显示名称(可修改)"` // 角色显示名称(可修改) + Description string `json:"description" orm:"description" description:"角色说明,在分配界面展示"` // 角色说明,在分配界面展示 + IsBuiltin bool `json:"is_builtin" orm:"is_builtin" description:"是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)"` // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删) + IsEnabled bool `json:"is_enabled" orm:"is_enabled" description:"是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联"` // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联 + Sort int `json:"sort" orm:"sort" description:"排序权重(升序)"` // 排序权重(升序) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 +} diff --git a/internal/model/entity/sys_admin_user_roles.go b/internal/model/entity/sys_admin_user_roles.go new file mode 100644 index 0000000..c6b156f --- /dev/null +++ b/internal/model/entity/sys_admin_user_roles.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminUserRoles is the golang structure for table sys_admin_user_roles. +type SysAdminUserRoles struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + AdminUserId int64 `json:"admin_user_id" orm:"admin_user_id" description:"管理员用户ID(逻辑关联 sys_admin_users.id,无外键)"` // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键) + RoleId int64 `json:"role_id" orm:"role_id" description:"角色ID(逻辑关联 sys_admin_roles.id,无外键)"` // 角色ID(逻辑关联 sys_admin_roles.id,无外键) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 +} diff --git a/internal/service/admin.go b/internal/service/admin.go index 2bd912f..8beaeb0 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -83,6 +83,11 @@ type ( ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) // ChangePassword handles admin password change. ChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (*v1.AdminChangePasswordRes, error) + // GetMe 返回当前登录用户的信息与有效权限。 + // + // 权限此前只在登录响应里下发一次,之后前端从 localStorage 恢复:管理员改了某人的权限, + // 对方不重新登录就一直按旧权限渲染菜单。前端在应用启动与 token 刷新后调用本接口刷新。 + GetMe(ctx context.Context, _ *v1.AdminMeReq) (*v1.AdminMeRes, error) // CreateChangelog 创建更新日志 CreateChangelog(ctx context.Context, req *v1.ChangelogCreateReq) (*v1.ChangelogCreateRes, error) // ListChangelogs 更新日志列表(管理后台,含草稿) @@ -190,7 +195,9 @@ type ( // 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号), // 供现金对账与开票追溯。 OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) - // GetWalletInfo 获取租户钱包信息(管理后台) + // GetWalletInfo 获取租户钱包信息(管理后台)。 + // balance/frozen_balance 以 Redis 权威值为准(Redis 是资金提交点,调整余额/线下入账后立即可见), + // DB 侧余额是物化器周期覆盖的滞后副本,仅作钱包行存在性校验与 Redis 不可用时的降级展示。 GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) // GetWalletTransactions 获取租户钱包交易流水(管理后台) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) @@ -402,7 +409,10 @@ type ( UpdateUserPermissions(ctx context.Context, req *v1.AdminPermissionUpdateReq) (*v1.AdminPermissionUpdateRes, error) // UpdateUserDataScopes updates data scopes for an admin user. UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) - // GetAllPermissions returns all predefined permission groups. + // GetAllPermissions returns all predefined permission groups plus tier metadata. + // + // 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用): + // 二者是同一份数据的两个视图,由后端统一给出,避免前端复刻一份档位定义造成漂移。 GetAllPermissions(ctx context.Context, _ *v1.AdminAllPermissionsReq) (*v1.AdminAllPermissionsRes, error) // ListPlans 获取套餐列表 ListPlans(ctx context.Context, req *v1.PlanListReq) (*v1.PlanListRes, error) @@ -447,6 +457,30 @@ type ( ListRedemptionUsages(ctx context.Context, req *v1.RedemptionUsagesReq) (*v1.RedemptionUsagesRes, error) // ExportRedemptions exports redemption list to CSV or Excel. ExportRedemptions(ctx context.Context, req *v1.RedemptionExportReq) (*v1.RedemptionExportRes, error) + // AssignUserRoles 为管理员分配角色(全量覆盖)。 + AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) + // GetUserRoles 查询管理员已分配的角色。 + GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) + // ListRoles 返回角色列表(角色数量少,不分页)。 + ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) + // GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。 + GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) + // CreateRole 新建角色。权限可直接给出,也可从现有角色复制。 + CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) + // UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。 + UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) + // UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。 + UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) + // DeleteRole 删除角色,并在同一事务内级联清理其权限行与用户关联。 + // + // 无外键,级联必须由业务层完成,否则会留下悬空的权限行与用户关联。 + // 预置角色同样可删:is_builtin 只标识来源,不构成删除保护。 + // + // 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权, + // 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。 + DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) + // ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。 + ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) // Verify2FA handles the 2FA verification step during admin login. Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.Admin2FAVerifyRes, error) // Setup2FA starts the 2FA setup process for the current admin user. @@ -549,7 +583,11 @@ type ( UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (*v1.UsageLogCleanupCancelRes, error) // GetWorkbenchSummary 工作台汇总。 GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSummaryReq) (*v1.AdminWorkbenchSummaryRes, error) - // GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。 + // GetWorkbenchBadges 工作台菜单角标计数(与 summary 共用缓存,不额外压库)。 + // + // 角标只挂工作台菜单一项:待办的排查线索只存在于工作台的描述文案里, + // 业务菜单里没有对应的定位入口,往各业务菜单挂数字只会带来 + // 「进去了却找不到问题」的困惑(红点引路却无路可走)。 GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error) } ) diff --git a/migrations/000020_admin_role_management.sql b/migrations/000020_admin_role_management.sql new file mode 100644 index 0000000..48f3b2f --- /dev/null +++ b/migrations/000020_admin_role_management.sql @@ -0,0 +1,182 @@ +-- +goose Up +-- 管理后台角色权限管理:引入可自定义的角色实体 +-- +-- 背景:此前管理后台只有 super_admin / admin 两种身份,权限点直接绑在用户上 +-- (sys_admin_role_perms 名为 role 实为 user_perms),无法复用、无法按岗位批量调整, +-- 结果是全团队共用超管账号。本迁移补齐「角色」这一层。 +-- +-- 角色模型: +-- · 超级管理员不入本表 —— 它由 sys_admin_users.role='super_admin' 唯一判定并在鉴权时短路, +-- 特权判定只保留一个真相源;即便角色被删光,超管仍可登录并重建角色体系。 +-- · 三个预置角色(管理员/运营/技术支持)只是安装时的初始数据,不是系统契约: +-- 用户可以改权限、改名、禁用,也可以直接删掉自建一套。 +-- · 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限(sys_admin_role_perms),只做并集不做否定。 + +-- ── 角色定义 ── +CREATE TABLE IF NOT EXISTS sys_admin_roles ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + name VARCHAR(50) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_roles_code UNIQUE (code) +); + +COMMENT ON TABLE sys_admin_roles IS '管理后台角色定义(超级管理员不入表,由 sys_admin_users.role 判定)'; +COMMENT ON COLUMN sys_admin_roles.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_roles.code IS '角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)'; +COMMENT ON COLUMN sys_admin_roles.name IS '角色显示名称(可修改)'; +COMMENT ON COLUMN sys_admin_roles.description IS '角色说明,在分配界面展示'; +COMMENT ON COLUMN sys_admin_roles.is_builtin IS '是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)'; +COMMENT ON COLUMN sys_admin_roles.is_enabled IS '是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联'; +COMMENT ON COLUMN sys_admin_roles.sort IS '排序权重(升序)'; +COMMENT ON COLUMN sys_admin_roles.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_roles.updated_at IS '更新时间'; + +-- ── 角色 → 权限点 ── +CREATE TABLE IF NOT EXISTS sys_admin_role_permissions ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + permission_point VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_role_permissions UNIQUE (role_id, permission_point) +); + +CREATE INDEX IF NOT EXISTS idx_sys_admin_role_permissions_role ON sys_admin_role_permissions (role_id); + +COMMENT ON TABLE sys_admin_role_permissions IS '角色权限点关联'; +COMMENT ON COLUMN sys_admin_role_permissions.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_role_permissions.role_id IS '角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)'; +COMMENT ON COLUMN sys_admin_role_permissions.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; +COMMENT ON COLUMN sys_admin_role_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_role_permissions.updated_at IS '更新时间'; + +-- ── 用户 → 角色(多对多,覆盖兼岗场景) ── +CREATE TABLE IF NOT EXISTS sys_admin_user_roles ( + id BIGSERIAL PRIMARY KEY, + admin_user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_user_roles UNIQUE (admin_user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_sys_admin_user_roles_user ON sys_admin_user_roles (admin_user_id); +CREATE INDEX IF NOT EXISTS idx_sys_admin_user_roles_role ON sys_admin_user_roles (role_id); + +COMMENT ON TABLE sys_admin_user_roles IS '管理员用户与角色关联(多对多:一个账号可同时担任多个角色,有效权限取并集)'; +COMMENT ON COLUMN sys_admin_user_roles.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_user_roles.admin_user_id IS '管理员用户ID(逻辑关联 sys_admin_users.id,无外键)'; +COMMENT ON COLUMN sys_admin_user_roles.role_id IS '角色ID(逻辑关联 sys_admin_roles.id,无外键)'; +COMMENT ON COLUMN sys_admin_user_roles.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_user_roles.updated_at IS '更新时间'; + +-- ── 存量表语义调整:sys_admin_role_perms 由「用户权限」改为「用户特批权限」 ── +-- 数据不迁移、不删除。它现在表示「在角色之外额外授予该用户的权限」, +-- 保留「临时给某人单加一个权限」的口子,不必为一个权限点新建角色。 +COMMENT ON TABLE sys_admin_role_perms IS '管理员用户特批权限(角色之外的额外授权,与角色权限取并集)'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '特批的权限点标识(独立于角色,角色禁用不影响特批)'; + +-- ── 预置角色种子 ── +-- ON CONFLICT DO NOTHING 有两层作用:① 迁移可重复执行; +-- ② 用户改过或删过的预置角色,重跑迁移不会被覆盖或复活。 +INSERT INTO sys_admin_roles (code, name, description, is_builtin, is_enabled, sort) VALUES + ('admin', '管理员', '二把手:能碰钱(退款)与系统配置(支付、汇率、存储、邮件),不能管理账号与角色', TRUE, TRUE, 10), + ('operator', '运营', '平台日常运营:渠道、模型、租户、套餐、内容的完整权限,不涉及资金与系统设置', TRUE, TRUE, 20), + ('support', '技术支持', '排查客户问题:除财务数据外全站只读,唯一写权限是工单、反馈与帮助中心', TRUE, TRUE, 30) +ON CONFLICT (code) DO NOTHING; + +-- 管理员:除 user:*(账号与角色)、system:update/plugin 外的全部权限 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + 'dashboard:view', + 'channel:view','channel:create','channel:edit','channel:test','channel:delete', + 'model:view','model:create','model:edit','model:delete', + 'tenant:view','tenant:create','tenant:edit','tenant:suspend','tenant:delete','tenant:close', + 'member:view','member:import','member:model_scope', + 'plan:view','plan:create','plan:edit','plan:delete', + 'billing:view','billing:export','billing:refund', + 'order:view','order:refund', + 'promo:view','promo:create','promo:edit', + 'redemption:view','redemption:create','redemption:edit', + 'invoice:view','invoice:manage', + 'operation:view','operation:edit', + 'support:view','support:reply','support:edit', + 'monitor:view','monitor:edit', + 'task:view','task:edit', + 'file:view','file:delete','file:cleanup', + 'audit:view','audit:export', + 'system:view','system:edit' +]) +FROM sys_admin_roles r WHERE r.code = 'admin' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 运营:业务配置的完整权限;不给退款、审计、系统设置、账号管理 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + 'dashboard:view', + 'channel:view','channel:create','channel:edit','channel:test','channel:delete', + 'model:view','model:create','model:edit','model:delete', + 'tenant:view','tenant:create','tenant:edit','tenant:suspend', + 'member:view','member:import','member:model_scope', + 'plan:view','plan:create','plan:edit','plan:delete', + 'billing:view', + 'order:view', + 'promo:view','promo:create','promo:edit', + 'redemption:view','redemption:create','redemption:edit', + 'invoice:view','invoice:manage', + 'operation:view','operation:edit', + 'support:view','support:reply','support:edit', + 'monitor:view','monitor:edit', + 'task:view','task:edit', + 'file:view','file:delete','file:cleanup' +]) +FROM sys_admin_roles r WHERE r.code = 'operator' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 技术支持:除财务面(billing/order/promo/redemption/invoice)外全站只读 + 工单完整权限。 +-- 排查主力工具不在财务面:请求审计日志走 audit:view,错误日志与渠道错误监控走 monitor:view。 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + 'dashboard:view', + 'channel:view', + 'model:view', + 'tenant:view', + 'member:view', + 'plan:view', + 'operation:view', + 'support:view','support:reply','support:edit', + 'monitor:view', + 'task:view', + 'file:view', + 'audit:view' +]) +FROM sys_admin_roles r WHERE r.code = 'support' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- ── 存量账号回填 ── +-- 非超管账号一律挂到「管理员」角色,保持升级前后的可用权限不缩水 +-- (升级前 admin 账号的实际权限来自 sys_admin_role_perms,特批权限继续并集生效)。 +INSERT INTO sys_admin_user_roles (admin_user_id, role_id) +SELECT u.id, r.id +FROM sys_admin_users u +CROSS JOIN sys_admin_roles r +WHERE u.role <> 'super_admin' AND r.code = 'admin' +ON CONFLICT (admin_user_id, role_id) DO NOTHING; + +-- +goose Down +-- 删除角色体系三张表。存量用户权限不受影响:它们本就存放在 sys_admin_role_perms, +-- 回滚后 HasPermission 退回「仅查用户特批权限」的旧语义。 +DROP TABLE IF EXISTS sys_admin_user_roles; +DROP TABLE IF EXISTS sys_admin_role_permissions; +DROP TABLE IF EXISTS sys_admin_roles; + +COMMENT ON TABLE sys_admin_role_perms IS '管理员角色权限关联'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 76e9dc8..1f5e1b0 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -22,13 +22,21 @@ const adminRoutes: RouteRecordRaw[] = [ path: '', name: 'AdminDashboard', component: () => import('@/views/DashboardPage.vue'), - meta: { title: '仪表盘' }, + meta: { title: '仪表盘', perm: 'dashboard:view' }, }, { path: 'workbench', name: 'AdminWorkbench', component: () => import('@/views/WorkbenchPage.vue'), - meta: { title: '工作台' }, + meta: { title: '工作台', perm: 'dashboard:view' }, + }, + { + // 权限不足的落地页:路由守卫拦截越权访问后跳到这里。 + // 接口层本就会 403,但直接给一个空白页加一片报错的体验很差。 + path: '403', + name: 'AdminForbidden', + component: () => import('@/views/ForbiddenPage.vue'), + meta: { title: '无权访问' }, }, { path: 'profile', @@ -40,55 +48,55 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'users', name: 'AdminUsers', component: () => import('@/views/system/UsersPage.vue'), - meta: { title: '用户管理' }, + meta: { title: '用户管理', perm: 'user:view' }, }, { path: 'plugins', name: 'AdminPlugins', component: () => import('@/views/system/PluginsPage.vue'), - meta: { title: '插件管理' }, + meta: { title: '插件管理', perm: 'system:plugin' }, }, { path: 'models', name: 'AdminModels', component: () => import('@/views/ai/ModelsPage.vue'), - meta: { title: '模型管理' }, + meta: { title: '模型管理', perm: 'model:view' }, }, { path: 'model-groups', name: 'AdminModelGroups', component: () => import('@/views/ai/ModelGroupsPage.vue'), - meta: { title: '模型分组' }, + meta: { title: '模型分组', perm: 'model:view' }, }, { path: 'channels', name: 'AdminChannels', component: () => import('@/views/ai/ChannelsPage.vue'), - meta: { title: '渠道管理' }, + meta: { title: '渠道管理', perm: 'channel:view' }, }, { path: 'channels/:id', name: 'AdminChannelDetail', component: () => import('@/views/ai/ChannelDetailPage.vue'), - meta: { title: '渠道详情' }, + meta: { title: '渠道详情', perm: 'channel:view' }, }, { path: 'usage-logs', name: 'AdminUsageLogs', component: () => import('@/views/tenant/UsageLogsPage.vue'), - meta: { title: '用量日志' }, + meta: { title: '用量日志', perm: 'billing:view' }, }, { path: 'transactions', name: 'AdminTransactions', component: () => import('@/views/finance/TransactionsPage.vue'), - meta: { title: '交易流水' }, + meta: { title: '交易流水', perm: 'billing:view' }, }, { path: 'orders', name: 'AdminOrders', component: () => import('@/views/finance/OrdersPage.vue'), - meta: { title: '订单管理' }, + meta: { title: '订单管理', perm: 'order:view' }, }, { // 旧"支付设置"独立页已并入系统设置(payment 分类),URL 保留重定向兼容 @@ -99,13 +107,13 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'redemptions', name: 'AdminRedemptions', component: () => import('@/views/finance/RedemptionsPage.vue'), - meta: { title: '兑换码管理' }, + meta: { title: '兑换码管理', perm: 'redemption:view' }, }, { path: 'promo-codes', name: 'AdminPromoCodes', component: () => import('@/views/finance/PromoCodesPage.vue'), - meta: { title: '优惠码管理' }, + meta: { title: '优惠码管理', perm: 'promo:view' }, }, { path: 'sessions', @@ -117,193 +125,193 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'permissions', name: 'AdminPermissions', component: () => import('@/views/security/PermissionsPage.vue'), - meta: { title: '权限管理' }, + meta: { title: '角色权限', perm: 'user:view' }, }, { path: 'login-history', name: 'AdminLoginHistory', component: () => import('@/views/security/LoginHistoryPage.vue'), - meta: { title: '登录历史' }, + meta: { title: '登录历史', perm: 'audit:view' }, }, { path: 'tenant-login-history', name: 'AdminTenantLoginHistory', component: () => import('@/views/security/TenantLoginHistoryPage.vue'), - meta: { title: '租户登录历史' }, + meta: { title: '租户登录历史', perm: 'audit:view' }, }, { path: 'task-logs', name: 'AdminTaskLogs', component: () => import('@/views/ai/TaskLogsPage.vue'), - meta: { title: '任务日志' }, + meta: { title: '任务日志', perm: 'task:view' }, }, { path: 'tenants', name: 'AdminTenants', component: () => import('@/views/tenant/TenantsPage.vue'), - meta: { title: '租户列表' }, + meta: { title: '租户列表', perm: 'tenant:view' }, }, { path: 'tenants/:id', name: 'AdminTenantDetail', component: () => import('@/views/tenant/TenantDetailPage.vue'), - meta: { title: '租户详情' }, + meta: { title: '租户详情', perm: 'tenant:view' }, }, { path: 'tenant-levels', name: 'AdminTenantLevels', component: () => import('@/views/tenant/TenantLevelsPage.vue'), - meta: { title: '租户级别' }, + meta: { title: '租户级别', perm: 'tenant:view' }, }, { path: 'members', name: 'AdminMembers', component: () => import('@/views/tenant/MembersPage.vue'), - meta: { title: '成员列表' }, + meta: { title: '成员列表', perm: 'member:view' }, }, { path: 'audit', name: 'AdminAudit', component: () => import('@/views/security/AuditPage.vue'), - meta: { title: '操作日志' }, + meta: { title: '操作日志', perm: 'audit:view' }, }, { path: 'request-audit-logs', name: 'AdminRequestAuditLogs', component: () => import('@/views/security/RequestAuditLogsPage.vue'), - meta: { title: '请求审计日志' }, + meta: { title: '请求审计日志', perm: 'audit:view' }, }, { path: 'notification-templates', name: 'AdminNotificationTemplates', component: () => import('@/views/operation/NotificationTemplatesPage.vue'), - meta: { title: '通知模板' }, + meta: { title: '通知模板', perm: 'operation:view' }, }, { path: 'messages', name: 'AdminMessages', component: () => import('@/views/operation/MessagesPage.vue'), - meta: { title: '消息管理' }, + meta: { title: '消息管理', perm: 'operation:view' }, }, { path: 'email-logs', name: 'AdminEmailLogs', component: () => import('@/views/operation/EmailLogsPage.vue'), - meta: { title: '邮件发送记录' }, + meta: { title: '邮件发送记录', perm: 'operation:view' }, }, { path: 'announcements', name: 'AdminAnnouncements', component: () => import('@/views/operation/AnnouncementsPage.vue'), - meta: { title: '公告管理' }, + meta: { title: '公告管理', perm: 'operation:view' }, }, { path: 'tickets', name: 'AdminTickets', component: () => import('@/views/operation/TicketsPage.vue'), - meta: { title: '工单管理' }, + meta: { title: '工单管理', perm: 'support:view' }, }, { path: 'feedback', name: 'AdminFeedback', component: () => import('@/views/operation/FeedbackPage.vue'), - meta: { title: '反馈管理' }, + meta: { title: '反馈管理', perm: 'support:view' }, }, { path: 'settings', name: 'AdminSettings', component: () => import('@/views/system/SettingsPage.vue'), - meta: { title: '系统设置' }, + meta: { title: '系统设置', perm: 'system:view' }, }, { path: 'files', name: 'AdminFiles', component: () => import('@/views/system/FilesPage.vue'), - meta: { title: '文件管理' }, + meta: { title: '文件管理', perm: 'file:view' }, }, { path: 'help-categories', name: 'AdminHelpCategories', component: () => import('@/views/system/HelpCategoriesPage.vue'), - meta: { title: '帮助分类' }, + meta: { title: '帮助分类', perm: 'support:view' }, }, { path: 'help-articles', name: 'AdminHelpArticles', component: () => import('@/views/system/HelpArticlesPage.vue'), - meta: { title: '帮助文章' }, + meta: { title: '帮助文章', perm: 'support:view' }, }, { path: 'changelogs', name: 'AdminChangelogs', component: () => import('@/views/operation/ChangelogsPage.vue'), - meta: { title: '更新日志' }, + meta: { title: '更新日志', perm: 'operation:view' }, }, { path: 'agreements', name: 'AdminAgreements', component: () => import('@/views/operation/AgreementsPage.vue'), - meta: { title: '用户协议' }, + meta: { title: '用户协议', perm: 'system:view' }, }, { path: 'realtime', name: 'AdminRealtimeMonitor', component: () => import('@/views/monitor/RealtimeMonitorPage.vue'), - meta: { title: '实时监控面板' }, + meta: { title: '实时监控面板', perm: 'monitor:view' }, }, { path: 'monitor', name: 'AdminMonitor', component: () => import('@/views/monitor/MonitorPage.vue'), - meta: { title: '系统监控' }, + meta: { title: '系统监控', perm: 'monitor:view' }, }, { path: 'traffic-flow', name: 'AdminTrafficFlow', component: () => import('@/views/monitor/TrafficFlowPage.vue'), - meta: { title: '流量流向' }, + meta: { title: '流量流向', perm: 'monitor:view' }, }, { path: 'model-performance', name: 'AdminModelPerformance', component: () => import('@/views/monitor/ModelPerformancePage.vue'), - meta: { title: '模型性能' }, + meta: { title: '模型性能', perm: 'monitor:view' }, }, { path: 'alert-rules', name: 'AdminAlertRules', component: () => import('@/views/monitor/AlertRulesPage.vue'), - meta: { title: '告警规则' }, + meta: { title: '告警规则', perm: 'monitor:view' }, }, { path: 'alert-events', name: 'AdminAlertEvents', component: () => import('@/views/monitor/AlertEventsPage.vue'), - meta: { title: '告警记录' }, + meta: { title: '告警记录', perm: 'monitor:view' }, }, { path: 'error-logs', name: 'AdminErrorLogs', component: () => import('@/views/monitor/ErrorLogsPage.vue'), - meta: { title: '系统错误日志' }, + meta: { title: '系统错误日志', perm: 'monitor:view' }, }, { path: 'channel-errors', name: 'AdminChannelErrors', component: () => import('@/views/monitor/ChannelErrorsPage.vue'), - meta: { title: '渠道错误监控' }, + meta: { title: '渠道错误监控', perm: 'monitor:view' }, }, { path: 'content-filter-logs', name: 'AdminContentFilterLogs', component: () => import('@/views/monitor/ContentFilterLogsPage.vue'), - meta: { title: '拦截日志' }, + meta: { title: '拦截日志', perm: 'audit:view' }, }, { path: 'cron-jobs', name: 'AdminCronJobs', component: () => import('@/views/monitor/CronJobsPage.vue'), - meta: { title: '定时任务' }, + meta: { title: '定时任务', perm: 'system:view' }, }, ], }, diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 3f56125..588dd67 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' +import { hasPermission } from '@/utils/permission' import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' import { usePublicSettings } from '@/composables/usePublicSettings' @@ -45,6 +46,10 @@ async function checkSetupStatus(): Promise { return systemInitialized === true } +// 每次页面加载只向服务端同步一次权限:守卫在每次路由跳转都会执行, +// 不加这道闸门会让每次点菜单都多打一个请求。 +let permissionsSynced = false + const { siteName, fetchSiteName } = useSiteName() const { fetchSettings: fetchPublicSettings } = usePublicSettings() const { start, done } = useTopProgress() @@ -87,6 +92,22 @@ router.beforeEach(async (to) => { return { name: 'AdminLogin', query: { redirect: to.fullPath } } } + // 本次会话首次进入受保护页面时,从服务端刷新一次有效权限。 + // 权限只在登录响应里下发过一次,之后由 localStorage 恢复:管理员改了某人的权限, + // 对方不重新登录就会一直按旧权限渲染菜单。这里补上刷新。 + // 注意 await:权限到位后再做下面的 meta.perm 判断,否则首屏可能被误判为越权。 + if (!permissionsSynced) { + permissionsSynced = true + await authStore.fetchMe() + } + + // 页面级权限校验。菜单已按权限过滤,这里拦的是直接输 URL 的情况。 + // 接口层本就会 403,但没有这道守卫,用户会先看到一个空白页再看到一片报错。 + const requiredPerm = to.meta.perm as string | undefined + if (requiredPerm && !hasPermission(requiredPerm)) { + return { name: 'AdminForbidden' } + } + return true }) diff --git a/web/admin/src/stores/auth.ts b/web/admin/src/stores/auth.ts index 87d9318..bde3448 100644 --- a/web/admin/src/stores/auth.ts +++ b/web/admin/src/stores/auth.ts @@ -10,6 +10,13 @@ export interface AdminUser { role: string } +export interface AdminRoleBrief { + id: number + code: string + name: string + is_enabled: boolean +} + export interface PendingAgreement { id: number code: string @@ -23,6 +30,7 @@ interface LoginResponse { expires_at: number user: AdminUser permissions: string[] + roles?: AdminRoleBrief[] pending_agreements?: PendingAgreement[] } @@ -34,6 +42,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const expiresAt = ref(null) const user = ref(null) const permissions = ref([]) + const roles = ref([]) const pendingAgreements = ref([]) const rememberMe = ref(getRememberMe()) @@ -48,6 +57,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt: expiresAt.value, user: user.value, permissions: permissions.value, + roles: roles.value, } localStorage.setItem(STORE_KEY, JSON.stringify(data)) } @@ -62,12 +72,17 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt: number | null user: AdminUser | null permissions: string[] + roles?: AdminRoleBrief[] } token.value = data.token refreshToken.value = data.refreshToken expiresAt.value = data.expiresAt user.value = data.user permissions.value = data.permissions ?? [] + roles.value = data.roles ?? [] + // 恢复 permission.ts 读取的镜像副本:hasPermission() 走 localStorage, + // 只恢复 Pinia 而不同步这份镜像,会让刷新后的按钮级权限全部失效 + setAdminSession(data.user?.role ?? '', permissions.value) } catch { // corrupted data — ignore } @@ -79,6 +94,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt.value = loginRes.expires_at user.value = loginRes.user permissions.value = loginRes.permissions ?? [] + roles.value = loginRes.roles ?? [] setTokens({ accessToken: loginRes.access_token, @@ -120,6 +136,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt.value = null user.value = null permissions.value = [] + roles.value = [] pendingAgreements.value = [] clearTokens() @@ -152,6 +169,36 @@ export const useAuthStore = defineStore('admin-auth', () => { hydrate() } + /** + * 从服务端拉取当前用户的最新权限。 + * + * 权限此前只在登录响应里下发一次,之后由 localStorage 恢复:管理员改了某人的权限, + * 对方不重新登录就一直按旧权限渲染菜单。在应用启动与 token 刷新后调用本方法即可跟上。 + * + * 失败时保持现有权限不变 —— 后端鉴权以服务端为准,前端权限只影响展示, + * 一次网络抖动不该把用户的菜单清空。 + */ + async function fetchMe(): Promise { + if (!token.value) return + try { + const { data } = await request.get('/admin/auth/me') + const me = data?.data + if (!me) return + user.value = { + id: me.id, + username: me.username, + display_name: me.display_name, + role: me.role, + } + permissions.value = me.permissions ?? [] + roles.value = me.roles ?? [] + setAdminSession(me.role, permissions.value) + persist() + } catch { + // 保持现有权限 + } + } + // Sync Pinia store when Axios interceptor refreshes tokens onTokenRefreshed((tokens) => { token.value = tokens.accessToken @@ -176,6 +223,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt, user, permissions, + roles, pendingAgreements, rememberMe, isLoggedIn, @@ -186,6 +234,7 @@ export const useAuthStore = defineStore('admin-auth', () => { logoutLocal, refreshTokens, loadFromStorage, + fetchMe, clearPendingAgreements, } }) diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 6250eeb..5eba314 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -37,6 +37,7 @@ import { IconCheckCircleFill, } from '@arco-design/web-vue/es/icon' import { useAuthStore } from '@/stores/auth' +import { hasPermission } from '@/utils/permission' const router = useRouter() const route = useRoute() @@ -240,7 +241,7 @@ function onGroupMouseEnter(groupKey: string, event: MouseEvent) { clearTimeout(popupHideTimer!) hoveredGroup.value = groupKey const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const groupData = menuGroups.find(g => g.key === groupKey) + const groupData = menuGroups.value.find(g => g.key === groupKey) const estimatedHeight = 32 + (groupData?.items.length || 0) * 36 popupTop.value = Math.max(8, Math.min(rect.top, window.innerHeight - estimatedHeight - 8)) } @@ -262,7 +263,7 @@ function onPopupMouseLeave() { } const hoveredGroupData = computed(() => { - return menuGroups.find(g => g.key === hoveredGroup.value) + return menuGroups.value.find(g => g.key === hoveredGroup.value) }) function handlePopupItemClick(key: string) { @@ -300,15 +301,17 @@ function onCollapseAfterLeave(el: Element) { e.style.overflow = '' } -const menuGroups = [ +// 菜单的完整定义。每项声明它需要的权限点,实际显隐由下方 menuGroups 派生 —— +// 菜单是权限的投影,不存在独立的「菜单权限」配置。 +const ALL_MENU_GROUPS = [ { key: 'dashboard', label: '数据看板', icon: IconDashboard, items: [ - { name: 'AdminWorkbench', label: '工作台', icon: IconDesktop }, - { name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard }, - { name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand }, + { name: 'AdminWorkbench', label: '工作台', icon: IconDesktop, perm: 'dashboard:view' }, + { name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard, perm: 'dashboard:view' }, + { name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand, perm: 'monitor:view' }, ], }, { @@ -316,12 +319,12 @@ const menuGroups = [ label: '大模型', icon: IconApps, items: [ - { name: 'AdminModels', label: '模型列表', icon: IconApps }, - { name: 'AdminModelGroups', label: '模型分组', icon: IconStorage }, - { name: 'AdminChannels', label: '渠道管理', icon: IconBranch }, - { name: 'AdminTaskLogs', label: '任务日志', icon: IconCalendar }, - { name: 'AdminUsageLogs', label: '用量日志', icon: IconFile }, - { name: 'AdminRequestAuditLogs', label: '请求审计日志', icon: IconCommand }, + { name: 'AdminModels', label: '模型列表', icon: IconApps, perm: 'model:view' }, + { name: 'AdminModelGroups', label: '模型分组', icon: IconStorage, perm: 'model:view' }, + { name: 'AdminChannels', label: '渠道管理', icon: IconBranch, perm: 'channel:view' }, + { name: 'AdminTaskLogs', label: '任务日志', icon: IconCalendar, perm: 'task:view' }, + { name: 'AdminUsageLogs', label: '用量日志', icon: IconFile, perm: 'billing:view' }, + { name: 'AdminRequestAuditLogs', label: '请求审计日志', icon: IconCommand, perm: 'audit:view' }, ], }, { @@ -329,9 +332,9 @@ const menuGroups = [ label: '租户管理', icon: IconUserGroup, items: [ - { name: 'AdminTenants', label: '租户列表', icon: IconHome }, - { name: 'AdminTenantLevels', label: '租户级别', icon: IconLayers }, - { name: 'AdminMembers', label: '成员列表', icon: IconUser }, + { name: 'AdminTenants', label: '租户列表', icon: IconHome, perm: 'tenant:view' }, + { name: 'AdminTenantLevels', label: '租户级别', icon: IconLayers, perm: 'tenant:view' }, + { name: 'AdminMembers', label: '成员列表', icon: IconUser, perm: 'member:view' }, ], }, { @@ -339,10 +342,10 @@ const menuGroups = [ label: '财务中心', icon: IconIdcard, items: [ - { name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock }, - { name: 'AdminTransactions', label: '交易流水', icon: IconCommand }, - { name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift }, - { name: 'AdminPromoCodes', label: '优惠码管理', icon: IconTag }, + { name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock, perm: 'order:view' }, + { name: 'AdminTransactions', label: '交易流水', icon: IconCommand, perm: 'billing:view' }, + { name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift, perm: 'redemption:view' }, + { name: 'AdminPromoCodes', label: '优惠码管理', icon: IconTag, perm: 'promo:view' }, ], }, { @@ -350,11 +353,11 @@ const menuGroups = [ label: '安全审计', icon: IconSafe, items: [ - { name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle }, - { name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle }, + { name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle, perm: 'audit:view' }, + { name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle, perm: 'audit:view' }, { name: 'AdminSessions', label: '会话管理', icon: IconClockCircle }, - { name: 'AdminPermissions', label: '权限管理', icon: IconSafe }, - { name: 'AdminAudit', label: '操作日志', icon: IconFile }, + { name: 'AdminPermissions', label: '角色权限', icon: IconSafe, perm: 'user:view' }, + { name: 'AdminAudit', label: '操作日志', icon: IconFile, perm: 'audit:view' }, ], }, { @@ -362,15 +365,15 @@ const menuGroups = [ label: '运营管理', icon: IconNotification, items: [ - { name: 'AdminNotificationTemplates', label: '通知模板', icon: IconNotification }, - { name: 'AdminMessages', label: '消息管理', icon: IconSend }, - { name: 'AdminEmailLogs', label: '邮件发送记录', icon: IconFile }, - { name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage }, - { name: 'AdminTickets', label: '工单管理', icon: IconCommand }, - { name: 'AdminFeedback', label: '反馈管理', icon: IconMessage }, - { name: 'AdminAgreements', label: '用户协议', icon: IconFile }, - { name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers }, - { name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile }, + { name: 'AdminNotificationTemplates', label: '通知模板', icon: IconNotification, perm: 'operation:view' }, + { name: 'AdminMessages', label: '消息管理', icon: IconSend, perm: 'operation:view' }, + { name: 'AdminEmailLogs', label: '邮件发送记录', icon: IconFile, perm: 'operation:view' }, + { name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage, perm: 'operation:view' }, + { name: 'AdminTickets', label: '工单管理', icon: IconCommand, perm: 'support:view' }, + { name: 'AdminFeedback', label: '反馈管理', icon: IconMessage, perm: 'support:view' }, + { name: 'AdminAgreements', label: '用户协议', icon: IconFile, perm: 'system:view' }, + { name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers, perm: 'support:view' }, + { name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile, perm: 'support:view' }, ], }, { @@ -378,15 +381,15 @@ const menuGroups = [ label: '运维监控', icon: IconCommand, items: [ - { name: 'AdminMonitor', label: '系统监控', icon: IconCommand }, - { name: 'AdminTrafficFlow', label: '流量流向', icon: IconCommand }, - { name: 'AdminModelPerformance', label: '模型性能', icon: IconCommand }, - { name: 'AdminAlertRules', label: '告警规则', icon: IconNotification }, - { name: 'AdminAlertEvents', label: '告警记录', icon: IconFile }, - { name: 'AdminErrorLogs', label: '错误日志', icon: IconFile }, - { name: 'AdminChannelErrors', label: '渠道错误监控', icon: IconFile }, - { name: 'AdminContentFilterLogs', label: '拦截日志', icon: IconFile }, - { name: 'AdminCronJobs', label: '定时任务', icon: IconFile }, + { name: 'AdminMonitor', label: '系统监控', icon: IconCommand, perm: 'monitor:view' }, + { name: 'AdminTrafficFlow', label: '流量流向', icon: IconCommand, perm: 'monitor:view' }, + { name: 'AdminModelPerformance', label: '模型性能', icon: IconCommand, perm: 'monitor:view' }, + { name: 'AdminAlertRules', label: '告警规则', icon: IconNotification, perm: 'monitor:view' }, + { name: 'AdminAlertEvents', label: '告警记录', icon: IconFile, perm: 'monitor:view' }, + { name: 'AdminErrorLogs', label: '错误日志', icon: IconFile, perm: 'monitor:view' }, + { name: 'AdminChannelErrors', label: '渠道错误监控', icon: IconFile, perm: 'monitor:view' }, + { name: 'AdminContentFilterLogs', label: '拦截日志', icon: IconFile, perm: 'audit:view' }, + { name: 'AdminCronJobs', label: '定时任务', icon: IconFile, perm: 'system:view' }, ], }, { @@ -394,14 +397,25 @@ const menuGroups = [ label: '系统', icon: IconSettings, items: [ - { name: 'AdminUsers', label: '用户管理', icon: IconUserGroup }, - { name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock }, - { name: 'AdminSettings', label: '系统设置', icon: IconSettings }, - { name: 'AdminFiles', label: '文件管理', icon: IconStorage }, + { name: 'AdminUsers', label: '用户管理', icon: IconUserGroup, perm: 'user:view' }, + { name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock, perm: 'system:plugin' }, + { name: 'AdminSettings', label: '系统设置', icon: IconSettings, perm: 'system:view' }, + { name: 'AdminFiles', label: '文件管理', icon: IconStorage, perm: 'file:view' }, ], }, ] +// 按当前用户权限过滤菜单:整组条目都被过滤掉时,分组本身也不再渲染, +// 避免出现点开是空的分组。super_admin 在 hasPermission 内直接放行。 +const menuGroups = computed(() => + ALL_MENU_GROUPS + .map(group => ({ + ...group, + items: group.items.filter(item => !item.perm || hasPermission(item.perm)), + })) + .filter(group => group.items.length > 0) +) + const breadcrumbItems = computed(() => { return route.matched .filter(item => item.meta?.title) diff --git a/web/admin/src/views/ForbiddenPage.vue b/web/admin/src/views/ForbiddenPage.vue new file mode 100644 index 0000000..34880af --- /dev/null +++ b/web/admin/src/views/ForbiddenPage.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/admin/src/views/security/PermissionsPage.vue b/web/admin/src/views/security/PermissionsPage.vue index cbe019a..ddffeaf 100644 --- a/web/admin/src/views/security/PermissionsPage.vue +++ b/web/admin/src/views/security/PermissionsPage.vue @@ -1,80 +1,560 @@ + + diff --git a/web/admin/src/views/system/UsersPage.vue b/web/admin/src/views/system/UsersPage.vue index 2fcf126..71cd8db 100644 --- a/web/admin/src/views/system/UsersPage.vue +++ b/web/admin/src/views/system/UsersPage.vue @@ -1,5 +1,5 @@ diff --git a/web/admin/src/views/system/UsersPage.vue b/web/admin/src/views/system/UsersPage.vue index 71cd8db..a52497a 100644 --- a/web/admin/src/views/system/UsersPage.vue +++ b/web/admin/src/views/system/UsersPage.vue @@ -1,7 +1,7 @@ @@ -118,4 +257,136 @@ const values = useFormValues() line-height: 1.6; margin-top: -8px; } +.section-title-with-help { + display: flex; + align-items: center; + gap: 6px; +} +.help-icon { + color: var(--color-text-3); + cursor: pointer; + transition: color 0.2s; + flex: 0 0 auto; +} +.help-icon:hover { + color: rgb(var(--arcoblue-6)); +} + +/* 路由策略说明弹窗(样式惯例对齐 ChannelsPage 调度说明弹窗) */ +.policy-guide { + color: var(--color-text-1); + font-size: 13px; + line-height: 1.7; + max-height: 65vh; + overflow-y: auto; + padding-right: 6px; +} +.policy-guide .guide-intro { + margin: 0 0 14px; + color: var(--color-text-2); +} +.policy-guide .guide-section { + margin-bottom: 16px; +} +.policy-guide .guide-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-weight: 600; +} +.policy-guide .guide-index { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + flex: 0 0 22px; + border-radius: 50%; + color: rgb(var(--arcoblue-6)); + background: var(--color-primary-light-1); + font-size: 12px; +} +.policy-guide p { + margin: 0; +} +.policy-guide p + p { + margin-top: 6px; +} +.policy-guide code { + padding: 1px 4px; + border-radius: 3px; + color: rgb(var(--arcoblue-6)); + background: var(--color-primary-light-1); +} +.guide-warning { + display: flex; + gap: 8px; + padding: 10px 12px; + margin-bottom: 14px; + border-radius: 6px; + background: var(--color-warning-light-1); +} +.guide-warning .warning-icon { + color: rgb(var(--warning-6)); + flex: 0 0 auto; + margin-top: 3px; +} +.guide-rules { + margin: 0; + padding-left: 20px; +} +.guide-rules li { + margin-bottom: 4px; +} +.policy-table-wrap { + overflow-x: auto; +} +.policy-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} +.policy-table th, +.policy-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--color-fill-2); + text-align: left; + vertical-align: top; +} +.policy-table th { + font-weight: 600; + white-space: nowrap; + background: var(--color-fill-1); +} +.policy-table td:first-child { + white-space: nowrap; + font-weight: 500; + color: var(--color-text-1); +} +.policy-guide .guide-example { + padding: 14px 16px; + border-radius: 6px; + background: var(--color-fill-2); +} +.policy-guide .guide-example-title { + margin-bottom: 6px; + font-weight: 600; +} +.policy-guide .guide-example p { + margin: 8px 0 0; +} +.policy-guide pre { + margin: 8px 0 0; + padding: 12px 14px; + border-radius: 6px; + background: var(--color-fill-1); + overflow-x: auto; + font-size: 12.5px; +} +.policy-guide pre code { + padding: 0; + background: none; + color: inherit; +} From 71135aa321baa3a0b8581dc36301422a9b4cff0c Mon Sep 17 00:00:00 2001 From: qian Date: Wed, 2 Sep 2026 00:10:26 +0800 Subject: [PATCH 08/24] =?UTF-8?q?fix(setting):=E7=A7=BB=E9=99=A4=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/000020_admin_role_management.sql | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/migrations/000020_admin_role_management.sql b/migrations/000020_admin_role_management.sql index 48f3b2f..0eded35 100644 --- a/migrations/000020_admin_role_management.sql +++ b/migrations/000020_admin_role_management.sql @@ -170,6 +170,30 @@ CROSS JOIN sys_admin_roles r WHERE u.role <> 'super_admin' AND r.code = 'admin' ON CONFLICT (admin_user_id, role_id) DO NOTHING; + +-- 移除不再使用的配置项 +DELETE FROM sys_options +WHERE "key" IN ( + -- 安全 + 'max_sessions_per_user', + 'admin_max_sessions', + 'new_device_notification', + 'password_min_length', + -- 性能 + 'cache_enabled', + 'cache_ttl_seconds', + 'channel_affinity_ttl', + 'auto_test_interval', + -- 审计/数据治理 + 'audit_retention_days', + 'operation_log_retention_days', + 'data_deletion_completion_days', + -- 沙箱 + 'sandbox_enabled', + 'sandbox_default_quota' +); + + -- +goose Down -- 删除角色体系三张表。存量用户权限不受影响:它们本就存放在 sys_admin_role_perms, -- 回滚后 HasPermission 退回「仅查用户特批权限」的旧语义。 From ec182c5a71e097184b38bca272b211f7ae1c8f7b Mon Sep 17 00:00:00 2001 From: chengqian Date: Wed, 2 Sep 2026 14:14:45 +0800 Subject: [PATCH 09/24] =?UTF-8?q?chore(doc):=E5=AE=8C=E5=96=84=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 10 +- README.md | 8 +- THIRD_PARTY_NOTICES.md | 180 ++++++++++++++++++ internal/cmd/cmd.go | 2 +- web/admin/src/main.ts | 4 +- web/admin/src/views/LoginPage.vue | 4 +- .../src/components/layout/AuthLayout.vue | 2 +- web/tenant/src/main.ts | 4 +- web/tenant/src/views/landing/LandingPage.vue | 4 +- 9 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/LICENSE b/LICENSE index d0ad6f4..3e75ed5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,11 @@ -Team-API -Copyright (C) 2026 qianfree <406615373@qq.com> +Team-API Community Edition +Copyright (C) 2026 CHENGQIAN WU (qianfree) + +This copy of Team-API is offered under the GNU Affero General Public License +version 3 or any later version. Additional commercial licensing rights, such as +waivers of AGPL obligations or contractual SaaS/OEM/resale rights, are granted +only under a separate written agreement with the copyright holder; this file +does not grant those additional rights. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/README.md b/README.md index d7b87f8..25e0894 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,8 @@ make migrate-status # 查看迁移状态 请遵循项目现有代码风格和 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 提交规范。 +项目当前由 qianfree 单独维护。未来如接受外部贡献,将在合并贡献前提供贡献者许可协议(CLA),以确保 AGPL 与商业授权的权利链清晰完整。 + ## 在线交流 欢迎加QQ群聊天吹水:1095286563 @@ -407,7 +409,7 @@ make migrate-status # 查看迁移状态 ## 许可证 -Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License v3.0](LICENSE) 许可证。 +Copyright (C) 2026 qianfree。本项目社区版采用 [AGPL v3.0 或更高版本](LICENSE) 许可证。 ### 核心要求 @@ -417,7 +419,9 @@ Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License ### 商业授权 -如果你想将本项目代码用于闭源商业产品,AGPL-3.0 许可证不适用。需要单独获取商业授权,请联系:**406615373@qq.com** +如果你希望在不履行 AGPL 开源义务的情况下闭源使用本项目,需要单独获取商业授权,请联系:**406615373@qq.com**。 +商业授权不转让本项目的著作权、专利权、商标权或其他知识产权,具体权利以双方签署的书面协议为准。 + ## 致谢 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..de38c3d --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,180 @@ +# 第三方开源组件声明 / Third-Party Notices + +版权所有 © 2026 qianfree。 + +Team-API 使用了以下开源组件,感谢各项目的作者与贡献者。 +本文件用于满足各组件许可证的署名要求,商业授权交付时需随产品附带。 +本文件不改变 Team-API 的主许可证,也不授予任何额外的软件使用、复制、修改、分发、SaaS、OEM 或转售权利;各第三方组件仍受其各自许可证约束。 + +> 本文件由脚本扫描 `go.mod`(Go 模块缓存)与 `web/*/node_modules`(package.json 的 license 字段)生成。 +> 重新生成方式见文末。 + +## 许可证分布概览 + +- **Go 依赖**:以本文件表格为准;发布前应重新扫描并复核许可证,重点关注 GPL/AGPL/LGPL 等 copyleft 许可证 +- **前端依赖**:以本文件表格为准;发布前应重新扫描并复核生产依赖与构建期依赖 + +## Go 依赖(含间接依赖) + +> 下表覆盖生成时已缓存的 122 个模块;另有 66 个模块(均为其他平台专用的间接依赖,如 Windows/macOS 专属包)本次未下载,发布前用文末方法重新扫描补全。 + +| 模块 | 版本 | 许可证 | +|------|------|--------| +| `github.com/aws/aws-sdk-go-v2` | v1.41.6 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` | v1.7.9 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/config` | v1.32.16 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/credentials` | v1.19.15 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` | v1.18.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/internal/configsources` | v1.4.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/internal/endpoints/v2` | v2.7.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/internal/v4a` | v1.4.23 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding` | v1.13.8 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/checksum` | v1.9.14 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/presigned-url` | v1.13.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/s3shared` | v1.19.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/s3` | v1.99.1 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/signin` | v1.0.10 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/sso` | v1.30.16 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/ssooidc` | v1.35.20 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/sts` | v1.42.0 | Apache-2.0 | +| `github.com/aws/smithy-go` | v1.25.0 | Apache-2.0 | +| `github.com/go-logr/logr` | v1.4.3 | Apache-2.0 | +| `github.com/go-logr/stdr` | v1.2.2 | Apache-2.0 | +| `github.com/pquerna/otp` | v1.5.0 | Apache-2.0 | +| `github.com/richardlehane/mscfb` | v1.0.6 | Apache-2.0 | +| `github.com/richardlehane/msoleps` | v1.0.6 | Apache-2.0 | +| `github.com/sethvargo/go-retry` | v0.3.0 | Apache-2.0 | +| `github.com/tklauser/numcpus` | v0.6.1 | Apache-2.0 | +| `github.com/wenlng/go-captcha-assets` | v1.0.7 | Apache-2.0 | +| `github.com/wenlng/go-captcha/v2` | v2.0.5 | Apache-2.0 | +| `go.opentelemetry.io/auto/sdk` | v1.2.1 | Apache-2.0 | +| `go.opentelemetry.io/otel` | v1.43.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/metric` | v1.43.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/sdk` | v1.39.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/sdk/metric` | v1.39.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/trace` | v1.43.0 | Apache-2.0 | +| `gopkg.in/yaml.v3` | v3.0.1 | Apache-2.0 | +| `github.com/emirpasic/gods/v2` | v2.0.0-alpha | BSD-2-Clause | +| `github.com/gorilla/websocket` | v1.5.3 | BSD-2-Clause | +| `github.com/magiconair/properties` | v1.8.10 | BSD-2-Clause | +| `github.com/pmezard/go-difflib` | v1.0.0 | BSD-2-Clause | +| `github.com/redis/go-redis/v9` | v9.19.0 | BSD-2-Clause | +| `github.com/zeebo/xxh3` | v1.1.0 | BSD-2-Clause | +| `gopkg.in/check.v1` | v1.0.0-20201130134442-10cb98267c6c | BSD-2-Clause | +| `github.com/cloudflare/ahocorasick` | v0.0.0-20240916140611-054963ec9396 | BSD-3-Clause | +| `github.com/fsnotify/fsnotify` | v1.9.0 | BSD-3-Clause | +| `github.com/google/go-cmp` | v0.7.0 | BSD-3-Clause | +| `github.com/google/go-querystring` | v1.0.0 | BSD-3-Clause | +| `github.com/google/uuid` | v1.6.0 | BSD-3-Clause | +| `github.com/grokify/html-strip-tags-go` | v0.1.0 | BSD-3-Clause | +| `github.com/lufia/plan9stats` | v0.0.0-20211012122336-39d0f177ccd0 | BSD-3-Clause | +| `github.com/remyoudompheng/bigfft` | v0.0.0-20230129092748-24d4a6f8daec | BSD-3-Clause | +| `github.com/rogpeppe/go-internal` | v1.14.1 | BSD-3-Clause | +| `github.com/shirou/gopsutil/v3` | v3.24.5 | BSD-3-Clause | +| `github.com/tklauser/go-sysconf` | v0.3.12 | BSD-3-Clause | +| `github.com/xuri/efp` | v0.0.1 | BSD-3-Clause | +| `github.com/xuri/excelize/v2` | v2.10.1 | BSD-3-Clause | +| `github.com/xuri/nfp` | v0.0.2-0.20250530014748-2ddeb826f9a9 | BSD-3-Clause | +| `golang.org/x/crypto` | v0.53.0 | BSD-3-Clause | +| `golang.org/x/image` | v0.25.0 | BSD-3-Clause | +| `golang.org/x/net` | v0.56.0 | BSD-3-Clause | +| `golang.org/x/sync` | v0.22.0 | BSD-3-Clause | +| `golang.org/x/sys` | v0.47.0 | BSD-3-Clause | +| `golang.org/x/text` | v0.40.0 | BSD-3-Clause | +| `golang.org/x/time` | v0.15.0 | BSD-3-Clause | +| `modernc.org/libc` | v1.72.1 | BSD-3-Clause | +| `modernc.org/mathutil` | v1.7.1 | BSD-3-Clause | +| `modernc.org/memory` | v1.11.0 | BSD-3-Clause | +| `modernc.org/sqlite` | v1.49.1 | BSD-3-Clause | +| `github.com/golang/freetype` | v0.0.0-20170609003504-e2365dfdc4a0 | FTL / GPL-2.0-or-later(本项目选择 FTL) | +| `github.com/shoenig/go-m1cpu` | v0.2.1 | MPL-2.0 | +| `github.com/shoenig/test` | v1.7.0 | MPL-2.0 | +| `github.com/davecgh/go-spew` | v1.1.1 | ISC | +| `github.com/BurntSushi/toml` | v1.5.0 | MIT | +| `github.com/Masterminds/semver/v3` | v3.5.0 | MIT | +| `github.com/alicebob/miniredis/v2` | v2.38.0 | MIT | +| `github.com/aliyun/aliyun-oss-go-sdk` | v3.0.2+incompatible | MIT | +| `github.com/boombuler/barcode` | v1.0.1-0.20190219062509-6c824513bacc | MIT | +| `github.com/bsm/ginkgo/v2` | v2.12.0 | MIT | +| `github.com/bsm/gomega` | v1.27.10 | MIT | +| `github.com/cespare/xxhash/v2` | v2.3.0 | MIT | +| `github.com/clbanning/mxj` | v1.8.4 | MIT | +| `github.com/clbanning/mxj/v2` | v2.7.0 | MIT | +| `github.com/dustin/go-humanize` | v1.0.1 | MIT | +| `github.com/fatih/color` | v1.18.0 | MIT | +| `github.com/go-ole/go-ole` | v1.2.6 | MIT | +| `github.com/gogf/gf/contrib/drivers/pgsql/v2` | v2.10.2 | MIT | +| `github.com/gogf/gf/contrib/nosql/redis/v2` | v2.10.2 | MIT | +| `github.com/gogf/gf/v2` | v2.10.2 | MIT | +| `github.com/golang-jwt/jwt/v5` | v5.3.1 | MIT | +| `github.com/klauspost/cpuid/v2` | v2.2.10 | MIT | +| `github.com/kr/pretty` | v0.3.1 | MIT | +| `github.com/kr/text` | v0.2.0 | MIT | +| `github.com/lib/pq` | v1.10.9 | MIT | +| `github.com/mattn/go-colorable` | v0.1.13 | MIT | +| `github.com/mattn/go-isatty` | v0.0.21 | MIT | +| `github.com/mattn/go-runewidth` | v0.0.16 | MIT | +| `github.com/mfridman/interpolate` | v0.0.2 | MIT | +| `github.com/mitchellh/mapstructure` | v1.4.3 | MIT | +| `github.com/mozillazg/go-httpheader` | v0.2.1 | MIT | +| `github.com/ncruces/go-strftime` | v1.0.0 | MIT | +| `github.com/olekukonko/errors` | v1.1.0 | MIT | +| `github.com/olekukonko/ll` | v0.0.9 | MIT | +| `github.com/olekukonko/tablewriter` | v1.1.0 | MIT | +| `github.com/power-devops/perfstat` | v0.0.0-20210106213030-5aafc221ea8c | MIT | +| `github.com/pressly/goose/v3` | v3.27.1 | MIT | +| `github.com/rivo/uniseg` | v0.2.0 | MIT | +| `github.com/robfig/cron/v3` | v3.0.1 | MIT | +| `github.com/samber/lo` | v1.53.0 | MIT | +| `github.com/shopspring/decimal` | v1.4.0 | MIT | +| `github.com/stretchr/testify` | v1.11.1 | MIT | +| `github.com/tencentyun/cos-go-sdk-v5` | v0.7.73 | MIT | +| `github.com/tidwall/gjson` | v1.18.0 | MIT | +| `github.com/tidwall/match` | v1.1.1 | MIT | +| `github.com/tidwall/pretty` | v1.2.0 | MIT | +| `github.com/tidwall/sjson` | v1.2.5 | MIT | +| `github.com/tiendc/go-deepcopy` | v1.7.2 | MIT | +| `github.com/yuin/gopher-lua` | v1.1.1 | MIT | +| `github.com/yusufpapurcu/wmi` | v1.2.4 | MIT | +| `go.uber.org/atomic` | v1.11.0 | MIT | +| `go.uber.org/goleak` | v1.3.0 | MIT | +| `go.uber.org/multierr` | v1.11.0 | MIT | +| `gopkg.in/alexcesaro/quotedprintable.v3` | v3.0.0-20150716171945-2caba252f4dc | MIT | +| `gopkg.in/gomail.v2` | v2.0.0-20160411212932-81ebce5c23df | MIT | +| `github.com/qianfree/team-api/relaykit` | v0.0.0-00010101000000-000000000000 | NO_LICENSE_FILE | + +### Go 依赖特别说明 + +- `github.com/golang/freetype`:双许可证(FTL / GPL-2.0+),本项目**选择 FTL**(类 BSD 带广告条款),不触发 GPL 义务 +- `github.com/shoenig/go-m1cpu`、`github.com/shoenig/test`:MPL-2.0(文件级弱传染),均为间接依赖(前者仅 macOS 平台编译、后者仅测试用),商业分发合规,保留其 LICENSE 即可 +- `github.com/qianfree/team-api/relaykit`:本项目自有子模块,非第三方 + +## 前端依赖(web/admin + web/tenant,含开发依赖) + +- **0BSD**(1 个): `tslib` +- **Apache-2.0**(3 个): `detect-libc`, `echarts`, `typescript` +- **BSD-2-Clause**(1 个): `entities` +- **BSD-3-Clause**(4 个): `highlight.js`, `source-map-js`, `speakingurl`, `zrender` +- **ISC**(2 个): `graceful-fs`, `picocolors` +- **MIT**(89 个): `alien-signals`, `async-validator`, `asynckit`, `axios`, `b-tween`, `b-validate`, `birpc`, `call-bind-apply-helpers`, `chart.js`, `color-convert`, `color-name`, `color-string`, `color`, `combined-stream`, `compute-scroll-into-view`, `copy-anything`, `css-render`, `csstype`, `date-fns-tz`, `date-fns`, `dayjs`, `delayed-stream`, `dunder-proto`, `enhanced-resolve`, `es-define-property`, `es-errors`, `es-object-atoms`, `es-set-tostringtag`, `estree-walker`, `evtd`, `fdir`, `follow-redirects`, `form-data`, `function-bind`, `get-intrinsic`, `get-proto`, `gopd`, `has-symbols`, `has-tostringtag`, `hasown`, `hookable`, `is-arrayish`, `is-what`, `jiti`, `lodash-es`, `lodash`, `magic-string`, `marked`, `marked`, `math-intrinsics`, `mime-db`, `mime-types`, `mitt`, `muggle-string`, `naive-ui`, `nanoid`, `number-precision`, `packrup`, `path-browserify`, `perfect-debounce`, `picomatch`, `pinia`, `postcss`, `proxy-from-env`, `resize-observer-polyfill`, `rfdc`, `rolldown`, `scroll-into-view-if-needed`, `seemly`, `simple-swizzle`, `superjson`, `tailwindcss`, `tapable`, `tinyglobby`, `treemate`, `undici-types`, `unhead`, `vdirs`, `vite`, `vooks`, `vscode-uri`, `vue-chartjs`, `vue-demi`, `vue-echarts`, `vue-router`, `vue-tsc`, `vueuc`, `vue`, `zhead` +- **MPL-2.0**(2 个): `lightningcss-win32-x64-msvc`, `lightningcss` + +### 前端依赖特别说明 + +- `lightningcss`(MPL-2.0):仅构建期 CSS 处理工具,不进入运行时产物 + +--- + +## 重新生成方法 + +```bash +# Go 依赖:列出全部模块并在模块缓存中检查 LICENSE 文件 +go list -m -f "{{if not .Main}}{{.Path}}|{{.Version}}|{{.Dir}}{{end}}" all +# 推荐工具:go install github.com/google/go-licenses@latest +# go-licenses report ./... 可生成带许可证文本链接的完整报告 + +# 前端依赖(在各 web/* 目录下): +# bunx license-checker --summary / npx license-checker --json +``` + +> ⚠️ 本文件列出版本为生成时快照。每次发布商业版本前应重新扫描,重点核对**新增依赖**是否引入 copyleft 类许可证。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index b43be74..cb4a990 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -354,7 +354,7 @@ func printBanner() { fmt.Printf(" %sTeam-API%s %s%s%s %s| %s%s企业级大模型 API 网关系统%s\n", cyan, reset, green, consts.Version, reset, dim, reset, dim, reset) fmt.Printf(" %shttps://github.com/qianfree/team-api%s\n", dim, reset) fmt.Println() - fmt.Printf(" %sAGPL v3.0 开源协议 | Copyright © 2025-2026 Team-API Contributors%s\n", dim, reset) + fmt.Printf(" %sAGPL-3.0-or-later | Copyright © 2026 qianfree%s\n", dim, reset) fmt.Println() } diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts index 0de17d8..7ec54df 100644 --- a/web/admin/src/main.ts +++ b/web/admin/src/main.ts @@ -14,11 +14,11 @@ console.log( 'background: #115e59; color: white; padding: 4px 8px; border-radius: 0 4px 4px 0;', ) console.log( - '%cAGPL v3.0 开源协议', + '%cAGPL-3.0-or-later 开源协议', 'color: #666;', ) console.log( - '%cCopyright © 2025-2026 Team-API Contributors', + '%cCopyright © 2026 qianfree', 'color: #666;', ) console.log( diff --git a/web/admin/src/views/LoginPage.vue b/web/admin/src/views/LoginPage.vue index dfdd753..df6a75f 100644 --- a/web/admin/src/views/LoginPage.vue +++ b/web/admin/src/views/LoginPage.vue @@ -198,7 +198,7 @@ async function handle2FAVerify() { @@ -307,7 +307,7 @@ async function handle2FAVerify() { diff --git a/web/tenant/src/components/layout/AuthLayout.vue b/web/tenant/src/components/layout/AuthLayout.vue index 0951233..72085e2 100644 --- a/web/tenant/src/components/layout/AuthLayout.vue +++ b/web/tenant/src/components/layout/AuthLayout.vue @@ -37,7 +37,7 @@
- © {{ new Date().getFullYear() }} qianfree. Licensed under AGPL-3.0. + © {{ new Date().getFullYear() }} qianfree. Licensed under AGPL-3.0-or-later. Powered by Team-API
diff --git a/web/tenant/src/main.ts b/web/tenant/src/main.ts index 104f37d..664bb38 100644 --- a/web/tenant/src/main.ts +++ b/web/tenant/src/main.ts @@ -17,11 +17,11 @@ console.log( 'background: #4b58c0; color: white; padding: 4px 8px; border-radius: 0 4px 4px 0;', ) console.log( - '%cAGPL v3.0 开源协议', + '%cAGPL-3.0-or-later 开源协议', 'color: #666;', ) console.log( - '%cCopyright © 2025-2026 Team-API Contributors', + '%cCopyright © 2026 qianfree', 'color: #666;', ) console.log( diff --git a/web/tenant/src/views/landing/LandingPage.vue b/web/tenant/src/views/landing/LandingPage.vue index 4f05cd3..99758d7 100644 --- a/web/tenant/src/views/landing/LandingPage.vue +++ b/web/tenant/src/views/landing/LandingPage.vue @@ -165,7 +165,7 @@