Files
geekai/api/service/ppt/ppt_service.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

885 lines
25 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 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_promptimage_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
}