完善角色权限与数据隔离

This commit is contained in:
yml2213
2026-07-26 14:00:50 +08:00
parent b483cbc26d
commit 16d9ab0e5e
38 changed files with 2847 additions and 292 deletions
+358
View File
@@ -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 | 边界保护 | 无法删除最后一个管理员,删除自定义角色时提示迁移账号,越权操作被拦截 |
+7 -1
View File
@@ -19,10 +19,11 @@ func main() {
} }
func seed() { func seed() {
// 默认后台管理员:kefu_admin / kefu_admin123(其它种子账号同密码)
hash, _ := bcrypt.GenerateFromPassword([]byte("kefu_admin123"), bcrypt.DefaultCost) hash, _ := bcrypt.GenerateFromPassword([]byte("kefu_admin123"), bcrypt.DefaultCost)
pwd := string(hash) pwd := string(hash)
model.EnsurePermissions()
// Plans // Plans
plans := []model.Plan{ plans := []model.Plan{
{Name: "基础版", PriceMonthly: 299, Seats: 2, StorageDays: 30, KBLimit: 50, Status: "active", Features: `{"stats":"basic","api":false,"channels":["web"],"brand":false}`}, {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) model.DB.Create(&tenants)
// 为每个租户创建内置角色及默认权限
for _, t := range tenants {
model.EnsureBuiltinRoles(t.ID)
}
// Platform admin // Platform admin
model.DB.Create(&model.User{ model.DB.Create(&model.User{
TenantID: 0, Role: "platform_admin", Username: "platform_admin", PasswordHash: pwd, Nickname: "平台管理员", Status: "online", TenantID: 0, Role: "platform_admin", Username: "platform_admin", PasswordHash: pwd, Nickname: "平台管理员", Status: "online",
+13
View File
@@ -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 { type UpdatePresenceReq struct {
Status string `json:"status" binding:"required"` Status string `json:"status" binding:"required"`
} }
+12 -7
View File
@@ -131,6 +131,10 @@ func durationLabel(duration string) string {
// Create 创建黑名单(支持从会话拉黑 IP 或设备)。 // Create 创建黑名单(支持从会话拉黑 IP 或设备)。
func (h *BlacklistHandler) Create(c *gin.Context) { 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) tenantID := middleware.GetTenantID(c)
var req CreateBlacklistReq var req CreateBlacklistReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -163,13 +167,6 @@ func (h *BlacklistHandler) Create(c *gin.Context) {
if !ok { if !ok {
return 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 session = s
sid := s.ID sid := s.ID
sessionID = &sid sessionID = &sid
@@ -305,6 +302,10 @@ func maskBlacklistValue(kind, value string) string {
// List 黑名单列表(有效 + 可选含已过期)。 // List 黑名单列表(有效 + 可选含已过期)。
func (h *BlacklistHandler) List(c *gin.Context) { 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) tenantID := middleware.GetTenantID(c)
q := model.DB.Where("tenant_id = ?", tenantID) q := model.DB.Where("tenant_id = ?", tenantID)
if c.Query("active") != "0" { if c.Query("active") != "0" {
@@ -323,6 +324,10 @@ func (h *BlacklistHandler) List(c *gin.Context) {
// Delete 解除黑名单。 // Delete 解除黑名单。
func (h *BlacklistHandler) Delete(c *gin.Context) { 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) tenantID := middleware.GetTenantID(c)
id := c.Param("id") id := c.Param("id")
var entry model.BlacklistEntry var entry model.BlacklistEntry
+9 -2
View File
@@ -59,7 +59,7 @@ func toChannelView(ch model.Channel) channelView {
} }
func requireTenantAdmin(c *gin.Context) bool { func requireTenantAdmin(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin") { if middleware.HasPermission(c, "settings.channel") {
return true return true
} }
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可操作"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可操作"})
@@ -74,8 +74,15 @@ func (h *ChannelHandler) List(c *gin.Context) {
return return
} }
views := make([]channelView, 0, len(channels)) views := make([]channelView, 0, len(channels))
canManage := middleware.HasPermission(c, "settings.channel")
for _, ch := range channels { 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) middleware.JSON(c, views)
} }
+9 -7
View File
@@ -14,12 +14,12 @@ type CustomerHandler struct{}
func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} } func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} }
func canAccessCustomer(c *gin.Context, customerID uint) bool { func canAccessCustomer(c *gin.Context, customerID uint) bool {
if middleware.HasAnyRole(c, "admin", "supervisor") { if !middleware.HasPermission(c, "customer.view") {
return true
}
if middleware.GetRole(c) != "agent" {
return false return false
} }
if middleware.CanAccessAllData(c, "customer") {
return true
}
var count int64 var count int64
model.DB.Model(&model.Session{}). model.DB.Model(&model.Session{}).
Where("tenant_id = ? AND customer_id = ? AND agent_id = ?", middleware.GetTenantID(c), customerID, middleware.GetUserID(c)). 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 var total int64
query := model.DB.Where("tenant_id = ?", tenantID) query := model.DB.Where("tenant_id = ?", tenantID)
if middleware.GetRole(c) == "agent" { if !middleware.CanAccessAllData(c, "customer") {
assignedCustomers := model.DB.Model(&model.Session{}). assignedCustomers := model.DB.Model(&model.Session{}).
Select("customer_id"). Select("customer_id").
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c))
@@ -74,13 +74,14 @@ func (h *CustomerHandler) Get(c *gin.Context) {
return return
} }
if !canAccessCustomer(c, customer.ID) { if !canAccessCustomer(c, customer.ID) {
middleware.AuditDataAccessDenied(c, "customer", &customer.ID)
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"})
return return
} }
var sessions []model.Session var sessions []model.Session
sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID) 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 = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c))
} }
sessionQuery.Order("created_at desc").Limit(20).Find(&sessions) sessionQuery.Order("created_at desc").Limit(20).Find(&sessions)
@@ -233,6 +234,7 @@ func (h *CustomerHandler) Update(c *gin.Context) {
return return
} }
if !canAccessCustomer(c, customer.ID) { if !canAccessCustomer(c, customer.ID) {
middleware.AuditDataAccessDenied(c, "customer", &customer.ID)
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"})
return return
} }
@@ -319,7 +321,7 @@ func (h *CustomerHandler) Update(c *gin.Context) {
} }
func (h *CustomerHandler) Delete(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": "仅主管或管理员可删除客户"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"})
return return
} }
+1 -2
View File
@@ -29,8 +29,7 @@ type CustomerTagHandler struct{}
func NewCustomerTagHandler() *CustomerTagHandler { return &CustomerTagHandler{} } func NewCustomerTagHandler() *CustomerTagHandler { return &CustomerTagHandler{} }
func requireCustomerTagManager(c *gin.Context) bool { func requireCustomerTagManager(c *gin.Context) bool {
// 管理员维护标签库;主管也可维护,便于运营 if middleware.HasPermission(c, "settings.customer_tag") {
if middleware.HasAnyRole(c, "admin", "supervisor") {
return true return true
} }
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理客户标签"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理客户标签"})
+9 -4
View File
@@ -21,7 +21,7 @@ func (h *CustomerHandler) Export(c *gin.Context) {
source := c.Query("source") source := c.Query("source")
query := model.DB.Where("tenant_id = ?", tenantID) query := model.DB.Where("tenant_id = ?", tenantID)
if middleware.GetRole(c) == "agent" { if !middleware.CanAccessAllData(c, "chat_history") {
assignedCustomers := model.DB.Model(&model.Session{}). assignedCustomers := model.DB.Model(&model.Session{}).
Select("customer_id"). Select("customer_id").
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) 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") to := c.Query("to")
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) 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") query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
} }
if status != "" { if status != "" {
@@ -253,7 +253,8 @@ func (h *StatisticsHandler) Export(c *gin.Context) {
return return
} }
tenantID := middleware.GetTenantID(c) 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"})
return return
@@ -282,7 +283,11 @@ func (h *StatisticsHandler) Export(c *gin.Context) {
// 坐席绩效 // 坐席绩效
var agents []model.User 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 { type perf struct {
Name string Name string
Conversations int Conversations int
+41 -14
View File
@@ -15,14 +15,18 @@ type KnowledgeHandler struct{}
func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} } func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} }
func requireKnowledgeManager(c *gin.Context) bool { func requireKnowledgePermission(c *gin.Context, code string) bool {
if middleware.HasAnyRole(c, "admin", "supervisor") { if middleware.HasPermission(c, code) {
return true return true
} }
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理知识库"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权执行该知识库操作"})
return false 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) { func hasKnowledgeCapacity(tenantID uint) (bool, error) {
var tenant model.Tenant var tenant model.Tenant
if err := model.DB.First(&tenant, tenantID).Error; err != nil { if err := model.DB.First(&tenant, tenantID).Error; err != nil {
@@ -139,7 +143,7 @@ var (
type catError string 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 (e catError) Error() string { return string(e) }
func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID uint) bool { 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) { func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
if !requireKnowledgeManager(c) { if !requireKnowledgePermission(c, "knowledge.create") {
return return
} }
var req CategoryReq var req CategoryReq
@@ -181,9 +185,10 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
return return
} }
category := model.Category{ category := model.Category{
TenantID: tenantID, TenantID: tenantID,
Name: name, CreatedBy: middleware.GetUserID(c),
ParentID: req.ParentID, Name: name,
ParentID: req.ParentID,
} }
if err := model.DB.Create(&category).Error; err != nil { if err := model.DB.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) 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) { func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) {
if !requireKnowledgeManager(c) { if !requireKnowledgePermission(c, "knowledge.edit") {
return return
} }
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
@@ -248,7 +253,7 @@ func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) {
} }
func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) { func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) {
if !requireKnowledgeManager(c) { if !requireKnowledgePermission(c, "knowledge.delete") {
return return
} }
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
@@ -258,6 +263,10 @@ func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"}) c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"})
return return
} }
if !canDeleteKnowledge(c, category.CreatedBy) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "主管仅可删除自己创建的分类"})
return
}
var childCnt int64 var childCnt int64
model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt) model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt)
if childCnt > 0 { if childCnt > 0 {
@@ -364,7 +373,7 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) {
} }
func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
if !requireKnowledgeManager(c) { if !requireKnowledgePermission(c, "knowledge.create") {
return return
} }
var entry model.KnowledgeEntry var entry model.KnowledgeEntry
@@ -373,6 +382,11 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
return return
} }
entry.TenantID = middleware.GetTenantID(c) 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) available, err := hasKnowledgeCapacity(entry.TenantID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "校验知识库容量失败"}) 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) { func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
if !requireKnowledgeManager(c) { if !requireKnowledgePermission(c, "knowledge.edit") {
return return
} }
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
@@ -432,6 +446,10 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return 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 { if err := model.DB.Model(&entry).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) 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) { func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) {
if !requireKnowledgeManager(c) { if !requireKnowledgePermission(c, "knowledge.delete") {
return return
} }
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
id := c.Param("id") 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 { if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"}) c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"})
return return
+457
View File
@@ -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(),
})
}
+18 -13
View File
@@ -35,8 +35,8 @@ type QuickReplyHandler struct{}
func NewQuickReplyHandler() *QuickReplyHandler { return &QuickReplyHandler{} } func NewQuickReplyHandler() *QuickReplyHandler { return &QuickReplyHandler{} }
func requireTeamQuickReplyManager(c *gin.Context) bool { func requireTeamQuickReplyPermission(c *gin.Context, code string) bool {
if middleware.HasAnyRole(c, "admin", "supervisor") { if middleware.HasPermission(c, code) {
return true return true
} }
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理团队快捷回复"}) 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 { func canEditQuickReply(c *gin.Context, item *model.QuickReply) bool {
uid := middleware.GetUserID(c) uid := middleware.GetUserID(c)
if item.Scope == quickReplyScopeTeam { 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 return item.OwnerUserID != nil && *item.OwnerUserID == uid
} }
@@ -171,7 +171,7 @@ func (h *QuickReplyHandler) List(c *gin.Context) {
case quickReplyScopeTeam: case quickReplyScopeTeam:
db = db.Where("scope = ?", quickReplyScopeTeam) db = db.Where("scope = ?", quickReplyScopeTeam)
// 非管理端:工作台只看已发布;管理页可传 status // 非管理端:工作台只看已发布;管理页可传 status
if !middleware.HasAnyRole(c, "admin", "supervisor") { if !middleware.HasAnyPermission(c, "quick_reply.team_create", "quick_reply.team_edit") {
db = db.Where("status = ?", quickReplyStatusPub) db = db.Where("status = ?", quickReplyStatusPub)
} else if status != "" { } else if status != "" {
db = db.Where("status = ?", 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 无效"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
return return
} }
if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { if scope == quickReplyScopeTeam && !requireTeamQuickReplyPermission(c, "quick_reply.team_create") {
return return
} }
@@ -441,7 +441,7 @@ func (h *QuickReplyHandler) Delete(c *gin.Context) {
// Publish POST /api/quick-replies/:id/publish 团队:暂存 → 发布同步 // Publish POST /api/quick-replies/:id/publish 团队:暂存 → 发布同步
func (h *QuickReplyHandler) Publish(c *gin.Context) { func (h *QuickReplyHandler) Publish(c *gin.Context) {
if !requireTeamQuickReplyManager(c) { if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") {
return return
} }
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) 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 // Unpublish POST /api/quick-replies/:id/unpublish
func (h *QuickReplyHandler) Unpublish(c *gin.Context) { func (h *QuickReplyHandler) Unpublish(c *gin.Context) {
if !requireTeamQuickReplyManager(c) { if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") {
return return
} }
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -494,7 +494,7 @@ func (h *QuickReplyHandler) Use(c *gin.Context) {
uid := middleware.GetUserID(c) uid := middleware.GetUserID(c)
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub { 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": "该话术尚未发布"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"})
return return
} }
@@ -590,7 +590,7 @@ func (h *QuickReplyHandler) Export(c *gin.Context) {
db := model.DB.Where("tenant_id = ?", tenantID) db := model.DB.Where("tenant_id = ?", tenantID)
switch scope { switch scope {
case quickReplyScopeTeam: case quickReplyScopeTeam:
if !requireTeamQuickReplyManager(c) { if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") {
return return
} }
db = db.Where("scope = ?", quickReplyScopeTeam) 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 无效"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
return return
} }
if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyPermission(c, "quick_reply.team_create") {
return return
} }
onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip")) onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip"))
if onConflict != "skip" && onConflict != "overwrite" { if onConflict != "skip" && onConflict != "overwrite" {
onConflict = "skip" 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") file, _, err := c.Request.FormFile("file")
if err != nil { if err != nil {
@@ -721,7 +722,7 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
rowScope = parsed rowScope = parsed
} }
if rowScope == quickReplyScopeTeam && !canManageTeam { if rowScope == quickReplyScopeTeam && !canCreateTeam {
skipped++ skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:无权限导入团队快捷回复", lineNo)) errors = append(errors, fmt.Sprintf("第 %d 行:无权限导入团队快捷回复", lineNo))
continue continue
@@ -775,6 +776,11 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
skipped++ skipped++
continue continue
} }
if rowScope == quickReplyScopeTeam && !canEditTeam {
skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:无权限覆盖团队快捷回复", lineNo))
continue
}
if err := model.DB.Model(&existing).Updates(map[string]interface{}{ if err := model.DB.Model(&existing).Updates(map[string]interface{}{
"title": title, "content": content, "shortcut": shortcut, "title": title, "content": content, "shortcut": shortcut,
}).Error; err != nil { }).Error; err != nil {
@@ -813,4 +819,3 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
"errors": errors, "errors": errors,
}) })
} }
+77 -58
View File
@@ -24,6 +24,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
wsHandler := NewWsHandler() wsHandler := NewWsHandler()
widget := NewWidgetHandler() widget := NewWidgetHandler()
upload := NewUploadHandler(store, storageCfg) upload := NewUploadHandler(store, storageCfg)
perm := NewPermissionHandler()
SetImagePublicBase(storageCfg.PublicBase) SetImagePublicBase(storageCfg.PublicBase)
// 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖) // 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖)
@@ -53,105 +54,123 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
{ {
// 当前用户 // 当前用户
authRequired.GET("/me", auth.Me) authRequired.GET("/me", auth.Me)
authRequired.GET("/me/permissions", auth.MePermissions)
authRequired.PUT("/me/status", auth.UpdatePresence) authRequired.PUT("/me/status", auth.UpdatePresence)
// WebSocket // WebSocket
authRequired.GET("/ws", wsHandler.Connect) 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 := authRequired.Group("/sessions")
sessions.GET("", session.List) sessions.GET("", middleware.RequirePermission("session.view", "chat_history.view"), session.List)
sessions.GET("/export", session.Export) sessions.GET("/export", middleware.RequirePermission("chat_history.export"), session.Export)
sessions.GET("/:id", session.Get) sessions.GET("/:id", middleware.RequirePermission("session.view", "chat_history.detail"), session.Get)
sessions.POST("", session.Create) sessions.POST("", middleware.RequirePermission("session.view"), session.Create)
sessions.POST("/batch-archive", session.BatchArchive) sessions.POST("/batch-archive", middleware.RequirePermission("chat_history.batch_archive"), session.BatchArchive)
sessions.POST("/:id/assign", session.Assign) sessions.POST("/:id/assign", middleware.RequirePermission("session.view"), session.Assign)
sessions.POST("/:id/transfer", session.Transfer) sessions.POST("/:id/transfer", middleware.RequirePermission("session.transfer"), session.Transfer)
sessions.POST("/:id/end", session.End) sessions.POST("/:id/end", middleware.RequirePermission("session.end"), session.End)
sessions.POST("/:id/archive", session.Archive) sessions.POST("/:id/archive", middleware.RequirePermission("chat_history.batch_archive"), session.Archive)
sessions.PUT("/:id/priority", session.UpdatePriority) sessions.PUT("/:id/priority", middleware.RequirePermission("session.priority"), session.UpdatePriority)
sessions.GET("/:id/messages", session.ListMessages) sessions.GET("/:id/messages", middleware.RequirePermission("session.view", "chat_history.detail"), session.ListMessages)
sessions.POST("/:id/messages", session.SendMessage) sessions.POST("/:id/messages", middleware.RequirePermission("session.reply"), session.SendMessage)
sessions.POST("/:id/read", session.MarkRead) sessions.POST("/:id/read", middleware.RequirePermission("session.view"), session.MarkRead)
sessions.POST("/:id/notes", session.AddNote) sessions.POST("/:id/notes", middleware.RequirePermission("session.note"), session.AddNote)
// 客户管理 // 客户管理
customers := authRequired.Group("/customers") customers := authRequired.Group("/customers")
customers.GET("", customer.List) customers.GET("", middleware.RequirePermission("customer.view"), customer.List)
customers.GET("/export", customer.Export) customers.GET("/export", middleware.RequirePermission("customer.export"), customer.Export)
customers.GET("/:id", customer.Get) customers.GET("/:id", middleware.RequirePermission("customer.view"), customer.Get)
customers.POST("", customer.Create) customers.POST("", middleware.RequirePermission("customer.create"), customer.Create)
customers.PUT("/:id", customer.Update) customers.PUT("/:id", middleware.RequirePermission("customer.edit"), customer.Update)
customers.DELETE("/:id", customer.Delete) customers.DELETE("/:id", middleware.RequirePermission("customer.export"), customer.Delete)
// 黑名单(拉黑 IP / 设备) // 黑名单(拉黑 IP / 设备)
bl := authRequired.Group("/blacklist") bl := authRequired.Group("/blacklist")
bl.GET("", blacklist.List) bl.GET("", middleware.RequirePermission("blacklist.view"), blacklist.List)
bl.POST("", blacklist.Create) bl.POST("", middleware.RequirePermission("blacklist.create"), blacklist.Create)
bl.DELETE("/:id", blacklist.Delete) bl.DELETE("/:id", middleware.RequirePermission("blacklist.delete"), blacklist.Delete)
// 客户标签库(管理员维护,全员可读可选) // 客户标签库(管理员维护,全员可读可选)
ctags := authRequired.Group("/customer-tags") ctags := authRequired.Group("/customer-tags")
ctags.GET("", customerTag.List) ctags.GET("", middleware.RequirePermission("customer.view", "settings.customer_tag"), customerTag.List)
ctags.POST("", customerTag.Create) ctags.POST("", middleware.RequirePermission("settings.customer_tag"), customerTag.Create)
ctags.PUT("/:id", customerTag.Update) ctags.PUT("/:id", middleware.RequirePermission("settings.customer_tag"), customerTag.Update)
ctags.DELETE("/:id", customerTag.Delete) ctags.DELETE("/:id", middleware.RequirePermission("settings.customer_tag"), customerTag.Delete)
// 知识库 // 知识库
kb := authRequired.Group("/knowledge") kb := authRequired.Group("/knowledge")
kb.GET("/categories", knowledge.ListCategories) kb.GET("/categories", middleware.RequirePermission("knowledge.view"), knowledge.ListCategories)
kb.POST("/categories", knowledge.CreateCategory) kb.POST("/categories", middleware.RequirePermission("knowledge.create"), knowledge.CreateCategory)
kb.PUT("/categories/:id", knowledge.UpdateCategory) kb.PUT("/categories/:id", middleware.RequirePermission("knowledge.edit"), knowledge.UpdateCategory)
kb.DELETE("/categories/:id", knowledge.DeleteCategory) kb.DELETE("/categories/:id", middleware.RequirePermission("knowledge.delete"), knowledge.DeleteCategory)
kb.GET("/entries", knowledge.ListEntries) kb.GET("/entries", middleware.RequirePermission("knowledge.view"), knowledge.ListEntries)
kb.POST("/entries", knowledge.CreateEntry) kb.POST("/entries", middleware.RequirePermission("knowledge.create"), knowledge.CreateEntry)
kb.PUT("/entries/:id", knowledge.UpdateEntry) kb.PUT("/entries/:id", middleware.RequirePermission("knowledge.edit"), knowledge.UpdateEntry)
kb.DELETE("/entries/:id", knowledge.DeleteEntry) kb.DELETE("/entries/:id", middleware.RequirePermission("knowledge.delete"), knowledge.DeleteEntry)
// 快捷回复(团队 / 个人,与知识库独立) // 快捷回复(团队 / 个人,与知识库独立)
qr := authRequired.Group("/quick-replies") qr := authRequired.Group("/quick-replies")
qr.GET("", quickReply.List) qr.GET("", middleware.RequirePermission("quick_reply.view"), quickReply.List)
qr.GET("/suggest", quickReply.Suggest) qr.GET("/suggest", middleware.RequirePermission("quick_reply.view"), quickReply.Suggest)
qr.GET("/export", quickReply.Export) qr.GET("/export", middleware.RequirePermission("quick_reply.view"), quickReply.Export)
qr.GET("/import-template", quickReply.ImportTemplate) qr.GET("/import-template", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create"), quickReply.ImportTemplate)
qr.POST("/import", quickReply.Import) qr.POST("/import", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create", "quick_reply.team_edit"), quickReply.Import)
qr.POST("", quickReply.Create) qr.POST("", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create"), quickReply.Create)
qr.PUT("/:id", quickReply.Update) qr.PUT("/:id", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_edit"), quickReply.Update)
qr.DELETE("/:id", quickReply.Delete) qr.DELETE("/:id", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_edit"), quickReply.Delete)
qr.POST("/:id/publish", quickReply.Publish) qr.POST("/:id/publish", middleware.RequirePermission("quick_reply.team_edit"), quickReply.Publish)
qr.POST("/:id/unpublish", quickReply.Unpublish) qr.POST("/:id/unpublish", middleware.RequirePermission("quick_reply.team_edit"), quickReply.Unpublish)
qr.POST("/:id/use", quickReply.Use) qr.POST("/:id/use", middleware.RequirePermission("quick_reply.view"), quickReply.Use)
// 渠道设置(租户级) // 渠道设置(租户级)
channels := authRequired.Group("/channels") channels := authRequired.Group("/channels")
channels.GET("", channel.List) channels.GET("", channel.List)
channels.POST("", channel.Create) channels.POST("", middleware.RequirePermission("settings.channel"), channel.Create)
channels.PUT("/:id", channel.Update) channels.PUT("/:id", middleware.RequirePermission("settings.channel"), channel.Update)
// 租户系统设置 // 租户系统设置
settingsGroup := authRequired.Group("/settings") settingsGroup := authRequired.Group("/settings")
settingsGroup.GET("", settings.Get) settingsGroup.GET("", middleware.RequirePermission(
settingsGroup.PUT("", settings.Update) "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 := authRequired.Group("/staff")
staffGroup.Use(middleware.AdminRequired(), middleware.RequirePermission("settings.staff"))
staffGroup.GET("", staff.List) staffGroup.GET("", staff.List)
staffGroup.POST("", staff.Create) staffGroup.POST("", staff.Create)
staffGroup.POST("/batch", staff.Batch)
staffGroup.PUT("/:id", staff.Update) staffGroup.PUT("/:id", staff.Update)
staffGroup.DELETE("/:id", staff.Delete) 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 := authRequired.Group("/statistics")
statistics.GET("/kpi", stats.KPIs) statistics.GET("/kpi", middleware.RequirePermission("statistics.view"), stats.KPIs)
statistics.GET("/trend", stats.SessionTrend) statistics.GET("/trend", middleware.RequirePermission("statistics.view"), stats.SessionTrend)
statistics.GET("/response-distribution", stats.ResponseDistribution) statistics.GET("/response-distribution", middleware.RequirePermission("statistics.view"), stats.ResponseDistribution)
statistics.GET("/performance", stats.AgentPerformance) statistics.GET("/performance", middleware.RequirePermission("statistics.performance"), stats.AgentPerformance)
statistics.GET("/channels", stats.ChannelDistribution) statistics.GET("/channels", middleware.RequirePermission("statistics.view"), stats.ChannelDistribution)
statistics.GET("/export", stats.Export) statistics.GET("/export", middleware.RequirePermission("statistics.export"), stats.Export)
// 管理端接口(需要管理员权限) // 管理端接口(需要管理员权限)
adminGroup := authRequired.Group("/admin") adminGroup := authRequired.Group("/admin")
@@ -74,7 +74,6 @@ func multipartImageRequest(t *testing.T, method, target string, fileField string
return req return req
} }
func createTenant(t *testing.T, name, status string) model.Tenant { func createTenant(t *testing.T, name, status string) model.Tenant {
t.Helper() t.Helper()
tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)} 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") tenant := createTenant(t, "工作台租户", "normal")
agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent") agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent")
agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "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: "网页"} customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"}
if err := model.DB.Create(&customer).Error; err != nil { if err := model.DB.Create(&customer).Error; err != nil {
t.Fatalf("创建工作台客户失败: %v", err) t.Fatalf("创建工作台客户失败: %v", err)
@@ -446,8 +446,14 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
transferRecorder := httptest.NewRecorder() transferRecorder := httptest.NewRecorder()
transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID)) 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)) router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne))
if transferRecorder.Code != http.StatusOK { if transferRecorder.Code != http.StatusForbidden {
t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String()) 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() 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) { func TestCustomerAndKnowledgeCRUD(t *testing.T) {
router := setupRouter(t) router := setupRouter(t)
tenant := createTenant(t, "业务CRUD租户", "normal") tenant := createTenant(t, "业务CRUD租户", "normal")
+43 -19
View File
@@ -84,7 +84,7 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
return 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) model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq)
session.LastReadSeq = msg.Seq session.LastReadSeq = msg.Seq
} }
@@ -104,7 +104,7 @@ type AssignSessionReq struct {
} }
func isTenantManager(c *gin.Context) bool { 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) { 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 { 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 return true
} }
if middleware.GetRole(c) == "agent" { userID := middleware.GetUserID(c)
userID := middleware.GetUserID(c) return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID)
return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID)
}
return false
} }
func canOperateSession(c *gin.Context, session *model.Session) bool { func canOperateSession(c *gin.Context, session *model.Session) bool {
if isTenantManager(c) { if isTenantManager(c) {
return true 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) { 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 { if err := model.DB.First(&agent, agentID).Error; err != nil {
return err 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 gorm.ErrRecordNotFound
} }
return nil 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 { func unreadCount(session model.Session) int {
var count int64 var count int64
model.DB.Model(&model.Message{}). model.DB.Model(&model.Message{}).
@@ -189,7 +212,7 @@ func (h *SessionHandler) List(c *gin.Context) {
var total int64 var total int64
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) 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") query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
} }
if status != "" { if status != "" {
@@ -283,7 +306,7 @@ func (h *SessionHandler) List(c *gin.Context) {
items := make([]SessionListItem, 0, len(sessions)) items := make([]SessionListItem, 0, len(sessions))
for _, session := range sessions { for _, session := range sessions {
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]} 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) item.UnreadCount = unreadCount(session)
} }
if cu, ok := customerMap[session.CustomerID]; ok { 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": "无权标记该会话已读"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权标记该会话已读"})
return 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}) middleware.JSON(c, gin.H{"last_read_seq": session.LastReadSeq})
return return
} }
@@ -496,9 +519,10 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
} }
// all=1 返回租户全部坐席(含离线),用于对话记录筛选; // all=1 返回租户全部坐席(含离线),用于对话记录筛选;
// 默认:可转接坐席(role=agent,在线/忙碌),便于前端展示状态 // 默认:可转接坐席(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" { 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 var users []model.User
if err := q.Order("nickname asc").Find(&users).Error; err != nil { 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": "会话已被分配"}) c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"})
return return
} }
if middleware.GetRole(c) == "agent" { if !middleware.HasPermission(c, "session.transfer") {
req.AgentID = middleware.GetUserID(c) 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": "无权分配会话"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权分配会话"})
return return
} }
@@ -784,7 +808,7 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) {
// Archive 将已结束会话归档(主管/管理员) // Archive 将已结束会话归档(主管/管理员)
func (h *SessionHandler) Archive(c *gin.Context) { 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": "仅主管或管理员可归档"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
return return
} }
@@ -816,7 +840,7 @@ func (h *SessionHandler) Archive(c *gin.Context) {
// BatchArchive 批量归档已结束会话 // BatchArchive 批量归档已结束会话
func (h *SessionHandler) BatchArchive(c *gin.Context) { 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": "仅主管或管理员可归档"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
return return
} }
+1 -1
View File
@@ -359,7 +359,7 @@ type updateSettingsReq struct {
} }
func (h *SettingsHandler) Update(c *gin.Context) { 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": "仅租户管理员可修改设置"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"})
return return
} }
+162 -10
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -9,6 +10,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model" "kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/ws"
) )
type StaffHandler struct{} type StaffHandler struct{}
@@ -16,22 +18,35 @@ type StaffHandler struct{}
func NewStaffHandler() *StaffHandler { return &StaffHandler{} } func NewStaffHandler() *StaffHandler { return &StaffHandler{} }
func requireStaffManager(c *gin.Context) bool { func requireStaffManager(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin") { if middleware.HasPermission(c, "settings.staff") {
return true return true
} }
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"})
return false return false
} }
// 坐席占用:租户下 agent / supervisor / admin 均计 1 席(不含 disabled // 坐席占用:租户内所有启用账号均计 1 席(含自定义角色)。
func countActiveSeats(tenantID uint) (int64, error) { func countActiveSeats(tenantID uint) (int64, error) {
var n int64 var n int64
err := model.DB.Model(&model.User{}). 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 Count(&n).Error
return n, err 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) { func loadTenantSeatLimit(tenantID uint) (int, error) {
var tenant model.Tenant var tenant model.Tenant
if err := model.DB.Select("id", "seat_count").First(&tenant, tenantID).Error; err != nil { 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) { 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": "无权查看坐席列表"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看坐席列表"})
return return
} }
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
var users []model.User 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 { Order("role asc, id asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"})
return return
@@ -116,8 +131,9 @@ func (h *StaffHandler) Create(c *gin.Context) {
if role == "" { if role == "" {
role = "agent" role = "agent"
} }
if role != "agent" && role != "supervisor" { tenantID := middleware.GetTenantID(c)
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"}) if !tenantRoleExists(tenantID, role) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"})
return return
} }
nickname := strings.TrimSpace(req.Nickname) nickname := strings.TrimSpace(req.Nickname)
@@ -129,7 +145,6 @@ func (h *StaffHandler) Create(c *gin.Context) {
return return
} }
tenantID := middleware.GetTenantID(c)
seatLimit, err := loadTenantSeatLimit(tenantID) seatLimit, err := loadTenantSeatLimit(tenantID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"}) 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": "无法操作该账号"}) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无法操作该账号"})
return return
} }
originalRole := user.Role
originalStatus := user.Status
var req UpdateStaffReq var req UpdateStaffReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -223,8 +240,8 @@ func (h *StaffHandler) Update(c *gin.Context) {
} }
if req.Role != nil { if req.Role != nil {
r := *req.Role r := *req.Role
if r != "agent" && r != "supervisor" && r != "admin" { if !tenantRoleExists(tenantID, r) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"})
return return
} }
// 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin // 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin
@@ -292,6 +309,9 @@ func (h *StaffHandler) Update(c *gin.Context) {
return return
} }
model.DB.First(&user, user.ID) 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{ model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c), OperatorID: middleware.GetUserID(c),
@@ -343,6 +363,8 @@ func (h *StaffHandler) Delete(c *gin.Context) {
return return
} }
ws.DefaultHub.DisconnectUser(user.ID, "账号已被停用,请重新登录")
model.DB.Create(&model.OperationLog{ model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c), OperatorID: middleware.GetUserID(c),
Action: "disable_staff", Action: "disable_staff",
@@ -353,3 +375,133 @@ func (h *StaffHandler) Delete(c *gin.Context) {
}) })
middleware.JSON(c, gin.H{"message": "已禁用"}) 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,
})
}
+24 -11
View File
@@ -16,7 +16,7 @@ type StatisticsHandler struct{}
func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} } func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} }
func requireStatisticsAccess(c *gin.Context) bool { func requireStatisticsAccess(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin", "supervisor") { if middleware.HasPermission(c, "statistics.view") {
return true return true
} }
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"}) 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 var sessions []model.Session
q := model.DB.Where("tenant_id = ?", tenantID) q := model.DB.Where("tenant_id = ?", tenantID)
if !allData {
q = q.Where("agent_id = ?", userID)
}
if !r.From.IsZero() { if !r.From.IsZero() {
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To) 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()}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"})
return return
@@ -209,6 +214,9 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
var sessions []model.Session var sessions []model.Session
q := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)) q := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c))
if !middleware.CanAccessAllData(c, "statistics") {
q = q.Where("agent_id = ?", middleware.GetUserID(c))
}
// 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 6 个月走势 // 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 6 个月走势
if hasCustom || period == "today" || period == "week" || period == "day" { if hasCustom || period == "today" || period == "week" || period == "day" {
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To) 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()}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"})
return return
@@ -296,13 +306,18 @@ func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
return return
} }
tenantID := middleware.GetTenantID(c) 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
return return
} }
var agents []model.User 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": "查询客服失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
return return
} }
@@ -365,7 +380,9 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
return return
} }
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
sessions, _, err := loadStatisticsData(tenantID, r) sessions, _, err := loadStatisticsData(
tenantID, middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
return return
@@ -375,10 +392,6 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
return 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) channelTypes := make(map[uint]string)
for _, channel := range channels { for _, channel := range channels {
channelTypes[channel.ID] = channel.Type channelTypes[channel.ID] = channel.Type
+4
View File
@@ -128,6 +128,10 @@ func AuthRequired() gin.HandlerFunc {
c.Set("user_id", claims.UserID) c.Set("user_id", claims.UserID)
c.Set("tenant_id", claims.TenantID) c.Set("tenant_id", claims.TenantID)
c.Set("role", claims.Role) c.Set("role", claims.Role)
permissionVersion := LoadPermissions(c, claims.UserID, claims.TenantID, claims.Role)
if permissionVersion != "" {
c.Header("X-Permission-Version", permissionVersion)
}
c.Next() c.Next()
} }
} }
+249
View File
@@ -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{})
}
+4
View File
@@ -48,5 +48,9 @@ func Migrate(db *gorm.DB) error {
&OperationLog{}, &OperationLog{},
&Announcement{}, &Announcement{},
&TenantSetting{}, &TenantSetting{},
&Role{},
&Permission{},
&RolePermission{},
&RoleDataScope{},
) )
} }
+86 -46
View File
@@ -24,7 +24,7 @@ type Tenant struct {
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_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"` Username string `gorm:"size:50;not null;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"` PasswordHash string `gorm:"size:255;not null" json:"-"`
Nickname string `gorm:"size:50" json:"nickname"` Nickname string `gorm:"size:50" json:"nickname"`
@@ -50,12 +50,12 @@ type Customer struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
Name string `gorm:"size:50;not null" json:"name"` Name string `gorm:"size:50;not null" json:"name"`
Phone string `gorm:"size:20" json:"phone"` // 主手机号(兼容列表搜索) Phone string `gorm:"size:20" json:"phone"` // 主手机号(兼容列表搜索)
Tel string `gorm:"size:30" json:"tel"` // 联系电话/固话 Tel string `gorm:"size:30" json:"tel"` // 联系电话/固话
Email string `gorm:"size:100" json:"email"` 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"` 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"` Tags string `gorm:"type:text" json:"tags"`
Source string `gorm:"size:30" json:"source"` Source string `gorm:"size:30" json:"source"`
Status string `gorm:"size:20;default:online" json:"status"` Status string `gorm:"size:20;default:online" json:"status"`
@@ -95,15 +95,15 @@ type BlacklistEntry struct {
} }
type Session struct { type Session struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
ChannelID uint `json:"channel_id"` ChannelID uint `json:"channel_id"`
CustomerID uint `gorm:"index" json:"customer_id"` CustomerID uint `gorm:"index" json:"customer_id"`
AgentID *uint `gorm:"index" json:"agent_id"` AgentID *uint `gorm:"index" json:"agent_id"`
VisitorTokenHash string `gorm:"size:64;index" json:"-"` VisitorTokenHash string `gorm:"size:64;index" json:"-"`
VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorIP string `gorm:"size:64" json:"visitor_ip"`
VisitorRegion string `gorm:"size:100" json:"visitor_region"` VisitorRegion string `gorm:"size:100" json:"visitor_region"`
UserAgent string `gorm:"size:500" json:"user_agent"` UserAgent string `gorm:"size:500" json:"user_agent"`
// DeviceKey 访客端持久设备指纹(localStorage),用于设备级拉黑 // DeviceKey 访客端持久设备指纹(localStorage),用于设备级拉黑
DeviceKey string `gorm:"size:64;index" json:"device_key"` DeviceKey string `gorm:"size:64;index" json:"device_key"`
// 落地页 / 当前页(访客浏览轨迹) // 落地页 / 当前页(访客浏览轨迹)
@@ -115,11 +115,11 @@ type Session struct {
// LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态 // LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态
LastSeenAt *time.Time `json:"last_seen_at"` LastSeenAt *time.Time `json:"last_seen_at"`
// DraftText 访客输入框未发送草稿(实时监控用,非聊天消息) // DraftText 访客输入框未发送草稿(实时监控用,非聊天消息)
DraftText string `gorm:"type:text" json:"draft_text"` DraftText string `gorm:"type:text" json:"draft_text"`
DraftUpdatedAt *time.Time `json:"draft_updated_at"` DraftUpdatedAt *time.Time `json:"draft_updated_at"`
LastReadSeq int `gorm:"default:0" json:"last_read_seq"` LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
Status string `gorm:"size:20;default:waiting" json:"status"` Status string `gorm:"size:20;default:waiting" json:"status"`
Priority string `gorm:"size:20;default:normal" json:"priority"` Priority string `gorm:"size:20;default:normal" json:"priority"`
SatisfactionScore *int `json:"satisfaction_score"` SatisfactionScore *int `json:"satisfaction_score"`
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
EndReason string `gorm:"size:50" json:"end_reason"` EndReason string `gorm:"size:50" json:"end_reason"`
@@ -133,7 +133,7 @@ type CustomerContact struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
CustomerID uint `gorm:"uniqueIndex:idx_cust_contact;not null" json:"customer_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"` Value string `gorm:"size:100;uniqueIndex:idx_cust_contact;not null" json:"value"`
Source string `gorm:"size:30" json:"source"` // draft|message|leave|agent Source string `gorm:"size:30" json:"source"` // draft|message|leave|agent
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -165,21 +165,23 @@ type SessionEvent struct {
SessionID uint `gorm:"index;not null" json:"session_id"` SessionID uint `gorm:"index;not null" json:"session_id"`
OperatorID uint `json:"operator_id"` OperatorID uint `json:"operator_id"`
Action string `gorm:"size:50;not null" json:"action"` 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"` CreatedAt time.Time `json:"created_at"`
} }
type Category struct { type Category struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
ParentID *uint `json:"parent_id"` CreatedBy uint `gorm:"index" json:"created_by"`
Name string `gorm:"size:30;not null" json:"name"` ParentID *uint `json:"parent_id"`
Name string `gorm:"size:30;not null" json:"name"`
} }
type KnowledgeEntry struct { type KnowledgeEntry struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
CategoryID uint `json:"category_id"` CategoryID uint `json:"category_id"`
CreatedBy uint `gorm:"index" json:"created_by"`
Title string `gorm:"size:100;not null" json:"title"` Title string `gorm:"size:100;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"` Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:20;default:draft" json:"status"` Status string `gorm:"size:20;default:draft" json:"status"`
@@ -191,16 +193,16 @@ type KnowledgeEntry struct {
// QuickReply 团队/个人快捷回复(与知识库独立)。 // QuickReply 团队/个人快捷回复(与知识库独立)。
// Scope=team 时 OwnerUserID 为空,全租户共享;Scope=personal 时归属 OwnerUserID。 // Scope=team 时 OwnerUserID 为空,全租户共享;Scope=personal 时归属 OwnerUserID。
type QuickReply struct { type QuickReply struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"index;not null" json:"tenant_id"` TenantID uint `gorm:"index;not null" json:"tenant_id"`
Scope string `gorm:"size:20;index;not null" json:"scope"` // team | personal Scope string `gorm:"size:20;index;not null" json:"scope"` // team | personal
OwnerUserID *uint `gorm:"index" json:"owner_user_id,omitempty"` OwnerUserID *uint `gorm:"index" json:"owner_user_id,omitempty"`
Title string `gorm:"size:100;not null" json:"title"` Title string `gorm:"size:100;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"` Content string `gorm:"type:text;not null" json:"content"`
Shortcut string `gorm:"size:32;index" json:"shortcut"` // 输入码,如 nh → /nh Shortcut string `gorm:"size:32;index" json:"shortcut"` // 输入码,如 nh → /nh
GroupName string `gorm:"size:50" json:"group_name"` GroupName string `gorm:"size:50" json:"group_name"`
Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published
SortOrder int `gorm:"default:0" json:"sort_order"` SortOrder int `gorm:"default:0" json:"sort_order"`
// UsageCount 全局累计(管理页展示);排序以个人用量为准见 QuickReplyUserUsage // UsageCount 全局累计(管理页展示);排序以个人用量为准见 QuickReplyUserUsage
UsageCount int `gorm:"default:0" json:"usage_count"` UsageCount int `gorm:"default:0" json:"usage_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -254,26 +256,64 @@ type Announcement struct {
// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关、分配策略等)。 // TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关、分配策略等)。
type TenantSetting struct { type TenantSetting struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"` TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"`
DisplayName string `gorm:"size:100" json:"display_name"` DisplayName string `gorm:"size:100" json:"display_name"`
AgentNickname string `gorm:"size:50" json:"agent_nickname"` AgentNickname string `gorm:"size:50" json:"agent_nickname"`
Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"` Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"`
WelcomeMessage string `gorm:"size:500" json:"welcome_message"` WelcomeMessage string `gorm:"size:500" json:"welcome_message"`
// WelcomeMessagesJSON 多段欢迎语 JSON[{type:text|image, content:...}, ...] // WelcomeMessagesJSON 多段欢迎语 JSON[{type:text|image, content:...}, ...]
WelcomeMessagesJSON string `gorm:"type:text" json:"welcome_messages_json"` WelcomeMessagesJSON string `gorm:"type:text" json:"welcome_messages_json"`
OfflinePrompt string `gorm:"size:500" json:"offline_prompt"` OfflinePrompt string `gorm:"size:500" json:"offline_prompt"`
WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"` WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"`
WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"` WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"`
NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"` NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"`
NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"` NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"`
NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"` NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"`
// AssignStrategy 自动分配策略:least_load(默认)| round_robin // AssignStrategy 自动分配策略:least_load(默认)| round_robin
AssignStrategy string `gorm:"size:30;default:least_load" json:"assign_strategy"` AssignStrategy string `gorm:"size:30;default:least_load" json:"assign_strategy"`
// MaxActivePerAgent 每位坐席最大进行中会话数;0 表示不限制 // MaxActivePerAgent 每位坐席最大进行中会话数;0 表示不限制
MaxActivePerAgent int `gorm:"default:0" json:"max_active_per_agent"` MaxActivePerAgent int `gorm:"default:0" json:"max_active_per_agent"`
// RRLastAgentID 轮询策略的上次分配坐席游标 // RRLastAgentID 轮询策略的上次分配坐席游标
RRLastAgentID *uint `json:"-"` RRLastAgentID *uint `json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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/selfmodule 维度为后续 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"`
}
+190
View File
@@ -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
})
}
+101 -9
View File
@@ -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 返回当前连接统计(总连接 / 坐席 / 访客)。 // Stats 返回当前连接统计(总连接 / 坐席 / 访客)。
func (h *Hub) Stats() (total, agents, visitors int) { func (h *Hub) Stats() (total, agents, visitors int) {
h.mu.RLock() h.mu.RLock()
@@ -134,8 +218,9 @@ func (h *Hub) BroadcastToSession(tenantID, sessionID uint, agentID *uint, messag
} }
continue continue
} }
if client.Role == "admin" || client.Role == "supervisor" || if canReceiveSessionEvent(client, &model.Session{
(agentID != nil && client.Role == "agent" && client.UserID == *agentID) { TenantID: tenantID, ID: sessionID, AgentID: agentID, Status: "active",
}) {
h.send(client, message) h.send(client, message)
} }
} }
@@ -146,8 +231,19 @@ func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() 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(&current).Error; err == nil {
session = &current
} else {
session = &model.Session{TenantID: tenantID, ID: event.SessionID, Status: "active"}
}
}
for client := range h.clients { for client := range h.clients {
if client.TenantID == tenantID && client.Kind == "agent" { if client.TenantID == tenantID && canReceiveSessionEvent(client, session) {
h.send(client, message) 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" { if client.TenantID != tenantID || client.Kind != "agent" {
continue continue
} }
if client.Role == "admin" || client.Role == "supervisor" || if canReceiveSessionEvent(client, &model.Session{TenantID: tenantID, AgentID: agentID, Status: "active"}) {
(agentID != nil && client.Role == "agent" && client.UserID == *agentID) {
h.send(client, message) h.send(client, message)
} }
} }
@@ -247,10 +342,7 @@ func handleClientEvent(client *Client, event ClientEvent) {
if client.Kind != "agent" || event.Type != "typing" { if client.Kind != "agent" || event.Type != "typing" {
return return
} }
if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) { if !canReceiveSessionEvent(client, &session) {
return
}
if client.Role != "agent" && client.Role != "admin" && client.Role != "supervisor" {
return return
} }
payload, err := NewEvent("typing", session.ID, map[string]string{"from": "agent"}) payload, err := NewEvent("typing", session.ID, map[string]string{"from": "agent"})
+13
View File
@@ -1,4 +1,5 @@
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom'
import { Result, Spin } from 'antd'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
const RequireAuth = () => { const RequireAuth = () => {
@@ -35,4 +36,16 @@ export const RequireTenantAdmin = () => {
return <Outlet /> return <Outlet />
} }
export const RequirePermission = ({ anyOf }: { anyOf: string[] }) => {
const { user, permissions, permissionsLoaded } = useAuth()
if (!user) return <Navigate to="/login" replace />
if (!permissionsLoaded) {
return <div className="h-full flex items-center justify-center"><Spin /></div>
}
if (!anyOf.some(code => permissions.has(code))) {
return <Result status="403" title="403" subTitle="无权访问此页面" />
}
return <Outlet />
}
export default RequireAuth export default RequireAuth
+13 -15
View File
@@ -3,21 +3,23 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { import {
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined, ThunderboltOutlined, DownOutlined, StopOutlined, SafetyOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Dropdown, message as antMsg } from 'antd' import { Dropdown, message as antMsg } from 'antd'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
import type { AgentPresence } from '@/services/api' import type { AgentPresence } from '@/services/api'
const menuItems = [ const menuItems = [
{ key: '/agent/dashboard', icon: <AppstoreOutlined />, label: '工作台' }, { key: '/agent/dashboard', icon: <AppstoreOutlined />, label: '工作台', permissions: ['session.view'] },
{ key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理' }, { key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理', permissions: ['customer.view'] },
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' }, { key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录', permissions: ['chat_history.view'] },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' }, { key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库', permissions: ['knowledge.view'] },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' }, { key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复', permissions: ['quick_reply.view'] },
{ key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单' }, { key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单', permissions: ['blacklist.view'] },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' }, { key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计', permissions: ['statistics.view'] },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/agent/staff', icon: <TeamOutlined />, label: '成员账号', permissions: ['settings.staff'] },
{ key: '/agent/roles', icon: <SafetyOutlined />, label: '权限控制', permissions: ['permission.view'] },
{ key: '/agent/settings', icon: <SettingOutlined />, 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 }[] = [ const presenceOptions: { value: AgentPresence; text: string; dot: string }[] = [
@@ -33,14 +35,10 @@ function presenceMeta(status?: string) {
const AgentSidebar = () => { const AgentSidebar = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout, setPresence } = useAuth() const { user, permissions, logout, setPresence } = useAuth()
const [switching, setSwitching] = useState(false) const [switching, setSwitching] = useState(false)
const visibleMenuItems = menuItems.filter(item => { const visibleMenuItems = menuItems.filter(item => item.permissions.some(code => permissions.has(code)))
if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor'
if (item.key === '/agent/settings') return user?.role === 'admin'
return true
})
const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard'
+3 -1
View File
@@ -3,6 +3,7 @@ import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'an
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { ReloadOutlined, StopOutlined } from '@ant-design/icons' import { ReloadOutlined, StopOutlined } from '@ant-design/icons'
import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api' import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api'
import { usePermission } from '@/stores/auth'
function isActive(entry: BlacklistEntry) { function isActive(entry: BlacklistEntry) {
if (!entry.expires_at) return true if (!entry.expires_at) return true
@@ -32,6 +33,7 @@ function maskValue(kind: string, value: string) {
} }
const Blacklist = () => { const Blacklist = () => {
const canRelease = usePermission('blacklist.delete')
const [list, setList] = useState<BlacklistEntry[]>([]) const [list, setList] = useState<BlacklistEntry[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [kind, setKind] = useState<string | undefined>() const [kind, setKind] = useState<string | undefined>()
@@ -140,7 +142,7 @@ const Blacklist = () => {
width: 100, width: 100,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
isActive(row) ? ( isActive(row) && canRelease ? (
<Popconfirm <Popconfirm
title="确认解除该黑名单?" title="确认解除该黑名单?"
description="解除后访客可重新进入在线客服" description="解除后访客可重新进入在线客服"
+7 -3
View File
@@ -12,7 +12,7 @@ import {
} from '@/services/api' } from '@/services/api'
import { ChatImage } from '@/components/common/ImagePreview' import { ChatImage } from '@/components/common/ImagePreview'
import MarkdownBody from '@/components/common/MarkdownBody' import MarkdownBody from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth' import { usePermission } from '@/stores/auth'
const { RangePicker } = DatePicker const { RangePicker } = DatePicker
@@ -137,8 +137,8 @@ const TagChip = ({ label, bg, color }: { label: string; bg: string; color: strin
) )
const ChatHistory = () => { const ChatHistory = () => {
const { user } = useAuth() const canExport = usePermission('chat_history.export')
const canArchive = user?.role === 'admin' || user?.role === 'supervisor' const canArchive = usePermission('chat_history.batch_archive')
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -335,6 +335,7 @@ const ChatHistory = () => {
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200"> <header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
<h1 className="text-lg font-semibold text-neutral-900 m-0"></h1> <h1 className="text-lg font-semibold text-neutral-900 m-0"></h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canExport && (
<button <button
type="button" type="button"
disabled={exporting} disabled={exporting}
@@ -361,6 +362,8 @@ const ChatHistory = () => {
<DownloadOutlined /> <DownloadOutlined />
{exporting ? '导出中…' : '导出'} {exporting ? '导出中…' : '导出'}
</button> </button>
)}
{canArchive && (
<button <button
type="button" type="button"
disabled={archiving} disabled={archiving}
@@ -370,6 +373,7 @@ const ChatHistory = () => {
<InboxOutlined /> <InboxOutlined />
</button> </button>
)}
</div> </div>
</header> </header>
+8 -3
View File
@@ -12,7 +12,7 @@ import {
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer, createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
type Customer, type CustomerContact, type CustomerTag, type Session, type Customer, type CustomerContact, type CustomerTag, type Session,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' import { usePermission } from '@/stores/auth'
const statusMap: Record<string, { color: string; text: string; dot: string }> = { const statusMap: Record<string, { color: string; text: string; dot: string }> = {
online: { color: '#16a34a', text: '在线', dot: '#16a34a' }, online: { color: '#16a34a', text: '在线', dot: '#16a34a' },
@@ -133,9 +133,10 @@ const StatusDot = ({ status }: { status: string }) => {
} }
const Customers = () => { const Customers = () => {
const { user } = useAuth()
const navigate = useNavigate() 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<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -412,6 +413,7 @@ const Customers = () => {
</div> </div>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{canExport && (
<Button <Button
icon={<ExportOutlined />} icon={<ExportOutlined />}
loading={exporting} loading={exporting}
@@ -430,6 +432,8 @@ const Customers = () => {
> >
</Button> </Button>
)}
{canCreate && (
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
@@ -438,6 +442,7 @@ const Customers = () => {
> >
</Button> </Button>
)}
</div> </div>
</div> </div>
</div> </div>
+21 -19
View File
@@ -12,7 +12,7 @@ import {
getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeCategory, updateKnowledgeEntry, getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeCategory, updateKnowledgeEntry,
type KnowledgeCategory, type KnowledgeEntry, type KnowledgeCategory, type KnowledgeEntry,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' import { usePermission } from '@/stores/auth'
type CatNode = KnowledgeCategory & { children: KnowledgeCategory[] } type CatNode = KnowledgeCategory & { children: KnowledgeCategory[] }
@@ -47,8 +47,10 @@ function formatUsage(n: number) {
} }
const Knowledge = () => { const Knowledge = () => {
const { user } = useAuth() const canCreate = usePermission('knowledge.create')
const canManage = user?.role === 'admin' || user?.role === 'supervisor' const canEdit = usePermission('knowledge.edit')
const canDelete = usePermission('knowledge.delete')
const canManage = canCreate || canEdit || canDelete
const [categories, setCategories] = useState<KnowledgeCategory[]>([]) const [categories, setCategories] = useState<KnowledgeCategory[]>([])
const [totalEntries, setTotalEntries] = useState(0) const [totalEntries, setTotalEntries] = useState(0)
@@ -355,27 +357,27 @@ const Knowledge = () => {
const renderCatActions = (cat: KnowledgeCategory, isRoot: boolean) => { const renderCatActions = (cat: KnowledgeCategory, isRoot: boolean) => {
if (!canManage) return null if (!canManage) return null
return ( return (
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={e => e.stopPropagation()}> <span className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={e => e.stopPropagation()}>
{isRoot && ( {isRoot && canCreate && (
<button <button
type="button" type="button"
title="添加子分类" title="添加子分类"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer" className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={e => { e.stopPropagation(); openCreateCategory(cat.id) }} onClick={e => { e.stopPropagation(); openCreateCategory(cat.id) }}
> >
<PlusCircleOutlined className="text-xs" /> <PlusCircleOutlined className="text-xs" />
</button> </button>
)} )}
<button {canEdit && <button
type="button" type="button"
title="编辑" title="编辑"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer" className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
onClick={e => openEditCategory(cat, e)} onClick={e => openEditCategory(cat, e)}
> >
<EditOutlined className="text-xs" /> <EditOutlined className="text-xs" />
</button> </button>}
<Popconfirm {canDelete && <Popconfirm
title="确认删除该分类?" title="确认删除该分类?"
description="需无子分类且无条目" description="需无子分类且无条目"
onConfirm={() => handleDeleteCategory(cat)} onConfirm={() => handleDeleteCategory(cat)}
@@ -388,7 +390,7 @@ const Knowledge = () => {
> >
<DeleteOutlined className="text-xs" /> <DeleteOutlined className="text-xs" />
</button> </button>
</Popconfirm> </Popconfirm>}
</span> </span>
) )
} }
@@ -503,7 +505,7 @@ const Knowledge = () => {
)} )}
</div> </div>
{canManage && ( {canCreate && (
<div className="shrink-0 p-3 border-t border-neutral-200"> <div className="shrink-0 p-3 border-t border-neutral-200">
<button <button
type="button" type="button"
@@ -566,7 +568,7 @@ const Knowledge = () => {
</div> </div>
<div className="flex-1 min-w-2" /> <div className="flex-1 min-w-2" />
<span className="text-xs text-neutral-400 whitespace-nowrap shrink-0"> {total} </span> <span className="text-xs text-neutral-400 whitespace-nowrap shrink-0"> {total} </span>
{canManage && ( {canCreate && (
<Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm shrink-0" onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm shrink-0" onClick={openCreate}>
</Button> </Button>
@@ -649,7 +651,7 @@ const Knowledge = () => {
className="w-[12%] flex items-center justify-end gap-0.5 pr-1" className="w-[12%] flex items-center justify-end gap-0.5 pr-1"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{canManage && ( {canEdit && (
<button <button
type="button" type="button"
title="编辑" title="编辑"
@@ -667,7 +669,7 @@ const Knowledge = () => {
> >
<CopyOutlined className="text-xs" /> <CopyOutlined className="text-xs" />
</button> </button>
{canManage && ( {canDelete && (
<Popconfirm title="确认删除该条目?" onConfirm={() => handleDelete(entry.id)}> <Popconfirm title="确认删除该条目?" onConfirm={() => handleDelete(entry.id)}>
<button <button
type="button" type="button"
@@ -718,7 +720,7 @@ const Knowledge = () => {
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(detailEntry)}> <Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(detailEntry)}>
</Button> </Button>
{canManage && ( {canEdit && (
<Button size="small" icon={<EditOutlined />} onClick={() => { setDetailEntry(null); openEdit(detailEntry) }}> <Button size="small" icon={<EditOutlined />} onClick={() => { setDetailEntry(null); openEdit(detailEntry) }}>
</Button> </Button>
+5 -4
View File
@@ -12,11 +12,12 @@ import {
getQuickReplies, importQuickReplies, publishQuickReply, unpublishQuickReply, updateQuickReply, getQuickReplies, importQuickReplies, publishQuickReply, unpublishQuickReply, updateQuickReply,
type QuickReply, type QuickReplyScope, type QuickReply, type QuickReplyScope,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' import { usePermission } from '@/stores/auth'
const QuickReplies = () => { const QuickReplies = () => {
const { user } = useAuth() const canCreateTeam = usePermission('quick_reply.team_create')
const canManageTeam = user?.role === 'admin' || user?.role === 'supervisor' const canEditTeam = usePermission('quick_reply.team_edit')
const canManageTeam = canCreateTeam || canEditTeam
const [tab, setTab] = useState<QuickReplyScope>(canManageTeam ? 'team' : 'personal') const [tab, setTab] = useState<QuickReplyScope>(canManageTeam ? 'team' : 'personal')
const [list, setList] = useState<QuickReply[]>([]) const [list, setList] = useState<QuickReply[]>([])
@@ -160,7 +161,7 @@ const QuickReplies = () => {
} }
const canEditRow = (row: QuickReply) => { const canEditRow = (row: QuickReply) => {
if (row.scope === 'team') return canManageTeam if (row.scope === 'team') return canEditTeam
return true return true
} }
+184
View File
@@ -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<RoleDetail | null>(null)
const [allPerms, setAllPerms] = useState<Permission[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [dataScopes, setDataScopes] = useState<Record<string, string>>({})
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<string, Permission[]>()
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<string, string> = {
: 'session', : 'customer', : 'chat_history', : 'statistics',
}
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
if (!detail) return <div className="h-full flex items-center justify-center text-neutral-400"></div>
const { role } = detail
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-4xl mx-auto">
<Breadcrumb
className="mb-4"
items={[
{ title: <a onClick={() => navigate('/agent/roles')}></a> },
{ title: `配置角色:${role.name}` },
]}
/>
<Card className="mb-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<span className="text-blue-500 font-semibold">{role.name.slice(0, 1)}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-neutral-900">{role.name}</span>
<Tag color={role.type === 'builtin' ? 'blue' : 'green'}>
{role.type === 'builtin' ? '内置角色' : '自定义角色'}
</Tag>
{role.type === 'builtin' && (
<span className="text-xs text-neutral-400">{role.code} · {detail.member_count} </span>
)}
</div>
{role.type === 'custom' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input value={roleName} maxLength={50} onChange={event => setRoleName(event.target.value)} placeholder="角色名称" />
<Input value={roleDesc} maxLength={200} onChange={event => setRoleDesc(event.target.value)} placeholder="角色描述" />
</div>
) : (
<Input value={roleDesc} maxLength={200} onChange={event => setRoleDesc(event.target.value)} className="mt-3" placeholder="角色描述" />
)}
</div>
</div>
</Card>
{modules.map(mod => (
<Card
key={mod.module}
className="mb-4 shadow-sm"
title={mod.module}
extra={scopeModuleByLabel[mod.module] ? (
<Select
className="w-32"
disabled={role.code === 'admin'}
value={dataScopes[scopeModuleByLabel[mod.module]] || 'self'}
onChange={scope => setDataScopes(prev => ({ ...prev, [scopeModuleByLabel[mod.module]]: scope }))}
options={[{ label: '全部数据', value: 'all' }, { label: '仅自己', value: 'self' }]}
/>
) : undefined}
>
<div className="flex flex-col gap-2">
{mod.params.map(p => (
<label
key={p.code}
className="flex items-center gap-3 py-1.5 cursor-pointer hover:bg-neutral-50 px-2 rounded"
>
<Checkbox
checked={selected.has(p.code)}
onChange={() => toggle(p.code)}
disabled={role.type === 'builtin' && role.code === 'admin' && p.code.startsWith('permission.')}
/>
<span className="text-sm text-neutral-700">{p.name}</span>
<Tag className="ml-auto" color={p.category === 'view' ? 'default' : 'blue'}>
{p.category === 'view' ? '查看' : '操作'}
</Tag>
</label>
))}
</div>
</Card>
))}
<Divider />
<div className="flex justify-end gap-3">
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/agent/roles')}></Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
</Button>
</div>
</div>
</div>
)
}
+148
View File
@@ -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<string, string> = { builtin: 'blue', custom: 'green' }
const roleTypeLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
export default function RoleList() {
const navigate = useNavigate()
const [roles, setRoles] = useState<RoleWithStats[]>([])
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 <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-neutral-900"></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
</Button>
</div>
{roles.length === 0 ? (
<Card><Empty description="暂无角色" /></Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{roles.map(role => (
<Card
key={role.id}
className="shadow-sm"
actions={[
<Button
key="config"
type="link"
icon={<SettingOutlined />}
onClick={() => navigate(`/agent/roles/${role.id}`)}
>
</Button>,
role.type === 'custom' && (
<Popconfirm
key="delete"
title="确定删除此角色?"
onConfirm={() => handleDelete(role.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
].filter(Boolean)}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<SafetyOutlined className="text-blue-500 text-lg" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-neutral-900 text-base">{role.name}</span>
<Tag color={roleTypeColors[role.type]}>{roleTypeLabels[role.type]}</Tag>
</div>
<p className="text-sm text-neutral-500 mb-3 truncate">
{role.desc || '暂无描述'}
</p>
<div className="flex items-center gap-4 text-xs text-neutral-400">
<span className="inline-flex items-center gap-1">
<TeamOutlined /> {role.member_count}
</span>
<span>{role.perm_count}/42</span>
<span>{role.type === 'builtin' ? '系统默认' : role.updated_at?.slice(0, 10)}</span>
</div>
</div>
</div>
</Card>
))}
</div>
)}
<Modal
title="新建自定义角色"
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleCreate}
confirmLoading={saving}
okText="创建"
cancelText="取消"
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input maxLength={50} placeholder="如:VIP 专属客服" />
</Form.Item>
<Form.Item name="desc" label="角色描述">
<Input.TextArea maxLength={200} rows={3} placeholder="描述该角色的职责与权限范围" />
</Form.Item>
</Form>
</Modal>
</div>
</div>
)
}
+3 -3
View File
@@ -15,7 +15,7 @@ import {
updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage, updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage,
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment, type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' import { useAuth, usePermission } from '@/stores/auth'
import MarkdownEditor from '@/components/common/MarkdownEditor' import MarkdownEditor from '@/components/common/MarkdownEditor'
import RichTextEditor from '@/components/common/RichTextEditor' import RichTextEditor from '@/components/common/RichTextEditor'
@@ -143,8 +143,8 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
const Settings = () => { const Settings = () => {
const { user } = useAuth() const { user } = useAuth()
const isAdmin = user?.role === 'admin' const isAdmin = usePermission('settings.basic')
const canViewStaff = user?.role === 'admin' || user?.role === 'supervisor' const canViewStaff = usePermission('settings.staff')
const [activeTab, setActiveTab] = useState('basic') const [activeTab, setActiveTab] = useState('basic')
const [basicForm] = Form.useForm() const [basicForm] = Form.useForm()
+255
View File
@@ -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<string, string> = { admin: '管理员', supervisor: '主管', agent: '客服' }
const statusColors: Record<string, string> = { online: 'green', busy: 'gold', offline: 'default', disabled: 'red' }
const statusLabels: Record<string, string> = { online: '在线', busy: '忙碌', offline: '离线', disabled: '已停用' }
export default function Staff() {
const { user } = useAuth()
const canManage = usePermission('settings.staff')
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [staff, setStaff] = useState<StaffUser[]>([])
const [seatUsed, setSeatUsed] = useState(0)
const [seatLimit, setSeatLimit] = useState(0)
const [roles, setRoles] = useState<RoleWithStats[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [editingUser, setEditingUser] = useState<StaffUser | null>(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<string, string> = { 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<StaffUser> = [
{ title: '账号', dataIndex: 'username', key: 'username', width: 160 },
{ title: '姓名', dataIndex: 'nickname', key: 'nickname', width: 120 },
{
title: '角色', dataIndex: 'role', key: 'role', width: 90,
render: (role: string) => <Tag>{roleLabels[role] || role}</Tag>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{statusLabels[s] || s}</Tag>,
},
{
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 (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(u)}>
</Button>
<Button
type="link" size="small"
danger={u.status !== 'disabled'}
disabled={u.id === user?.user_id}
onClick={() => handleToggleStatus(u)}
>
{u.status === 'disabled' ? '启用' : '停用'}
</Button>
</Space>
)
},
},
]
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900"></h2>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
{canManage && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={seatUsed >= seatLimit}>
</Button>
)}
</Space>
</div>
<Card size="small" className="mb-4">
<span className="text-sm text-neutral-500">
{seatUsed} / {seatLimit}
{seatUsed >= seatLimit && <Tag color="red" className="ml-2"></Tag>}
</span>
{selectedRowKeys.length > 0 && (
<Space className="float-right">
<span className="text-xs text-neutral-400"> {selectedRowKeys.length} </span>
<Button size="small" onClick={() => handleBatch('enable')}></Button>
<Button size="small" danger onClick={() => handleBatch('disable')}></Button>
<Button size="small" onClick={() => {
const role = prompt('请输入新角色码(admin/supervisor/agent):')
if (role) handleBatch('change_role', role)
}}></Button>
<Button size="small" onClick={() => setSelectedRowKeys([])}></Button>
</Space>
)}
</Card>
<Table
rowKey="id"
columns={columns}
dataSource={staff}
rowSelection={rowSelection}
pagination={{ pageSize: 20, showSizeChanger: false, showTotal: t => `${t} 个账号` }}
scroll={{ x: 700 }}
/>
<Modal
title={editingUser ? '编辑账号' : '新增账号'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" className="mt-4">
{!editingUser && (
<>
<Form.Item name="username" label="登录账号" rules={[{ required: true, min: 3, max: 30, message: '3-30个字符' }]}>
<Input placeholder="如 agent01@company.com" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '至少6位' }]}>
<Input.Password placeholder="至少6位" />
</Form.Item>
</>
)}
<Form.Item name="nickname" label="姓名">
<Input maxLength={50} placeholder="客服姓名" />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={roles.map(r => ({ label: `${r.name}${r.type === 'builtin' ? ' (内置)' : ''}`, value: r.code }))}
/>
</Form.Item>
{editingUser && (
<Form.Item name="status" label="状态">
<Select
options={[
{ label: '在线', value: 'online' },
{ label: '忙碌', value: 'busy' },
{ label: '离线', value: 'offline' },
{ label: '已停用', value: 'disabled' },
]}
/>
</Form.Item>
)}
{editingUser && (
<Form.Item name="password" label="密码(留空不修改)">
<Input.Password placeholder="留空不修改" />
</Form.Item>
)}
</Form>
</Modal>
</div>
</div>
)
}
+20 -13
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { Navigate, createBrowserRouter } from 'react-router-dom' import { Navigate, createBrowserRouter } from 'react-router-dom'
import { Spin } from 'antd' 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 AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout')) 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 Blacklist = lazy(() => import('@/pages/agent/Blacklist'))
const Statistics = lazy(() => import('@/pages/agent/Statistics')) const Statistics = lazy(() => import('@/pages/agent/Statistics'))
const Settings = lazy(() => import('@/pages/agent/Settings')) 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 AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
const Tenants = lazy(() => import('@/pages/admin/Tenants')) const Tenants = lazy(() => import('@/pages/admin/Tenants'))
const Plans = lazy(() => import('@/pages/admin/Plans')) const Plans = lazy(() => import('@/pages/admin/Plans'))
@@ -64,20 +67,24 @@ export const router = createBrowserRouter([
element: <Lazy><AgentLayout /></Lazy>, element: <Lazy><AgentLayout /></Lazy>,
children: [ children: [
{ index: true, element: <Navigate to="/agent/dashboard" replace /> }, { index: true, element: <Navigate to="/agent/dashboard" replace /> },
{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> }, { element: <RequirePermission anyOf={['session.view']} />, children: [{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> }] },
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> }, { element: <RequirePermission anyOf={['chat_history.view']} />, children: [{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> }] },
{ path: 'customers', element: <Lazy><Customers /></Lazy> }, { element: <RequirePermission anyOf={['customer.view']} />, children: [{ path: 'customers', element: <Lazy><Customers /></Lazy> }] },
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> }, { element: <RequirePermission anyOf={['knowledge.view']} />, children: [{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> }] },
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> }, { element: <RequirePermission anyOf={['quick_reply.view']} />, children: [{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> }] },
{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> }, { element: <RequirePermission anyOf={['blacklist.view']} />, children: [{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> }] },
{ { element: <RequirePermission anyOf={['statistics.view']} />, children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }] },
element: <RequireSupervisor />,
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
},
{ {
element: <RequireTenantAdmin />, element: <RequireTenantAdmin />,
children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }], children: [
}, { element: <RequirePermission anyOf={['settings.basic']} />, children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }] },
{ element: <RequirePermission anyOf={['permission.view']} />, children: [
{ path: 'roles', element: <Lazy><RoleList /></Lazy> },
{ path: 'roles/:id', element: <Lazy><RoleConfig /></Lazy> },
] },
{ element: <RequirePermission anyOf={['settings.staff']} />, children: [{ path: 'staff', element: <Lazy><Staff /></Lazy> }] },
],
},
], ],
}, },
], ],
+48
View File
@@ -587,6 +587,7 @@ export const createStaff = (data: { username: string; password: string; nickname
post<StaffUser>('/staff', data) post<StaffUser>('/staff', data)
export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) => export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) =>
put<StaffUser>(`/staff/${id}`, data) put<StaffUser>(`/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}`) export const deleteStaff = (id: number) => del(`/staff/${id}`)
// Tenant settings // Tenant settings
@@ -678,3 +679,50 @@ export const createAnnouncement = (data: { title: string; content: string; statu
export const updateAnnouncement = (id: number, data: Partial<Announcement>) => export const updateAnnouncement = (id: number, data: Partial<Announcement>) =>
put<Announcement>(`/admin/announcements/${id}`, data) put<Announcement>(`/admin/announcements/${id}`, data)
export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`) 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<string, 'all' | 'self'>
member_count: number
}
export interface MePermissions {
role: string
permissions: string[]
}
export const getPermissions = () => get<Permission[]>('/roles/permissions')
export const getRoles = () => get<RoleWithStats[]>('/roles')
export const getRoleDetail = (id: number) => get<RoleDetail>(`/roles/${id}`)
export const createRole = (data: { name: string; desc?: string; permissions: string[]; data_scopes?: Record<string, string> }) =>
post<RoleWithStats>('/roles', data)
export const updateRole = (id: number, data: { name?: string; desc?: string; permissions: string[]; data_scopes: Record<string, string> }) =>
put<RoleWithStats>(`/roles/${id}`, data)
export const updateBuiltinRole = (id: number, data: { desc?: string; permissions: string[]; data_scopes: Record<string, string> }) =>
put<RoleWithStats>(`/roles/${id}/builtin`, data)
export const deleteRole = (id: number) => del(`/roles/${id}`)
export const mePermissions = () => get<MePermissions>('/me/permissions')
+16 -3
View File
@@ -1,8 +1,14 @@
const BASE = '/api' 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 } export function getToken() { return token }
interface Response<T = unknown> { interface Response<T = unknown> {
@@ -32,6 +38,14 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
if (token) headers['Authorization'] = `Bearer ${token}` if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE}${url}`, { ...options, headers }) 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() const json = await res.json()
if (json.code !== 0) { if (json.code !== 0) {
@@ -87,4 +101,3 @@ export async function downloadFile(path: string, fallbackName: string) {
a.remove() a.remove()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
+76 -21
View File
@@ -1,9 +1,10 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react' import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import { setToken } from '@/services/request' import { PERMISSIONS_CHANGED_EVENT, setToken } from '@/services/request'
import { import {
getMe, getMe,
login as loginApi, login as loginApi,
updateMyStatus, updateMyStatus,
mePermissions,
type AgentPresence, type AgentPresence,
type LoginResult, type LoginResult,
} from '@/services/api' } from '@/services/api'
@@ -11,18 +12,24 @@ import {
interface AuthState { interface AuthState {
user: LoginResult | null user: LoginResult | null
loading: boolean loading: boolean
permissions: Set<string>
permissionsLoaded: boolean
login: (username: string, password: string) => Promise<LoginResult> login: (username: string, password: string) => Promise<LoginResult>
logout: () => void logout: () => void
/** 切换本人在线 / 忙碌 / 离线 */ /** 切换本人在线 / 忙碌 / 离线 */
setPresence: (status: AgentPresence) => Promise<void> setPresence: (status: AgentPresence) => Promise<void>
refreshPermissions: () => Promise<void>
} }
const AuthContext = createContext<AuthState>({ const AuthContext = createContext<AuthState>({
user: null, user: null,
loading: false, loading: false,
permissions: new Set(),
permissionsLoaded: false,
login: async () => { throw new Error('认证上下文未初始化') }, login: async () => { throw new Error('认证上下文未初始化') },
logout: () => {}, logout: () => {},
setPresence: async () => {}, setPresence: async () => {},
refreshPermissions: async () => {},
}) })
function persistUser(u: LoginResult | null) { function persistUser(u: LoginResult | null) {
@@ -48,29 +55,62 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return null return null
}) })
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [permissions, setPermissions] = useState<Set<string>>(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(() => { useEffect(() => {
if (!user?.token) return if (!user?.token) return
let cancelled = false let cancelled = false
getMe() setPermissionsLoaded(false)
.then(res => { Promise.all([getMe(), mePermissions()])
if (cancelled || !res.data) return .then(([meRes, permRes]) => {
setUser(prev => { if (cancelled) return
if (!prev) return prev if (meRes.data) {
const next = { setUser(prev => {
...prev, if (!prev) return prev
nickname: res.data.nickname || prev.nickname, const next = {
role: res.data.role || prev.role, ...prev,
status: res.data.status || prev.status || 'offline', nickname: meRes.data.nickname || prev.nickname,
} role: meRes.data.role || prev.role,
persistUser(next) status: meRes.data.status || prev.status || 'offline',
return next }
}) 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 } return () => { cancelled = true }
// 仅挂载时拉一次
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.token]) }, [user?.token])
@@ -82,11 +122,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(u.token) setToken(u.token)
setUser(u) setUser(u)
persistUser(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 return u
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [])
const setPresence = useCallback(async (status: AgentPresence) => { const setPresence = useCallback(async (status: AgentPresence) => {
const res = await updateMyStatus(status) const res = await updateMyStatus(status)
@@ -100,18 +149,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const logout = useCallback(() => { const logout = useCallback(() => {
// 尽力把状态置为离线,不阻塞退出
void updateMyStatus('offline').catch(() => {}) void updateMyStatus('offline').catch(() => {})
setToken('') setToken('')
setUser(null) setUser(null)
setPermissions(new Set())
setPermissionsLoaded(false)
persistUser(null) persistUser(null)
}, []) }, [])
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout, setPresence }}> <AuthContext.Provider value={{ user, loading, permissions, permissionsLoaded, login, logout, setPresence, refreshPermissions }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
} }
export const useAuth = () => useContext(AuthContext) export const useAuth = () => useContext(AuthContext)
export function usePermission(code: string): boolean {
const { permissions } = useAuth()
return permissions.has(code)
}