Files
kefu_cloud/server/internal/handler/admin.go
T
yml2213 d4ba9e7a56 全局重命名:kefu_sys / kefu-sys 改为 kefu_cloud / kefu-cloud
- Go module 与全部 import 路径
- 数据库默认库名、Docker 服务/卷命名与部署文档
2026-07-15 15:23:10 +08:00

527 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model"
"kefu-cloud/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": "已删除"})
}