From 16d9ab0e5e1e8a3fee805ceade5a4a73ae67c0fa Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 26 Jul 2026 14:00:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=A7=92=E8=89=B2=E6=9D=83?= =?UTF-8?q?=E9=99=90=E4=B8=8E=E6=95=B0=E6=8D=AE=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/权限.md | 358 ++++++++++++++ server/cmd/seed/main.go | 8 +- server/internal/handler/auth.go | 13 + server/internal/handler/blacklist.go | 19 +- server/internal/handler/channel.go | 11 +- server/internal/handler/customer.go | 16 +- server/internal/handler/customer_tag.go | 3 +- server/internal/handler/export.go | 13 +- server/internal/handler/knowledge.go | 55 ++- server/internal/handler/permission.go | 457 ++++++++++++++++++ server/internal/handler/quick_reply.go | 31 +- server/internal/handler/router.go | 135 +++--- .../handler/security_integration_test.go | 155 +++++- server/internal/handler/session.go | 62 ++- server/internal/handler/settings.go | 2 +- server/internal/handler/staff.go | 172 ++++++- server/internal/handler/statistics.go | 35 +- server/internal/middleware/auth.go | 4 + server/internal/middleware/permission.go | 249 ++++++++++ server/internal/model/db.go | 4 + server/internal/model/models.go | 132 +++-- server/internal/model/seed_permissions.go | 190 ++++++++ server/internal/ws/ws.go | 110 ++++- web/src/components/RequireAuth.tsx | 13 + web/src/components/layout/AgentSidebar.tsx | 28 +- web/src/pages/agent/Blacklist.tsx | 4 +- web/src/pages/agent/ChatHistory.tsx | 10 +- web/src/pages/agent/Customers.tsx | 11 +- web/src/pages/agent/Knowledge.tsx | 40 +- web/src/pages/agent/QuickReplies.tsx | 9 +- web/src/pages/agent/RoleConfig.tsx | 184 +++++++ web/src/pages/agent/RoleList.tsx | 148 ++++++ web/src/pages/agent/Settings.tsx | 6 +- web/src/pages/agent/Staff.tsx | 255 ++++++++++ web/src/router/index.tsx | 33 +- web/src/services/api.ts | 48 ++ web/src/services/request.ts | 19 +- web/src/stores/auth.tsx | 97 +++- 38 files changed, 2847 insertions(+), 292 deletions(-) create mode 100644 docs/权限.md create mode 100644 server/internal/handler/permission.go create mode 100644 server/internal/middleware/permission.go create mode 100644 server/internal/model/seed_permissions.go create mode 100644 web/src/pages/agent/RoleConfig.tsx create mode 100644 web/src/pages/agent/RoleList.tsx create mode 100644 web/src/pages/agent/Staff.tsx diff --git a/docs/权限.md b/docs/权限.md new file mode 100644 index 0000000..2e1315d --- /dev/null +++ b/docs/权限.md @@ -0,0 +1,358 @@ +# 在线客服系统权限控制页面需求文档 + +## 1 需求背景与目标 + +当前在线客服系统已具备工作台、客户管理、对话记录、知识库、快捷回复、黑名单、数据统计、系统设置八大模块。随着客服团队规模扩大,需要为不同岗位配置差异化的功能访问与操作权限,避免普通客服误改系统配置,同时让主管能够管理团队数据而不触及租户级配置。 + +### 1.1 业务目标 + +- 建立 管理员、客服主管、客服 三级角色体系,覆盖租户内全部功能模块。 +- 提供可视化的 角色管理与权限配置页面,管理员可自定义角色权限并分配给账号。 +- 明确每个角色的 菜单可见范围、页面操作权限、数据隔离范围,降低越权风险。 + +### 1.2 范围说明 + +本期权限控制聚焦于租户内部的角色与账号管理,不包含平台级多租户运营权限。默认内置三个角色:管理员、客服主管、客服。管理员可新建自定义角色,但系统保留对内置角色的保护(不可删除、不可修改角色标识)。 + +## 2 角色定义 + +| 角色 | 定位 | 数据范围 | 核心能力 | +| --- | --- | --- | --- | +| 管理员 | 租户最高权限,通常是企业客服负责人或 IT 管理员 | 全租户数据 | 管理账号与角色、配置系统设置、查看全部数据、操作所有模块 | +| 客服主管 | 一线客服团队管理者 | 全租户数据(查看),操作受限于权限项 | 监控团队会话、管理团队绩效、维护知识库与快捷回复、管理黑名单 | +| 客服 | 一线接待人员 | 仅自己的会话与客户数据 | 使用工作台接待访客、使用知识库/快捷回复、维护自己接待的客户资料 | + +## 3 系统模块清单 + +根据现有系统设计,本次权限控制需要覆盖以下模块与页面。每个模块拆分为「查看」与「操作」两类权限,部分模块还需细分数据范围。 + +| 一级模块 | 二级页面/功能 | 权限项说明 | +| --- | --- | --- | +| 工作台 | 会话列表、聊天区、客户信息面板 | 查看会话、回复消息、转接会话、结束会话、添加内部备注、标记优先级 | +| 客户管理 | 客户列表、客户详情抽屉 | 查看客户、新增客户、编辑客户、导出客户、管理客户标签、查看全部/仅自己客户 | +| 对话记录 | 会话记录列表、会话详情 | 查看对话记录、导出记录、批量归档、查看全部/仅自己记录 | +| 知识库 | 知识分类、知识条目 | 查看知识库、新建条目、编辑条目、发布/下架、删除条目、管理分类 | +| 快捷回复 | 团队快捷回复、我的快捷回复 | 查看快捷回复、新建团队回复、编辑团队回复、删除团队回复、管理个人回复 | +| 黑名单 | 黑名单列表 | 查看黑名单、拉黑访客/设备、解除黑名单 | +| 数据统计 | KPI 看板、趋势图、绩效排行 | 查看数据统计、导出报告、查看团队/个人数据 | +| 系统设置 | 基本设置、渠道管理、坐席账号、客服分配规则、客户标签、自动回复、工作时间、通知设置 | 查看设置、修改各子项配置 | +| 权限控制 | 角色管理、权限配置、成员账号 | 查看角色、创建/编辑角色、删除自定义角色、分配角色给账号 | + +## 4 角色权限矩阵 + +下表定义三个内置角色的默认权限。符号说明:**√** 表示拥有权限,**○** 表示仅查看/仅自己数据,**×** 表示无权限。 + +| 模块 | 权限项 | 管理员 | 客服主管 | 客服 | +| --- | --- | --- | --- | --- | +| 工作台 | 查看会话列表 | √ | √ | √ | +| 回复访客消息 | √ | √ | √ | +| 转接会话给其他客服 | √ | √ | × | +| 结束会话 | √ | √ | √ | +| 添加内部备注 | √ | √ | √ | +| 标记会话优先级 | √ | √ | ○(仅自己) | +| 客户管理 | 查看客户列表 | √ | √ | ○(仅自己) | +| 新增客户 | √ | √ | √ | +| 编辑客户资料 | √ | √ | ○(仅自己) | +| 导出客户 | √ | √ | × | +| 管理客户标签 | √ | √ | ○(仅自己客户) | +| 对话记录 | 查看对话记录 | √ | √ | ○(仅自己) | +| 查看会话详情 | √ | √ | ○(仅自己) | +| 导出对话记录 | √ | √ | × | +| 批量归档记录 | √ | √ | × | +| 知识库 | 查看知识库 | √ | √ | √ | +| 新建知识条目 | √ | √ | × | +| 编辑知识条目 | √ | √ | × | +| 发布/下架条目 | √ | √ | × | +| 删除知识条目/分类 | √ | ○(主管可删除自己创建) | × | +| 快捷回复 | 查看快捷回复 | √ | √ | √ | +| 新建团队快捷回复 | √ | √ | × | +| 编辑/删除团队回复 | √ | √ | × | +| 管理我的快捷回复 | √ | √ | √ | +| 黑名单 | 查看黑名单 | √ | √ | × | +| 拉黑访客/IP/设备 | √ | √ | × | +| 解除黑名单 | √ | √ | × | +| 数据统计 | 查看数据统计 | √ | √ | ○(仅个人) | +| 导出报告 | √ | √ | × | +| 查看客服绩效排行 | √ | √ | ○(仅自己排名) | +| 系统设置 | 基本设置 | √ | × | × | +| 渠道管理 | √ | × | × | +| 坐席账号 | √ | × | × | +| 客服分配规则 | √ | × | × | +| 客户标签 | √ | √ | × | +| 自动回复 | √ | √ | × | +| 工作时间 | √ | × | × | +| 通知设置 | √ | × | × | +| 权限控制 | 查看角色列表 | √ | × | × | +| 创建/编辑角色 | √ | × | × | +| 删除自定义角色 | √ | × | × | +| 给账号分配角色 | √ | × | × | + +**数据隔离原则**:客服角色在所有模块中默认只能看到「自己接待的会话」和「自己创建/跟进过的客户」;客服主管与管理员可查看全租户数据,但主管的操作权限受限于矩阵中的勾选范围。 + +## 5 页面原型设计 + +权限控制模块包含三个核心页面:角色管理列表、角色权限配置、成员账号管理。以下原型展示页面布局、字段与主要交互,开发时可作为视觉与交互参考。 + +### 5.1 角色管理列表页 + +管理员进入「系统设置 > 权限控制 > 角色管理」后,看到租户内所有角色卡片。内置角色不可删除、不可修改标识,仅可调整权限范围;自定义角色支持完整编辑与删除。 + +角色管理列表 + +客服云 + +工作台 + +客户管理 + +对话记录 + +知识库 + +快捷回复 + +黑名单 + +数据统计 + +系统设置 + +角色管理 + +\+ 新建角色 + +角色列表 + +成员账号 + +操作日志 + +内置 管理员 + +配置权限 + +租户最高权限,可管理账号、角色与全部系统设置。不可删除。 + +成员数:1权限项:38/38最后更新:系统默认 + +内置 客服主管 + +配置权限 + +管理一线团队,可查看全部数据并维护知识库、快捷回复、黑名单等。 + +成员数:3权限项:24/38最后更新:2026-07-18 + +内置 客服 + +配置权限 + +一线接待人员,仅可查看和操作自己相关的会话、客户与个人数据。 + +成员数:12权限项:14/38最后更新:系统默认 + +自定义 VIP 专属客服 + +配置权限 + +删除 + +基于客服角色扩展,可查看被分配为高优先级客户的资料,但不可导出数据。 + +成员数:2权限项:16/38最后更新:2026-07-15 + +**列表页字段说明**:角色名称、类型(内置/自定义)、成员数、已启用权限项数量、最近更新时间、操作按钮。点击「配置权限」进入角色权限配置页。 + +### 5.2 角色权限配置页 + +该页面为角色勾选可访问模块与具体权限项。左侧按模块分组,右侧展示权限项;每个模块支持「查看」与「操作」两类权限,数据范围通过下拉选项单独配置。 + +配置角色:客服主管 + +客服主管 + +内置角色 · 标识 supervisor · 3 位成员 + +取消 + +保存 + +工作台 数据范围:全部会话 + +查看会话列表 + +回复访客消息 + +转接会话 + +结束会话 + +添加内部备注 + +标记优先级 + +客户管理 数据范围:全部客户 + +查看客户列表 + +新增客户 + +编辑客户资料 + +导出客户 + +管理客户标签 + +系统设置 仅部分子项 + +客户标签 + +自动回复 + +基本设置 + +渠道管理 + +坐席账号 + +工作时间 + +**交互规则:**取消「查看」权限时,该模块下所有「操作」权限自动取消勾选;勾选任意「操作」权限时,「查看」权限自动勾选。数据范围选项随模块不同而变化(全部 / 仅自己 / 指定分组)。 + +### 5.3 成员账号管理页 + +成员账号页用于查看所有系统账号、分配角色、控制账号状态。只有管理员拥有完整编辑权限;客服主管可在被授权的情况下查看成员列表(本期暂不开放给主管)。 + +成员账号管理 + +客服云 + +工作台 + +客户管理 + +对话记录 + +知识库 + +快捷回复 + +黑名单 + +数据统计 + +系统设置 + +成员账号 + +\+ 新增账号 + +| 账号 | 姓名 | 角色 | 状态 | 最近登录 | 操作 | +| --- | --- | --- | --- | --- | --- | +| admin@company.com | 赵总 | 管理员 | 在线 | 2026-07-19 16:52 | 编辑 | +| supervisor@company.com | 李主管 | 客服主管 | 在线 | 2026-07-19 15:30 | 编辑 | +| agent01@company.com | 王小明 | 客服 | 离线 | 2026-07-18 09:15 | 编辑 | +| vip01@company.com | 张小红 | VIP 专属客服 | 在线 | 2026-07-19 14:20 | 编辑 | + +**批量操作:**选中多个账号后可批量「启用/停用」或「批量更换角色」。被停用的账号立即失去系统访问权限,已登录会话在下次请求时鉴权失败并跳转登录页。 + +## 6 权限逻辑规则 + +权限控制需要在路由、页面元素、数据查询、接口四个层面生效。以下规则确保同一套权限配置在前后端保持一致,防止越权。 + +### 6.1 菜单与路由控制 + +- 用户登录后,前端根据当前账号角色获取 可见菜单列表,未授权的菜单不在侧边栏渲染。 +- 用户直接输入未授权页面的 URL 时,前端路由守卫拦截并跳转至「无权访问」提示页或工作台。 +- 权限控制模块本身仅对管理员可见,其他角色即使知道路径也无法进入。 + +### 6.2 页面元素控制 + +- 每个操作按钮绑定独立权限码,前端根据权限码的布尔值决定是否渲染。 +- 对客服角色,页面中「导出」「删除」「批量操作」等按钮默认隐藏,避免误操作。 +- 数据范围相关的筛选器(如「全部客服 / 我的数据」)根据角色默认选中并锁定不可修改。 + +### 6.3 数据查询隔离 + +- **客服**:客户管理、对话记录、数据统计等模块的 SQL 查询必须附加 owner_id = current_user_id 条件,仅返回自己接待或跟进的数据。 +- **客服主管**:默认返回全租户数据;若主管被配置为仅查看指定分组,则按分组过滤。 +- **管理员**:返回全租户数据,不受额外数据范围限制。 + +### 6.4 后端接口鉴权 + +- 所有写操作接口(POST/PUT/DELETE)必须在网关层校验当前账号是否具备对应权限码。 +- 接口鉴权不依赖前端隐藏,必须基于服务端角色权限表实时判断。 +- 对于「查看」类接口,除了校验权限码,还需校验数据范围参数是否超出角色允许范围。 + +### 6.5 权限生效时机 + +- 角色权限保存后,下一次请求即时生效,不需要用户重新登录。 +- 账号被禁用或角色被变更时,服务端 Token 可保持有效期,但在每次请求时校验账号状态与角色绑定。 +- 对于长连接场景(如 WebSocket 会话通道),服务端在推送消息前校验该客服是否仍有权限接收对应会话消息。 + +## 7 边界情况与异常处理 + +### 7.1 内置角色保护 + +管理员、客服主管、客服为系统内置角色,拥有固定角色标识(admin、supervisor、agent)。这些角色不可删除、不可修改标识、不可清空全部权限,防止系统失去基础管理能力。 + +### 7.2 删除自定义角色 + +删除自定义角色前,系统需校验该角色下是否仍有账号。若存在绑定账号,则禁止删除并提示「请先为该角色下 N 个账号更换角色」。删除后,原绑定账号默认降级为「客服」角色,需管理员二次确认。 + +### 7.3 唯一管理员的保护 + +当租户内仅剩一个管理员账号时,该账号不可被删除、不可被禁用、不可被降级为其他角色。若管理员需要离职,必须先创建或提升另一个账号为管理员。 + +### 7.4 账号状态与权限 + +- **启用**:账号正常拥有角色对应权限。 +- **停用**:账号立即失去所有权限,已登录状态在下次请求时被踢出。 +- **待激活**:新创建账号但未完成首次登录,角色已分配但不计入在线客服数。 + +### 7.5 数据越权访问 + +若用户通过接口篡改参数(如将 agent_id 改为他人 ID)试图查看他人数据,服务端应拒绝并记录审计日志。对于频繁越权尝试的账号,可触发风控策略临时锁定。 + +### 7.6 角色变更后的会话处理 + +管理员在客服正在接待访客时调整其角色(如从客服主管降级为客服),该客服已接待的会话不受影响,可继续处理;但变更后新进入的会话按新权限规则分配。若变更导致该客服失去工作台权限,则系统 gracefully 关闭其会话通道并提示重新登录。 + +## 8 非功能性需求 + +### 8.1 性能 + +- 权限列表查询接口响应时间 ≤ 200ms(P95)。 +- 单次登录后权限数据可缓存于前端,权限变更时通过 WebSocket 或接口响应头通知刷新。 + +### 8.2 安全性 + +- 角色权限数据存储需与业务数据同等级别保护,防止越权篡改。 +- 所有权限变更操作记录审计日志,包括操作人、时间、变更前后权限快照。 +- 敏感操作(如删除角色、变更管理员角色)需二次确认或输入账号密码确认。 + +### 8.3 可扩展性 + +- 权限项采用码表形式维护,新增模块时只需插入新的权限码即可,无需修改角色表结构。 +- 角色与权限之间为多对多关系,支持未来扩展更多自定义角色。 +- 数据范围字段预留扩展字段,支持未来按客服分组、按渠道、按客户标签等维度隔离。 + +### 8.4 兼容性 + +- 老版本账号首次迁移至新权限体系时,默认映射为「客服」角色,管理员可后续调整。 +- 未命中任何角色的账号(异常数据)默认无任何权限,登录后仅显示「联系管理员」页面。 + +## 9 验收标准 + +以下验收标准用于判断权限控制功能是否达到可上线状态。 + +| 编号 | 验收项 | 通过标准 | +| --- | --- | --- | +| A1 | 角色列表 | 管理员可见内置与自定义角色,非管理员无法访问权限控制菜单与页面 | +| A2 | 权限配置 | 管理员可编辑角色权限并保存,保存后下次请求即时生效;内置角色标识不可修改 | +| A3 | 菜单控制 | 客服登录后侧边栏不显示「系统设置」「权限控制」「黑名单」「数据统计」等未授权菜单 | +| A4 | 按钮控制 | 客服在工作台、客户管理、对话记录等页面看不到「导出」「批量操作」「删除」等按钮 | +| A5 | 数据隔离 | 客服只能查看自己接待的会话与跟进客户;主管可查看全租户数据;管理员可查看全部 | +| A6 | 接口鉴权 | 使用客服账号 Token 调用主管/管理员接口返回 403,并在审计日志中记录 | +| A7 | 成员管理 | 管理员可新增、编辑、停用账号并分配角色;停用账号立即无法访问系统 | +| A8 | 边界保护 | 无法删除最后一个管理员,删除自定义角色时提示迁移账号,越权操作被拦截 | \ No newline at end of file diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index 22eeb41..2fdbe4d 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -19,10 +19,11 @@ func main() { } func seed() { - // 默认后台管理员:kefu_admin / kefu_admin123(其它种子账号同密码) hash, _ := bcrypt.GenerateFromPassword([]byte("kefu_admin123"), bcrypt.DefaultCost) pwd := string(hash) + model.EnsurePermissions() + // Plans plans := []model.Plan{ {Name: "基础版", PriceMonthly: 299, Seats: 2, StorageDays: 30, KBLimit: 50, Status: "active", Features: `{"stats":"basic","api":false,"channels":["web"],"brand":false}`}, @@ -42,6 +43,11 @@ func seed() { } model.DB.Create(&tenants) + // 为每个租户创建内置角色及默认权限 + for _, t := range tenants { + model.EnsureBuiltinRoles(t.ID) + } + // Platform admin model.DB.Create(&model.User{ TenantID: 0, Role: "platform_admin", Username: "platform_admin", PasswordHash: pwd, Nickname: "平台管理员", Status: "online", diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index cd75491..381860c 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -88,6 +88,19 @@ func (h *AuthHandler) Me(c *gin.Context) { }) } +// MePermissions 当前登录用户的权限码列表(供前端按钮控制)。 +func (h *AuthHandler) MePermissions(c *gin.Context) { + perms := middleware.GetPermissions(c) + codes := make([]string, 0, len(perms)) + for k := range perms { + codes = append(codes, k) + } + middleware.JSON(c, gin.H{ + "role": middleware.GetRole(c), + "permissions": codes, + }) +} + type UpdatePresenceReq struct { Status string `json:"status" binding:"required"` } diff --git a/server/internal/handler/blacklist.go b/server/internal/handler/blacklist.go index edbc07e..1f6270d 100644 --- a/server/internal/handler/blacklist.go +++ b/server/internal/handler/blacklist.go @@ -131,6 +131,10 @@ func durationLabel(duration string) string { // Create 创建黑名单(支持从会话拉黑 IP 或设备)。 func (h *BlacklistHandler) Create(c *gin.Context) { + if !middleware.HasPermission(c, "blacklist.create") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理黑名单"}) + return + } tenantID := middleware.GetTenantID(c) var req CreateBlacklistReq if err := c.ShouldBindJSON(&req); err != nil { @@ -163,13 +167,6 @@ func (h *BlacklistHandler) Create(c *gin.Context) { if !ok { return } - // 管理员/主管,或当前接待坐席可拉黑 - if !isTenantManager(c) { - if s.AgentID == nil || *s.AgentID != middleware.GetUserID(c) { - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅接待坐席或管理员可拉黑该访客"}) - return - } - } session = s sid := s.ID sessionID = &sid @@ -305,6 +302,10 @@ func maskBlacklistValue(kind, value string) string { // List 黑名单列表(有效 + 可选含已过期)。 func (h *BlacklistHandler) List(c *gin.Context) { + if !middleware.HasPermission(c, "blacklist.view") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看黑名单"}) + return + } tenantID := middleware.GetTenantID(c) q := model.DB.Where("tenant_id = ?", tenantID) if c.Query("active") != "0" { @@ -323,6 +324,10 @@ func (h *BlacklistHandler) List(c *gin.Context) { // Delete 解除黑名单。 func (h *BlacklistHandler) Delete(c *gin.Context) { + if !middleware.HasPermission(c, "blacklist.delete") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可解除黑名单"}) + return + } tenantID := middleware.GetTenantID(c) id := c.Param("id") var entry model.BlacklistEntry diff --git a/server/internal/handler/channel.go b/server/internal/handler/channel.go index 61c5cb6..ff52278 100644 --- a/server/internal/handler/channel.go +++ b/server/internal/handler/channel.go @@ -59,7 +59,7 @@ func toChannelView(ch model.Channel) channelView { } func requireTenantAdmin(c *gin.Context) bool { - if middleware.HasAnyRole(c, "admin") { + if middleware.HasPermission(c, "settings.channel") { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可操作"}) @@ -74,8 +74,15 @@ func (h *ChannelHandler) List(c *gin.Context) { return } views := make([]channelView, 0, len(channels)) + canManage := middleware.HasPermission(c, "settings.channel") for _, ch := range channels { - views = append(views, toChannelView(ch)) + view := toChannelView(ch) + if !canManage { + view.Config = "" + view.ScriptCode = "" + view.ChannelKey = "" + } + views = append(views, view) } middleware.JSON(c, views) } diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index 7332845..3c25beb 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -14,12 +14,12 @@ type CustomerHandler struct{} func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} } func canAccessCustomer(c *gin.Context, customerID uint) bool { - if middleware.HasAnyRole(c, "admin", "supervisor") { - return true - } - if middleware.GetRole(c) != "agent" { + if !middleware.HasPermission(c, "customer.view") { return false } + if middleware.CanAccessAllData(c, "customer") { + return true + } var count int64 model.DB.Model(&model.Session{}). Where("tenant_id = ? AND customer_id = ? AND agent_id = ?", middleware.GetTenantID(c), customerID, middleware.GetUserID(c)). @@ -38,7 +38,7 @@ func (h *CustomerHandler) List(c *gin.Context) { var total int64 query := model.DB.Where("tenant_id = ?", tenantID) - if middleware.GetRole(c) == "agent" { + if !middleware.CanAccessAllData(c, "customer") { assignedCustomers := model.DB.Model(&model.Session{}). Select("customer_id"). Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) @@ -74,13 +74,14 @@ func (h *CustomerHandler) Get(c *gin.Context) { return } if !canAccessCustomer(c, customer.ID) { + middleware.AuditDataAccessDenied(c, "customer", &customer.ID) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"}) return } var sessions []model.Session sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID) - if middleware.GetRole(c) == "agent" { + if !middleware.CanAccessAllData(c, "customer") { sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c)) } sessionQuery.Order("created_at desc").Limit(20).Find(&sessions) @@ -233,6 +234,7 @@ func (h *CustomerHandler) Update(c *gin.Context) { return } if !canAccessCustomer(c, customer.ID) { + middleware.AuditDataAccessDenied(c, "customer", &customer.ID) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"}) return } @@ -319,7 +321,7 @@ func (h *CustomerHandler) Update(c *gin.Context) { } func (h *CustomerHandler) Delete(c *gin.Context) { - if !middleware.HasAnyRole(c, "admin", "supervisor") { + if !middleware.HasAnyPermission(c, "customer.export") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"}) return } diff --git a/server/internal/handler/customer_tag.go b/server/internal/handler/customer_tag.go index 4fde3df..580acc7 100644 --- a/server/internal/handler/customer_tag.go +++ b/server/internal/handler/customer_tag.go @@ -29,8 +29,7 @@ type CustomerTagHandler struct{} func NewCustomerTagHandler() *CustomerTagHandler { return &CustomerTagHandler{} } func requireCustomerTagManager(c *gin.Context) bool { - // 管理员维护标签库;主管也可维护,便于运营 - if middleware.HasAnyRole(c, "admin", "supervisor") { + if middleware.HasPermission(c, "settings.customer_tag") { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理客户标签"}) diff --git a/server/internal/handler/export.go b/server/internal/handler/export.go index b018fa6..9408e00 100644 --- a/server/internal/handler/export.go +++ b/server/internal/handler/export.go @@ -21,7 +21,7 @@ func (h *CustomerHandler) Export(c *gin.Context) { source := c.Query("source") query := model.DB.Where("tenant_id = ?", tenantID) - if middleware.GetRole(c) == "agent" { + if !middleware.CanAccessAllData(c, "chat_history") { assignedCustomers := model.DB.Model(&model.Session{}). Select("customer_id"). Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) @@ -76,7 +76,7 @@ func (h *SessionHandler) Export(c *gin.Context) { to := c.Query("to") query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) - if middleware.GetRole(c) == "agent" { + if !middleware.CanAccessAllData(c, "customer") { query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting") } if status != "" { @@ -253,7 +253,8 @@ func (h *StatisticsHandler) Export(c *gin.Context) { return } tenantID := middleware.GetTenantID(c) - sessions, messages, err := loadStatisticsData(tenantID, r) + allData := middleware.CanAccessAllData(c, "statistics") + sessions, messages, err := loadStatisticsData(tenantID, middleware.GetUserID(c), allData, r) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"}) return @@ -282,7 +283,11 @@ func (h *StatisticsHandler) Export(c *gin.Context) { // 坐席绩效 var agents []model.User - model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents) + agentQuery := model.DB.Where("tenant_id = ?", tenantID) + if !allData { + agentQuery = agentQuery.Where("id = ?", middleware.GetUserID(c)) + } + agentQuery.Find(&agents) type perf struct { Name string Conversations int diff --git a/server/internal/handler/knowledge.go b/server/internal/handler/knowledge.go index 69bdbf8..773a8a3 100644 --- a/server/internal/handler/knowledge.go +++ b/server/internal/handler/knowledge.go @@ -15,14 +15,18 @@ type KnowledgeHandler struct{} func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} } -func requireKnowledgeManager(c *gin.Context) bool { - if middleware.HasAnyRole(c, "admin", "supervisor") { +func requireKnowledgePermission(c *gin.Context, code string) bool { + if middleware.HasPermission(c, code) { return true } - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理知识库"}) + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权执行该知识库操作"}) return false } +func canDeleteKnowledge(c *gin.Context, createdBy uint) bool { + return middleware.GetRole(c) == "admin" || (createdBy != 0 && createdBy == middleware.GetUserID(c)) +} + func hasKnowledgeCapacity(tenantID uint) (bool, error) { var tenant model.Tenant if err := model.DB.First(&tenant, tenantID).Error; err != nil { @@ -139,7 +143,7 @@ var ( type catError string -func errCat(s string) catError { return catError(s) } +func errCat(s string) catError { return catError(s) } func (e catError) Error() string { return string(e) } func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID uint) bool { @@ -158,7 +162,7 @@ func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID u } func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { - if !requireKnowledgeManager(c) { + if !requireKnowledgePermission(c, "knowledge.create") { return } var req CategoryReq @@ -181,9 +185,10 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { return } category := model.Category{ - TenantID: tenantID, - Name: name, - ParentID: req.ParentID, + TenantID: tenantID, + CreatedBy: middleware.GetUserID(c), + Name: name, + ParentID: req.ParentID, } if err := model.DB.Create(&category).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) @@ -193,7 +198,7 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { } func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) { - if !requireKnowledgeManager(c) { + if !requireKnowledgePermission(c, "knowledge.edit") { return } tenantID := middleware.GetTenantID(c) @@ -248,7 +253,7 @@ func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) { } func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) { - if !requireKnowledgeManager(c) { + if !requireKnowledgePermission(c, "knowledge.delete") { return } tenantID := middleware.GetTenantID(c) @@ -258,6 +263,10 @@ func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"}) return } + if !canDeleteKnowledge(c, category.CreatedBy) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "主管仅可删除自己创建的分类"}) + return + } var childCnt int64 model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt) if childCnt > 0 { @@ -364,7 +373,7 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) { } func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { - if !requireKnowledgeManager(c) { + if !requireKnowledgePermission(c, "knowledge.create") { return } var entry model.KnowledgeEntry @@ -373,6 +382,11 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { return } entry.TenantID = middleware.GetTenantID(c) + entry.CreatedBy = middleware.GetUserID(c) + if entry.Status == "published" && !middleware.HasPermission(c, "knowledge.publish") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权直接发布知识条目"}) + return + } available, err := hasKnowledgeCapacity(entry.TenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "校验知识库容量失败"}) @@ -397,7 +411,7 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { } func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) { - if !requireKnowledgeManager(c) { + if !requireKnowledgePermission(c, "knowledge.edit") { return } tenantID := middleware.GetTenantID(c) @@ -432,6 +446,10 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } + if status, exists := updates["status"]; exists && status != entry.Status && !middleware.HasPermission(c, "knowledge.publish") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权发布或下架知识条目"}) + return + } if err := model.DB.Model(&entry).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) @@ -442,13 +460,22 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) { } func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) { - if !requireKnowledgeManager(c) { + if !requireKnowledgePermission(c, "knowledge.delete") { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") - result := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&model.KnowledgeEntry{}) + var entry model.KnowledgeEntry + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&entry).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"}) + return + } + if !canDeleteKnowledge(c, entry.CreatedBy) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "主管仅可删除自己创建的知识条目"}) + return + } + result := model.DB.Delete(&entry) if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"}) return diff --git a/server/internal/handler/permission.go b/server/internal/handler/permission.go new file mode 100644 index 0000000..fe9394b --- /dev/null +++ b/server/internal/handler/permission.go @@ -0,0 +1,457 @@ +package handler + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "kefu-cloud/server/internal/middleware" + "kefu-cloud/server/internal/model" +) + +type PermissionHandler struct{} + +func NewPermissionHandler() *PermissionHandler { return &PermissionHandler{} } + +func ensurePermissionData(tenantID uint) error { + if err := model.EnsurePermissions(); err != nil { + return err + } + return model.EnsureBuiltinRoles(tenantID) +} + +// ListPermissions 获取所有系统权限码(全租户共享,供权限配置页使用)。 +func (h *PermissionHandler) ListPermissions(c *gin.Context) { + if err := ensurePermissionData(middleware.GetTenantID(c)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "初始化权限数据失败"}) + return + } + var permissions []model.Permission + if err := model.DB.Order("sort_order asc").Find(&permissions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"}) + return + } + middleware.JSON(c, permissions) +} + +// ListRoles 获取当前租户的角色列表(含成员数、权限数)。 +func (h *PermissionHandler) ListRoles(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + if err := ensurePermissionData(tenantID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "初始化权限数据失败"}) + return + } + var roles []model.Role + if err := model.DB.Where("tenant_id = ?", tenantID).Order("id asc").Find(&roles).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"}) + return + } + + type roleWithStats struct { + model.Role + MemberCount int64 `json:"member_count"` + PermCount int64 `json:"perm_count"` + } + result := make([]roleWithStats, len(roles)) + for i, role := range roles { + result[i].Role = role + if err := model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ?", tenantID, role.Code).Count(&result[i].MemberCount).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计角色成员失败"}) + return + } + if err := model.DB.Model(&model.RolePermission{}).Where("role_id = ?", role.ID).Count(&result[i].PermCount).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计角色权限失败"}) + return + } + } + middleware.JSON(c, result) +} + +type roleAccessSnapshot struct { + Permissions []string `json:"permissions"` + DataScopes map[string]string `json:"data_scopes"` +} + +func loadRoleAccess(tx *gorm.DB, role model.Role) (roleAccessSnapshot, error) { + snapshot := roleAccessSnapshot{Permissions: []string{}, DataScopes: model.DefaultRoleDataScopes(role.Code)} + if err := tx.Table("role_permissions rp"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("rp.role_id = ?", role.ID). + Order("p.sort_order asc"). + Pluck("p.code", &snapshot.Permissions).Error; err != nil { + return snapshot, err + } + var scopes []model.RoleDataScope + if err := tx.Where("role_id = ?", role.ID).Find(&scopes).Error; err != nil { + return snapshot, err + } + for _, scope := range scopes { + snapshot.DataScopes[scope.Module] = scope.Scope + } + return snapshot, nil +} + +// GetRole 获取角色详情(含权限码与数据范围)。 +func (h *PermissionHandler) GetRole(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + var role model.Role + if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"}) + return + } + access, err := loadRoleAccess(model.DB, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询角色权限失败"}) + return + } + var memberCount int64 + if err := model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ?", tenantID, role.Code).Count(&memberCount).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计角色成员失败"}) + return + } + middleware.JSON(c, gin.H{ + "role": role, "permissions": access.Permissions, + "data_scopes": access.DataScopes, "member_count": memberCount, + }) +} + +type saveRoleReq struct { + Name *string `json:"name"` + Desc *string `json:"desc"` + Permissions []string `json:"permissions"` + DataScopes map[string]string `json:"data_scopes"` +} + +func validateRoleName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > 50 { + return "", errors.New("角色名称需 1-50 个字符") + } + return name, nil +} + +func validateRoleAccess(tx *gorm.DB, permissions []string, dataScopes map[string]string) ([]model.Permission, error) { + uniqueCodes := make(map[string]struct{}, len(permissions)) + for _, code := range permissions { + code = strings.TrimSpace(code) + if code != "" { + uniqueCodes[code] = struct{}{} + } + } + codes := make([]string, 0, len(uniqueCodes)) + for code := range uniqueCodes { + codes = append(codes, code) + } + sort.Strings(codes) + var records []model.Permission + if len(codes) > 0 { + if err := tx.Where("code IN ?", codes).Order("sort_order asc").Find(&records).Error; err != nil { + return nil, err + } + if len(records) != len(codes) { + return nil, errors.New("包含无效权限码") + } + } + + selected := make(map[string]bool, len(records)) + moduleViews := make(map[string][]string) + for _, permission := range records { + selected[permission.Code] = true + } + var allPermissions []model.Permission + if err := tx.Order("sort_order asc").Find(&allPermissions).Error; err != nil { + return nil, err + } + for _, permission := range allPermissions { + if permission.Category == "view" { + moduleViews[permission.Module] = append(moduleViews[permission.Module], permission.Code) + } + } + for _, permission := range records { + if permission.Category != "operate" || len(moduleViews[permission.Module]) == 0 { + continue + } + hasView := false + for _, viewCode := range moduleViews[permission.Module] { + hasView = hasView || selected[viewCode] + } + if !hasView { + return nil, errors.New(permission.Module + "的操作权限必须同时启用查看权限") + } + } + + allowedModules := make(map[string]bool) + for _, module := range model.DataScopeModules() { + allowedModules[module] = true + } + for module, scope := range dataScopes { + if !allowedModules[module] || (scope != model.DataScopeAll && scope != model.DataScopeSelf) { + return nil, errors.New("包含无效数据范围") + } + } + return records, nil +} + +func saveRoleAccess(tx *gorm.DB, role model.Role, permissions []string, dataScopes map[string]string) error { + records, err := validateRoleAccess(tx, permissions, dataScopes) + if err != nil { + return err + } + if err := tx.Where("role_id = ?", role.ID).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + for _, permission := range records { + if err := tx.Create(&model.RolePermission{RoleID: role.ID, PermissionID: permission.ID}).Error; err != nil { + return err + } + } + for module, scope := range dataScopes { + item := model.RoleDataScope{RoleID: role.ID, Module: module} + if err := tx.Where(item).Assign(model.RoleDataScope{Scope: scope}).FirstOrCreate(&item).Error; err != nil { + return err + } + } + // UpdatedAt 同时作为前端权限缓存版本,关联表变更时也必须推进。 + return tx.Model(&role).UpdateColumn("updated_at", time.Now()).Error +} + +func newCustomRoleCode() (string, error) { + random := make([]byte, 8) + if _, err := rand.Read(random); err != nil { + return "", err + } + return "custom_" + hex.EncodeToString(random), nil +} + +// CreateRole 创建自定义角色。 +func (h *PermissionHandler) CreateRole(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + var req saveRoleReq + if err := c.ShouldBindJSON(&req); err != nil || req.Name == nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + name, err := validateRoleName(*req.Name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + var duplicate int64 + if err := model.DB.Model(&model.Role{}).Where("tenant_id = ? AND name = ?", tenantID, name).Count(&duplicate).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "检查角色名称失败"}) + return + } + if duplicate > 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "角色名称已被占用"}) + return + } + code, err := newCustomRoleCode() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成角色标识失败"}) + return + } + desc := "" + if req.Desc != nil { + desc = strings.TrimSpace(*req.Desc) + } + role := model.Role{TenantID: tenantID, Name: name, Code: code, Type: "custom", Desc: desc} + dataScopes := req.DataScopes + if dataScopes == nil { + dataScopes = model.DefaultRoleDataScopes(role.Code) + } + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&role).Error; err != nil { + return err + } + return saveRoleAccess(tx, role, req.Permissions, dataScopes) + }); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + middleware.InvalidatePermissionCache(tenantID, role.Code) + logPermissionChange(c, "create_role", role, roleAccessSnapshot{}, roleAccessSnapshot{Permissions: req.Permissions, DataScopes: dataScopes}) + middleware.JSON(c, role) +} + +// UpdateRole 更新自定义角色信息、权限与数据范围,角色标识保持不变。 +func (h *PermissionHandler) UpdateRole(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + var role model.Role + if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"}) + return + } + if role.Type == "builtin" { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "内置角色请使用专用配置接口"}) + return + } + var req saveRoleReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + before, err := loadRoleAccess(model.DB, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取原权限失败"}) + return + } + if req.Permissions == nil { + req.Permissions = before.Permissions + } + if req.DataScopes == nil { + req.DataScopes = before.DataScopes + } + updates := map[string]interface{}{} + if req.Name != nil { + name, err := validateRoleName(*req.Name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + var duplicate int64 + model.DB.Model(&model.Role{}).Where("tenant_id = ? AND name = ? AND id <> ?", tenantID, name, role.ID).Count(&duplicate) + if duplicate > 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "角色名称已被占用"}) + return + } + updates["name"] = name + } + if req.Desc != nil { + updates["desc"] = strings.TrimSpace(*req.Desc) + } + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if len(updates) > 0 { + if err := tx.Model(&role).Updates(updates).Error; err != nil { + return err + } + } + return saveRoleAccess(tx, role, req.Permissions, req.DataScopes) + }); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + model.DB.First(&role, role.ID) + after, _ := loadRoleAccess(model.DB, role) + middleware.InvalidatePermissionCache(tenantID, role.Code) + logPermissionChange(c, "update_role", role, before, after) + middleware.JSON(c, role) +} + +// UpdateBuiltinRole 更新内置角色权限与数据范围,不允许清空权限或修改标识。 +func (h *PermissionHandler) UpdateBuiltinRole(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + var role model.Role + if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"}) + return + } + if role.Type != "builtin" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅内置角色可使用此接口"}) + return + } + var req saveRoleReq + if err := c.ShouldBindJSON(&req); err != nil || len(req.Permissions) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "内置角色至少需要一项权限"}) + return + } + before, err := loadRoleAccess(model.DB, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取原权限失败"}) + return + } + if req.DataScopes == nil { + req.DataScopes = before.DataScopes + } + if role.Code == "admin" { + required := map[string]bool{ + "permission.view": false, "permission.create_role": false, + "permission.delete_role": false, "permission.assign_role": false, + } + for _, code := range req.Permissions { + if _, exists := required[code]; exists { + required[code] = true + } + } + for _, enabled := range required { + if !enabled { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "管理员必须保留全部权限控制权限"}) + return + } + } + for _, module := range model.DataScopeModules() { + req.DataScopes[module] = model.DataScopeAll + } + } + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if req.Desc != nil { + if err := tx.Model(&role).Update("desc", strings.TrimSpace(*req.Desc)).Error; err != nil { + return err + } + } + return saveRoleAccess(tx, role, req.Permissions, req.DataScopes) + }); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + model.DB.First(&role, role.ID) + after, _ := loadRoleAccess(model.DB, role) + middleware.InvalidatePermissionCache(tenantID, role.Code) + logPermissionChange(c, "update_builtin_role", role, before, after) + middleware.JSON(c, role) +} + +// DeleteRole 删除无成员绑定的自定义角色。 +func (h *PermissionHandler) DeleteRole(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + var role model.Role + if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"}) + return + } + if role.Type == "builtin" { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "内置角色不可删除"}) + return + } + var memberCount int64 + if err := model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ?", tenantID, role.Code).Count(&memberCount).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "检查角色成员失败"}) + return + } + if memberCount > 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "请先为该角色下的成员更换角色", "member_count": memberCount}) + return + } + before, _ := loadRoleAccess(model.DB, role) + if err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("role_id = ?", role.ID).Delete(&model.RolePermission{}).Error; err != nil { + return err + } + if err := tx.Where("role_id = ?", role.ID).Delete(&model.RoleDataScope{}).Error; err != nil { + return err + } + return tx.Delete(&role).Error + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"}) + return + } + middleware.InvalidatePermissionCache(tenantID, role.Code) + logPermissionChange(c, "delete_role", role, before, roleAccessSnapshot{}) + middleware.JSON(c, gin.H{"message": "已删除"}) +} + +func logPermissionChange(c *gin.Context, action string, role model.Role, before, after roleAccessSnapshot) { + detail, _ := json.Marshal(gin.H{ + "role": role.Name, "code": role.Code, "before": before, "after": after, + }) + model.DB.Create(&model.OperationLog{ + OperatorID: middleware.GetUserID(c), Action: action, Detail: string(detail), + TargetType: "role", TargetID: &role.ID, IP: c.ClientIP(), + }) +} diff --git a/server/internal/handler/quick_reply.go b/server/internal/handler/quick_reply.go index 510714c..d6393df 100644 --- a/server/internal/handler/quick_reply.go +++ b/server/internal/handler/quick_reply.go @@ -35,8 +35,8 @@ type QuickReplyHandler struct{} func NewQuickReplyHandler() *QuickReplyHandler { return &QuickReplyHandler{} } -func requireTeamQuickReplyManager(c *gin.Context) bool { - if middleware.HasAnyRole(c, "admin", "supervisor") { +func requireTeamQuickReplyPermission(c *gin.Context, code string) bool { + if middleware.HasPermission(c, code) { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理团队快捷回复"}) @@ -81,7 +81,7 @@ func loadQuickReply(c *gin.Context, id uint) (*model.QuickReply, bool) { func canEditQuickReply(c *gin.Context, item *model.QuickReply) bool { uid := middleware.GetUserID(c) if item.Scope == quickReplyScopeTeam { - return middleware.HasAnyRole(c, "admin", "supervisor") + return middleware.HasPermission(c, "quick_reply.team_edit") } return item.OwnerUserID != nil && *item.OwnerUserID == uid } @@ -171,7 +171,7 @@ func (h *QuickReplyHandler) List(c *gin.Context) { case quickReplyScopeTeam: db = db.Where("scope = ?", quickReplyScopeTeam) // 非管理端:工作台只看已发布;管理页可传 status - if !middleware.HasAnyRole(c, "admin", "supervisor") { + if !middleware.HasAnyPermission(c, "quick_reply.team_create", "quick_reply.team_edit") { db = db.Where("status = ?", quickReplyStatusPub) } else if status != "" { db = db.Where("status = ?", status) @@ -316,7 +316,7 @@ func (h *QuickReplyHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) return } - if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { + if scope == quickReplyScopeTeam && !requireTeamQuickReplyPermission(c, "quick_reply.team_create") { return } @@ -441,7 +441,7 @@ func (h *QuickReplyHandler) Delete(c *gin.Context) { // Publish POST /api/quick-replies/:id/publish 团队:暂存 → 发布同步 func (h *QuickReplyHandler) Publish(c *gin.Context) { - if !requireTeamQuickReplyManager(c) { + if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") { return } id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) @@ -463,7 +463,7 @@ func (h *QuickReplyHandler) Publish(c *gin.Context) { // Unpublish POST /api/quick-replies/:id/unpublish func (h *QuickReplyHandler) Unpublish(c *gin.Context) { - if !requireTeamQuickReplyManager(c) { + if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") { return } id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) @@ -494,7 +494,7 @@ func (h *QuickReplyHandler) Use(c *gin.Context) { uid := middleware.GetUserID(c) tenantID := middleware.GetTenantID(c) if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub { - if !middleware.HasAnyRole(c, "admin", "supervisor") { + if !middleware.HasAnyPermission(c, "quick_reply.team_create", "quick_reply.team_edit") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"}) return } @@ -590,7 +590,7 @@ func (h *QuickReplyHandler) Export(c *gin.Context) { db := model.DB.Where("tenant_id = ?", tenantID) switch scope { case quickReplyScopeTeam: - if !requireTeamQuickReplyManager(c) { + if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") { return } db = db.Where("scope = ?", quickReplyScopeTeam) @@ -646,14 +646,15 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) return } - if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { + if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyPermission(c, "quick_reply.team_create") { return } onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip")) if onConflict != "skip" && onConflict != "overwrite" { onConflict = "skip" } - canManageTeam := middleware.HasAnyRole(c, "admin", "supervisor") + canCreateTeam := middleware.HasPermission(c, "quick_reply.team_create") + canEditTeam := middleware.HasPermission(c, "quick_reply.team_edit") file, _, err := c.Request.FormFile("file") if err != nil { @@ -721,7 +722,7 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { rowScope = parsed } - if rowScope == quickReplyScopeTeam && !canManageTeam { + if rowScope == quickReplyScopeTeam && !canCreateTeam { skipped++ errors = append(errors, fmt.Sprintf("第 %d 行:无权限导入团队快捷回复", lineNo)) continue @@ -775,6 +776,11 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { skipped++ continue } + if rowScope == quickReplyScopeTeam && !canEditTeam { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:无权限覆盖团队快捷回复", lineNo)) + continue + } if err := model.DB.Model(&existing).Updates(map[string]interface{}{ "title": title, "content": content, "shortcut": shortcut, }).Error; err != nil { @@ -813,4 +819,3 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { "errors": errors, }) } - diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 24da92a..17c84bf 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -24,6 +24,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S wsHandler := NewWsHandler() widget := NewWidgetHandler() upload := NewUploadHandler(store, storageCfg) + perm := NewPermissionHandler() SetImagePublicBase(storageCfg.PublicBase) // 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖) @@ -53,105 +54,123 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S { // 当前用户 authRequired.GET("/me", auth.Me) + authRequired.GET("/me/permissions", auth.MePermissions) authRequired.PUT("/me/status", auth.UpdatePresence) // WebSocket authRequired.GET("/ws", wsHandler.Connect) // 上传 - authRequired.POST("/uploads", upload.UploadImage) + authRequired.POST("/uploads", middleware.RequirePermission("session.reply"), upload.UploadImage) // 会话管理 - authRequired.GET("/agents/available", session.ListAvailableAgents) + authRequired.GET("/agents/available", middleware.RequirePermission("session.transfer", "chat_history.view"), session.ListAvailableAgents) sessions := authRequired.Group("/sessions") - sessions.GET("", session.List) - sessions.GET("/export", session.Export) - sessions.GET("/:id", session.Get) - sessions.POST("", session.Create) - sessions.POST("/batch-archive", session.BatchArchive) - sessions.POST("/:id/assign", session.Assign) - sessions.POST("/:id/transfer", session.Transfer) - sessions.POST("/:id/end", session.End) - sessions.POST("/:id/archive", session.Archive) - sessions.PUT("/:id/priority", session.UpdatePriority) - sessions.GET("/:id/messages", session.ListMessages) - sessions.POST("/:id/messages", session.SendMessage) - sessions.POST("/:id/read", session.MarkRead) - sessions.POST("/:id/notes", session.AddNote) + sessions.GET("", middleware.RequirePermission("session.view", "chat_history.view"), session.List) + sessions.GET("/export", middleware.RequirePermission("chat_history.export"), session.Export) + sessions.GET("/:id", middleware.RequirePermission("session.view", "chat_history.detail"), session.Get) + sessions.POST("", middleware.RequirePermission("session.view"), session.Create) + sessions.POST("/batch-archive", middleware.RequirePermission("chat_history.batch_archive"), session.BatchArchive) + sessions.POST("/:id/assign", middleware.RequirePermission("session.view"), session.Assign) + sessions.POST("/:id/transfer", middleware.RequirePermission("session.transfer"), session.Transfer) + sessions.POST("/:id/end", middleware.RequirePermission("session.end"), session.End) + sessions.POST("/:id/archive", middleware.RequirePermission("chat_history.batch_archive"), session.Archive) + sessions.PUT("/:id/priority", middleware.RequirePermission("session.priority"), session.UpdatePriority) + sessions.GET("/:id/messages", middleware.RequirePermission("session.view", "chat_history.detail"), session.ListMessages) + sessions.POST("/:id/messages", middleware.RequirePermission("session.reply"), session.SendMessage) + sessions.POST("/:id/read", middleware.RequirePermission("session.view"), session.MarkRead) + sessions.POST("/:id/notes", middleware.RequirePermission("session.note"), session.AddNote) // 客户管理 customers := authRequired.Group("/customers") - customers.GET("", customer.List) - customers.GET("/export", customer.Export) - customers.GET("/:id", customer.Get) - customers.POST("", customer.Create) - customers.PUT("/:id", customer.Update) - customers.DELETE("/:id", customer.Delete) + customers.GET("", middleware.RequirePermission("customer.view"), customer.List) + customers.GET("/export", middleware.RequirePermission("customer.export"), customer.Export) + customers.GET("/:id", middleware.RequirePermission("customer.view"), customer.Get) + customers.POST("", middleware.RequirePermission("customer.create"), customer.Create) + customers.PUT("/:id", middleware.RequirePermission("customer.edit"), customer.Update) + customers.DELETE("/:id", middleware.RequirePermission("customer.export"), customer.Delete) // 黑名单(拉黑 IP / 设备) bl := authRequired.Group("/blacklist") - bl.GET("", blacklist.List) - bl.POST("", blacklist.Create) - bl.DELETE("/:id", blacklist.Delete) + bl.GET("", middleware.RequirePermission("blacklist.view"), blacklist.List) + bl.POST("", middleware.RequirePermission("blacklist.create"), blacklist.Create) + bl.DELETE("/:id", middleware.RequirePermission("blacklist.delete"), blacklist.Delete) // 客户标签库(管理员维护,全员可读可选) ctags := authRequired.Group("/customer-tags") - ctags.GET("", customerTag.List) - ctags.POST("", customerTag.Create) - ctags.PUT("/:id", customerTag.Update) - ctags.DELETE("/:id", customerTag.Delete) + ctags.GET("", middleware.RequirePermission("customer.view", "settings.customer_tag"), customerTag.List) + ctags.POST("", middleware.RequirePermission("settings.customer_tag"), customerTag.Create) + ctags.PUT("/:id", middleware.RequirePermission("settings.customer_tag"), customerTag.Update) + ctags.DELETE("/:id", middleware.RequirePermission("settings.customer_tag"), customerTag.Delete) // 知识库 kb := authRequired.Group("/knowledge") - kb.GET("/categories", knowledge.ListCategories) - kb.POST("/categories", knowledge.CreateCategory) - kb.PUT("/categories/:id", knowledge.UpdateCategory) - kb.DELETE("/categories/:id", knowledge.DeleteCategory) - kb.GET("/entries", knowledge.ListEntries) - kb.POST("/entries", knowledge.CreateEntry) - kb.PUT("/entries/:id", knowledge.UpdateEntry) - kb.DELETE("/entries/:id", knowledge.DeleteEntry) + kb.GET("/categories", middleware.RequirePermission("knowledge.view"), knowledge.ListCategories) + kb.POST("/categories", middleware.RequirePermission("knowledge.create"), knowledge.CreateCategory) + kb.PUT("/categories/:id", middleware.RequirePermission("knowledge.edit"), knowledge.UpdateCategory) + kb.DELETE("/categories/:id", middleware.RequirePermission("knowledge.delete"), knowledge.DeleteCategory) + kb.GET("/entries", middleware.RequirePermission("knowledge.view"), knowledge.ListEntries) + kb.POST("/entries", middleware.RequirePermission("knowledge.create"), knowledge.CreateEntry) + kb.PUT("/entries/:id", middleware.RequirePermission("knowledge.edit"), knowledge.UpdateEntry) + kb.DELETE("/entries/:id", middleware.RequirePermission("knowledge.delete"), knowledge.DeleteEntry) // 快捷回复(团队 / 个人,与知识库独立) qr := authRequired.Group("/quick-replies") - qr.GET("", quickReply.List) - qr.GET("/suggest", quickReply.Suggest) - qr.GET("/export", quickReply.Export) - qr.GET("/import-template", quickReply.ImportTemplate) - qr.POST("/import", quickReply.Import) - qr.POST("", quickReply.Create) - qr.PUT("/:id", quickReply.Update) - qr.DELETE("/:id", quickReply.Delete) - qr.POST("/:id/publish", quickReply.Publish) - qr.POST("/:id/unpublish", quickReply.Unpublish) - qr.POST("/:id/use", quickReply.Use) + qr.GET("", middleware.RequirePermission("quick_reply.view"), quickReply.List) + qr.GET("/suggest", middleware.RequirePermission("quick_reply.view"), quickReply.Suggest) + qr.GET("/export", middleware.RequirePermission("quick_reply.view"), quickReply.Export) + qr.GET("/import-template", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create"), quickReply.ImportTemplate) + qr.POST("/import", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create", "quick_reply.team_edit"), quickReply.Import) + qr.POST("", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create"), quickReply.Create) + qr.PUT("/:id", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_edit"), quickReply.Update) + qr.DELETE("/:id", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_edit"), quickReply.Delete) + qr.POST("/:id/publish", middleware.RequirePermission("quick_reply.team_edit"), quickReply.Publish) + qr.POST("/:id/unpublish", middleware.RequirePermission("quick_reply.team_edit"), quickReply.Unpublish) + qr.POST("/:id/use", middleware.RequirePermission("quick_reply.view"), quickReply.Use) // 渠道设置(租户级) channels := authRequired.Group("/channels") channels.GET("", channel.List) - channels.POST("", channel.Create) - channels.PUT("/:id", channel.Update) + channels.POST("", middleware.RequirePermission("settings.channel"), channel.Create) + channels.PUT("/:id", middleware.RequirePermission("settings.channel"), channel.Update) // 租户系统设置 settingsGroup := authRequired.Group("/settings") - settingsGroup.GET("", settings.Get) - settingsGroup.PUT("", settings.Update) + settingsGroup.GET("", middleware.RequirePermission( + "settings.basic", "settings.assign_rule", "settings.auto_reply", "settings.worktime", "settings.notification", + ), settings.Get) + settingsGroup.PUT("", middleware.RequirePermission("settings.basic"), settings.Update) // 坐席账号(租户内) staffGroup := authRequired.Group("/staff") + staffGroup.Use(middleware.AdminRequired(), middleware.RequirePermission("settings.staff")) staffGroup.GET("", staff.List) staffGroup.POST("", staff.Create) + staffGroup.POST("/batch", staff.Batch) staffGroup.PUT("/:id", staff.Update) staffGroup.DELETE("/:id", staff.Delete) + // 权限管理(管理员专属) + rolesGroup := authRequired.Group("/roles") + rolesGroup.Use(middleware.AdminRequired()) + { + rolesGroup.GET("", middleware.RequirePermission("permission.view"), perm.ListRoles) + rolesGroup.GET("/permissions", middleware.RequirePermission("permission.view"), perm.ListPermissions) + rolesGroup.GET("/:id", middleware.RequirePermission("permission.view"), perm.GetRole) + rolesGroup.POST("", middleware.RequirePermission("permission.create_role"), perm.CreateRole) + rolesGroup.PUT("/:id", middleware.RequirePermission("permission.create_role"), perm.UpdateRole) + rolesGroup.PUT("/:id/builtin", middleware.RequirePermission("permission.create_role"), perm.UpdateBuiltinRole) + rolesGroup.DELETE("/:id", middleware.RequirePermission("permission.delete_role"), perm.DeleteRole) + } + // 统计 statistics := authRequired.Group("/statistics") - statistics.GET("/kpi", stats.KPIs) - statistics.GET("/trend", stats.SessionTrend) - statistics.GET("/response-distribution", stats.ResponseDistribution) - statistics.GET("/performance", stats.AgentPerformance) - statistics.GET("/channels", stats.ChannelDistribution) - statistics.GET("/export", stats.Export) + statistics.GET("/kpi", middleware.RequirePermission("statistics.view"), stats.KPIs) + statistics.GET("/trend", middleware.RequirePermission("statistics.view"), stats.SessionTrend) + statistics.GET("/response-distribution", middleware.RequirePermission("statistics.view"), stats.ResponseDistribution) + statistics.GET("/performance", middleware.RequirePermission("statistics.performance"), stats.AgentPerformance) + statistics.GET("/channels", middleware.RequirePermission("statistics.view"), stats.ChannelDistribution) + statistics.GET("/export", middleware.RequirePermission("statistics.export"), stats.Export) // 管理端接口(需要管理员权限) adminGroup := authRequired.Group("/admin") diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 830e3bf..7435017 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -74,7 +74,6 @@ func multipartImageRequest(t *testing.T, method, target string, fileField string return req } - func createTenant(t *testing.T, name, status string) model.Tenant { t.Helper() tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)} @@ -381,6 +380,7 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) { tenant := createTenant(t, "工作台租户", "normal") agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent") agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "agent") + supervisor := createUser(t, tenant.ID, "workbench-supervisor", "supervisor") customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"} if err := model.DB.Create(&customer).Error; err != nil { t.Fatalf("创建工作台客户失败: %v", err) @@ -446,8 +446,14 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) { transferRecorder := httptest.NewRecorder() transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID)) router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne)) - if transferRecorder.Code != http.StatusOK { - t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String()) + if transferRecorder.Code != http.StatusForbidden { + t.Fatalf("普通客服转接状态码 = %d,期望 %d,响应 = %s", transferRecorder.Code, http.StatusForbidden, transferRecorder.Body.String()) + } + + managerTransferRecorder := httptest.NewRecorder() + router.ServeHTTP(managerTransferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, supervisor)) + if managerTransferRecorder.Code != http.StatusOK { + t.Fatalf("主管转接会话失败: %d %s", managerTransferRecorder.Code, managerTransferRecorder.Body.String()) } oldAgentMessageRecorder := httptest.NewRecorder() @@ -630,6 +636,149 @@ func TestChannelListAndToggleRequiresAdmin(t *testing.T) { } } +func TestCustomRoleCodeIsStableAndInvalidPermissionUpdateIsAtomic(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "自定义角色租户", "normal") + admin := createUser(t, tenant.ID, "role-admin", "admin") + + // 首次访问角色列表会初始化权限码和内置角色。 + seedRecorder := httptest.NewRecorder() + router.ServeHTTP(seedRecorder, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin)) + if seedRecorder.Code != http.StatusOK { + t.Fatalf("初始化角色失败: %d %s", seedRecorder.Code, seedRecorder.Body.String()) + } + + createRecorder := httptest.NewRecorder() + createBody := []byte(`{"name":"VIP 客服","permissions":["customer.view"],"data_scopes":{"customer":"self"}}`) + router.ServeHTTP(createRecorder, bearerRequest(t, http.MethodPost, "/api/roles", createBody, admin)) + if createRecorder.Code != http.StatusOK { + t.Fatalf("创建自定义角色失败: %d %s", createRecorder.Code, createRecorder.Body.String()) + } + var createResponse struct { + Data model.Role `json:"data"` + } + if err := json.Unmarshal(createRecorder.Body.Bytes(), &createResponse); err != nil { + t.Fatalf("解析自定义角色失败: %v", err) + } + originalCode := createResponse.Data.Code + if originalCode == "" { + t.Fatalf("自定义角色缺少稳定标识: %s", createRecorder.Body.String()) + } + + updateRecorder := httptest.NewRecorder() + updateBody := []byte(`{"name":"VIP 专属客服","permissions":["customer.view"],"data_scopes":{"customer":"self"}}`) + router.ServeHTTP(updateRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", createResponse.Data.ID), updateBody, admin)) + if updateRecorder.Code != http.StatusOK { + t.Fatalf("重命名自定义角色失败: %d %s", updateRecorder.Code, updateRecorder.Body.String()) + } + var role model.Role + if err := model.DB.First(&role, createResponse.Data.ID).Error; err != nil { + t.Fatalf("查询重命名角色失败: %v", err) + } + if role.Code != originalCode { + t.Fatalf("角色改名后标识发生变化: %q -> %q", originalCode, role.Code) + } + + invalidRecorder := httptest.NewRecorder() + invalidBody := []byte(`{"permissions":["permission.not_exists"],"data_scopes":{"customer":"all"}}`) + router.ServeHTTP(invalidRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", role.ID), invalidBody, admin)) + if invalidRecorder.Code != http.StatusBadRequest { + t.Fatalf("无效权限保存状态码 = %d,期望 %d,响应 = %s", invalidRecorder.Code, http.StatusBadRequest, invalidRecorder.Body.String()) + } + var permissionCount int64 + model.DB.Table("role_permissions rp"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("rp.role_id = ? AND p.code = ?", role.ID, "customer.view"). + Count(&permissionCount) + if permissionCount != 1 { + t.Fatalf("无效保存破坏了原权限配置,customer.view 数量 = %d", permissionCount) + } + + customUser := createUser(t, tenant.ID, "vip-agent", role.Code) + beforeVersionRecorder := httptest.NewRecorder() + router.ServeHTTP(beforeVersionRecorder, bearerRequest(t, http.MethodGet, "/api/me/permissions", nil, customUser)) + beforeVersion := beforeVersionRecorder.Header().Get("X-Permission-Version") + if beforeVersionRecorder.Code != http.StatusOK || beforeVersion == "" { + t.Fatalf("首次权限版本响应异常: %d version=%q body=%s", beforeVersionRecorder.Code, beforeVersion, beforeVersionRecorder.Body.String()) + } + + validRecorder := httptest.NewRecorder() + validBody := []byte(`{"permissions":["customer.view","customer.create"],"data_scopes":{"customer":"self"}}`) + router.ServeHTTP(validRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", role.ID), validBody, admin)) + if validRecorder.Code != http.StatusOK { + t.Fatalf("更新自定义角色权限失败: %d %s", validRecorder.Code, validRecorder.Body.String()) + } + + afterVersionRecorder := httptest.NewRecorder() + router.ServeHTTP(afterVersionRecorder, bearerRequest(t, http.MethodGet, "/api/me/permissions", nil, customUser)) + afterVersion := afterVersionRecorder.Header().Get("X-Permission-Version") + if afterVersionRecorder.Code != http.StatusOK || afterVersion == "" || afterVersion == beforeVersion { + t.Fatalf("权限更新后版本未推进: before=%q after=%q body=%s", beforeVersion, afterVersion, afterVersionRecorder.Body.String()) + } +} + +func TestCustomerAndStatisticsDataScopesPreventCrossAgentAccess(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "数据范围租户", "normal") + agentOne := createUser(t, tenant.ID, "scope-agent-one", "agent") + agentTwo := createUser(t, tenant.ID, "scope-agent-two", "agent") + customerOne := model.Customer{TenantID: tenant.ID, Name: "客服一客户", Source: "网页"} + customerTwo := model.Customer{TenantID: tenant.ID, Name: "客服二客户", Source: "网页"} + if err := model.DB.Create(&customerOne).Error; err != nil { + t.Fatalf("创建客服一客户失败: %v", err) + } + if err := model.DB.Create(&customerTwo).Error; err != nil { + t.Fatalf("创建客服二客户失败: %v", err) + } + now := time.Now() + sessions := []model.Session{ + {TenantID: tenant.ID, CustomerID: customerOne.ID, AgentID: &agentOne.ID, Status: "ended", CreatedAt: now}, + {TenantID: tenant.ID, CustomerID: customerTwo.ID, AgentID: &agentTwo.ID, Status: "ended", CreatedAt: now}, + } + if err := model.DB.Create(&sessions).Error; err != nil { + t.Fatalf("创建数据范围会话失败: %v", err) + } + + listRecorder := httptest.NewRecorder() + router.ServeHTTP(listRecorder, bearerRequest(t, http.MethodGet, "/api/customers", nil, agentOne)) + if listRecorder.Code != http.StatusOK { + t.Fatalf("查询本人客户失败: %d %s", listRecorder.Code, listRecorder.Body.String()) + } + var listResponse struct { + List []model.Customer `json:"list"` + } + if err := json.Unmarshal(listRecorder.Body.Bytes(), &listResponse); err != nil || len(listResponse.List) != 1 || listResponse.List[0].ID != customerOne.ID { + t.Fatalf("客户数据范围未生效: %s", listRecorder.Body.String()) + } + + detailRecorder := httptest.NewRecorder() + router.ServeHTTP(detailRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/customers/%d", customerTwo.ID), nil, agentOne)) + if detailRecorder.Code != http.StatusForbidden { + t.Fatalf("跨客服读取客户状态码 = %d,期望 %d", detailRecorder.Code, http.StatusForbidden) + } + var deniedCount int64 + model.DB.Model(&model.OperationLog{}). + Where("operator_id = ? AND action = ? AND target_type = ?", agentOne.ID, "data_access_denied", "customer"). + Count(&deniedCount) + if deniedCount != 1 { + t.Fatalf("客户越权未记录审计日志,数量 = %d", deniedCount) + } + + statsRecorder := httptest.NewRecorder() + router.ServeHTTP(statsRecorder, bearerRequest(t, http.MethodGet, "/api/statistics/kpi?period=today", nil, agentOne)) + if statsRecorder.Code != http.StatusOK { + t.Fatalf("查询个人统计失败: %d %s", statsRecorder.Code, statsRecorder.Body.String()) + } + var statsResponse struct { + Data struct { + TotalSessions int `json:"total_sessions"` + } `json:"data"` + } + if err := json.Unmarshal(statsRecorder.Body.Bytes(), &statsResponse); err != nil || statsResponse.Data.TotalSessions != 1 { + t.Fatalf("统计数据范围未生效: %s", statsRecorder.Body.String()) + } +} + func TestCustomerAndKnowledgeCRUD(t *testing.T) { router := setupRouter(t) tenant := createTenant(t, "业务CRUD租户", "normal") diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index da4da21..27fac08 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -84,7 +84,7 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) return } - if middleware.GetRole(c) == "agent" { + if !middleware.CanAccessAllData(c, "session") { model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq) session.LastReadSeq = msg.Seq } @@ -104,7 +104,7 @@ type AssignSessionReq struct { } func isTenantManager(c *gin.Context) bool { - return middleware.HasAnyRole(c, "admin", "supervisor") + return middleware.CanAccessAllData(c, "session") } func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) { @@ -121,21 +121,22 @@ func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) { } func canReadSession(c *gin.Context, session *model.Session) bool { - if isTenantManager(c) { + module := "session" + if session.Status == "ended" || session.Status == "archived" { + module = "chat_history" + } + if middleware.CanAccessAllData(c, module) { return true } - if middleware.GetRole(c) == "agent" { - userID := middleware.GetUserID(c) - return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID) - } - return false + userID := middleware.GetUserID(c) + return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID) } func canOperateSession(c *gin.Context, session *model.Session) bool { if isTenantManager(c) { return true } - return middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) + return session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) } func broadcastSessionMessage(session *model.Session, message model.Message) { @@ -160,12 +161,34 @@ func loadAssignableAgent(tenantID, agentID uint) error { if err := model.DB.First(&agent, agentID).Error; err != nil { return err } - if agent.TenantID != tenantID || agent.Role != "agent" || agent.Status == "disabled" { + if agent.TenantID != tenantID || agent.Status == "disabled" { + return gorm.ErrRecordNotFound + } + if agent.Role == "agent" { + return nil + } + var count int64 + if err := model.DB.Table("roles r"). + Joins("JOIN role_permissions rp ON rp.role_id = r.id"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("r.tenant_id = ? AND r.code = ? AND r.type = ? AND p.code = ?", tenantID, agent.Role, "custom", "session.reply"). + Count(&count).Error; err != nil || count == 0 { return gorm.ErrRecordNotFound } return nil } +func assignableRoleCodes(tenantID uint) []string { + codes := []string{"agent"} + var customCodes []string + model.DB.Table("roles r"). + Joins("JOIN role_permissions rp ON rp.role_id = r.id"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("r.tenant_id = ? AND r.type = ? AND p.code = ?", tenantID, "custom", "session.reply"). + Distinct().Pluck("r.code", &customCodes) + return append(codes, customCodes...) +} + func unreadCount(session model.Session) int { var count int64 model.DB.Model(&model.Message{}). @@ -189,7 +212,7 @@ func (h *SessionHandler) List(c *gin.Context) { var total int64 query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) - if middleware.GetRole(c) == "agent" { + if !middleware.CanAccessAllData(c, "session") || !middleware.CanAccessAllData(c, "chat_history") { query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting") } if status != "" { @@ -283,7 +306,7 @@ func (h *SessionHandler) List(c *gin.Context) { items := make([]SessionListItem, 0, len(sessions)) for _, session := range sessions { item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]} - if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) { + if !middleware.CanAccessAllData(c, "session") && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) { item.UnreadCount = unreadCount(session) } if cu, ok := customerMap[session.CustomerID]; ok { @@ -444,7 +467,7 @@ func (h *SessionHandler) MarkRead(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权标记该会话已读"}) return } - if session.AgentID == nil || middleware.GetRole(c) != "agent" || *session.AgentID != middleware.GetUserID(c) { + if session.AgentID == nil || middleware.CanAccessAllData(c, "session") || *session.AgentID != middleware.GetUserID(c) { middleware.JSON(c, gin.H{"last_read_seq": session.LastReadSeq}) return } @@ -496,9 +519,10 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) { } // all=1 返回租户全部坐席(含离线),用于对话记录筛选; // 默认:可转接坐席(role=agent,在线/忙碌),便于前端展示状态 - q := model.DB.Where("tenant_id = ? AND role IN ?", middleware.GetTenantID(c), []string{"agent", "supervisor", "admin"}) + tenantID := middleware.GetTenantID(c) + q := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, assignableRoleCodes(tenantID)) if c.Query("all") != "1" { - q = q.Where("role = ? AND status IN ?", "agent", []string{"online", "busy"}) + q = q.Where("status IN ?", []string{"online", "busy"}) } var users []model.User if err := q.Order("nickname asc").Find(&users).Error; err != nil { @@ -584,9 +608,9 @@ func (h *SessionHandler) Assign(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"}) return } - if middleware.GetRole(c) == "agent" { + if !middleware.HasPermission(c, "session.transfer") { req.AgentID = middleware.GetUserID(c) - } else if !isTenantManager(c) { + } else if !isTenantManager(c) && req.AgentID != middleware.GetUserID(c) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权分配会话"}) return } @@ -784,7 +808,7 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) { // Archive 将已结束会话归档(主管/管理员) func (h *SessionHandler) Archive(c *gin.Context) { - if !isTenantManager(c) { + if !middleware.HasPermission(c, "chat_history.batch_archive") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"}) return } @@ -816,7 +840,7 @@ func (h *SessionHandler) Archive(c *gin.Context) { // BatchArchive 批量归档已结束会话 func (h *SessionHandler) BatchArchive(c *gin.Context) { - if !isTenantManager(c) { + if !middleware.HasPermission(c, "chat_history.batch_archive") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"}) return } diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index 28516b6..12d6368 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -359,7 +359,7 @@ type updateSettingsReq struct { } func (h *SettingsHandler) Update(c *gin.Context) { - if !middleware.HasAnyRole(c, "admin") { + if !middleware.HasPermission(c, "settings.basic") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"}) return } diff --git a/server/internal/handler/staff.go b/server/internal/handler/staff.go index 934f7b1..62a242d 100644 --- a/server/internal/handler/staff.go +++ b/server/internal/handler/staff.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strings" "unicode/utf8" @@ -9,6 +10,7 @@ import ( "golang.org/x/crypto/bcrypt" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/ws" ) type StaffHandler struct{} @@ -16,22 +18,35 @@ type StaffHandler struct{} func NewStaffHandler() *StaffHandler { return &StaffHandler{} } func requireStaffManager(c *gin.Context) bool { - if middleware.HasAnyRole(c, "admin") { + if middleware.HasPermission(c, "settings.staff") { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"}) return false } -// 坐席占用:本租户下 agent / supervisor / admin 均计 1 席(不含 disabled) +// 坐席占用:租户内所有启用账号均计 1 席(含自定义角色)。 func countActiveSeats(tenantID uint) (int64, error) { var n int64 err := model.DB.Model(&model.User{}). - Where("tenant_id = ? AND role IN ? AND status <> ?", tenantID, []string{"agent", "supervisor", "admin"}, "disabled"). + Where("tenant_id = ? AND role <> ? AND status <> ?", tenantID, "platform_admin", "disabled"). Count(&n).Error return n, err } +func tenantRoleExists(tenantID uint, role string) bool { + if role == "platform_admin" || strings.TrimSpace(role) == "" { + return false + } + if role == "admin" || role == "supervisor" || role == "agent" { + return true + } + var count int64 + return model.DB.Model(&model.Role{}). + Where("tenant_id = ? AND code = ?", tenantID, role). + Count(&count).Error == nil && count > 0 +} + func loadTenantSeatLimit(tenantID uint) (int, error) { var tenant model.Tenant if err := model.DB.Select("id", "seat_count").First(&tenant, tenantID).Error; err != nil { @@ -54,13 +69,13 @@ type StaffItem struct { } func (h *StaffHandler) List(c *gin.Context) { - if !middleware.HasAnyRole(c, "admin", "supervisor") { + if !middleware.HasPermission(c, "settings.staff") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看坐席列表"}) return } tenantID := middleware.GetTenantID(c) var users []model.User - if err := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, []string{"agent", "supervisor", "admin"}). + if err := model.DB.Where("tenant_id = ? AND role <> ?", tenantID, "platform_admin"). Order("role asc, id asc").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"}) return @@ -116,8 +131,9 @@ func (h *StaffHandler) Create(c *gin.Context) { if role == "" { role = "agent" } - if role != "agent" && role != "supervisor" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"}) + tenantID := middleware.GetTenantID(c) + if !tenantRoleExists(tenantID, role) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"}) return } nickname := strings.TrimSpace(req.Nickname) @@ -129,7 +145,6 @@ func (h *StaffHandler) Create(c *gin.Context) { return } - tenantID := middleware.GetTenantID(c) seatLimit, err := loadTenantSeatLimit(tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"}) @@ -204,6 +219,8 @@ func (h *StaffHandler) Update(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无法操作该账号"}) return } + originalRole := user.Role + originalStatus := user.Status var req UpdateStaffReq if err := c.ShouldBindJSON(&req); err != nil { @@ -223,8 +240,8 @@ func (h *StaffHandler) Update(c *gin.Context) { } if req.Role != nil { r := *req.Role - if r != "agent" && r != "supervisor" && r != "admin" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"}) + if !tenantRoleExists(tenantID, r) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"}) return } // 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin @@ -292,6 +309,9 @@ func (h *StaffHandler) Update(c *gin.Context) { return } model.DB.First(&user, user.ID) + if user.Role != originalRole || (originalStatus != "disabled" && user.Status == "disabled") { + ws.DefaultHub.DisconnectUser(user.ID, "账号权限已变更,请重新登录") + } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), @@ -343,6 +363,8 @@ func (h *StaffHandler) Delete(c *gin.Context) { return } + ws.DefaultHub.DisconnectUser(user.ID, "账号已被停用,请重新登录") + model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "disable_staff", @@ -353,3 +375,133 @@ func (h *StaffHandler) Delete(c *gin.Context) { }) middleware.JSON(c, gin.H{"message": "已禁用"}) } + +// Batch 批量操作:启用/停用/更换角色。 +func (h *StaffHandler) Batch(c *gin.Context) { + if !requireStaffManager(c) { + return + } + tenantID := middleware.GetTenantID(c) + var req struct { + IDs []uint `json:"ids" binding:"required"` + Action string `json:"action" binding:"required"` // enable | disable | change_role + NewRole string `json:"new_role"` // change_role 时必填 + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + if len(req.IDs) == 0 || len(req.IDs) > 100 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择 1-100 个账号"}) + return + } + operatorID := middleware.GetUserID(c) + if req.Action != "enable" && req.Action != "disable" && req.Action != "change_role" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的批量操作"}) + return + } + if req.Action == "change_role" && !tenantRoleExists(tenantID, req.NewRole) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"}) + return + } + + var users []model.User + if err := model.DB.Where("id IN ? AND tenant_id = ?", req.IDs, tenantID).Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"}) + return + } + if len(users) != len(req.IDs) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "部分账号不存在或不属于当前租户"}) + return + } + if req.Action == "enable" { + toEnable := int64(0) + for _, user := range users { + if user.Status == "disabled" && user.ID != operatorID { + toEnable++ + } + } + used, err := countActiveSeats(tenantID) + seatLimit, limitErr := loadTenantSeatLimit(tenantID) + if err != nil || limitErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "检查坐席配额失败"}) + return + } + if used+toEnable > int64(seatLimit) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "批量启用后将超过坐席配额"}) + return + } + } + + processed := 0 + skipped := 0 + for _, u := range users { + if u.ID == operatorID { + skipped++ + continue + } + switch req.Action { + case "enable": + if u.Status == "disabled" { + if err := model.DB.Model(&u).Updates(map[string]interface{}{"status": "offline"}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量启用失败"}) + return + } + processed++ + model.DB.Create(&model.OperationLog{ + OperatorID: operatorID, Action: "enable_staff", Detail: "启用坐席: " + u.Username, + TargetType: "user", TargetID: &u.ID, IP: c.ClientIP(), + }) + } + case "disable": + if u.Role == "admin" { + var adminCnt int64 + model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt) + if adminCnt <= 1 && u.Status != "disabled" { + skipped++ + continue + } + } + if u.Status != "disabled" { + if err := model.DB.Model(&u).Update("status", "disabled").Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量停用失败"}) + return + } + processed++ + ws.DefaultHub.DisconnectUser(u.ID, "账号已被停用,请重新登录") + model.DB.Create(&model.OperationLog{ + OperatorID: operatorID, Action: "disable_staff", Detail: "禁用坐席: " + u.Username, + TargetType: "user", TargetID: &u.ID, IP: c.ClientIP(), + }) + } + case "change_role": + if u.Role == "admin" && req.NewRole != "admin" { + var adminCnt int64 + model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt) + if adminCnt <= 1 { + skipped++ + continue + } + } + if u.Role != req.NewRole { + if err := model.DB.Model(&u).Update("role", req.NewRole).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量更换角色失败"}) + return + } + processed++ + ws.DefaultHub.DisconnectUser(u.ID, "账号角色已变更,请重新登录") + model.DB.Create(&model.OperationLog{ + OperatorID: operatorID, Action: "change_staff_role", + Detail: fmt.Sprintf("变更角色: %s %s → %s", u.Username, u.Role, req.NewRole), + TargetType: "user", TargetID: &u.ID, IP: c.ClientIP(), + }) + middleware.InvalidatePermissionCache(tenantID, u.Role) + } + } + } + + middleware.JSON(c, gin.H{ + "message": fmt.Sprintf("批量操作完成,成功 %d 个,跳过 %d 个", processed, skipped), + "processed": processed, "skipped": skipped, + }) +} diff --git a/server/internal/handler/statistics.go b/server/internal/handler/statistics.go index 7461988..db0b3b4 100644 --- a/server/internal/handler/statistics.go +++ b/server/internal/handler/statistics.go @@ -16,7 +16,7 @@ type StatisticsHandler struct{} func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} } func requireStatisticsAccess(c *gin.Context) bool { - if middleware.HasAnyRole(c, "admin", "supervisor") { + if middleware.HasPermission(c, "statistics.view") { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"}) @@ -70,9 +70,12 @@ func parseStatsRange(c *gin.Context) (statsRange, error) { } } -func loadStatisticsData(tenantID uint, r statsRange) ([]model.Session, []model.Message, error) { +func loadStatisticsData(tenantID, userID uint, allData bool, r statsRange) ([]model.Session, []model.Message, error) { var sessions []model.Session q := model.DB.Where("tenant_id = ?", tenantID) + if !allData { + q = q.Where("agent_id = ?", userID) + } if !r.From.IsZero() { q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To) } @@ -158,7 +161,9 @@ func (h *StatisticsHandler) KPIs(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } - sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c), r) + sessions, messages, err := loadStatisticsData( + middleware.GetTenantID(c), middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r, + ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"}) return @@ -209,6 +214,9 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) { var sessions []model.Session q := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)) + if !middleware.CanAccessAllData(c, "statistics") { + q = q.Where("agent_id = ?", middleware.GetUserID(c)) + } // 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 6 个月走势 if hasCustom || period == "today" || period == "week" || period == "day" { q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To) @@ -259,7 +267,9 @@ func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } - _, messages, err := loadStatisticsData(middleware.GetTenantID(c), r) + _, messages, err := loadStatisticsData( + middleware.GetTenantID(c), middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r, + ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"}) return @@ -296,13 +306,18 @@ func (h *StatisticsHandler) AgentPerformance(c *gin.Context) { return } tenantID := middleware.GetTenantID(c) - sessions, messages, err := loadStatisticsData(tenantID, r) + allData := middleware.CanAccessAllData(c, "statistics") + sessions, messages, err := loadStatisticsData(tenantID, middleware.GetUserID(c), allData, r) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"}) return } var agents []model.User - if err := model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents).Error; err != nil { + agentQuery := model.DB.Where("tenant_id = ?", tenantID) + if !allData { + agentQuery = agentQuery.Where("id = ?", middleware.GetUserID(c)) + } + if err := agentQuery.Find(&agents).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"}) return } @@ -365,7 +380,9 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) { return } tenantID := middleware.GetTenantID(c) - sessions, _, err := loadStatisticsData(tenantID, r) + sessions, _, err := loadStatisticsData( + tenantID, middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r, + ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) return @@ -375,10 +392,6 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) return } - if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) - return - } channelTypes := make(map[uint]string) for _, channel := range channels { channelTypes[channel.ID] = channel.Type diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index 9005d16..2572028 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -128,6 +128,10 @@ func AuthRequired() gin.HandlerFunc { c.Set("user_id", claims.UserID) c.Set("tenant_id", claims.TenantID) c.Set("role", claims.Role) + permissionVersion := LoadPermissions(c, claims.UserID, claims.TenantID, claims.Role) + if permissionVersion != "" { + c.Header("X-Permission-Version", permissionVersion) + } c.Next() } } diff --git a/server/internal/middleware/permission.go b/server/internal/middleware/permission.go new file mode 100644 index 0000000..2f75c66 --- /dev/null +++ b/server/internal/middleware/permission.go @@ -0,0 +1,249 @@ +package middleware + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "kefu-cloud/server/internal/model" +) + +type cacheEntry struct { + db *gorm.DB + perms map[string]bool + dataScopes map[string]string + version string + expireAt time.Time +} + +var ( + permCache = sync.Map{} + cacheTTL = 5 * time.Minute + permCtxKey = "permissions" +) + +// LoadPermissions 加载当前用户角色的权限码集合,存入 context。 +// 使用内存缓存减少数据库查询。 +func LoadPermissions(c Context, _ uint, tenantID uint, role string) string { + key := permissionCacheKey(tenantID, role) + if raw, ok := permCache.Load(key); ok { + if entry, ok := raw.(cacheEntry); ok && entry.db == model.DB && time.Now().Before(entry.expireAt) { + c.Set(permCtxKey, entry.perms) + c.Set(dataScopeCtxKey, entry.dataScopes) + return entry.version + } + } + + var roleRecord model.Role + err := model.DB.Where("tenant_id = ? AND code = ?", tenantID, role).First(&roleRecord).Error + if err != nil { + perms := legacyRolePermissions(role) + dataScopes := model.DefaultRoleDataScopes(role) + entry := cacheEntry{db: model.DB, perms: perms, dataScopes: dataScopes, version: "legacy:" + role, expireAt: time.Now().Add(cacheTTL)} + permCache.Store(key, entry) + c.Set(permCtxKey, perms) + c.Set(dataScopeCtxKey, dataScopes) + return entry.version + } + + var codes []string + model.DB.Table("role_permissions rp"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("rp.role_id = ?", roleRecord.ID). + Pluck("p.code", &codes) + + perms := make(map[string]bool, len(codes)) + for _, c := range codes { + perms[c] = true + } + dataScopes := model.DefaultRoleDataScopes(role) + var scopes []model.RoleDataScope + if err := model.DB.Where("role_id = ?", roleRecord.ID).Find(&scopes).Error; err == nil { + for _, scope := range scopes { + dataScopes[scope.Module] = scope.Scope + } + } + + entry := cacheEntry{ + db: model.DB, perms: perms, dataScopes: dataScopes, + version: roleRecord.UpdatedAt.UTC().Format(time.RFC3339Nano), expireAt: time.Now().Add(cacheTTL), + } + permCache.Store(key, entry) + c.Set(permCtxKey, perms) + c.Set(dataScopeCtxKey, dataScopes) + return entry.version +} + +// InvalidatePermissionCache 权限变更后清除角色缓存。 +func InvalidatePermissionCache(tenantID uint, role string) { + permCache.Delete(permissionCacheKey(tenantID, role)) +} + +// InvalidateTenantPermissionCache 清除租户下所有角色缓存。 +func InvalidateTenantPermissionCache(tenantID uint) { + prefix := strconv.FormatUint(uint64(tenantID), 10) + ":" + permCache.Range(func(k, _ interface{}) bool { + if key, ok := k.(string); ok { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + permCache.Delete(key) + } + } + return true + }) +} + +const dataScopeCtxKey = "data_scopes" + +func permissionCacheKey(tenantID uint, role string) string { + return strconv.FormatUint(uint64(tenantID), 10) + ":" + role +} + +// GetDataScope 返回当前角色在指定模块的数据范围,异常或未知范围按 self 收紧。 +func GetDataScope(c Context, module string) string { + raw, ok := c.Get(dataScopeCtxKey) + if !ok { + return model.DataScopeSelf + } + scopes, ok := raw.(map[string]string) + if !ok || scopes[module] != model.DataScopeAll { + return model.DataScopeSelf + } + return model.DataScopeAll +} + +// CanAccessAllData 判断当前请求是否可读取指定模块的全租户数据。 +func CanAccessAllData(c Context, module string) bool { + return GetDataScope(c, module) == model.DataScopeAll +} + +// RequirePermission 在路由层强制校验任意一个权限码。 +func RequirePermission(codes ...string) gin.HandlerFunc { + return func(c *gin.Context) { + if HasAnyPermission(c, codes...) { + c.Next() + return + } + model.DB.Create(&model.OperationLog{ + OperatorID: GetUserID(c), Action: "permission_denied", + Detail: "接口权限不足: " + c.Request.Method + " " + c.FullPath(), + TargetType: "permission", IP: c.ClientIP(), + }) + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权限执行此操作"}) + c.Abort() + } +} + +// AuditDataAccessDenied 记录通过参数尝试访问越权数据的行为。 +func AuditDataAccessDenied(c *gin.Context, targetType string, targetID *uint) { + model.DB.Create(&model.OperationLog{ + OperatorID: GetUserID(c), Action: "data_access_denied", + Detail: "数据范围越权: " + c.Request.Method + " " + c.FullPath(), + TargetType: targetType, TargetID: targetID, IP: c.ClientIP(), + }) +} + +// HasPermission 检查当前用户是否拥有指定权限码。 +func HasPermission(c Context, code string) bool { + raw, ok := c.Get(permCtxKey) + if !ok { + return false + } + perms, ok := raw.(map[string]bool) + if !ok { + return false + } + return perms[code] +} + +// HasAnyPermission 检查是否拥有任意一项权限码。 +func HasAnyPermission(c Context, codes ...string) bool { + raw, ok := c.Get(permCtxKey) + if !ok { + return false + } + perms, ok := raw.(map[string]bool) + if !ok { + return false + } + for _, code := range codes { + if perms[code] { + return true + } + } + return false +} + +// GetPermissions 返回当前用户所有权限码集合。 +func GetPermissions(c Context) map[string]bool { + raw, ok := c.Get(permCtxKey) + if !ok { + return nil + } + perms, ok := raw.(map[string]bool) + if !ok { + return nil + } + return perms +} + +// legacyRolePermissions 当角色表尚无记录时的兜底权限映射。 +func legacyRolePermissions(role string) map[string]bool { + base := map[string]map[string]bool{ + "platform_admin": {}, + "admin": { + "session.view": true, "session.reply": true, "session.transfer": true, + "session.end": true, "session.note": true, "session.priority": true, + "customer.view": true, "customer.create": true, "customer.edit": true, + "customer.export": true, "customer.tag": true, + "chat_history.view": true, "chat_history.detail": true, + "chat_history.export": true, "chat_history.batch_archive": true, + "knowledge.view": true, "knowledge.create": true, "knowledge.edit": true, + "knowledge.publish": true, "knowledge.delete": true, + "quick_reply.view": true, "quick_reply.team_create": true, + "quick_reply.team_edit": true, "quick_reply.personal_manage": true, + "blacklist.view": true, "blacklist.create": true, "blacklist.delete": true, + "statistics.view": true, "statistics.export": true, "statistics.performance": true, + "settings.basic": true, "settings.channel": true, "settings.staff": true, + "settings.assign_rule": true, "settings.customer_tag": true, + "settings.auto_reply": true, "settings.worktime": true, "settings.notification": true, + "permission.view": true, "permission.create_role": true, + "permission.delete_role": true, "permission.assign_role": true, + }, + "supervisor": { + "session.view": true, "session.reply": true, "session.transfer": true, + "session.end": true, "session.note": true, "session.priority": true, + "customer.view": true, "customer.create": true, "customer.edit": true, + "customer.export": true, "customer.tag": true, + "chat_history.view": true, "chat_history.detail": true, + "chat_history.export": true, "chat_history.batch_archive": true, + "knowledge.view": true, "knowledge.create": true, "knowledge.edit": true, + "knowledge.publish": true, "knowledge.delete": true, + "quick_reply.view": true, "quick_reply.team_create": true, + "quick_reply.team_edit": true, "quick_reply.personal_manage": true, + "blacklist.view": true, "blacklist.create": true, "blacklist.delete": true, + "statistics.view": true, "statistics.export": true, "statistics.performance": true, + "settings.customer_tag": true, "settings.auto_reply": true, + }, + "agent": { + "session.view": true, "session.reply": true, + "session.end": true, "session.note": true, "session.priority": true, + "customer.view": true, "customer.create": true, "customer.edit": true, "customer.tag": true, + "chat_history.view": true, "chat_history.detail": true, + "knowledge.view": true, + "quick_reply.view": true, "quick_reply.personal_manage": true, + "statistics.view": true, "statistics.performance": true, + }, + } + if perms, ok := base[role]; ok { + return perms + } + return nil +} + +type Context interface { + Get(key string) (value interface{}, exists bool) + Set(key string, value interface{}) +} diff --git a/server/internal/model/db.go b/server/internal/model/db.go index db2008c..a109952 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -48,5 +48,9 @@ func Migrate(db *gorm.DB) error { &OperationLog{}, &Announcement{}, &TenantSetting{}, + &Role{}, + &Permission{}, + &RolePermission{}, + &RoleDataScope{}, ) } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 6903b9f..4c17d1d 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -24,7 +24,7 @@ type Tenant struct { type User struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"index;not null" json:"tenant_id"` - Role string `gorm:"size:20;default:agent" json:"role"` + Role string `gorm:"size:30;default:agent" json:"role"` Username string `gorm:"size:50;not null;uniqueIndex" json:"username"` PasswordHash string `gorm:"size:255;not null" json:"-"` Nickname string `gorm:"size:50" json:"nickname"` @@ -50,12 +50,12 @@ type Customer struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"index;not null" json:"tenant_id"` Name string `gorm:"size:50;not null" json:"name"` - Phone string `gorm:"size:20" json:"phone"` // 主手机号(兼容列表搜索) - Tel string `gorm:"size:30" json:"tel"` // 联系电话/固话 + Phone string `gorm:"size:20" json:"phone"` // 主手机号(兼容列表搜索) + Tel string `gorm:"size:30" json:"tel"` // 联系电话/固话 Email string `gorm:"size:100" json:"email"` - Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空 + Wechat string `gorm:"size:50" json:"wechat"` // 微信号,默认可空 QQ string `gorm:"size:20;column:qq" json:"qq"` - Remark string `gorm:"type:text" json:"remark"` // 客户备注 + Remark string `gorm:"type:text" json:"remark"` // 客户备注 Tags string `gorm:"type:text" json:"tags"` Source string `gorm:"size:30" json:"source"` Status string `gorm:"size:20;default:online" json:"status"` @@ -95,15 +95,15 @@ type BlacklistEntry struct { } type Session struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - ChannelID uint `json:"channel_id"` - CustomerID uint `gorm:"index" json:"customer_id"` - AgentID *uint `gorm:"index" json:"agent_id"` - VisitorTokenHash string `gorm:"size:64;index" json:"-"` - VisitorIP string `gorm:"size:64" json:"visitor_ip"` - VisitorRegion string `gorm:"size:100" json:"visitor_region"` - UserAgent string `gorm:"size:500" json:"user_agent"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + ChannelID uint `json:"channel_id"` + CustomerID uint `gorm:"index" json:"customer_id"` + AgentID *uint `gorm:"index" json:"agent_id"` + VisitorTokenHash string `gorm:"size:64;index" json:"-"` + VisitorIP string `gorm:"size:64" json:"visitor_ip"` + VisitorRegion string `gorm:"size:100" json:"visitor_region"` + UserAgent string `gorm:"size:500" json:"user_agent"` // DeviceKey 访客端持久设备指纹(localStorage),用于设备级拉黑 DeviceKey string `gorm:"size:64;index" json:"device_key"` // 落地页 / 当前页(访客浏览轨迹) @@ -115,11 +115,11 @@ type Session struct { // LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态 LastSeenAt *time.Time `json:"last_seen_at"` // DraftText 访客输入框未发送草稿(实时监控用,非聊天消息) - DraftText string `gorm:"type:text" json:"draft_text"` - DraftUpdatedAt *time.Time `json:"draft_updated_at"` - LastReadSeq int `gorm:"default:0" json:"last_read_seq"` - Status string `gorm:"size:20;default:waiting" json:"status"` - Priority string `gorm:"size:20;default:normal" json:"priority"` + DraftText string `gorm:"type:text" json:"draft_text"` + DraftUpdatedAt *time.Time `json:"draft_updated_at"` + LastReadSeq int `gorm:"default:0" json:"last_read_seq"` + Status string `gorm:"size:20;default:waiting" json:"status"` + Priority string `gorm:"size:20;default:normal" json:"priority"` SatisfactionScore *int `json:"satisfaction_score"` SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` EndReason string `gorm:"size:50" json:"end_reason"` @@ -133,7 +133,7 @@ type CustomerContact struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"index;not null" json:"tenant_id"` CustomerID uint `gorm:"uniqueIndex:idx_cust_contact;not null" json:"customer_id"` - Kind string `gorm:"size:20;uniqueIndex:idx_cust_contact;not null" json:"kind"` // phone|wechat|email|qq + Kind string `gorm:"size:20;uniqueIndex:idx_cust_contact;not null" json:"kind"` // phone|wechat|email|qq Value string `gorm:"size:100;uniqueIndex:idx_cust_contact;not null" json:"value"` Source string `gorm:"size:30" json:"source"` // draft|message|leave|agent CreatedAt time.Time `json:"created_at"` @@ -165,21 +165,23 @@ type SessionEvent struct { SessionID uint `gorm:"index;not null" json:"session_id"` OperatorID uint `json:"operator_id"` Action string `gorm:"size:50;not null" json:"action"` - Detail string `gorm:"size:500" json:"detail"` + Detail string `gorm:"type:text" json:"detail"` CreatedAt time.Time `json:"created_at"` } type Category struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - ParentID *uint `json:"parent_id"` - Name string `gorm:"size:30;not null" json:"name"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + CreatedBy uint `gorm:"index" json:"created_by"` + ParentID *uint `json:"parent_id"` + Name string `gorm:"size:30;not null" json:"name"` } type KnowledgeEntry struct { ID uint `gorm:"primaryKey" json:"id"` TenantID uint `gorm:"index;not null" json:"tenant_id"` CategoryID uint `json:"category_id"` + CreatedBy uint `gorm:"index" json:"created_by"` Title string `gorm:"size:100;not null" json:"title"` Content string `gorm:"type:text;not null" json:"content"` Status string `gorm:"size:20;default:draft" json:"status"` @@ -191,16 +193,16 @@ type KnowledgeEntry struct { // QuickReply 团队/个人快捷回复(与知识库独立)。 // Scope=team 时 OwnerUserID 为空,全租户共享;Scope=personal 时归属 OwnerUserID。 type QuickReply struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"index;not null" json:"tenant_id"` - Scope string `gorm:"size:20;index;not null" json:"scope"` // team | personal - OwnerUserID *uint `gorm:"index" json:"owner_user_id,omitempty"` - Title string `gorm:"size:100;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - Shortcut string `gorm:"size:32;index" json:"shortcut"` // 输入码,如 nh → /nh - GroupName string `gorm:"size:50" json:"group_name"` - Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published - SortOrder int `gorm:"default:0" json:"sort_order"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + Scope string `gorm:"size:20;index;not null" json:"scope"` // team | personal + OwnerUserID *uint `gorm:"index" json:"owner_user_id,omitempty"` + Title string `gorm:"size:100;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Shortcut string `gorm:"size:32;index" json:"shortcut"` // 输入码,如 nh → /nh + GroupName string `gorm:"size:50" json:"group_name"` + Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published + SortOrder int `gorm:"default:0" json:"sort_order"` // UsageCount 全局累计(管理页展示);排序以个人用量为准见 QuickReplyUserUsage UsageCount int `gorm:"default:0" json:"usage_count"` CreatedAt time.Time `json:"created_at"` @@ -254,26 +256,64 @@ type Announcement struct { // TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关、分配策略等)。 type TenantSetting struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"` - DisplayName string `gorm:"size:100" json:"display_name"` - AgentNickname string `gorm:"size:50" json:"agent_nickname"` - Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"` - WelcomeMessage string `gorm:"size:500" json:"welcome_message"` + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"` + DisplayName string `gorm:"size:100" json:"display_name"` + AgentNickname string `gorm:"size:50" json:"agent_nickname"` + Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"` + WelcomeMessage string `gorm:"size:500" json:"welcome_message"` // WelcomeMessagesJSON 多段欢迎语 JSON:[{type:text|image, content:...}, ...] WelcomeMessagesJSON string `gorm:"type:text" json:"welcome_messages_json"` OfflinePrompt string `gorm:"size:500" json:"offline_prompt"` WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"` - WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"` - NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"` - NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"` - NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"` + WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"` + NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"` + NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"` + NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"` // AssignStrategy 自动分配策略:least_load(默认)| round_robin AssignStrategy string `gorm:"size:30;default:least_load" json:"assign_strategy"` // MaxActivePerAgent 每位坐席最大进行中会话数;0 表示不限制 MaxActivePerAgent int `gorm:"default:0" json:"max_active_per_agent"` // RRLastAgentID 轮询策略的上次分配坐席游标 - RRLastAgentID *uint `json:"-"` + RRLastAgentID *uint `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// Role 租户级角色定义。 +// Code 为角色标识(admin/supervisor/agent 为内置,其他为自定义)。 +// Type="builtin" 的内置角色不可删除、不可修改 Code。 +type Role struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"uniqueIndex:idx_tenant_role_code;not null" json:"tenant_id"` + Name string `gorm:"size:50;not null" json:"name"` + Code string `gorm:"size:30;uniqueIndex:idx_tenant_role_code;not null" json:"code"` + Type string `gorm:"size:10;default:custom" json:"type"` // builtin | custom + Desc string `gorm:"size:200" json:"desc"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Permission 系统权限码表(全租户共享,不可删除)。 +type Permission struct { + ID uint `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:50;uniqueIndex;not null" json:"code"` + Name string `gorm:"size:50;not null" json:"name"` + Module string `gorm:"size:30;not null" json:"module"` + Category string `gorm:"size:20;not null" json:"category"` // view | operate + SortOrder int `gorm:"default:0" json:"sort_order"` +} + +// RolePermission 角色-权限多对多关联。 +type RolePermission struct { + RoleID uint `gorm:"primaryKey" json:"role_id"` + PermissionID uint `gorm:"primaryKey" json:"permission_id"` +} + +// RoleDataScope 保存角色在业务模块中的数据范围。 +// 当前支持 all/self,module 维度为后续 group/channel/tag 等范围扩展预留。 +type RoleDataScope struct { + RoleID uint `gorm:"primaryKey" json:"role_id"` + Module string `gorm:"size:30;primaryKey" json:"module"` + Scope string `gorm:"size:20;not null" json:"scope"` +} diff --git a/server/internal/model/seed_permissions.go b/server/internal/model/seed_permissions.go new file mode 100644 index 0000000..9c39c87 --- /dev/null +++ b/server/internal/model/seed_permissions.go @@ -0,0 +1,190 @@ +package model + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + DataScopeAll = "all" + DataScopeSelf = "self" +) + +var dataScopeModules = []string{"session", "customer", "chat_history", "statistics"} + +var predefinedPermissions = []Permission{ + {Code: "session.view", Name: "查看会话列表", Module: "工作台", Category: "view", SortOrder: 1}, + {Code: "session.reply", Name: "回复访客消息", Module: "工作台", Category: "operate", SortOrder: 2}, + {Code: "session.transfer", Name: "转接会话", Module: "工作台", Category: "operate", SortOrder: 3}, + {Code: "session.end", Name: "结束会话", Module: "工作台", Category: "operate", SortOrder: 4}, + {Code: "session.note", Name: "添加内部备注", Module: "工作台", Category: "operate", SortOrder: 5}, + {Code: "session.priority", Name: "标记优先级", Module: "工作台", Category: "operate", SortOrder: 6}, + {Code: "customer.view", Name: "查看客户列表", Module: "客户管理", Category: "view", SortOrder: 7}, + {Code: "customer.create", Name: "新增客户", Module: "客户管理", Category: "operate", SortOrder: 8}, + {Code: "customer.edit", Name: "编辑客户资料", Module: "客户管理", Category: "operate", SortOrder: 9}, + {Code: "customer.export", Name: "导出客户", Module: "客户管理", Category: "operate", SortOrder: 10}, + {Code: "customer.tag", Name: "管理客户标签", Module: "客户管理", Category: "operate", SortOrder: 11}, + {Code: "chat_history.view", Name: "查看对话记录", Module: "对话记录", Category: "view", SortOrder: 12}, + {Code: "chat_history.detail", Name: "查看会话详情", Module: "对话记录", Category: "view", SortOrder: 13}, + {Code: "chat_history.export", Name: "导出对话记录", Module: "对话记录", Category: "operate", SortOrder: 14}, + {Code: "chat_history.batch_archive", Name: "批量归档记录", Module: "对话记录", Category: "operate", SortOrder: 15}, + {Code: "knowledge.view", Name: "查看知识库", Module: "知识库", Category: "view", SortOrder: 16}, + {Code: "knowledge.create", Name: "新建知识条目", Module: "知识库", Category: "operate", SortOrder: 17}, + {Code: "knowledge.edit", Name: "编辑知识条目", Module: "知识库", Category: "operate", SortOrder: 18}, + {Code: "knowledge.publish", Name: "发布/下架条目", Module: "知识库", Category: "operate", SortOrder: 19}, + {Code: "knowledge.delete", Name: "删除知识条目/分类", Module: "知识库", Category: "operate", SortOrder: 20}, + {Code: "quick_reply.view", Name: "查看快捷回复", Module: "快捷回复", Category: "view", SortOrder: 21}, + {Code: "quick_reply.team_create", Name: "新建团队快捷回复", Module: "快捷回复", Category: "operate", SortOrder: 22}, + {Code: "quick_reply.team_edit", Name: "编辑/删除团队回复", Module: "快捷回复", Category: "operate", SortOrder: 23}, + {Code: "quick_reply.personal_manage", Name: "管理我的快捷回复", Module: "快捷回复", Category: "operate", SortOrder: 24}, + {Code: "blacklist.view", Name: "查看黑名单", Module: "黑名单", Category: "view", SortOrder: 25}, + {Code: "blacklist.create", Name: "拉黑访客/IP/设备", Module: "黑名单", Category: "operate", SortOrder: 26}, + {Code: "blacklist.delete", Name: "解除黑名单", Module: "黑名单", Category: "operate", SortOrder: 27}, + {Code: "statistics.view", Name: "查看数据统计", Module: "数据统计", Category: "view", SortOrder: 28}, + {Code: "statistics.export", Name: "导出报告", Module: "数据统计", Category: "operate", SortOrder: 29}, + {Code: "statistics.performance", Name: "查看客服绩效排行", Module: "数据统计", Category: "view", SortOrder: 30}, + {Code: "settings.basic", Name: "基本设置", Module: "系统设置", Category: "operate", SortOrder: 31}, + {Code: "settings.channel", Name: "渠道管理", Module: "系统设置", Category: "operate", SortOrder: 32}, + {Code: "settings.staff", Name: "坐席账号", Module: "系统设置", Category: "operate", SortOrder: 33}, + {Code: "settings.assign_rule", Name: "客服分配规则", Module: "系统设置", Category: "operate", SortOrder: 34}, + {Code: "settings.customer_tag", Name: "客户标签", Module: "系统设置", Category: "operate", SortOrder: 35}, + {Code: "settings.auto_reply", Name: "自动回复", Module: "系统设置", Category: "operate", SortOrder: 36}, + {Code: "settings.worktime", Name: "工作时间", Module: "系统设置", Category: "operate", SortOrder: 37}, + {Code: "settings.notification", Name: "通知设置", Module: "系统设置", Category: "operate", SortOrder: 38}, + {Code: "permission.view", Name: "查看角色列表", Module: "权限控制", Category: "view", SortOrder: 39}, + {Code: "permission.create_role", Name: "创建/编辑角色", Module: "权限控制", Category: "operate", SortOrder: 40}, + {Code: "permission.delete_role", Name: "删除自定义角色", Module: "权限控制", Category: "operate", SortOrder: 41}, + {Code: "permission.assign_role", Name: "给账号分配角色", Module: "权限控制", Category: "operate", SortOrder: 42}, +} + +// PredefinedPermissions 返回系统所有权限码列表。 +func PredefinedPermissions() []Permission { + result := make([]Permission, len(predefinedPermissions)) + copy(result, predefinedPermissions) + return result +} + +// DataScopeModules 返回支持数据范围配置的业务模块。 +func DataScopeModules() []string { + result := make([]string, len(dataScopeModules)) + copy(result, dataScopeModules) + return result +} + +// DefaultRoleDataScopes 返回角色的默认数据范围。 +func DefaultRoleDataScopes(roleCode string) map[string]string { + scope := DataScopeSelf + if roleCode == "admin" || roleCode == "supervisor" { + scope = DataScopeAll + } + result := make(map[string]string, len(dataScopeModules)) + for _, module := range dataScopeModules { + result[module] = scope + } + return result +} + +// BuiltinRolePermissionCodes 返回内置角色对应的权限码集合。 +func BuiltinRolePermissionCodes() map[string][]string { + return map[string][]string{ + "admin": { + "session.view", "session.reply", "session.transfer", "session.end", "session.note", "session.priority", + "customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", + "chat_history.view", "chat_history.detail", "chat_history.export", "chat_history.batch_archive", + "knowledge.view", "knowledge.create", "knowledge.edit", "knowledge.publish", "knowledge.delete", + "quick_reply.view", "quick_reply.team_create", "quick_reply.team_edit", "quick_reply.personal_manage", + "blacklist.view", "blacklist.create", "blacklist.delete", + "statistics.view", "statistics.export", "statistics.performance", + "settings.basic", "settings.channel", "settings.staff", "settings.assign_rule", + "settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification", + "permission.view", "permission.create_role", "permission.delete_role", "permission.assign_role", + }, + "supervisor": { + "session.view", "session.reply", "session.transfer", "session.end", "session.note", "session.priority", + "customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", + "chat_history.view", "chat_history.detail", "chat_history.export", "chat_history.batch_archive", + "knowledge.view", "knowledge.create", "knowledge.edit", "knowledge.publish", "knowledge.delete", + "quick_reply.view", "quick_reply.team_create", "quick_reply.team_edit", "quick_reply.personal_manage", + "blacklist.view", "blacklist.create", "blacklist.delete", + "statistics.view", "statistics.export", "statistics.performance", + "settings.customer_tag", "settings.auto_reply", + }, + "agent": { + "session.view", "session.reply", "session.end", "session.note", "session.priority", + "customer.view", "customer.create", "customer.edit", "customer.tag", + "chat_history.view", "chat_history.detail", + "knowledge.view", + "quick_reply.view", "quick_reply.personal_manage", + "statistics.view", "statistics.performance", + }, + } +} + +// EnsurePermissions 补齐并更新系统权限码,支持后续平滑新增权限项。 +func EnsurePermissions() error { + for _, permission := range predefinedPermissions { + if err := DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "code"}}, + DoUpdates: clause.AssignmentColumns([]string{"name", "module", "category", "sort_order"}), + }).Create(&permission).Error; err != nil { + return err + } + } + return nil +} + +// EnsureBuiltinRoles 确保指定租户拥有三个内置角色。 +func EnsureBuiltinRoles(tenantID uint) error { + builtins := []struct{ name, code, desc string }{ + {"管理员", "admin", "租户最高权限,可管理账号、角色与全部系统设置"}, + {"客服主管", "supervisor", "管理一线团队,可查看全部数据并维护知识库、快捷回复、黑名单等"}, + {"客服", "agent", "一线接待人员,仅可查看和操作自己相关的会话、客户与个人数据"}, + } + permMap := BuiltinRolePermissionCodes() + + return DB.Transaction(func(tx *gorm.DB) error { + for _, b := range builtins { + var role Role + created := false + err := tx.Where("tenant_id = ? AND code = ?", tenantID, b.code).First(&role).Error + if err != nil { + if err != gorm.ErrRecordNotFound { + return err + } + role = Role{TenantID: tenantID, Name: b.name, Code: b.code, Type: "builtin", Desc: b.desc} + if err := tx.Create(&role).Error; err != nil { + return err + } + created = true + } + + var permissionCount int64 + if err := tx.Model(&RolePermission{}).Where("role_id = ?", role.ID).Count(&permissionCount).Error; err != nil { + return err + } + // 新建角色或迁移期空关联才写默认权限,避免覆盖管理员的自定义配置。 + if created || permissionCount == 0 { + var permissionIDs []uint + if err := tx.Model(&Permission{}).Where("code IN ?", permMap[b.code]).Pluck("id", &permissionIDs).Error; err != nil { + return err + } + for _, permissionID := range permissionIDs { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&RolePermission{ + RoleID: role.ID, PermissionID: permissionID, + }).Error; err != nil { + return err + } + } + } + + for module, scope := range DefaultRoleDataScopes(b.code) { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&RoleDataScope{ + RoleID: role.ID, Module: module, Scope: scope, + }).Error; err != nil { + return err + } + } + } + return nil + }) +} diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index 747de94..14b8e2a 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -97,6 +97,90 @@ func (h *Hub) Run() { } } +// DisconnectUser 断开指定用户的所有 WebSocket 连接并推送通知。 +func (h *Hub) DisconnectUser(userID uint, message string) { + h.mu.Lock() + defer h.mu.Unlock() + for client := range h.clients { + if client.UserID == userID { + if message != "" { + payload, err := NewEvent("kicked", 0, map[string]string{"message": message}) + if err == nil { + h.send(client, payload) + } + } + delete(h.clients, client) + close(client.Send) + if client.Conn != nil { + client.Conn.Close() + } + } + } +} + +func roleHasPermission(tenantID uint, role, code string) bool { + var roleRecord model.Role + if err := model.DB.Where("tenant_id = ? AND code = ?", tenantID, role).First(&roleRecord).Error; err != nil { + for _, permissionCode := range model.BuiltinRolePermissionCodes()[role] { + if permissionCode == code { + return true + } + } + return false + } + var count int64 + return model.DB.Table("role_permissions rp"). + Joins("JOIN permissions p ON p.id = rp.permission_id"). + Where("rp.role_id = ? AND p.code = ?", roleRecord.ID, code). + Count(&count).Error == nil && count > 0 +} + +func roleDataScope(tenantID uint, role, module string) string { + defaultScope := model.DefaultRoleDataScopes(role)[module] + var roleRecord model.Role + if err := model.DB.Where("tenant_id = ? AND code = ?", tenantID, role).First(&roleRecord).Error; err != nil { + return defaultScope + } + var scope model.RoleDataScope + if err := model.DB.Where("role_id = ? AND module = ?", roleRecord.ID, module).First(&scope).Error; err != nil { + return defaultScope + } + return scope.Scope +} + +// canReceiveSessionEvent 在每次推送前重新校验账号、角色权限和会话数据范围。 +func canReceiveSessionEvent(client *Client, session *model.Session) bool { + if client.Kind != "agent" { + return false + } + // 单元测试未初始化数据库时保留原有内置角色判定;生产环境始终走实时数据库校验。 + if model.DB == nil { + if client.Role == "admin" || client.Role == "supervisor" { + return true + } + return session == nil || session.Status == "waiting" || + (session.AgentID != nil && client.Role == "agent" && *session.AgentID == client.UserID) + } + var user model.User + if err := model.DB.Select("id", "tenant_id", "role", "status").First(&user, client.UserID).Error; err != nil || + user.TenantID != client.TenantID || user.Status == "disabled" { + return false + } + permissionCode := "session.view" + module := "session" + if session != nil && (session.Status == "ended" || session.Status == "archived") { + permissionCode = "chat_history.view" + module = "chat_history" + } + if !roleHasPermission(user.TenantID, user.Role, permissionCode) { + return false + } + if session == nil || roleDataScope(user.TenantID, user.Role, module) == model.DataScopeAll { + return true + } + return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == user.ID) +} + // Stats 返回当前连接统计(总连接 / 坐席 / 访客)。 func (h *Hub) Stats() (total, agents, visitors int) { h.mu.RLock() @@ -134,8 +218,9 @@ func (h *Hub) BroadcastToSession(tenantID, sessionID uint, agentID *uint, messag } continue } - if client.Role == "admin" || client.Role == "supervisor" || - (agentID != nil && client.Role == "agent" && client.UserID == *agentID) { + if canReceiveSessionEvent(client, &model.Session{ + TenantID: tenantID, ID: sessionID, AgentID: agentID, Status: "active", + }) { h.send(client, message) } } @@ -146,8 +231,19 @@ func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) { h.mu.RLock() defer h.mu.RUnlock() + var event Event + _ = json.Unmarshal(message, &event) + var session *model.Session + if event.SessionID != 0 { + var current model.Session + if err := model.DB.Where("id = ? AND tenant_id = ?", event.SessionID, tenantID).First(¤t).Error; err == nil { + session = ¤t + } else { + session = &model.Session{TenantID: tenantID, ID: event.SessionID, Status: "active"} + } + } for client := range h.clients { - if client.TenantID == tenantID && client.Kind == "agent" { + if client.TenantID == tenantID && canReceiveSessionEvent(client, session) { h.send(client, message) } } @@ -173,8 +269,7 @@ func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []by if client.TenantID != tenantID || client.Kind != "agent" { continue } - if client.Role == "admin" || client.Role == "supervisor" || - (agentID != nil && client.Role == "agent" && client.UserID == *agentID) { + if canReceiveSessionEvent(client, &model.Session{TenantID: tenantID, AgentID: agentID, Status: "active"}) { h.send(client, message) } } @@ -247,10 +342,7 @@ func handleClientEvent(client *Client, event ClientEvent) { if client.Kind != "agent" || event.Type != "typing" { return } - if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) { - return - } - if client.Role != "agent" && client.Role != "admin" && client.Role != "supervisor" { + if !canReceiveSessionEvent(client, &session) { return } payload, err := NewEvent("typing", session.ID, map[string]string{"from": "agent"}) diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx index 667b7d7..72aa3ee 100644 --- a/web/src/components/RequireAuth.tsx +++ b/web/src/components/RequireAuth.tsx @@ -1,4 +1,5 @@ import { Navigate, Outlet } from 'react-router-dom' +import { Result, Spin } from 'antd' import { useAuth } from '@/stores/auth' const RequireAuth = () => { @@ -35,4 +36,16 @@ export const RequireTenantAdmin = () => { return } +export const RequirePermission = ({ anyOf }: { anyOf: string[] }) => { + const { user, permissions, permissionsLoaded } = useAuth() + if (!user) return + if (!permissionsLoaded) { + return
+ } + if (!anyOf.some(code => permissions.has(code))) { + return + } + return +} + export default RequireAuth diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 8b83e3c..7ba1fb6 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -3,21 +3,23 @@ import { useLocation, useNavigate } from 'react-router-dom' import { AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, - ThunderboltOutlined, DownOutlined, StopOutlined, + ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined, } from '@ant-design/icons' import { Dropdown, message as antMsg } from 'antd' import { useAuth } from '@/stores/auth' import type { AgentPresence } from '@/services/api' const menuItems = [ - { key: '/agent/dashboard', icon: , label: '工作台' }, - { key: '/agent/customers', icon: , label: '客户管理' }, - { key: '/agent/chat-history', icon: , label: '对话记录' }, - { key: '/agent/knowledge', icon: , label: '知识库' }, - { key: '/agent/quick-replies', icon: , label: '快捷回复' }, - { key: '/agent/blacklist', icon: , label: '黑名单' }, - { key: '/agent/statistics', icon: , label: '数据统计' }, - { key: '/agent/settings', icon: , label: '系统设置' }, + { key: '/agent/dashboard', icon: , label: '工作台', permissions: ['session.view'] }, + { key: '/agent/customers', icon: , label: '客户管理', permissions: ['customer.view'] }, + { key: '/agent/chat-history', icon: , label: '对话记录', permissions: ['chat_history.view'] }, + { key: '/agent/knowledge', icon: , label: '知识库', permissions: ['knowledge.view'] }, + { key: '/agent/quick-replies', icon: , label: '快捷回复', permissions: ['quick_reply.view'] }, + { key: '/agent/blacklist', icon: , label: '黑名单', permissions: ['blacklist.view'] }, + { key: '/agent/statistics', icon: , label: '数据统计', permissions: ['statistics.view'] }, + { key: '/agent/staff', icon: , label: '成员账号', permissions: ['settings.staff'] }, + { key: '/agent/roles', icon: , label: '权限控制', permissions: ['permission.view'] }, + { key: '/agent/settings', icon: , label: '系统设置', permissions: ['settings.basic', 'settings.channel', 'settings.assign_rule', 'settings.customer_tag', 'settings.auto_reply', 'settings.worktime', 'settings.notification'] }, ] const presenceOptions: { value: AgentPresence; text: string; dot: string }[] = [ @@ -33,14 +35,10 @@ function presenceMeta(status?: string) { const AgentSidebar = () => { const location = useLocation() const navigate = useNavigate() - const { user, logout, setPresence } = useAuth() + const { user, permissions, logout, setPresence } = useAuth() const [switching, setSwitching] = useState(false) - const visibleMenuItems = menuItems.filter(item => { - if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor' - if (item.key === '/agent/settings') return user?.role === 'admin' - return true - }) + const visibleMenuItems = menuItems.filter(item => item.permissions.some(code => permissions.has(code))) const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' diff --git a/web/src/pages/agent/Blacklist.tsx b/web/src/pages/agent/Blacklist.tsx index 628987e..6e42545 100644 --- a/web/src/pages/agent/Blacklist.tsx +++ b/web/src/pages/agent/Blacklist.tsx @@ -3,6 +3,7 @@ import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'an import type { ColumnsType } from 'antd/es/table' import { ReloadOutlined, StopOutlined } from '@ant-design/icons' import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api' +import { usePermission } from '@/stores/auth' function isActive(entry: BlacklistEntry) { if (!entry.expires_at) return true @@ -32,6 +33,7 @@ function maskValue(kind: string, value: string) { } const Blacklist = () => { + const canRelease = usePermission('blacklist.delete') const [list, setList] = useState([]) const [loading, setLoading] = useState(false) const [kind, setKind] = useState() @@ -140,7 +142,7 @@ const Blacklist = () => { width: 100, fixed: 'right', render: (_, row) => ( - isActive(row) ? ( + isActive(row) && canRelease ? ( { - const { user } = useAuth() - const canArchive = user?.role === 'admin' || user?.role === 'supervisor' + const canExport = usePermission('chat_history.export') + const canArchive = usePermission('chat_history.batch_archive') const [sessions, setSessions] = useState([]) const [total, setTotal] = useState(0) @@ -335,6 +335,7 @@ const ChatHistory = () => {

对话记录

+ {canExport && ( + )} + {canArchive && ( + )}
diff --git a/web/src/pages/agent/Customers.tsx b/web/src/pages/agent/Customers.tsx index 5e0c47a..771e30a 100644 --- a/web/src/pages/agent/Customers.tsx +++ b/web/src/pages/agent/Customers.tsx @@ -12,7 +12,7 @@ import { createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer, type Customer, type CustomerContact, type CustomerTag, type Session, } from '@/services/api' -import { useAuth } from '@/stores/auth' +import { usePermission } from '@/stores/auth' const statusMap: Record = { online: { color: '#16a34a', text: '在线', dot: '#16a34a' }, @@ -133,9 +133,10 @@ const StatusDot = ({ status }: { status: string }) => { } const Customers = () => { - const { user } = useAuth() const navigate = useNavigate() - const canDelete = user?.role === 'admin' || user?.role === 'supervisor' + const canExport = usePermission('customer.export') + const canCreate = usePermission('customer.create') + const canDelete = usePermission('customer.export') const [customers, setCustomers] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -412,6 +413,7 @@ const Customers = () => {
+ {canExport && ( + )} + {canCreate && ( + )}
diff --git a/web/src/pages/agent/Knowledge.tsx b/web/src/pages/agent/Knowledge.tsx index a01bac6..92ab3f6 100644 --- a/web/src/pages/agent/Knowledge.tsx +++ b/web/src/pages/agent/Knowledge.tsx @@ -12,7 +12,7 @@ import { getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeCategory, updateKnowledgeEntry, type KnowledgeCategory, type KnowledgeEntry, } from '@/services/api' -import { useAuth } from '@/stores/auth' +import { usePermission } from '@/stores/auth' type CatNode = KnowledgeCategory & { children: KnowledgeCategory[] } @@ -47,8 +47,10 @@ function formatUsage(n: number) { } const Knowledge = () => { - const { user } = useAuth() - const canManage = user?.role === 'admin' || user?.role === 'supervisor' + const canCreate = usePermission('knowledge.create') + const canEdit = usePermission('knowledge.edit') + const canDelete = usePermission('knowledge.delete') + const canManage = canCreate || canEdit || canDelete const [categories, setCategories] = useState([]) const [totalEntries, setTotalEntries] = useState(0) @@ -355,27 +357,27 @@ const Knowledge = () => { const renderCatActions = (cat: KnowledgeCategory, isRoot: boolean) => { if (!canManage) return null - return ( - e.stopPropagation()}> - {isRoot && ( - - )} - - } + {canDelete && handleDeleteCategory(cat)} @@ -388,7 +390,7 @@ const Knowledge = () => { > - + } ) } @@ -503,7 +505,7 @@ const Knowledge = () => { )} - {canManage && ( + {canCreate && (
共 {total} 条 - {canManage && ( + {canCreate && ( @@ -649,7 +651,7 @@ const Knowledge = () => { className="w-[12%] flex items-center justify-end gap-0.5 pr-1" onClick={e => e.stopPropagation()} > - {canManage && ( + {canEdit && ( - {canManage && ( + {canDelete && ( handleDelete(entry.id)}> - {canManage && ( + {canEdit && ( diff --git a/web/src/pages/agent/QuickReplies.tsx b/web/src/pages/agent/QuickReplies.tsx index f6381d4..d4401e3 100644 --- a/web/src/pages/agent/QuickReplies.tsx +++ b/web/src/pages/agent/QuickReplies.tsx @@ -12,11 +12,12 @@ import { getQuickReplies, importQuickReplies, publishQuickReply, unpublishQuickReply, updateQuickReply, type QuickReply, type QuickReplyScope, } from '@/services/api' -import { useAuth } from '@/stores/auth' +import { usePermission } from '@/stores/auth' const QuickReplies = () => { - const { user } = useAuth() - const canManageTeam = user?.role === 'admin' || user?.role === 'supervisor' + const canCreateTeam = usePermission('quick_reply.team_create') + const canEditTeam = usePermission('quick_reply.team_edit') + const canManageTeam = canCreateTeam || canEditTeam const [tab, setTab] = useState(canManageTeam ? 'team' : 'personal') const [list, setList] = useState([]) @@ -160,7 +161,7 @@ const QuickReplies = () => { } const canEditRow = (row: QuickReply) => { - if (row.scope === 'team') return canManageTeam + if (row.scope === 'team') return canEditTeam return true } diff --git a/web/src/pages/agent/RoleConfig.tsx b/web/src/pages/agent/RoleConfig.tsx new file mode 100644 index 0000000..b627039 --- /dev/null +++ b/web/src/pages/agent/RoleConfig.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { Button, Spin, Card, Checkbox, Divider, Input, Select, message, Tag, Breadcrumb } from 'antd' +import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons' +import { getPermissions, getRoleDetail, updateRole, updateBuiltinRole, type Permission, type RoleDetail } from '@/services/api' + +interface ModulePerms { + module: string + params: Permission[] +} + +export default function RoleConfig() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [detail, setDetail] = useState(null) + const [allPerms, setAllPerms] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [dataScopes, setDataScopes] = useState>({}) + const [roleName, setRoleName] = useState('') + const [roleDesc, setRoleDesc] = useState('') + + const load = useCallback(async () => { + setLoading(true) + try { + const [permsRes, detailRes] = await Promise.all([ + getPermissions(), + getRoleDetail(Number(id)), + ]) + setAllPerms(permsRes.data || []) + setDetail(detailRes.data) + setSelected(new Set(detailRes.data?.permissions || [])) + setDataScopes(detailRes.data?.data_scopes || {}) + setRoleName(detailRes.data?.role.name || '') + setRoleDesc(detailRes.data?.role.desc || '') + } catch { + message.error('加载失败') + } finally { + setLoading(false) + } + }, [id]) + + useEffect(() => { void load() }, [load]) + + const toggle = (code: string) => { + setSelected(prev => { + const next = new Set(prev) + const permission = allPerms.find(item => item.code === code) + if (!permission) return next + const modulePermissions = allPerms.filter(item => item.module === permission.module) + const viewCodes = modulePermissions.filter(item => item.category === 'view').map(item => item.code) + if (next.has(code)) { + next.delete(code) + if (permission.category === 'view' && !viewCodes.some(viewCode => next.has(viewCode))) { + modulePermissions.filter(item => item.category === 'operate').forEach(item => next.delete(item.code)) + } + } else { + next.add(code) + if (permission.category === 'operate' && viewCodes.length > 0) { + next.add(viewCodes[0]) + } + } + return next + }) + } + + const handleSave = async () => { + if (!detail) return + setSaving(true) + try { + const permCodes = Array.from(selected) + if (detail.role.type === 'builtin') { + await updateBuiltinRole(Number(id), { desc: roleDesc, permissions: permCodes, data_scopes: dataScopes }) + } else { + await updateRole(Number(id), { name: roleName, desc: roleDesc, permissions: permCodes, data_scopes: dataScopes }) + } + message.success('权限已保存,下次请求即时生效') + } catch { + message.error('保存失败') + } finally { + setSaving(false) + } + } + + const moduleMap = new Map() + for (const permission of allPerms) { + moduleMap.set(permission.module, [...(moduleMap.get(permission.module) || []), permission]) + } + const modules: ModulePerms[] = Array.from(moduleMap, ([module, params]) => ({ module, params })) + const scopeModuleByLabel: Record = { + 工作台: 'session', 客户管理: 'customer', 对话记录: 'chat_history', 数据统计: 'statistics', + } + + if (loading) return
+ if (!detail) return
角色不存在
+ + const { role } = detail + + return ( +
+
+ navigate('/agent/roles')}>角色管理 }, + { title: `配置角色:${role.name}` }, + ]} + /> + + +
+
+ {role.name.slice(0, 1)} +
+
+
+ {role.name} + + {role.type === 'builtin' ? '内置角色' : '自定义角色'} + + {role.type === 'builtin' && ( + 标识:{role.code} · {detail.member_count} 位成员 + )} +
+ {role.type === 'custom' ? ( +
+ setRoleName(event.target.value)} placeholder="角色名称" /> + setRoleDesc(event.target.value)} placeholder="角色描述" /> +
+ ) : ( + setRoleDesc(event.target.value)} className="mt-3" placeholder="角色描述" /> + )} +
+
+
+ + {modules.map(mod => ( + setDataScopes(prev => ({ ...prev, [scopeModuleByLabel[mod.module]]: scope }))} + options={[{ label: '全部数据', value: 'all' }, { label: '仅自己', value: 'self' }]} + /> + ) : undefined} + > +
+ {mod.params.map(p => ( + + ))} +
+
+ ))} + + +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/agent/RoleList.tsx b/web/src/pages/agent/RoleList.tsx new file mode 100644 index 0000000..af5a9f9 --- /dev/null +++ b/web/src/pages/agent/RoleList.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button, Card, Spin, Tag, Modal, Form, Input, message, Popconfirm, Empty } from 'antd' +import { PlusOutlined, SettingOutlined, DeleteOutlined, TeamOutlined, SafetyOutlined } from '@ant-design/icons' +import { getRoles, createRole, deleteRole, type RoleWithStats } from '@/services/api' + +const roleTypeColors: Record = { builtin: 'blue', custom: 'green' } +const roleTypeLabels: Record = { builtin: '内置', custom: '自定义' } + +export default function RoleList() { + const navigate = useNavigate() + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [modalOpen, setModalOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [form] = Form.useForm() + + const load = useCallback(async () => { + setLoading(true) + try { + const res = await getRoles() + setRoles(res.data || []) + } catch { + message.error('加载角色列表失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { void load() }, [load]) + + const handleCreate = async () => { + const values = await form.validateFields().catch(() => null) + if (!values) return + setSaving(true) + try { + await createRole({ name: values.name, desc: values.desc, permissions: [] }) + message.success('角色创建成功') + setModalOpen(false) + form.resetFields() + void load() + } catch { + message.error('创建失败') + } finally { + setSaving(false) + } + } + + const handleDelete = async (id: number) => { + try { + await deleteRole(id) + message.success('已删除') + void load() + } catch (e: any) { + message.error(e?.message || '删除失败') + } + } + + if (loading) return
+ + return ( +
+
+
+

角色管理

+ +
+ + {roles.length === 0 ? ( + + ) : ( +
+ {roles.map(role => ( + } + onClick={() => navigate(`/agent/roles/${role.id}`)} + > + 配置权限 + , + role.type === 'custom' && ( + handleDelete(role.id)} + okText="确定" + cancelText="取消" + > + + + ), + ].filter(Boolean)} + > +
+
+ +
+
+
+ {role.name} + {roleTypeLabels[role.type]} +
+

+ {role.desc || '暂无描述'} +

+
+ + 成员数:{role.member_count} + + 权限项:{role.perm_count}/42 + 最后更新:{role.type === 'builtin' ? '系统默认' : role.updated_at?.slice(0, 10)} +
+
+
+
+ ))} +
+ )} + + { setModalOpen(false); form.resetFields() }} + onOk={handleCreate} + confirmLoading={saving} + okText="创建" + cancelText="取消" + > +
+ + + + + + +
+
+
+
+ ) +} diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index a30dbd8..308dead 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -15,7 +15,7 @@ import { updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage, type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment, } from '@/services/api' -import { useAuth } from '@/stores/auth' +import { useAuth, usePermission } from '@/stores/auth' import MarkdownEditor from '@/components/common/MarkdownEditor' import RichTextEditor from '@/components/common/RichTextEditor' @@ -143,8 +143,8 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] const Settings = () => { const { user } = useAuth() - const isAdmin = user?.role === 'admin' - const canViewStaff = user?.role === 'admin' || user?.role === 'supervisor' + const isAdmin = usePermission('settings.basic') + const canViewStaff = usePermission('settings.staff') const [activeTab, setActiveTab] = useState('basic') const [basicForm] = Form.useForm() diff --git a/web/src/pages/agent/Staff.tsx b/web/src/pages/agent/Staff.tsx new file mode 100644 index 0000000..35cfdb3 --- /dev/null +++ b/web/src/pages/agent/Staff.tsx @@ -0,0 +1,255 @@ +import { useState, useEffect, useCallback } from 'react' +import { Button, Spin, Card, Table, Modal, Form, Input, Select, Tag, message, Space, type TableColumnsType } from 'antd' +import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons' +import { useAuth, usePermission } from '@/stores/auth' +import { getStaff, createStaff, updateStaff, deleteStaff, batchStaff, getRoles, type StaffUser, type RoleWithStats } from '@/services/api' + +const roleLabels: Record = { admin: '管理员', supervisor: '主管', agent: '客服' } +const statusColors: Record = { online: 'green', busy: 'gold', offline: 'default', disabled: 'red' } +const statusLabels: Record = { online: '在线', busy: '忙碌', offline: '离线', disabled: '已停用' } + +export default function Staff() { + const { user } = useAuth() + const canManage = usePermission('settings.staff') + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + const [staff, setStaff] = useState([]) + const [seatUsed, setSeatUsed] = useState(0) + const [seatLimit, setSeatLimit] = useState(0) + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [modalOpen, setModalOpen] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [saving, setSaving] = useState(false) + const [form] = Form.useForm() + + const load = useCallback(async () => { + setLoading(true) + try { + const [staffRes, rolesRes] = await Promise.all([ + getStaff(), + getRoles(), + ]) + setStaff(Array.isArray(staffRes.data?.list) ? staffRes.data.list : []) + setSeatUsed(staffRes.data?.seat_used || 0) + setSeatLimit(staffRes.data?.seat_limit || 0) + setRoles(rolesRes.data || []) + } catch { + message.error('加载失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { void load() }, [load]) + + const openCreate = () => { + setEditingUser(null) + form.resetFields() + form.setFieldsValue({ role: 'agent' }) + setModalOpen(true) + } + + const openEdit = (u: StaffUser) => { + setEditingUser(u) + form.setFieldsValue({ + nickname: u.nickname, + role: u.role, + status: u.status, + }) + setModalOpen(true) + } + + const handleSave = async () => { + const values = await form.validateFields().catch(() => null) + if (!values) return + setSaving(true) + try { + if (editingUser) { + await updateStaff(editingUser.id, values) + message.success('已更新') + } else { + await createStaff(values) + message.success('已创建') + } + setModalOpen(false) + void load() + } catch (e: any) { + message.error(e?.message || '操作失败') + } finally { + setSaving(false) + } + } + + const handleToggleStatus = async (u: StaffUser) => { + try { + if (u.status === 'disabled') { + await updateStaff(u.id, { status: 'offline' }) + message.success('已启用') + } else { + await deleteStaff(u.id) + message.success('已停用') + } + void load() + } catch (e: any) { + message.error(e?.message || '操作失败') + } + } + + const handleBatch = async (action: string, newRole?: string) => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择账号') + return + } + const actions: Record = { enable: '启用', disable: '停用', change_role: '更换角色' } + const confirmed = confirm(`确定批量${actions[action]} ${selectedRowKeys.length} 个账号?`) + if (!confirmed) return + try { + await batchStaff({ ids: selectedRowKeys.map(Number), action, new_role: newRole }) + message.success(`批量${actions[action]}完成`) + setSelectedRowKeys([]) + void load() + } catch (e: any) { + message.error(e?.message || '操作失败') + } + } + + const rowSelection = canManage ? { + selectedRowKeys, + onChange: (keys: React.Key[]) => setSelectedRowKeys(keys), + } : undefined + + const columns: TableColumnsType = [ + { title: '账号', dataIndex: 'username', key: 'username', width: 160 }, + { title: '姓名', dataIndex: 'nickname', key: 'nickname', width: 120 }, + { + title: '角色', dataIndex: 'role', key: 'role', width: 90, + render: (role: string) => {roleLabels[role] || role}, + }, + { + title: '状态', dataIndex: 'status', key: 'status', width: 80, + render: (s: string) => {statusLabels[s] || s}, + }, + { + title: '最近登录', dataIndex: 'last_online_at', key: 'last_online_at', width: 150, + render: (v: string) => v ? v.slice(0, 19).replace('T', ' ') : '-', + }, + { + title: '操作', key: 'actions', width: 140, fixed: 'right', + render: (_, u) => { + if (!canManage) return null + return ( + + + + + ) + }, + }, + ] + + if (loading) return
+ + return ( +
+
+
+

成员账号

+ + + {canManage && ( + + )} + +
+ + + + 坐席配额:{seatUsed} / {seatLimit} + {seatUsed >= seatLimit && 已满} + + {selectedRowKeys.length > 0 && ( + + 已选 {selectedRowKeys.length} 个 + + + + + + )} + + + `共 ${t} 个账号` }} + scroll={{ x: 700 }} + /> + + setModalOpen(false)} + onOk={handleSave} + confirmLoading={saving} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ {!editingUser && ( + <> + + + + + + + + )} + + + + + + + )} + {editingUser && ( + + + + )} + +
+ + + ) +} diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 6842dc6..7e29503 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense } from 'react' import { Navigate, createBrowserRouter } from 'react-router-dom' import { Spin } from 'antd' -import RequireAuth, { RequirePlatformAdmin, RequireStaff, RequireSupervisor, RequireTenantAdmin } from '@/components/RequireAuth' +import RequireAuth, { RequirePermission, RequirePlatformAdmin, RequireStaff, RequireTenantAdmin } from '@/components/RequireAuth' const AgentLayout = lazy(() => import('@/components/layout/AgentLayout')) const AdminLayout = lazy(() => import('@/components/layout/AdminLayout')) @@ -14,6 +14,9 @@ const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies')) const Blacklist = lazy(() => import('@/pages/agent/Blacklist')) const Statistics = lazy(() => import('@/pages/agent/Statistics')) const Settings = lazy(() => import('@/pages/agent/Settings')) +const RoleList = lazy(() => import('@/pages/agent/RoleList')) +const RoleConfig = lazy(() => import('@/pages/agent/RoleConfig')) +const Staff = lazy(() => import('@/pages/agent/Staff')) const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard')) const Tenants = lazy(() => import('@/pages/admin/Tenants')) const Plans = lazy(() => import('@/pages/admin/Plans')) @@ -64,20 +67,24 @@ export const router = createBrowserRouter([ element: , children: [ { index: true, element: }, - { path: 'dashboard', element: }, - { path: 'chat-history', element: }, - { path: 'customers', element: }, - { path: 'knowledge', element: }, - { path: 'quick-replies', element: }, - { path: 'blacklist', element: }, - { - element: , - children: [{ path: 'statistics', element: }], - }, + { element: , children: [{ path: 'dashboard', element: }] }, + { element: , children: [{ path: 'chat-history', element: }] }, + { element: , children: [{ path: 'customers', element: }] }, + { element: , children: [{ path: 'knowledge', element: }] }, + { element: , children: [{ path: 'quick-replies', element: }] }, + { element: , children: [{ path: 'blacklist', element: }] }, + { element: , children: [{ path: 'statistics', element: }] }, { element: , - children: [{ path: 'settings', element: }], - }, + children: [ + { element: , children: [{ path: 'settings', element: }] }, + { element: , children: [ + { path: 'roles', element: }, + { path: 'roles/:id', element: }, + ] }, + { element: , children: [{ path: 'staff', element: }] }, + ], + }, ], }, ], diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 5a57e1c..0f686fa 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -587,6 +587,7 @@ export const createStaff = (data: { username: string; password: string; nickname post('/staff', data) export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) => put(`/staff/${id}`, data) +export const batchStaff = (data: { ids: number[]; action: string; new_role?: string }) => post('/staff/batch', data) export const deleteStaff = (id: number) => del(`/staff/${id}`) // Tenant settings @@ -678,3 +679,50 @@ export const createAnnouncement = (data: { title: string; content: string; statu export const updateAnnouncement = (id: number, data: Partial) => put(`/admin/announcements/${id}`, data) export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`) + +// 权限管理 +export interface Permission { + id: number + code: string + name: string + module: string + category: string + sort_order: number +} + +export interface RoleWithStats { + id: number + tenant_id: number + name: string + code: string + type: 'builtin' | 'custom' + desc: string + member_count: number + perm_count: number + created_at: string + updated_at: string +} + +export interface RoleDetail { + role: RoleWithStats + permissions: string[] + data_scopes: Record + member_count: number +} + +export interface MePermissions { + role: string + permissions: string[] +} + +export const getPermissions = () => get('/roles/permissions') +export const getRoles = () => get('/roles') +export const getRoleDetail = (id: number) => get(`/roles/${id}`) +export const createRole = (data: { name: string; desc?: string; permissions: string[]; data_scopes?: Record }) => + post('/roles', data) +export const updateRole = (id: number, data: { name?: string; desc?: string; permissions: string[]; data_scopes: Record }) => + put(`/roles/${id}`, data) +export const updateBuiltinRole = (id: number, data: { desc?: string; permissions: string[]; data_scopes: Record }) => + put(`/roles/${id}/builtin`, data) +export const deleteRole = (id: number) => del(`/roles/${id}`) +export const mePermissions = () => get('/me/permissions') diff --git a/web/src/services/request.ts b/web/src/services/request.ts index 81e2825..0b39e87 100644 --- a/web/src/services/request.ts +++ b/web/src/services/request.ts @@ -1,8 +1,14 @@ const BASE = '/api' -let token = '' +export const PERMISSIONS_CHANGED_EVENT = 'permissions-changed' -export function setToken(t: string) { token = t } +let token = '' +let permissionVersion = '' + +export function setToken(t: string) { + if (token !== t) permissionVersion = '' + token = t +} export function getToken() { return token } interface Response { @@ -32,6 +38,14 @@ async function request(url: string, options: RequestInit = {}): Promise { if (token) headers['Authorization'] = `Bearer ${token}` const res = await fetch(`${BASE}${url}`, { ...options, headers }) + const nextPermissionVersion = res.headers.get('X-Permission-Version') || '' + if (nextPermissionVersion) { + const changed = permissionVersion !== '' && permissionVersion !== nextPermissionVersion + permissionVersion = nextPermissionVersion + if (changed && typeof window !== 'undefined') { + window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT)) + } + } const json = await res.json() if (json.code !== 0) { @@ -87,4 +101,3 @@ export async function downloadFile(path: string, fallbackName: string) { a.remove() URL.revokeObjectURL(url) } - diff --git a/web/src/stores/auth.tsx b/web/src/stores/auth.tsx index 0d72567..2f730e4 100644 --- a/web/src/stores/auth.tsx +++ b/web/src/stores/auth.tsx @@ -1,9 +1,10 @@ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react' -import { setToken } from '@/services/request' +import { PERMISSIONS_CHANGED_EVENT, setToken } from '@/services/request' import { getMe, login as loginApi, updateMyStatus, + mePermissions, type AgentPresence, type LoginResult, } from '@/services/api' @@ -11,18 +12,24 @@ import { interface AuthState { user: LoginResult | null loading: boolean + permissions: Set + permissionsLoaded: boolean login: (username: string, password: string) => Promise logout: () => void /** 切换本人在线 / 忙碌 / 离线 */ setPresence: (status: AgentPresence) => Promise + refreshPermissions: () => Promise } const AuthContext = createContext({ user: null, loading: false, + permissions: new Set(), + permissionsLoaded: false, login: async () => { throw new Error('认证上下文未初始化') }, logout: () => {}, setPresence: async () => {}, + refreshPermissions: async () => {}, }) function persistUser(u: LoginResult | null) { @@ -48,29 +55,62 @@ export function AuthProvider({ children }: { children: ReactNode }) { return null }) const [loading, setLoading] = useState(false) + const [permissions, setPermissions] = useState>(new Set()) + const [permissionsLoaded, setPermissionsLoaded] = useState(false) - // 刷新后同步服务端真实状态(含 status) + const refreshPermissions = useCallback(async () => { + if (!user?.token) return + try { + const res = await mePermissions() + if (res.data?.permissions) { + setPermissions(new Set(res.data.permissions)) + } + } catch { + setPermissions(new Set()) + } finally { + setPermissionsLoaded(true) + } + }, [user?.token]) + + useEffect(() => { + const handlePermissionsChanged = () => { void refreshPermissions() } + window.addEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged) + return () => window.removeEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged) + }, [refreshPermissions]) + + // 刷新后同步服务端真实状态(含 status + permissions) useEffect(() => { if (!user?.token) return let cancelled = false - getMe() - .then(res => { - if (cancelled || !res.data) return - setUser(prev => { - if (!prev) return prev - const next = { - ...prev, - nickname: res.data.nickname || prev.nickname, - role: res.data.role || prev.role, - status: res.data.status || prev.status || 'offline', - } - persistUser(next) - return next - }) + setPermissionsLoaded(false) + Promise.all([getMe(), mePermissions()]) + .then(([meRes, permRes]) => { + if (cancelled) return + if (meRes.data) { + setUser(prev => { + if (!prev) return prev + const next = { + ...prev, + nickname: meRes.data.nickname || prev.nickname, + role: meRes.data.role || prev.role, + status: meRes.data.status || prev.status || 'offline', + } + persistUser(next) + return next + }) + } + if (permRes.data?.permissions) { + setPermissions(new Set(permRes.data.permissions)) + } + setPermissionsLoaded(true) }) - .catch(() => { /* 忽略:token 失效会在后续请求里处理 */ }) + .catch(() => { + if (!cancelled) { + setPermissions(new Set()) + setPermissionsLoaded(true) + } + }) return () => { cancelled = true } - // 仅挂载时拉一次 // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.token]) @@ -82,11 +122,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { setToken(u.token) setUser(u) persistUser(u) + setPermissionsLoaded(false) + try { + const permissionRes = await mePermissions() + setPermissions(new Set(permissionRes.data?.permissions || [])) + } catch { + setPermissions(new Set()) + } finally { + setPermissionsLoaded(true) + } return u } finally { setLoading(false) } - }, []) + }, []) const setPresence = useCallback(async (status: AgentPresence) => { const res = await updateMyStatus(status) @@ -100,18 +149,24 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const logout = useCallback(() => { - // 尽力把状态置为离线,不阻塞退出 void updateMyStatus('offline').catch(() => {}) setToken('') setUser(null) + setPermissions(new Set()) + setPermissionsLoaded(false) persistUser(null) }, []) return ( - + {children} ) } export const useAuth = () => useContext(AuthContext) + +export function usePermission(code: string): boolean { + const { permissions } = useAuth() + return permissions.has(code) +}