package handler import ( "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" "kefu-sys/server/internal/storage" ) type AdminHandler struct { store storage.ObjectStorage } func NewAdminHandler(store storage.ObjectStorage) *AdminHandler { return &AdminHandler{store: store} } func (h *AdminHandler) Stats(c *gin.Context) { var tenantTotal, activeTotal, suspendedTotal, expiringTotal, newThisMonth int64 model.DB.Model(&model.Tenant{}).Count(&tenantTotal) model.DB.Model(&model.Tenant{}).Where("status = ?", "normal").Count(&activeTotal) model.DB.Model(&model.Tenant{}).Where("status = ?", "suspended").Count(&suspendedTotal) model.DB.Model(&model.Tenant{}).Where("status = ?", "expiring").Count(&expiringTotal) now := time.Now() monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) model.DB.Model(&model.Tenant{}).Where("created_at >= ?", monthStart).Count(&newThisMonth) // 估算月收入:正常/即将到期租户 × 套餐月费 type planCount struct { PlanID uint Count int64 } var incomeCounts []planCount model.DB.Model(&model.Tenant{}). Select("plan_id, count(*) as count"). Where("status IN ? AND plan_id IS NOT NULL", []string{"normal", "expiring"}). Group("plan_id"). Scan(&incomeCounts) var monthlyIncome int64 for _, item := range incomeCounts { var plan model.Plan if err := model.DB.First(&plan, item.PlanID).Error; err != nil { continue } monthlyIncome += int64(plan.PriceMonthly) * item.Count } // 套餐分布:全部已绑定套餐的租户 var distCounts []planCount model.DB.Model(&model.Tenant{}). Select("plan_id, count(*) as count"). Where("plan_id IS NOT NULL"). Group("plan_id"). Scan(&distCounts) planDist := make([]gin.H, 0, len(distCounts)) for _, item := range distCounts { var plan model.Plan if err := model.DB.First(&plan, item.PlanID).Error; err != nil { continue } planDist = append(planDist, gin.H{ "plan_id": plan.ID, "name": plan.Name, "count": item.Count, }) } // 近 6 个月租户增长 growth := make([]gin.H, 0, 6) for offset := 5; offset >= 0; offset-- { mStart := time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, now.Location()) mEnd := mStart.AddDate(0, 1, 0) var newCnt, totalCnt int64 model.DB.Model(&model.Tenant{}).Where("created_at >= ? AND created_at < ?", mStart, mEnd).Count(&newCnt) model.DB.Model(&model.Tenant{}).Where("created_at < ?", mEnd).Count(&totalCnt) growth = append(growth, gin.H{ "month": mStart.Format("1月"), "month_key": mStart.Format("2006-01"), "new_count": newCnt, "total_count": totalCnt, }) } var recentLogs []model.OperationLog model.DB.Order("created_at desc").Limit(12).Find(&recentLogs) middleware.JSON(c, gin.H{ "tenant_total": tenantTotal, "active_tenant": activeTotal, "suspended_tenant": suspendedTotal, "expiring_tenant": expiringTotal, "new_tenants_month": newThisMonth, "monthly_income": monthlyIncome, "system_uptime": "99.95%", "plan_distribution": planDist, "tenant_growth": growth, "recent_logs": recentLogs, }) } func (h *AdminHandler) ListTenants(c *gin.Context) { page, pageSize := middleware.GetPageParams(c) search := c.Query("search") status := c.Query("status") plan := c.Query("plan") var tenants []model.Tenant var total int64 query := model.DB.Model(&model.Tenant{}) if search != "" { query = query.Where("name LIKE ? OR contact_name LIKE ?", "%"+search+"%", "%"+search+"%") } if status != "" { query = query.Where("status = ?", status) } if plan != "" { query = query.Where("plan_id IN (SELECT id FROM plans WHERE name = ?)", plan) } query.Count(&total) query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tenants) middleware.JSONList(c, tenants, total, page, pageSize) } func (h *AdminHandler) GetTenant(c *gin.Context) { id := c.Param("id") var tenant model.Tenant if err := model.DB.First(&tenant, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"}) return } middleware.JSON(c, tenant) } type createTenantReq struct { Name string `json:"name" binding:"required"` ContactName string `json:"contact_name" binding:"required"` ContactPhone string `json:"contact_phone" binding:"required"` ContactEmail string `json:"contact_email"` PlanID *uint `json:"plan_id"` SeatCount int `json:"seat_count"` DurationMonths int `json:"duration_months"` AdminUsername string `json:"admin_username"` AdminPassword string `json:"admin_password"` } func (h *AdminHandler) CreateTenant(c *gin.Context) { var req createTenantReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } name := strings.TrimSpace(req.Name) if len([]rune(name)) < 2 || len([]rune(name)) > 100 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "公司名称需为 2-100 个字符"}) return } var exists int64 model.DB.Model(&model.Tenant{}).Where("name = ?", name).Count(&exists) if exists > 0 { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "租户名称已存在"}) return } if req.SeatCount <= 0 { req.SeatCount = 2 } if req.DurationMonths <= 0 { req.DurationMonths = 12 } if req.DurationMonths > 36 { req.DurationMonths = 36 } if req.PlanID != nil { var plan model.Plan if err := model.DB.First(&plan, *req.PlanID).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "套餐不存在"}) return } if plan.Status != "active" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "套餐已下架,无法开通"}) return } if req.SeatCount < plan.Seats { req.SeatCount = plan.Seats } } tenant := model.Tenant{ Name: name, PlanID: req.PlanID, SeatCount: req.SeatCount, ExpireAt: time.Now().AddDate(0, req.DurationMonths, 0), Status: "normal", ContactName: strings.TrimSpace(req.ContactName), ContactPhone: strings.TrimSpace(req.ContactPhone), ContactEmail: strings.TrimSpace(req.ContactEmail), } if err := model.DB.Create(&tenant).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } // 默认租户管理员账号 adminUser := strings.TrimSpace(req.AdminUsername) if adminUser == "" { adminUser = fmt.Sprintf("admin_t%d", tenant.ID) } password := req.AdminPassword if password == "" { password = "kefu_admin123" } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建管理员账号失败"}) return } user := model.User{ TenantID: tenant.ID, Role: "admin", Username: adminUser, PasswordHash: string(hash), Nickname: tenant.ContactName, Status: "online", } if err := model.DB.Create(&user).Error; err != nil { // 回滚租户(尽力) model.DB.Delete(&tenant) c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "管理员用户名已存在"}) return } // 默认网页渠道 key, keyErr := newChannelKey("WK") if keyErr == nil { model.DB.Create(&model.Channel{ TenantID: tenant.ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: buildWebScript(key), }) } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "create_tenant", Detail: fmt.Sprintf("开通新租户: %s(管理员 %s)", tenant.Name, adminUser), TargetType: "tenant", TargetID: &tenant.ID, IP: c.ClientIP(), }) middleware.JSON(c, gin.H{ "tenant": tenant, "admin_username": adminUser, "admin_password": password, }) } func (h *AdminHandler) UpdateTenant(c *gin.Context) { id := c.Param("id") var tenant model.Tenant if err := model.DB.First(&tenant, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"}) return } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } allowed := map[string]bool{ "contact_name": true, "contact_phone": true, "contact_email": true, "seat_count": true, "plan_id": true, "expire_at": true, "status": true, "name": true, } for key := range updates { if !allowed[key] { delete(updates, key) } } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } if err := model.DB.Model(&tenant).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } model.DB.First(&tenant, tenant.ID) model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "update_tenant", Detail: "更新租户: " + tenant.Name, TargetType: "tenant", TargetID: &tenant.ID, IP: c.ClientIP(), }) middleware.JSON(c, tenant) } func (h *AdminHandler) SuspendTenant(c *gin.Context) { id := c.Param("id") var tenant model.Tenant if err := model.DB.First(&tenant, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"}) return } if err := model.DB.Model(&tenant).Update("status", "suspended").Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "暂停失败"}) return } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "suspend_tenant", Detail: "暂停租户: " + tenant.Name, TargetType: "tenant", TargetID: &tenant.ID, IP: c.ClientIP(), }) middleware.JSON(c, gin.H{"message": "已暂停"}) } func (h *AdminHandler) ResumeTenant(c *gin.Context) { id := c.Param("id") var tenant model.Tenant if err := model.DB.First(&tenant, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"}) return } status := "normal" if tenant.ExpireAt.Before(time.Now()) { status = "expired" } if err := model.DB.Model(&tenant).Update("status", status).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "恢复失败"}) return } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "resume_tenant", Detail: "恢复租户: " + tenant.Name, TargetType: "tenant", TargetID: &tenant.ID, IP: c.ClientIP(), }) middleware.JSON(c, gin.H{"message": "已恢复", "status": status}) } func (h *AdminHandler) ListPlans(c *gin.Context) { var plans []model.Plan model.DB.Find(&plans) middleware.JSON(c, plans) } func (h *AdminHandler) CreatePlan(c *gin.Context) { var plan model.Plan if err := c.ShouldBindJSON(&plan); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } model.DB.Create(&plan) middleware.JSON(c, plan) } func (h *AdminHandler) UpdatePlan(c *gin.Context) { id := c.Param("id") var plan model.Plan if err := model.DB.First(&plan, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "套餐不存在"}) return } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } allowed := map[string]bool{ "name": true, "price_monthly": true, "seats": true, "storage_days": true, "kb_limit": true, "features": true, "status": true, } for key := range updates { if !allowed[key] { delete(updates, key) } } if status, ok := updates["status"].(string); ok && status != "active" && status != "inactive" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "套餐状态仅支持 active/inactive"}) return } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } if err := model.DB.Model(&plan).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } model.DB.First(&plan, plan.ID) model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "update_plan", Detail: "更新套餐: " + plan.Name, TargetType: "plan", TargetID: &plan.ID, IP: c.ClientIP(), }) middleware.JSON(c, plan) } func (h *AdminHandler) ListLogs(c *gin.Context) { page, pageSize := middleware.GetPageParams(c) var logs []model.OperationLog var total int64 model.DB.Model(&model.OperationLog{}).Count(&total) model.DB.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs) middleware.JSONList(c, logs, total, page, pageSize) } func (h *AdminHandler) ListAnnouncements(c *gin.Context) { var announcements []model.Announcement model.DB.Order("created_at desc").Find(&announcements) middleware.JSON(c, announcements) } func (h *AdminHandler) CreateAnnouncement(c *gin.Context) { var ann model.Announcement if err := c.ShouldBindJSON(&ann); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } ann.Title = strings.TrimSpace(ann.Title) if ann.Title == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "公告标题不能为空"}) return } if ann.Status == "" { ann.Status = "draft" } if ann.Status != "draft" && ann.Status != "published" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态仅支持 draft/published"}) return } if err := model.DB.Create(&ann).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "create_announcement", Detail: "创建公告: " + ann.Title, TargetType: "announcement", TargetID: &ann.ID, IP: c.ClientIP(), }) middleware.JSON(c, ann) } func (h *AdminHandler) UpdateAnnouncement(c *gin.Context) { id := c.Param("id") var ann model.Announcement if err := model.DB.First(&ann, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "公告不存在"}) return } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } allowed := map[string]bool{"title": true, "content": true, "status": true} for key := range updates { if !allowed[key] { delete(updates, key) } } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } if err := model.DB.Model(&ann).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } model.DB.First(&ann, ann.ID) middleware.JSON(c, ann) } func (h *AdminHandler) DeleteAnnouncement(c *gin.Context) { id := c.Param("id") var ann model.Announcement if err := model.DB.First(&ann, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "公告不存在"}) return } if err := model.DB.Delete(&ann).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"}) return } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "delete_announcement", Detail: "删除公告: " + ann.Title, TargetType: "announcement", TargetID: &ann.ID, IP: c.ClientIP(), }) middleware.JSON(c, gin.H{"message": "已删除"}) }