Files
geekai/api/handler/ppt_task_handler.go
T
RockYang 9ccff4efbc feat(release): migrate GeekAI v4.3.0 to open source
- Sync backend and frontend from GeekAI Plus v4.3.0

- Remove commercial License flows and update open-source deployment defaults

- Preserve Docker Compose deployment and bump image tags to v4.3.0

BREAKING CHANGE: commercial License configuration and related endpoints are removed
2026-08-11 14:51:20 +08:00

548 lines
15 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
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
// * Use of this source code is governed by a Apache-2.0 license
// * that can be found in the LICENSE file.
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"context"
"errors"
"fmt"
"geekai/core"
"geekai/core/middleware"
"geekai/core/types"
"geekai/service"
"geekai/service/ppt"
"geekai/utils"
"geekai/utils/resp"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PPTTaskHandler 用户侧 PPT 生成任务处理器(薄层:参数、鉴权、调用 PptService)
type PPTTaskHandler struct {
BaseHandler
snowflake *service.Snowflake
pptService *ppt.PptService
}
// NewPPTTaskHandler 创建 PPT 任务处理器
func NewPPTTaskHandler(app *core.AppServer, db *gorm.DB, snowflake *service.Snowflake, pptService *ppt.PptService) *PPTTaskHandler {
return &PPTTaskHandler{
BaseHandler: BaseHandler{App: app, DB: db},
snowflake: snowflake,
pptService: pptService,
}
}
// RegisterRoutes 注册 PPT 任务相关路由
func (h *PPTTaskHandler) RegisterRoutes() {
group := h.App.Engine.Group("/api/v1/tasks")
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
{
group.POST("generate-slides", h.CreateTask)
group.POST("generate-slides/from-file", h.CreateTaskFromFile)
group.GET("", h.ListTasks) // 当前用户任务列表,须在 :task_id 前注册
group.GET(":task_id/export", h.ExportTask)
group.POST(":task_id/resume", h.ResumeTask)
group.POST(":task_id/slides/:slide_index/edit-image", h.EditSlideImage)
group.PATCH(":task_id/slides/:slide_index/active-image", h.SetActiveSlideImage)
group.GET(":task_id", h.GetTask)
group.DELETE(":task_id", h.DeleteTask)
}
}
type createPPTTaskRequest struct {
Content string `json:"content"`
Prompt string `json:"prompt"`
Language string `json:"language"` // 如 zh-CN, en,约束分镜输出语言
Pages int `json:"pages"` // 目标页数,0 表示用配置默认值
Mode string `json:"mode"` // 生成模式:detailed=详细演示文稿,slides=演示用幻灯片,空则默认 slides
}
type createPPTTaskFromFileRequest struct {
FileURL string `json:"file_url"`
Prompt string `json:"prompt"`
Language string `json:"language"` // 如 zh-CN, en,约束分镜输出语言
Pages int `json:"pages"` // 目标页数,0 表示用配置默认值
Mode string `json:"mode"` // 生成模式:detailed=详细演示文稿,slides=演示用幻灯片,空则默认 slides
}
// CreateTask 创建 PPT 生成任务(异步)
func (h *PPTTaskHandler) CreateTask(c *gin.Context) {
var req createPPTTaskRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Content == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
taskID, err := h.snowflake.Next(true)
if err != nil {
resp.ERROR(c, "生成任务ID失败:"+err.Error())
return
}
task, cfg, err := h.pptService.BuildPendingTask(taskID, user.Id, int(user.Power), req.Content, req.Prompt, req.Language, req.Mode, req.Pages)
if err != nil {
if errors.Is(err, ppt.ErrInsufficientPower) {
resp.ERROR(c, "当前用户算力不足以完成本次 PPT 生成任务!")
return
}
resp.ERROR(c, "加载 PPT 配置或校验失败:"+err.Error())
return
}
h.pptService.CreateTask(c.Request.Context(), task, cfg)
go h.pptService.RunTask(context.Background(), task, cfg)
resp.SUCCESS(c, map[string]any{
"task_id": taskID,
"status": ppt.TaskStatusPending,
})
}
// CreateTaskFromFile 创建 PPT 生成任务(基于上传材料文本提炼)
// 支持文件:PDF、Worddoc/docx)、TXT、Markdownmd/markdown
func (h *PPTTaskHandler) CreateTaskFromFile(c *gin.Context) {
var req createPPTTaskFromFileRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.FileURL) == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
fileURL := strings.TrimSpace(req.FileURL)
// 去掉 query,避免 ".pdf?xxx" 这种导致 ext 识别失败
urlNoQuery := strings.Split(fileURL, "?")[0]
ext := strings.ToLower(filepath.Ext(urlNoQuery))
if ext == "" {
resp.ERROR(c, "不支持的文件格式")
return
}
allowedExts := map[string]bool{
".pdf": true,
".doc": true,
".docx": true,
".txt": true,
".md": true,
".markdown": true,
}
if !allowedExts[ext] {
resp.ERROR(c, "不支持的文件格式")
return
}
designPrompt := strings.TrimSpace(req.Prompt)
language := strings.TrimSpace(req.Language)
if language == "" {
language = "zh-CN"
}
mode := strings.TrimSpace(req.Mode)
pages := req.Pages
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
taskID, err := h.snowflake.Next(true)
if err != nil {
resp.ERROR(c, "生成任务ID失败:"+err.Error())
return
}
// 1) 下载文件并提取原始文本(Notebook 风格提炼前的输入)
rawText, err := h.extractMaterialTextFromURL(c.Request.Context(), fileURL, ext)
if err != nil || strings.TrimSpace(rawText) == "" {
if err == nil {
err = errors.New("empty extracted text")
}
resp.ERROR(c, "读取文件失败:"+err.Error())
return
}
// 2) 校验算力与页数,组装待写入的 Task(先在提炼前做 power 检查)
task, cfg, err := h.pptService.BuildPendingTask(taskID, user.Id, int(user.Power), rawText, designPrompt, language, mode, pages)
if err != nil {
if errors.Is(err, ppt.ErrInsufficientPower) {
resp.ERROR(c, "当前用户算力不足以完成本次 PPT 生成任务!")
return
}
resp.ERROR(c, "加载 PPT 配置或校验失败:"+err.Error())
return
}
// 3) NotebookLM 风格提炼:rawText -> PPT 可用的 content(大纲/结构化要点)
notebookContent, err := h.pptService.GenerateNotebookContent(c.Request.Context(), cfg, rawText, designPrompt, language)
if err != nil || strings.TrimSpace(notebookContent) == "" {
if err == nil {
err = errors.New("empty notebook content")
}
resp.ERROR(c, "提炼材料失败:"+err.Error())
return
}
task.Content = notebookContent
// 4) 落库 + 异步生成分镜与图片
err = h.pptService.CreateTask(c.Request.Context(), task, cfg)
if err != nil {
resp.ERROR(c, "创建任务失败:"+err.Error())
return
}
go h.pptService.RunTask(context.Background(), task, cfg)
resp.SUCCESS(c, map[string]any{
"task_id": taskID,
"status": ppt.TaskStatusPending,
})
}
func (h *PPTTaskHandler) extractMaterialTextFromURL(ctx context.Context, fileURL string, ext string) (string, error) {
if strings.TrimSpace(fileURL) == "" {
return "", errors.New("empty file url")
}
switch ext {
case ".txt", ".md", ".markdown":
b, status, err := utils.FetchURLBytes(ctx, fileURL, "", 30*time.Second, 2, 8<<20)
if err != nil {
// status=0 时通常是请求阶段错误(例如 TLS 握手超时)
return "", fmt.Errorf("download file failed: status=%d: %w", status, err)
}
return strings.TrimSpace(string(b)), nil
default:
// PDF/Word 等走 Tika
return utils.ReadFileContent(fileURL, h.App.Config.TikaHost)
}
}
// ListTasks 当前用户的 PPT 任务列表(分页)
func (h *PPTTaskHandler) ListTasks(c *gin.Context) {
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
page := h.GetInt(c, "page", 1)
pageSize := h.GetInt(c, "page_size", 20)
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
slice, total := h.pptService.ListUserTasks(c.Request.Context(), user.Id, page, pageSize)
jobs := make([]map[string]any, 0, len(slice))
for _, t := range slice {
job := t.TaskSummaryMap()
job["prompt"] = t.Prompt
if t.ErrorMessage != "" {
job["error_message"] = t.ErrorMessage
}
jobs = append(jobs, job)
}
resp.SUCCESS(c, map[string]any{
"jobs": jobs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// ExportTask 导出任务幻灯片为 PDF 或 PPTX(按图片逐页)
func (h *PPTTaskHandler) ExportTask(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
if taskID == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
ef, ok := ppt.ParseExportFormat(c.Query("format"))
if !ok {
resp.ERROR(c, "format 参数无效,支持 pdf 或 pptx")
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
task, exists := h.pptService.GetTask(taskID)
if !exists {
resp.ERROR(c, "任务不存在")
return
}
if user.Id != task.UserID {
resp.NotAuth(c)
return
}
if task.Status != ppt.TaskStatusCompleted {
resp.ERROR(c, "仅已完成任务可导出")
return
}
ossCfg := types.OSSConfig{}
if h.App.SysConfig != nil {
ossCfg = h.App.SysConfig.OSS
}
data, err := ppt.BuildExportBytes(c.Request.Context(), task.Slides, ef, ossCfg, h.App.Config)
if err != nil {
resp.ERROR(c, err.Error())
return
}
base := ppt.SanitizeExportBaseName(task.Title, task.TaskID)
filename := base + ppt.ExportFileExt(ef)
c.Header("Content-Disposition", ppt.ContentDispositionAttachment(filename))
c.Data(http.StatusOK, ppt.ExportMimeType(ef), data)
}
// ResumeTask 继续生成缺图页(POST /api/v1/tasks/:task_id/resume
func (h *PPTTaskHandler) ResumeTask(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
if taskID == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
err = h.pptService.ResumeTask(c.Request.Context(), taskID, user.Id)
if err != nil {
if errors.Is(err, ppt.ErrPptTaskNotFound) {
resp.ERROR(c, "任务不存在")
return
}
if errors.Is(err, ppt.ErrPptTaskBusy) {
resp.ERROR(c, "任务正在处理中,请稍后再试")
return
}
if errors.Is(err, ppt.ErrPptTaskNotResumable) {
resp.ERROR(c, "当前任务无法继续生成(已完成或分镜数据不完整)")
return
}
if errors.Is(err, ppt.ErrInsufficientPower) {
resp.ERROR(c, "当前用户算力不足以完成剩余图片生成")
return
}
resp.ERROR(c, err.Error())
return
}
resp.SUCCESS(c, map[string]any{
"task_id": taskID,
"status": ppt.TaskStatusProcessing,
})
}
type editSlideImageRequest struct {
Prompt string `json:"prompt"`
}
type activeSlideImageRequest struct {
VersionIndex int `json:"version_index"`
}
// EditSlideImage 图生图编辑当前页(基于激活图)
func (h *PPTTaskHandler) EditSlideImage(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
slideIndexStr := strings.TrimSpace(c.Param("slide_index"))
if taskID == "" || slideIndexStr == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
slideIndex, err := strconv.Atoi(slideIndexStr)
if err != nil || slideIndex < 1 {
resp.ERROR(c, types.InvalidArgs)
return
}
var req editSlideImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
ossCfg := types.OSSConfig{}
if h.App.SysConfig != nil {
ossCfg = h.App.SysConfig.OSS
}
slides, err := h.pptService.EditSlideImage(c.Request.Context(), taskID, user.Id, slideIndex, req.Prompt, ossCfg, h.App.Config)
if err != nil {
if errors.Is(err, ppt.ErrPptTaskNotFound) {
resp.ERROR(c, "任务不存在")
return
}
if errors.Is(err, ppt.ErrPptSlideNotFound) {
resp.ERROR(c, "幻灯片不存在")
return
}
if errors.Is(err, ppt.ErrPptSlideNoImage) {
resp.ERROR(c, "该页暂无配图,无法编辑")
return
}
if errors.Is(err, ppt.ErrInsufficientPower) {
resp.ERROR(c, "当前用户算力不足以完成本次编辑")
return
}
resp.ERROR(c, err.Error())
return
}
resp.SUCCESS(c, map[string]any{"slides": slides})
}
// SetActiveSlideImage 切换当前页激活的历史版本
func (h *PPTTaskHandler) SetActiveSlideImage(c *gin.Context) {
taskID := strings.TrimSpace(c.Param("task_id"))
slideIndexStr := strings.TrimSpace(c.Param("slide_index"))
if taskID == "" || slideIndexStr == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
slideIndex, err := strconv.Atoi(slideIndexStr)
if err != nil || slideIndex < 1 {
resp.ERROR(c, types.InvalidArgs)
return
}
var req activeSlideImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
slides, err := h.pptService.SetActiveSlideVersion(taskID, user.Id, slideIndex, req.VersionIndex)
if err != nil {
if errors.Is(err, ppt.ErrPptTaskNotFound) {
resp.ERROR(c, "任务不存在")
return
}
if errors.Is(err, ppt.ErrPptSlideNotFound) {
resp.ERROR(c, "幻灯片不存在")
return
}
if errors.Is(err, ppt.ErrPptInvalidVersionIndex) {
resp.ERROR(c, "无效的历史版本序号")
return
}
resp.ERROR(c, err.Error())
return
}
resp.SUCCESS(c, map[string]any{"slides": slides})
}
// GetTask 查询 PPT 任务进度
func (h *PPTTaskHandler) GetTask(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
task, ok := h.pptService.GetTask(taskId)
if !ok {
resp.ERROR(c, "任务不存在")
return
}
user, err := h.GetLoginUser(c)
if err != nil || user.Id != task.UserID {
resp.NotAuth(c)
return
}
percentage := 0
if task.Total > 0 {
percentage = int(float64(task.Completed) / float64(task.Total) * 100)
}
resp.SUCCESS(c, map[string]any{
"task_id": task.TaskID,
"status": task.Status,
"progress": map[string]any{
"total_slides": task.Total,
"completed_slides": task.Completed,
"percentage": percentage,
},
"slides": task.Slides,
"error_message": task.ErrorMessage,
"content": task.Content,
"prompt": task.Prompt,
"title": task.Title,
"thumb": task.Thumb,
})
}
// DeleteTask 删除用户 PPT 任务(仅允许 completed / failed),同时删除该任务生成的图片对象。
func (h *PPTTaskHandler) DeleteTask(c *gin.Context) {
taskID := c.Param("task_id")
if taskID == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
if err := h.pptService.DeleteTask(taskID, user.Id); err != nil {
if errors.Is(err, ppt.ErrPptTaskNotFound) {
resp.ERROR(c, "任务不存在")
return
}
if errors.Is(err, ppt.ErrPptTaskNotDeletable) {
resp.ERROR(c, "仅允许删除已完成/已失败任务")
return
}
// 前端会统一在文案里拼接“删除失败:”,这里避免重复。
resp.ERROR(c, err.Error())
return
}
resp.SUCCESS(c, gin.H{"message": "删除成功"})
}