// 管理后台接口处理器,负责解析管理端请求并调用对应业务模块组织响应。 package handler import ( "encoding/json" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "log/slog" "mime/multipart" "net/http" "path/filepath" "strconv" "strings" "time" mediakey "juhe-factory/api/internal/media" adminmodule "juhe-factory/api/internal/modules/admin" "juhe-factory/api/internal/modules/prompt" "juhe-factory/api/internal/service" "juhe-factory/api/internal/storage" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" ) type AdminData struct { data *service.AdminData admin *adminmodule.Service prompts *prompt.Service cos *storage.COS } type optionalTime struct{ Time *time.Time } func (t *optionalTime) UnmarshalJSON(data []byte) error { if string(data) == "null" || strings.TrimSpace(string(data)) == `""` { t.Time = nil return nil } var value time.Time if err := json.Unmarshal(data, &value); err != nil { return err } t.Time = &value return nil } func NewAdminData(data *service.AdminData, admin *adminmodule.Service, prompts *prompt.Service, cos *storage.COS) *AdminData { return &AdminData{data: data, admin: admin, prompts: prompts, cos: cos} } func (h *AdminData) ListResource(c *gin.Context) { filters := map[string]string{"enabled": c.Query("enabled"), "payment_type": c.Query("payment_type"), "channel_type": c.Query("channel_type"), "status": c.Query("status"), "pinned": c.Query("pinned")} page, err := h.data.ListResource(c.Request.Context(), c.Param("resource"), c.Query("keyword"), filters, c.Query("page"), c.Query("page_size")) if err != nil { h.bad(c, err) return } c.JSON(http.StatusOK, gin.H{"data": page}) } func (h *AdminData) CreateResource(c *gin.Context) { var body map[string]any if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } id, err := h.data.SaveResource(c.Param("resource"), "", body) if err != nil { h.bad(c, err) return } h.audit(c, "create", c.Param("resource"), id, "", body) c.JSON(http.StatusCreated, gin.H{"data": gin.H{"id": id}}) } func (h *AdminData) UpdateResource(c *gin.Context) { var body map[string]any if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } id, err := h.data.SaveResource(c.Param("resource"), c.Param("id"), body) if err != nil { h.bad(c, err) return } h.audit(c, "update", c.Param("resource"), id, stringValue(body["reason"]), body) c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id}}) } func (h *AdminData) ToggleResource(c *gin.Context) { var body struct { Enabled bool `json:"enabled"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if err := h.data.ToggleResource(c.Param("resource"), c.Param("id"), body.Enabled); err != nil { h.bad(c, err) return } h.audit(c, "toggle", c.Param("resource"), c.Param("id"), body.Reason, body) c.Status(http.StatusNoContent) } func (h *AdminData) DeleteResource(c *gin.Context) { var body struct { Reason string `json:"reason"` } _ = c.ShouldBindJSON(&body) if err := h.data.DeleteResource(c.Param("resource"), c.Param("id")); err != nil { h.bad(c, err) return } h.audit(c, "delete", c.Param("resource"), c.Param("id"), body.Reason, nil) c.Status(http.StatusNoContent) } func (h *AdminData) ReorderStyles(c *gin.Context) { var body struct { IDs []string `json:"ids"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if err := h.data.ReorderStyles(body.IDs); err != nil { h.bad(c, err) return } h.audit(c, "reorder", "styles", strings.Join(body.IDs, ","), "", map[string]any{"count": len(body.IDs)}) c.Status(http.StatusNoContent) } func (h *AdminData) ListUsers(c *gin.Context) { page, err := h.data.ListUsers(c.Query("keyword"), c.Query("enabled"), c.Query("page"), c.Query("page_size")) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": page}) } // ListAuditLogs 返回管理端操作审计记录,不提供修改或删除能力。 func (h *AdminData) ListAuditLogs(c *gin.Context) { page, size := parsePage(c) result, err := h.admin.ListAuditLogs(c.Query("keyword"), page, size) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": result}) } func (h *AdminData) CreateUser(c *gin.Context) { var body struct { Account string `json:"account"` Password string `json:"password"` DailyLimit any `json:"daily_limit"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } item, err := h.data.CreateUser(body.Account, body.Password, body.DailyLimit) if err != nil { h.bad(c, err) return } h.audit(c, "create", "users", stringValue(item["id"]), "", map[string]any{"account": item["account"], "username": item["username"], "daily_limit": body.DailyLimit}) c.JSON(http.StatusCreated, gin.H{"data": item}) } func (h *AdminData) BatchCreateUsers(c *gin.Context) { var body struct { Prefix string `json:"prefix"` StartSequence string `json:"start_sequence"` EndSequence string `json:"end_sequence"` Password string `json:"password"` DailyLimit any `json:"daily_limit"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } items, err := h.data.BatchCreateUsers(body.Prefix, body.StartSequence, body.EndSequence, body.Password, body.DailyLimit) if err != nil { h.bad(c, err) return } h.audit(c, "batch_create", "users", "", "", map[string]any{"prefix": body.Prefix, "start_sequence": body.StartSequence, "end_sequence": body.EndSequence, "count": len(items)}) c.JSON(http.StatusCreated, gin.H{"data": items}) } func (h *AdminData) BatchUpdateUsers(c *gin.Context) { var body struct { IDs []string `json:"ids"` DailyLimit any `json:"daily_limit"` Enabled *bool `json:"enabled"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } updates := map[string]any{} if body.DailyLimit != nil { updates["daily_limit"] = body.DailyLimit } if body.Enabled != nil { updates["enabled"] = *body.Enabled } if err := h.data.UpdateUsers(body.IDs, updates); err != nil { h.bad(c, err) return } h.audit(c, "batch_update", "users", strings.Join(body.IDs, ","), body.Reason, updates) c.Status(http.StatusNoContent) } func (h *AdminData) BatchDeleteUsers(c *gin.Context) { var body struct { IDs []string `json:"ids"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if err := h.data.DeleteUsers(body.IDs); err != nil { h.bad(c, err) return } h.audit(c, "batch_delete", "users", strings.Join(body.IDs, ","), body.Reason, nil) c.Status(http.StatusNoContent) } // GrantUserPoints 校验管理端积分发放请求,完成入账后记录操作审计。 func (h *AdminData) GrantUserPoints(c *gin.Context) { userID, err := uuid.Parse(c.Param("id")) if err != nil { h.bad(c, errors.New("用户标识无效")) return } var body struct { RequestID string `json:"request_id"` Points any `json:"points"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } body.Reason = strings.TrimSpace(body.Reason) if body.Reason == "" { h.bad(c, errors.New("请填写积分发放原因")) return } if len([]rune(body.Reason)) > 200 { h.bad(c, errors.New("积分发放原因不能超过 200 个字符")) return } balance, credited, err := h.admin.GrantUserPoints(userID, body.RequestID, body.Points) if err != nil { h.bad(c, err) return } if credited { h.audit(c, "grant_points", "users", userID.String(), body.Reason, map[string]any{ "points": body.Points, "balance": balance, "request_id": body.RequestID, }) } c.JSON(http.StatusOK, gin.H{"data": gin.H{"balance": balance, "credited": credited}}) } func (h *AdminData) ListModels(c *gin.Context) { page, size := parsePage(c) result, err := h.admin.ListModels(c.Query("keyword"), c.Query("model_type"), c.Query("channel_id"), page, size) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": result}) } func (h *AdminData) SaveModel(c *gin.Context) { var body adminmodule.ModelInput if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } id, created, normalizedBody, err := h.admin.SaveModel(c.Param("id"), body) if err != nil { h.bad(c, err) return } action := "update" status := http.StatusOK if created { action = "create" status = http.StatusCreated } h.audit(c, action, "models", id, "", normalizedBody) c.JSON(status, gin.H{"data": gin.H{"id": id}}) } func (h *AdminData) ListPrompts(c *gin.Context) { page, size := parsePage(c) items, total, err := h.prompts.ListSystemPrompts(c.Query("keyword"), c.Query("type"), page, size) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": service.Page{Items: items, Total: total, Page: page, PageSize: size}}) } func (h *AdminData) SavePrompt(c *gin.Context) { var body struct { Code string `json:"code"` Name string `json:"name"` Category string `json:"category"` Type string `json:"type"` Content string `json:"content"` Variables []string `json:"variables"` VersionNote string `json:"version_note"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if strings.TrimSpace(body.Category) == "" { body.Category = body.Type } if strings.TrimSpace(body.Type) == "" { body.Type = body.Category } if err := service.ValidatePromptVariables(body.Content, body.Variables); err != nil { h.bad(c, err) return } id, err := h.prompts.SaveVersionedPrompt(currentAdmin(c).ID, c.Param("id"), prompt.VersionedPromptInput{ Code: body.Code, Name: body.Name, Category: body.Category, Type: body.Type, Content: body.Content, Variables: body.Variables, VersionNote: body.VersionNote, }) if err != nil { h.bad(c, err) return } h.audit(c, "save_draft", "prompts", id, body.VersionNote, nil) c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id}}) } // SavePromptSimple 保存系统提示词并立即应用,不启用历史版本工作流。 func (h *AdminData) SavePromptSimple(c *gin.Context) { var body prompt.SystemPromptInput if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } item, err := h.prompts.SaveSystemPrompt(c.Param("id"), body) if err != nil { h.bad(c, err) return } h.audit(c, "save", "prompts", item.ID, "", body) c.JSON(http.StatusOK, gin.H{"data": item}) } // DeletePromptSimple 删除管理端指定的系统提示词。 func (h *AdminData) DeletePromptSimple(c *gin.Context) { if err := h.prompts.DeleteSystemPrompt(c.Param("id")); err != nil { h.bad(c, err) return } c.Status(http.StatusNoContent) } func (h *AdminData) PromptAction(c *gin.Context) { var body struct { Action string `json:"action"` VersionID string `json:"version_id"` VersionNote string `json:"version_note"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } id := c.Param("id") if err := h.prompts.ApplyPromptAction(currentAdmin(c).ID, id, body.Action, body.VersionID, body.VersionNote); err != nil { h.bad(c, err) return } h.audit(c, body.Action, "prompts", id, body.VersionNote, body) c.Status(http.StatusNoContent) } func (h *AdminData) PromptHistory(c *gin.Context) { items, err := h.prompts.ListPromptHistory(c.Param("id")) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": items}) } func (h *AdminData) PromptActionSimple(c *gin.Context) { c.Status(http.StatusNoContent) } func (h *AdminData) PromptHistorySimple(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": []any{}}) } func (h *AdminData) ListRedemptions(c *gin.Context) { page, size := parsePage(c) result, err := h.admin.ListRedemptions(c.Query("keyword"), c.Query("status"), page, size) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": result}) } func (h *AdminData) CreateRedemptionBatch(c *gin.Context) { var body struct { Name string `json:"name"` Points any `json:"points"` Quantity int `json:"quantity"` ExpiresAt optionalTime `json:"expires_at"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if body.ExpiresAt.Time == nil { expiresAt := time.Now().AddDate(0, 0, 7) body.ExpiresAt.Time = &expiresAt } admin := currentAdmin(c) batchID, codes, points, err := h.admin.CreateRedemptionBatch(admin.ID, body.Name, body.Points, body.Quantity, *body.ExpiresAt.Time) if err != nil { h.bad(c, err) return } h.audit(c, "generate", "redemption-codes", batchID, "", map[string]any{"quantity": body.Quantity, "points": points}) c.JSON(http.StatusCreated, gin.H{"data": gin.H{"batch_id": batchID, "codes": codes}}) } // validateRedemptionBatchLimits 保留 Handler 包内既有测试入口,实际规则由管理后台模块统一实现。 func validateRedemptionBatchLimits(value any, quantity int) (string, error) { return adminmodule.ValidateRedemptionBatchLimits(value, quantity) } func (h *AdminData) UploadStyleImage(c *gin.Context) { if h.cos == nil { fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置") return } file, header, err := c.Request.FormFile("file") if err != nil { h.bad(c, errors.New("请选择图片文件")) return } defer file.Close() if header.Size <= 0 || header.Size > h.cos.MaxImageBytes() { h.bad(c, errors.New("图片大小超出限制")) return } contentType := header.Header.Get("Content-Type") if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" { h.bad(c, errors.New("仅支持 JPEG、PNG 或 GIF 图片")) return } width, height, err := imageDimensions(file, contentType) if err != nil { h.bad(c, errors.New("无法识别图片内容")) return } if width < 64 || height < 64 || width > 8192 || height > 8192 { h.bad(c, errors.New("图片尺寸必须在 64×64 至 8192×8192 之间")) return } if _, err = file.Seek(0, 0); err != nil { h.internal(c, err) return } ext := strings.ToLower(filepath.Ext(header.Filename)) if ext == "" { ext = ".img" } key := mediakey.StyleImage(uuid.New(), ext) url, err := h.cos.Put(c, key, contentType, file, header.Size) if err != nil { h.internal(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": gin.H{"image_key": key, "image_url": url, "image_mime": contentType, "image_size": header.Size, "image_width": width, "image_height": height}}) } func imageDimensions(file multipart.File, contentType string) (int, int, error) { if contentType == "image/webp" { return 0, 0, errors.New("WebP 尺寸解析暂不可用") } cfg, _, err := image.DecodeConfig(file) return cfg.Width, cfg.Height, err } func parsePage(c *gin.Context) (int, int) { page, _ := strconv.Atoi(c.Query("page")) if page < 1 { page = 1 } size, _ := strconv.Atoi(c.Query("page_size")) if size < 1 { size = 20 } if size > 100 { size = 100 } return page, size } func stringValue(v any) string { if v == nil { return "" } return fmt.Sprint(v) } func (h *AdminData) audit(c *gin.Context, action, resource, id, reason string, detail any) { _ = h.admin.WriteAudit(currentAdmin(c), action, resource, id, reason, c.ClientIP(), c.GetString("trace_id"), detail) } func (h *AdminData) bad(c *gin.Context, err error) { message := err.Error() code := "invalid_request" if errors.Is(err, gorm.ErrRecordNotFound) { code = "not_found" } fail(c, http.StatusBadRequest, code, message) } func (h *AdminData) internal(c *gin.Context, err error) { slog.ErrorContext(c.Request.Context(), "admin request failed", "trace_id", c.GetString("trace_id"), "error", err) fail(c, http.StatusInternalServerError, "internal_error", "操作失败,请稍后重试") }