实现租户坐席账号管理

新增 /api/staff 列表/创建/更新/禁用,按租户坐席配额校验;系统设置增加坐席账号页支持增改禁与配额展示。
This commit is contained in:
yml2213
2026-07-15 14:07:06 +08:00
parent 34d3866520
commit ea3901d8b0
4 changed files with 696 additions and 16 deletions
+8
View File
@@ -16,6 +16,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
admin := NewAdminHandler()
channel := NewChannelHandler()
settings := NewSettingsHandler()
staff := NewStaffHandler()
ws := NewWsHandler()
widget := NewWidgetHandler()
upload := NewUploadHandler(store, storageCfg)
@@ -94,6 +95,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
settingsGroup.GET("", settings.Get)
settingsGroup.PUT("", settings.Update)
// 坐席账号(租户内)
staffGroup := authRequired.Group("/staff")
staffGroup.GET("", staff.List)
staffGroup.POST("", staff.Create)
staffGroup.PUT("/:id", staff.Update)
staffGroup.DELETE("/:id", staff.Delete)
// 统计
statistics := authRequired.Group("/statistics")
statistics.GET("/kpi", stats.KPIs)
+355
View File
@@ -0,0 +1,355 @@
package handler
import (
"net/http"
"strings"
"unicode/utf8"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
)
type StaffHandler struct{}
func NewStaffHandler() *StaffHandler { return &StaffHandler{} }
func requireStaffManager(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin") {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"})
return false
}
// 坐席占用:本租户下 agent / supervisor / admin 均计 1 席(不含 disabled
func countActiveSeats(tenantID uint) (int64, error) {
var n int64
err := model.DB.Model(&model.User{}).
Where("tenant_id = ? AND role IN ? AND status <> ?", tenantID, []string{"agent", "supervisor", "admin"}, "disabled").
Count(&n).Error
return n, err
}
func loadTenantSeatLimit(tenantID uint) (int, error) {
var tenant model.Tenant
if err := model.DB.Select("id", "seat_count").First(&tenant, tenantID).Error; err != nil {
return 0, err
}
if tenant.SeatCount <= 0 {
return 2, nil
}
return tenant.SeatCount, nil
}
type StaffItem struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
LastOnlineAt string `json:"last_online_at,omitempty"`
}
func (h *StaffHandler) List(c *gin.Context) {
if !middleware.HasAnyRole(c, "admin", "supervisor") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看坐席列表"})
return
}
tenantID := middleware.GetTenantID(c)
var users []model.User
if err := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, []string{"agent", "supervisor", "admin"}).
Order("role asc, id asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"})
return
}
seatLimit, _ := loadTenantSeatLimit(tenantID)
used, _ := countActiveSeats(tenantID)
items := make([]StaffItem, 0, len(users))
for _, u := range users {
item := StaffItem{
ID: u.ID, Username: u.Username, Nickname: u.Nickname,
Role: u.Role, Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
}
if u.LastOnlineAt != nil {
item.LastOnlineAt = u.LastOnlineAt.Format("2006-01-02 15:04:05")
}
items = append(items, item)
}
middleware.JSON(c, gin.H{
"list": items,
"seat_limit": seatLimit,
"seat_used": used,
})
}
type CreateStaffReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Nickname string `json:"nickname"`
Role string `json:"role"`
}
func (h *StaffHandler) Create(c *gin.Context) {
if !requireStaffManager(c) {
return
}
var req CreateStaffReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
username := strings.TrimSpace(req.Username)
if utf8.RuneCountInString(username) < 3 || utf8.RuneCountInString(username) > 30 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "用户名需 3-30 个字符"})
return
}
if len(req.Password) < 6 || len(req.Password) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"})
return
}
role := req.Role
if role == "" {
role = "agent"
}
if role != "agent" && role != "supervisor" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"})
return
}
nickname := strings.TrimSpace(req.Nickname)
if nickname == "" {
nickname = username
}
if utf8.RuneCountInString(nickname) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称过长"})
return
}
tenantID := middleware.GetTenantID(c)
seatLimit, err := loadTenantSeatLimit(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"})
return
}
used, err := countActiveSeats(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计坐席失败"})
return
}
if used >= int64(seatLimit) {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,请升级套餐或增加坐席"})
return
}
var exists int64
model.DB.Model(&model.User{}).Where("username = ?", username).Count(&exists)
if exists > 0 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "用户名已存在"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return
}
user := model.User{
TenantID: tenantID, Role: role, Username: username,
PasswordHash: string(hash), Nickname: nickname, Status: "offline",
}
if err := model.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return
}
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "create_staff",
Detail: "创建坐席: " + username + " (" + role + ")",
TargetType: "user",
TargetID: &user.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, StaffItem{
ID: user.ID, Username: user.Username, Nickname: user.Nickname,
Role: user.Role, Status: user.Status,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
type UpdateStaffReq struct {
Nickname *string `json:"nickname"`
Role *string `json:"role"`
Status *string `json:"status"`
Password *string `json:"password"`
}
func (h *StaffHandler) Update(c *gin.Context) {
if !requireStaffManager(c) {
return
}
tenantID := middleware.GetTenantID(c)
id := c.Param("id")
var user model.User
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"})
return
}
if user.Role == "platform_admin" {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无法操作该账号"})
return
}
var req UpdateStaffReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
// 不可降级/删除最后一个 admin:若改角色离开 admin,检查剩余 admin
updates := map[string]interface{}{}
if req.Nickname != nil {
n := strings.TrimSpace(*req.Nickname)
if n == "" || utf8.RuneCountInString(n) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称无效"})
return
}
updates["nickname"] = n
}
if req.Role != nil {
r := *req.Role
if r != "agent" && r != "supervisor" && r != "admin" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"})
return
}
// 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin
if user.Role == "admin" && r != "admin" {
var adminCnt int64
model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt)
if adminCnt <= 1 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "至少保留一名启用的租户管理员"})
return
}
}
// 普通管理员不能创建/提升为 admin?允许 admin 设置另一 admin
updates["role"] = r
}
if req.Status != nil {
s := *req.Status
if s != "online" && s != "offline" && s != "busy" && s != "disabled" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态无效"})
return
}
// 禁用时检查坐席释放;启用时检查配额
if s == "disabled" && user.Status != "disabled" {
if user.Role == "admin" {
var adminCnt int64
model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ? AND id <> ?", tenantID, "admin", "disabled", user.ID).Count(&adminCnt)
if adminCnt < 1 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能禁用唯一的租户管理员"})
return
}
}
if user.ID == middleware.GetUserID(c) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能禁用当前登录账号"})
return
}
}
if s != "disabled" && user.Status == "disabled" {
seatLimit, _ := loadTenantSeatLimit(tenantID)
used, _ := countActiveSeats(tenantID)
if used >= int64(seatLimit) {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,无法启用"})
return
}
}
updates["status"] = s
}
if req.Password != nil && *req.Password != "" {
if len(*req.Password) < 6 || len(*req.Password) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新密码失败"})
return
}
updates["password_hash"] = string(hash)
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
}
if err := model.DB.Model(&user).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
model.DB.First(&user, user.ID)
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "update_staff",
Detail: "更新坐席: " + user.Username,
TargetType: "user",
TargetID: &user.ID,
IP: c.ClientIP(),
})
item := StaffItem{
ID: user.ID, Username: user.Username, Nickname: user.Nickname,
Role: user.Role, Status: user.Status,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
}
if user.LastOnlineAt != nil {
item.LastOnlineAt = user.LastOnlineAt.Format("2006-01-02 15:04:05")
}
middleware.JSON(c, item)
}
func (h *StaffHandler) Delete(c *gin.Context) {
if !requireStaffManager(c) {
return
}
tenantID := middleware.GetTenantID(c)
id := c.Param("id")
var user model.User
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"})
return
}
if user.ID == middleware.GetUserID(c) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能删除当前登录账号"})
return
}
if user.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 && user.Status != "disabled" {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能删除唯一的租户管理员"})
return
}
}
// 软禁用:保留数据关联;物理删除仅对无会话绑定的账号可选——统一用 disabled 更安全
if err := model.DB.Model(&user).Update("status", "disabled").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "禁用失败"})
return
}
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "disable_staff",
Detail: "禁用坐席: " + user.Username,
TargetType: "user",
TargetID: &user.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, gin.H{"message": "已禁用"})
}