mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-08-12 18:50:58 +00:00
9ccff4efbc
- 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
885 lines
25 KiB
Go
885 lines
25 KiB
Go
package ppt
|
||
|
||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
// * 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/types"
|
||
"geekai/service"
|
||
"geekai/service/oss"
|
||
"geekai/store/model"
|
||
"geekai/store/vo"
|
||
"geekai/utils"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/sync/errgroup"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ErrInsufficientPower 用户算力不足以完成本次 PPT 任务(由 handler 映射文案)
|
||
var ErrInsufficientPower = errors.New("insufficient power for ppt task")
|
||
|
||
var (
|
||
// ErrPptTaskNotFound 表示任务不存在
|
||
ErrPptTaskNotFound = errors.New("ppt task not found")
|
||
// ErrPptTaskNotDeletable 表示任务状态不允许删除
|
||
ErrPptTaskNotDeletable = errors.New("ppt task not deletable")
|
||
// ErrPptTaskBusy 任务正在处理中
|
||
ErrPptTaskBusy = errors.New("ppt task is processing")
|
||
// ErrPptTaskNotResumable 无法继续生成(无分镜占位或已完成)
|
||
ErrPptTaskNotResumable = errors.New("ppt task cannot be resumed")
|
||
// ErrPptSlideNotFound 指定 slide_index 不存在
|
||
ErrPptSlideNotFound = errors.New("ppt slide not found")
|
||
// ErrPptSlideNoImage 该页尚无配图
|
||
ErrPptSlideNoImage = errors.New("ppt slide has no image")
|
||
// ErrPptInvalidVersionIndex 历史版本下标无效
|
||
ErrPptInvalidVersionIndex = errors.New("invalid slide version index")
|
||
)
|
||
|
||
// TaskStatus 任务状态
|
||
type TaskStatus string
|
||
|
||
const (
|
||
TaskStatusPending TaskStatus = "pending"
|
||
TaskStatusProcessing TaskStatus = "processing"
|
||
TaskStatusCompleted TaskStatus = "completed"
|
||
TaskStatusFailed TaskStatus = "failed"
|
||
)
|
||
|
||
// SlideData 单页 PPT 数据
|
||
type SlideData struct {
|
||
SlideIndex int `json:"slide_index"`
|
||
Theme string `json:"theme"`
|
||
Title string `json:"title"`
|
||
Points []string `json:"points"`
|
||
ImagePrompt string `json:"image_prompt"`
|
||
ImageURL string `json:"image_url"`
|
||
ImageHistory []vo.PPTSlideImageVersion `json:"image_history,omitempty"`
|
||
}
|
||
|
||
// Task PPT 生成任务(用于业务层与 API 返回,持久化在 DB)
|
||
type Task struct {
|
||
TaskID string `json:"task_id"`
|
||
UserID uint `json:"user_id"`
|
||
Status TaskStatus `json:"status"`
|
||
Content string `json:"content"`
|
||
Prompt string `json:"prompt"`
|
||
Language string `json:"language"`
|
||
Mode string `json:"mode"`
|
||
Pages int `json:"pages"`
|
||
Total int `json:"total_slides"`
|
||
Completed int `json:"completed_slides"`
|
||
Slides []SlideData `json:"slides"`
|
||
Title string `json:"title"`
|
||
Thumb string `json:"thumb"`
|
||
|
||
ErrorMessage string `json:"error_message"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// TaskSummaryMap 返回任务摘要 map(公共字段),供 handler 补充独有字段后返回。
|
||
func (t *Task) TaskSummaryMap() map[string]any {
|
||
return map[string]any{
|
||
"task_id": t.TaskID,
|
||
"status": t.Status,
|
||
"total_slides": t.Total,
|
||
"completed_slides": t.Completed,
|
||
"created_at": t.CreatedAt.Unix(),
|
||
"updated_at": t.UpdatedAt.Unix(),
|
||
"title": t.Title,
|
||
"thumb": t.Thumb,
|
||
}
|
||
}
|
||
|
||
// Progress 任务进度信息
|
||
type Progress struct {
|
||
Total int `json:"total_slides"`
|
||
Completed int `json:"completed_slides"`
|
||
}
|
||
|
||
// PptService PPT 任务与生成流程(持久化、LLM 分镜、生图、转存、算力)
|
||
type PptService struct {
|
||
db *gorm.DB
|
||
userService *service.UserService
|
||
uploadManager *oss.UploaderManager
|
||
llm *LLMClient
|
||
// slidesLock 全局互斥:串行化 slides JSON 的写库,避免并发覆盖(写库极短,可接受排队)
|
||
slidesLock sync.Mutex
|
||
}
|
||
|
||
// NewPptService 创建 PptService
|
||
func NewPptService(db *gorm.DB, userService *service.UserService, uploadManager *oss.UploaderManager) *PptService {
|
||
return &PptService{
|
||
db: db,
|
||
userService: userService,
|
||
uploadManager: uploadManager,
|
||
llm: NewLLMClient(),
|
||
}
|
||
}
|
||
|
||
// GenerateNotebookContent 将原始文档文本提炼成 PPT 可用的大纲 content。
|
||
func (s *PptService) GenerateNotebookContent(ctx context.Context, cfg types.PPTConfig, rawDocText, designPrompt, language string) (string, error) {
|
||
if s.llm == nil {
|
||
s.llm = NewLLMClient()
|
||
}
|
||
return s.llm.GenerateNotebookContent(ctx, cfg, rawDocText, designPrompt, language)
|
||
}
|
||
|
||
func slideToVO(s SlideData) vo.PPTSlideData {
|
||
return vo.PPTSlideData{
|
||
SlideIndex: s.SlideIndex,
|
||
Theme: s.Theme,
|
||
Title: s.Title,
|
||
Points: s.Points,
|
||
ImagePrompt: s.ImagePrompt,
|
||
ImageURL: s.ImageURL,
|
||
ImageHistory: vo.PPTSlideImageVersions(s.ImageHistory),
|
||
}
|
||
}
|
||
|
||
func voToSlide(s vo.PPTSlideData) SlideData {
|
||
return SlideData{
|
||
SlideIndex: s.SlideIndex,
|
||
Theme: s.Theme,
|
||
Title: s.Title,
|
||
Points: s.Points,
|
||
ImagePrompt: s.ImagePrompt,
|
||
ImageURL: s.ImageURL,
|
||
ImageHistory: []vo.PPTSlideImageVersion(s.ImageHistory),
|
||
}
|
||
}
|
||
|
||
func voSlidesToBiz(slides vo.PPTSlides) []SlideData {
|
||
out := make([]SlideData, len(slides))
|
||
for i, sv := range slides {
|
||
out[i] = voToSlide(sv)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func voSlidesToBizNormalized(slides vo.PPTSlides) []SlideData {
|
||
out := make([]SlideData, len(slides))
|
||
for i, sv := range slides {
|
||
sd := voToSlide(sv)
|
||
normalizeSlideImageHistory(&sd)
|
||
out[i] = sd
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalizeSlideImageHistory 旧数据仅有 image_url 时补全 image_history,便于前端展示历史
|
||
func normalizeSlideImageHistory(s *SlideData) {
|
||
if strings.TrimSpace(s.ImageURL) != "" && len(s.ImageHistory) == 0 {
|
||
s.ImageHistory = []vo.PPTSlideImageVersion{
|
||
{ImageURL: s.ImageURL, Prompt: strings.TrimSpace(s.ImagePrompt)},
|
||
}
|
||
}
|
||
}
|
||
|
||
// DerivePPTThumbFromSlides 按 slide_index 升序取第一张有图 URL(与 vo.PPTSlides 规则一致)
|
||
func DerivePPTThumbFromSlides(slides []SlideData) string {
|
||
if len(slides) == 0 {
|
||
return ""
|
||
}
|
||
cp := make([]SlideData, len(slides))
|
||
copy(cp, slides)
|
||
sort.Slice(cp, func(i, j int) bool {
|
||
return cp[i].SlideIndex < cp[j].SlideIndex
|
||
})
|
||
for _, s := range cp {
|
||
if strings.TrimSpace(s.ImageURL) != "" {
|
||
return s.ImageURL
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func truncateTitleRunes(s string, max int) string {
|
||
if max <= 0 {
|
||
return s
|
||
}
|
||
r := []rune(s)
|
||
if len(r) <= max {
|
||
return s
|
||
}
|
||
return string(r[:max])
|
||
}
|
||
|
||
func taskToModel(t *Task) *model.PPTJob {
|
||
now := time.Now()
|
||
job := &model.PPTJob{
|
||
TaskId: t.TaskID,
|
||
UserId: t.UserID,
|
||
Status: string(t.Status),
|
||
ErrMsg: t.ErrorMessage,
|
||
Prompt: t.Prompt,
|
||
Title: t.Title,
|
||
Thumb: t.Thumb,
|
||
Content: t.Content,
|
||
Params: vo.PPTParams{Language: t.Language, Mode: t.Mode, Pages: t.Pages},
|
||
Slides: nil,
|
||
TotalSlides: t.Total,
|
||
CompletedSlides: t.Completed,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
if t.CreatedAt.IsZero() {
|
||
job.CreatedAt = now
|
||
job.UpdatedAt = now
|
||
} else {
|
||
job.CreatedAt = t.CreatedAt
|
||
job.UpdatedAt = t.UpdatedAt
|
||
}
|
||
return job
|
||
}
|
||
|
||
func modelToTask(j *model.PPTJob) *Task {
|
||
slides := voSlidesToBizNormalized(j.Slides)
|
||
return &Task{
|
||
TaskID: j.TaskId,
|
||
UserID: j.UserId,
|
||
Status: TaskStatus(j.Status),
|
||
Content: j.Content,
|
||
Prompt: j.Prompt,
|
||
Title: j.Title,
|
||
Thumb: j.Thumb,
|
||
Language: j.Params.Language,
|
||
Mode: j.Params.Mode,
|
||
Pages: j.Params.Pages,
|
||
Total: j.TotalSlides,
|
||
Completed: j.CompletedSlides,
|
||
Slides: slides,
|
||
ErrorMessage: j.ErrMsg,
|
||
CreatedAt: j.CreatedAt,
|
||
UpdatedAt: j.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
// BuildPendingTask 校验算力与页数,组装待写入的 Task(未落库)
|
||
func (s *PptService) BuildPendingTask(taskID string, userID uint, userPower int, content, prompt, language, mode string, reqPages int) (*Task, types.PPTConfig, error) {
|
||
cfg, err := s.loadPPTConfig()
|
||
if err != nil {
|
||
return nil, cfg, err
|
||
}
|
||
|
||
// 目标页数:用户指定时取 min(请求页数, 服务端上限);未指定(0)时按服务端上限作为默认生成规模
|
||
effectivePages := cfg.MaxSlidesPerTask
|
||
if reqPages > 0 {
|
||
effectivePages = reqPages
|
||
if effectivePages > cfg.MaxSlidesPerTask {
|
||
effectivePages = cfg.MaxSlidesPerTask
|
||
}
|
||
}
|
||
|
||
estimatePower := effectivePages * cfg.PowerCostPerSlide
|
||
if estimatePower > 0 && userPower < estimatePower {
|
||
return nil, cfg, ErrInsufficientPower
|
||
}
|
||
|
||
effectiveMode := mode
|
||
if effectiveMode != "detailed" && effectiveMode != "slides" {
|
||
effectiveMode = "slides"
|
||
}
|
||
|
||
task := &Task{
|
||
TaskID: taskID,
|
||
UserID: userID,
|
||
Status: TaskStatusPending,
|
||
Content: content,
|
||
Prompt: prompt,
|
||
Language: language,
|
||
Pages: effectivePages,
|
||
Mode: effectiveMode,
|
||
}
|
||
return task, cfg, nil
|
||
}
|
||
|
||
// CreateTask 创建新任务并写入数据库(调用大模型生成列表标题后落库)
|
||
func (s *PptService) CreateTask(ctx context.Context, task *Task, cfg types.PPTConfig) error {
|
||
if s.llm == nil {
|
||
s.llm = NewLLMClient()
|
||
}
|
||
title, err := s.llm.GeneratePPTTitle(ctx, cfg, task.Content, task.Language)
|
||
if err != nil {
|
||
logger.Warnf("GeneratePPTTitle failed task_id=%s: %v", task.TaskID, err)
|
||
title = "未命名演示文稿"
|
||
} else {
|
||
title = strings.TrimSpace(title)
|
||
if title == "" {
|
||
title = "未命名演示文稿"
|
||
}
|
||
}
|
||
task.Title = truncateTitleRunes(title, 255)
|
||
|
||
task.CreatedAt = time.Now()
|
||
task.UpdatedAt = task.CreatedAt
|
||
task.Status = TaskStatusPending
|
||
job := taskToModel(task)
|
||
return s.db.Create(job).Error
|
||
}
|
||
|
||
// GetTask 从数据库获取任务
|
||
func (s *PptService) GetTask(taskID string) (*Task, bool) {
|
||
var job model.PPTJob
|
||
err := s.db.Where("task_id = ?", taskID).First(&job).Error
|
||
if err != nil || job.TaskId == "" {
|
||
return nil, false
|
||
}
|
||
return modelToTask(&job), true
|
||
}
|
||
|
||
// UpdateStatus 更新任务状态
|
||
func (s *PptService) UpdateStatus(taskID string, status TaskStatus) {
|
||
s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).
|
||
Updates(map[string]interface{}{"status": string(status), "updated_at": time.Now()})
|
||
}
|
||
|
||
func countSlidesWithImage(slides []SlideData) int {
|
||
n := 0
|
||
for _, sl := range slides {
|
||
if strings.TrimSpace(sl.ImageURL) != "" {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func slidePlansToOutlines(plans []slidePlan) []SlideData {
|
||
out := make([]SlideData, len(plans))
|
||
for i, p := range plans {
|
||
out[i] = SlideData{
|
||
SlideIndex: p.SlideIndex,
|
||
Theme: p.Theme,
|
||
Title: p.Title,
|
||
Points: p.Points,
|
||
ImagePrompt: p.ImagePrompt,
|
||
ImageURL: "",
|
||
}
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
return out[i].SlideIndex < out[j].SlideIndex
|
||
})
|
||
return out
|
||
}
|
||
|
||
// saveSlidesOutline 分镜一出即落库:每页含 theme/title/points/image_prompt,image_url 为空
|
||
func (s *PptService) saveSlidesOutline(taskID string, total int, slides []SlideData) error {
|
||
s.slidesLock.Lock()
|
||
defer s.slidesLock.Unlock()
|
||
|
||
voSlides := make(vo.PPTSlides, len(slides))
|
||
for i := range slides {
|
||
voSlides[i] = slideToVO(slides[i])
|
||
}
|
||
completed := countSlidesWithImage(slides)
|
||
return s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).Updates(map[string]interface{}{
|
||
"slides": voSlides,
|
||
"total_slides": total,
|
||
"completed_slides": completed,
|
||
"updated_at": time.Now(),
|
||
}).Error
|
||
}
|
||
|
||
// ApplySlideImage 按 slide_index 原地写入 image_url,并刷新 completed_slides、thumb
|
||
func (s *PptService) ApplySlideImage(taskID string, slide SlideData) error {
|
||
s.slidesLock.Lock()
|
||
defer s.slidesLock.Unlock()
|
||
|
||
var job model.PPTJob
|
||
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
|
||
return err
|
||
}
|
||
slides := job.Slides
|
||
found := false
|
||
for i := range slides {
|
||
if slides[i].SlideIndex == slide.SlideIndex {
|
||
slides[i].ImageURL = slide.ImageURL
|
||
if strings.TrimSpace(slide.ImageURL) != "" && len(slides[i].ImageHistory) == 0 {
|
||
slides[i].ImageHistory = vo.PPTSlideImageVersions{
|
||
{ImageURL: slide.ImageURL, Prompt: strings.TrimSpace(slide.ImagePrompt)},
|
||
}
|
||
}
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("slide index %d not found", slide.SlideIndex)
|
||
}
|
||
job.Slides = slides
|
||
biz := voSlidesToBiz(slides)
|
||
return s.refreshJobMeta(&job, biz)
|
||
}
|
||
|
||
// refreshJobMeta 刷新 job 的 CompletedSlides/Thumb/UpdatedAt 并 Save。
|
||
// 调用前必须已持有 slidesLock。
|
||
func (s *PptService) refreshJobMeta(job *model.PPTJob, biz []SlideData) error {
|
||
job.CompletedSlides = countSlidesWithImage(biz)
|
||
job.Thumb = DerivePPTThumbFromSlides(biz)
|
||
job.UpdatedAt = time.Now()
|
||
return s.db.Save(job).Error
|
||
}
|
||
|
||
func (s *PptService) validateSlideOutline(task *Task) error {
|
||
if task.Total <= 0 {
|
||
return ErrPptTaskNotResumable
|
||
}
|
||
if len(task.Slides) < task.Total {
|
||
return ErrPptTaskNotResumable
|
||
}
|
||
seen := make(map[int]bool, len(task.Slides))
|
||
for _, sl := range task.Slides {
|
||
seen[sl.SlideIndex] = true
|
||
}
|
||
for i := 1; i <= task.Total; i++ {
|
||
if !seen[i] {
|
||
return ErrPptTaskNotResumable
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func slidesNeedingImages(task *Task) []SlideData {
|
||
var need []SlideData
|
||
for _, sl := range task.Slides {
|
||
if strings.TrimSpace(sl.ImageURL) == "" {
|
||
need = append(need, sl)
|
||
}
|
||
}
|
||
sort.Slice(need, func(i, j int) bool {
|
||
return need[i].SlideIndex < need[j].SlideIndex
|
||
})
|
||
return need
|
||
}
|
||
|
||
func (s *PptService) userPower(userID uint) (int, error) {
|
||
var u model.User
|
||
if err := s.db.Where("id = ?", userID).First(&u).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return u.Power, nil
|
||
}
|
||
|
||
// runSlideImageJobs 为给定幻灯片列表并发生图(每张成功后 ApplySlideImage)
|
||
func (s *PptService) runSlideImageJobs(ctx context.Context, task *Task, cfg types.PPTConfig, generator ImageGenerator, slides []SlideData) error {
|
||
if len(slides) == 0 {
|
||
return nil
|
||
}
|
||
if cfg.MaxConcurrentRequests <= 0 {
|
||
cfg.MaxConcurrentRequests = 3
|
||
}
|
||
group, ctx := errgroup.WithContext(ctx)
|
||
group.SetLimit(cfg.MaxConcurrentRequests)
|
||
for _, item := range slides {
|
||
slide := item
|
||
group.Go(func() error {
|
||
imgURL, err := generator.Generate(ctx, slide.ImagePrompt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
storedURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(imgURL, ".png", false)
|
||
if err != nil {
|
||
return fmt.Errorf("转存图片失败:%w", err)
|
||
}
|
||
full := slide
|
||
full.ImageURL = storedURL
|
||
if err := s.ApplySlideImage(task.TaskID, full); err != nil {
|
||
return err
|
||
}
|
||
if cfg.PowerCostPerSlide > 0 {
|
||
err = s.userService.DecreasePower(task.UserID, cfg.PowerCostPerSlide, model.PowerLog{
|
||
Type: types.PowerConsume,
|
||
Model: generator.Provider(),
|
||
Remark: fmt.Sprintf("PPT 任务 %s 第 %d 页图片生成", task.TaskID, slide.SlideIndex),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("扣减算力失败:%v", err)
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
return group.Wait()
|
||
}
|
||
|
||
// startSlideImageGenerationAsync 在后台为 missing 页并发生图;若 setProcessingBeforeRun 为 true 则先置为 processing(用户主动 resume)。
|
||
func (s *PptService) startSlideImageGenerationAsync(task *Task, missing []SlideData, setProcessingBeforeRun bool) error {
|
||
if len(missing) == 0 {
|
||
return nil
|
||
}
|
||
cfg, err := s.loadPPTConfig()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
cost := len(missing) * cfg.PowerCostPerSlide
|
||
if cost > 0 {
|
||
power, err := s.userPower(task.UserID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if power < cost {
|
||
return ErrInsufficientPower
|
||
}
|
||
}
|
||
|
||
generator, err := NewImageGenerator(cfg)
|
||
if err != nil {
|
||
return fmt.Errorf("初始化图片生成器失败:%w", err)
|
||
}
|
||
|
||
if setProcessingBeforeRun {
|
||
s.UpdateStatus(task.TaskID, TaskStatusProcessing)
|
||
}
|
||
t := task
|
||
go func() {
|
||
bg := context.Background()
|
||
if err := s.runSlideImageJobs(bg, t, cfg, generator, missing); err != nil {
|
||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("图片生成失败:%v", err))
|
||
return
|
||
}
|
||
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
|
||
}()
|
||
return nil
|
||
}
|
||
|
||
// RecoverStaleProcessingTasks 进程启动时扫描 DB 中仍为 processing 且存在缺图页的任务,重新拉起生图协程(用于服务中断后的恢复)。
|
||
func (s *PptService) RecoverStaleProcessingTasks() {
|
||
var jobs []model.PPTJob
|
||
if err := s.db.Where("status = ?", string(TaskStatusProcessing)).Find(&jobs).Error; err != nil {
|
||
logger.Warnf("PPT recover: list processing jobs failed: %v", err)
|
||
return
|
||
}
|
||
for i := range jobs {
|
||
task := modelToTask(&jobs[i])
|
||
missing := slidesNeedingImages(task)
|
||
if len(missing) == 0 {
|
||
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
|
||
logger.Infof("PPT recover: task %s was processing but all slides had images, marked completed", task.TaskID)
|
||
continue
|
||
}
|
||
if err := s.validateSlideOutline(task); err != nil {
|
||
logger.Warnf("PPT recover: task %s skip (invalid outline): %v", task.TaskID, err)
|
||
continue
|
||
}
|
||
if err := s.startSlideImageGenerationAsync(task, missing, false); err != nil {
|
||
if errors.Is(err, ErrInsufficientPower) {
|
||
logger.Warnf("PPT recover: task %s skip (insufficient power for %d slides)", task.TaskID, len(missing))
|
||
continue
|
||
}
|
||
logger.Warnf("PPT recover: task %s failed to restart: %v", task.TaskID, err)
|
||
continue
|
||
}
|
||
logger.Infof("PPT recover: restarted image generation for task %s (%d slides)", task.TaskID, len(missing))
|
||
}
|
||
}
|
||
|
||
// ResumeTask 继续为缺图页生图(需完整分镜占位;processing 时返回 ErrPptTaskBusy)
|
||
func (s *PptService) ResumeTask(ctx context.Context, taskID string, userID uint) error {
|
||
task, ok := s.GetTask(taskID)
|
||
if !ok {
|
||
return ErrPptTaskNotFound
|
||
}
|
||
if task.UserID != userID {
|
||
return ErrPptTaskNotFound
|
||
}
|
||
if task.Status == TaskStatusProcessing {
|
||
return ErrPptTaskBusy
|
||
}
|
||
if task.Status == TaskStatusCompleted {
|
||
return ErrPptTaskNotResumable
|
||
}
|
||
if err := s.validateSlideOutline(task); err != nil {
|
||
return err
|
||
}
|
||
missing := slidesNeedingImages(task)
|
||
if len(missing) == 0 {
|
||
s.UpdateStatus(taskID, TaskStatusCompleted)
|
||
return nil
|
||
}
|
||
|
||
return s.startSlideImageGenerationAsync(task, missing, true)
|
||
}
|
||
|
||
// MarkAsFailed 标记任务失败
|
||
func (s *PptService) MarkAsFailed(taskID string, msg string) {
|
||
s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).
|
||
Updates(map[string]interface{}{"status": string(TaskStatusFailed), "err_msg": msg, "updated_at": time.Now()})
|
||
}
|
||
|
||
// DeleteTask 删除任务并删除关联生成图片
|
||
// 仅允许删除 completed / failed 状态的任务,避免并发任务生成过程被打断。
|
||
func (s *PptService) DeleteTask(taskID string, userID uint) error {
|
||
var job model.PPTJob
|
||
if err := s.db.Where("task_id = ? AND user_id = ?", taskID, userID).First(&job).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) || job.TaskId == "" {
|
||
return ErrPptTaskNotFound
|
||
}
|
||
return err
|
||
}
|
||
|
||
if job.Status != string(TaskStatusCompleted) && job.Status != string(TaskStatusFailed) {
|
||
return ErrPptTaskNotDeletable
|
||
}
|
||
|
||
// 删除所有幻灯片对应的图片对象。
|
||
uploader := s.uploadManager.GetUploadHandler()
|
||
for _, slide := range job.Slides {
|
||
if slide.ImageURL == "" {
|
||
continue
|
||
}
|
||
if err := uploader.Delete(slide.ImageURL); err != nil {
|
||
// 图片可能已过期/不存在/对象已被清理,此时不应阻断“删除任务记录”的主流程。
|
||
// 这里只记录日志,确保数据库记录被删除后前端认为任务删除成功。
|
||
logger.Warnf("delete ppt image failed (task_id=%s, url=%s): %v", taskID, slide.ImageURL, err)
|
||
}
|
||
}
|
||
|
||
// 最后删除任务记录(slides 会随之从数据库消失)。
|
||
return s.db.Where("task_id = ? AND user_id = ?", taskID, userID).Delete(&model.PPTJob{}).Error
|
||
}
|
||
|
||
// List 返回所有任务列表(从数据库按创建时间倒序),供调用方按用户/状态过滤与分页
|
||
func (s *PptService) List() []*Task {
|
||
var jobs []model.PPTJob
|
||
s.db.Order("created_at DESC").Find(&jobs)
|
||
tasks := make([]*Task, 0, len(jobs))
|
||
for i := range jobs {
|
||
tasks = append(tasks, modelToTask(&jobs[i]))
|
||
}
|
||
sort.Slice(tasks, func(i, j int) bool {
|
||
return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
|
||
})
|
||
return tasks
|
||
}
|
||
|
||
// ListUserTasks 当前用户的任务分页列表(缺 title/thumb 时补全并写库)
|
||
func (s *PptService) ListUserTasks(ctx context.Context, userID uint, page, pageSize int) ([]*Task, int) {
|
||
all := s.List()
|
||
filtered := make([]*Task, 0, len(all))
|
||
for _, t := range all {
|
||
if t.UserID == userID {
|
||
filtered = append(filtered, t)
|
||
}
|
||
}
|
||
total := len(filtered)
|
||
start := (page - 1) * pageSize
|
||
if start > total {
|
||
start = total
|
||
}
|
||
end := start + pageSize
|
||
if end > total {
|
||
end = total
|
||
}
|
||
slice := filtered[start:end]
|
||
cfg, cfgErr := s.loadPPTConfig()
|
||
if cfgErr != nil {
|
||
logger.Warnf("ListUserTasks loadPPTConfig: %v", cfgErr)
|
||
}
|
||
if s.llm == nil {
|
||
s.llm = NewLLMClient()
|
||
}
|
||
for _, t := range slice {
|
||
s.ensureTaskMeta(ctx, t, cfg)
|
||
}
|
||
return slice, total
|
||
}
|
||
|
||
// EnsureTaskMeta 对外暴露标题/缩略图补全逻辑,供管理端列表/详情复用。
|
||
func (s *PptService) EnsureTaskMeta(ctx context.Context, task *Task) {
|
||
if task == nil {
|
||
return
|
||
}
|
||
cfg, cfgErr := s.loadPPTConfig()
|
||
if cfgErr != nil {
|
||
logger.Warnf("EnsureTaskMeta loadPPTConfig: %v", cfgErr)
|
||
}
|
||
if s.llm == nil {
|
||
s.llm = NewLLMClient()
|
||
}
|
||
s.ensureTaskMeta(ctx, task, cfg)
|
||
}
|
||
|
||
func (s *PptService) ensureTaskMeta(ctx context.Context, task *Task, cfg types.PPTConfig) {
|
||
updates := map[string]interface{}{}
|
||
if strings.TrimSpace(task.Title) == "" && strings.TrimSpace(task.Content) != "" {
|
||
title := "未命名演示文稿"
|
||
if cfg.OutlineLLMApiURL != "" && cfg.OutlineLLMApiKey != "" {
|
||
ti, err := s.llm.GeneratePPTTitle(ctx, cfg, task.Content, task.Language)
|
||
if err != nil {
|
||
logger.Warnf("ensureTaskMeta GeneratePPTTitle task_id=%s: %v", task.TaskID, err)
|
||
} else {
|
||
ti = strings.TrimSpace(ti)
|
||
if ti != "" {
|
||
title = truncateTitleRunes(ti, 255)
|
||
}
|
||
}
|
||
}
|
||
task.Title = title
|
||
updates["title"] = task.Title
|
||
}
|
||
if task.Thumb == "" && len(task.Slides) > 0 {
|
||
thumb := DerivePPTThumbFromSlides(task.Slides)
|
||
if thumb != "" {
|
||
task.Thumb = thumb
|
||
updates["thumb"] = thumb
|
||
}
|
||
}
|
||
if len(updates) > 0 {
|
||
updates["updated_at"] = time.Now()
|
||
_ = s.db.Model(&model.PPTJob{}).Where("task_id = ?", task.TaskID).Updates(updates).Error
|
||
}
|
||
}
|
||
|
||
// ListAdminJobs 管理后台任务列表(筛选 + 分页)
|
||
func (s *PptService) ListAdminJobs(ctx context.Context, page, pageSize, filterUserID int, status string) ([]*Task, int) {
|
||
items := s.List()
|
||
filtered := make([]*Task, 0, len(items))
|
||
for _, t := range items {
|
||
if filterUserID > 0 && int(t.UserID) != filterUserID {
|
||
continue
|
||
}
|
||
if status != "" && string(t.Status) != status {
|
||
continue
|
||
}
|
||
filtered = append(filtered, t)
|
||
}
|
||
total := len(filtered)
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start > total {
|
||
start = total
|
||
}
|
||
end := start + pageSize
|
||
if end > total {
|
||
end = total
|
||
}
|
||
slice := filtered[start:end]
|
||
for _, t := range slice {
|
||
s.EnsureTaskMeta(ctx, t)
|
||
}
|
||
return slice, total
|
||
}
|
||
|
||
// Stats 任务状态统计(管理后台)
|
||
func (s *PptService) Stats() (total, completed, processing, failed, pending int64) {
|
||
for _, t := range s.List() {
|
||
total++
|
||
switch t.Status {
|
||
case TaskStatusCompleted:
|
||
completed++
|
||
case TaskStatusProcessing:
|
||
processing++
|
||
case TaskStatusFailed:
|
||
failed++
|
||
case TaskStatusPending:
|
||
pending++
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// RunTask 执行 PPT 生成:分镜、并发生图、转存图片、扣算力
|
||
func (s *PptService) RunTask(ctx context.Context, task *Task, cfg types.PPTConfig) {
|
||
s.UpdateStatus(task.TaskID, TaskStatusProcessing)
|
||
|
||
maxPages := task.Pages
|
||
if maxPages <= 0 {
|
||
maxPages = cfg.MaxSlidesPerTask
|
||
}
|
||
plans, err := s.llm.GenerateSlides(ctx, cfg, task.Content, task.Prompt, task.Language, task.Mode, maxPages)
|
||
if err != nil {
|
||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("生成分镜失败:%v", err))
|
||
return
|
||
}
|
||
|
||
if len(plans) == 0 {
|
||
s.MarkAsFailed(task.TaskID, "分镜结果为空")
|
||
return
|
||
}
|
||
|
||
total := len(plans)
|
||
if cfg.MaxSlidesPerTask > 0 && total > cfg.MaxSlidesPerTask {
|
||
plans = plans[:cfg.MaxSlidesPerTask]
|
||
total = len(plans)
|
||
}
|
||
|
||
outlines := slidePlansToOutlines(plans)
|
||
if err := s.saveSlidesOutline(task.TaskID, total, outlines); err != nil {
|
||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("保存分镜占位失败:%v", err))
|
||
return
|
||
}
|
||
|
||
generator, err := NewImageGenerator(cfg)
|
||
if err != nil {
|
||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("初始化图片生成器失败:%v", err))
|
||
return
|
||
}
|
||
|
||
if err := s.runSlideImageJobs(ctx, task, cfg, generator, outlines); err != nil {
|
||
s.MarkAsFailed(task.TaskID, fmt.Sprintf("图片生成失败:%v", err))
|
||
return
|
||
}
|
||
|
||
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
|
||
}
|
||
|
||
func (s *PptService) loadPPTConfig() (types.PPTConfig, error) {
|
||
var cfgModel model.Config
|
||
var pptCfg types.PPTConfig
|
||
|
||
err := s.db.Where("name", types.ConfigKeyPPT).First(&cfgModel).Error
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
pptCfg.MaxSlidesPerTask = 30
|
||
pptCfg.MaxConcurrentRequests = 3
|
||
pptCfg.QPSLimit = 1
|
||
pptCfg.PowerCostPerSlide = 0
|
||
return pptCfg, nil
|
||
}
|
||
return pptCfg, err
|
||
}
|
||
|
||
err = utils.JsonDecode(cfgModel.Value, &pptCfg)
|
||
if err != nil {
|
||
return pptCfg, err
|
||
}
|
||
|
||
legacyMax10 := pptCfg.MaxSlidesPerTask == 10
|
||
if pptCfg.MaxSlidesPerTask <= 0 {
|
||
pptCfg.MaxSlidesPerTask = 30
|
||
}
|
||
if legacyMax10 {
|
||
// 与前端 PPT 页数控件 max=30 对齐;历史默认 10 会导致用户选择 12/15 仍被截断为 10
|
||
pptCfg.MaxSlidesPerTask = 30
|
||
}
|
||
if pptCfg.MaxConcurrentRequests <= 0 {
|
||
pptCfg.MaxConcurrentRequests = 3
|
||
}
|
||
if pptCfg.QPSLimit <= 0 {
|
||
pptCfg.QPSLimit = 1
|
||
}
|
||
|
||
if legacyMax10 {
|
||
val := utils.JsonEncode(pptCfg)
|
||
_ = s.db.Model(&model.Config{}).Where("name = ?", types.ConfigKeyPPT).Update("value", val)
|
||
}
|
||
|
||
return pptCfg, nil
|
||
}
|