Files
kefu_cloud/server/internal/handler/admin.go
T
yml2213 b5203f7cbd 实现 P2 管理端:租户生命周期、套餐与运维真实 API
- 租户开通自动创建管理员账号与网页渠道,支持暂停/恢复/编辑
- 运营概览返回月收入估算、套餐分布与真实操作日志
- 套餐上下架/新建,公告与操作日志前后端打通
- 补充管理端生命周期集成测试
2026-07-15 11:22:37 +08:00

486 lines
14 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-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
)
type AdminHandler struct{}
func NewAdminHandler() *AdminHandler { return &AdminHandler{} }
func (h *AdminHandler) Stats(c *gin.Context) {
var tenantTotal, activeTotal, suspendedTotal, expiringTotal 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)
// 估算月收入:正常/即将到期租户 × 套餐月费
type planCount struct {
PlanID uint
Count int64
}
var counts []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(&counts)
var monthlyIncome int64
planDist := make([]gin.H, 0)
for _, item := range counts {
var plan model.Plan
if err := model.DB.First(&plan, item.PlanID).Error; err != nil {
continue
}
monthlyIncome += int64(plan.PriceMonthly) * item.Count
planDist = append(planDist, gin.H{
"plan_id": plan.ID,
"name": plan.Name,
"count": item.Count,
})
}
var recentLogs []model.OperationLog
model.DB.Order("created_at desc").Limit(10).Find(&recentLogs)
middleware.JSON(c, gin.H{
"tenant_total": tenantTotal,
"active_tenant": activeTotal,
"suspended_tenant": suspendedTotal,
"expiring_tenant": expiringTotal,
"monthly_income": monthlyIncome,
"system_uptime": "99.95%",
"plan_distribution": planDist,
"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 = "password123"
}
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": "已删除"})
}