在线PPT后端服务设计

在线PPT后端服务设计

概述

在线PPT编辑器的后端服务负责PPT数据的存储、AI生成、PPTX导入导出等核心功能。本文将深入介绍PPT后端服务的设计与实现,包括数据模型、AI生成流程、文件处理等关键环节。

服务架构

技术栈

技术 版本 用途
Node.js ES Module 运行环境
Fastify 5.2.1 Web框架
MongoDB - 数据存储
Mongoose 8.10.0 ODM
Playwright 1.55.0 浏览器自动化
pptxtojson 2.0.0 PPTX解析
pptxgenjs 3.12.0 PPTX生成

服务结构

pptonline.api/
├── app.js                      # Fastify应用入口
├── server.js                   # 独立启动入口
├── routes/
│   ├── ppt/root.js            # PPT基础API
│   └── ppt-my/root.js         # 用户PPT管理API
├── services/
│   ├── core/
│   │   ├── llm.js             # AI PPT生成核心
│   │   ├── pptInfo.js         # PPT信息CRUD
│   │   ├── importPptx.js      # PPTX导入(724行)
│   │   └── tmpl.js            # 模板管理
│   ├── agent/                 # Agent任务框架
│   └── console-mock/          # Mock服务
├── daos/
│   └── core/
│       ├── schema/
│       │   └── pptInfoSchema.js
│       └── dac/
│           └── pptInfoDac.js
├── grpc/
│   ├── servers/
│   │   ├── pptInfo.js         # PPT信息gRPC服务
│   │   └── agentExecutor.js   # Agent执行服务
│   └── clients/               # gRPC客户端
├── types/
│   ├── shapes.js              # 形状定义(1010行)
│   └── slides.js              # 幻灯片类型
└── conf/
    └── system.config.js       # 系统配置

核心数据模型

PPT信息模型

// daos/core/schema/pptInfoSchema.js
import mongoose from 'mongoose'

const PptInfoSchema = new mongoose.Schema({
  // 基本信息
  pptCode: { type: String, required: true, unique: true },
  title: { type: String, required: true },
  description: { type: String, default: '' },
  
  // 创建信息
  createType: { 
    type: String, 
    enum: ['blank', 'template', 'ai', 'import'],
    default: 'blank'
  },
  
  // 内容数据(JSON格式存储完整PPT)
  content: {
    type: mongoose.Schema.Types.Mixed,
    default: () => ({
      slides: [],
      theme: {},
      viewport: { width: 1000, height: 562.5 }
    })
  },
  
  // 统计信息
  slideCount: { type: Number, default: 0 },
  wordCount: { type: Number, default: 0 },
  
  // 文件信息(导入时使用)
  fileInfo: {
    originalName: String,
    fileSize: Number,
    filePath: String,
    mimeType: String,
  },
  
  // AI生成信息
  aiInfo: {
    outline: String,           // 生成大纲
    model: String,             // 使用的模型
    prompt: String,            // 提示词
    generatedAt: Date,
  },
  
  // 模板信息
  templateInfo: {
    templateCode: String,
    templateName: String,
  },
  
  // 权限
  ownerId: { type: String, required: true },
  collaborators: [{
    userId: String,
    role: { type: String, enum: ['viewer', 'editor', 'admin'] },
    addedAt: Date,
  }],
  
  // 状态
  status: { 
    type: String, 
    enum: ['active', 'archived', 'deleted'],
    default: 'active'
  },
  
  // 标签
  tags: [String],
  
  // 时间戳
  createdAt: { type: Date, default: Date.now },
  updatedAt: { type: Date, default: Date.now },
}, {
  timestamps: true,
  indexes: [
    { ownerId: 1, updatedAt: -1 },
    { status: 1, createType: 1 },
    { tags: 1 },
  ],
})

export default mongoose.model('PptInfo', PptInfoSchema)

幻灯片数据结构

// types/slides.js
// 幻灯片类型
export const SlideType = {
  BLANK: 'blank',           // 空白页
  TITLE: 'title',           // 标题页
  CONTENT: 'content',       // 内容页
  TWO_COLUMN: 'two_column', // 两栏页
  PICTURE: 'picture',       // 图文页
  CHART: 'chart',           // 图表页
  TABLE: 'table',           // 表格页
  END: 'end',               // 结束页
}

// 幻灯片结构
export const SlideSchema = {
  id: String,               // 幻灯片ID
  type: String,             // 幻灯片类型
  elements: [Element],      // 元素列表
  background: Background,   // 背景设置
  animations: [Animation],  // 动画效果
  notes: String,            // 演讲者备注
  transition: Transition,   // 切换效果
}

// 元素类型
export const ElementTypes = {
  TEXT: 'text',
  IMAGE: 'image',
  SHAPE: 'shape',
  CHART: 'chart',
  TABLE: 'table',
  VIDEO: 'video',
  AUDIO: 'audio',
}

// 元素基类
export const BaseElement = {
  id: String,
  type: String,
  left: Number,
  top: Number,
  width: Number,
  height: Number,
  rotation: Number,
  zIndex: Number,
}

AI PPT生成

1. 生成流程

用户输入主题
    ↓
生成PPT大纲(Markdown格式)
    ↓
选择/匹配模板
    ↓
生成PPT内容(JSON格式)
    ↓
合成PPT对象(Playwright渲染)
    ↓
存储到MongoDB

2. 大纲生成服务

// services/core/llm.js
import OpenAI from 'openai'

class LLMService {
  constructor(fastify) {
    this.fastify = fastify
    this.openai = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
    })
  }

  // 生成PPT大纲
  async generateOutline(topic, pages = 10, style = 'academic') {
    const stylePrompts = {
      academic: '学术风格,客观、严谨、专业',
      business: '职场风格,简洁、决策导向',
      education: '教育风格,易懂、互动、分层讲解',
      marketing: '营销风格,有吸引力、情感化',
      general: '通用风格,中性、普适、清晰',
    }

    const prompt = `请为"${topic}"主题生成一个PPT大纲,共${pages}页。

要求:
1. ${stylePrompts[style]}
2. 包含封面、目录、正文内容、总结/结束页
3. 每页需要标题和简要内容描述
4. 使用Markdown格式输出

请按以下格式输出:

# ${topic}

## 第1页:封面
- 主标题:xxx
- 副标题:xxx

## 第2页:目录
- 目录项1
- 目录项2
...

## 第3页:xxx(标题)
- 要点1
- 要点2
- 要点3

...

## 第${pages}页:总结/结束
- 核心观点
- 感谢语
`

    const response = await this.openai.chat.completions.create({
      model: 'gpt-4',
      messages: [
        { role: 'system', content: '你是一位专业的PPT设计师,擅长根据主题生成结构清晰、内容丰富的PPT大纲。' },
        { role: 'user', content: prompt },
      ],
      temperature: 0.7,
    })

    return response.choices[0].message.content
  }

  // 解析大纲为结构化数据
  parseOutline(outlineText) {
    const pages = []
    const lines = outlineText.split('\n')
    let currentPage = null

    for (const line of lines) {
      const trimmed = line.trim()
      if (!trimmed) continue

      // 匹配页标题:## 第X页:标题
      const pageMatch = trimmed.match(/^## 第(\d+)页[::]\s*(.+)$/)
      if (pageMatch) {
        if (currentPage) pages.push(currentPage)
        currentPage = {
          pageNumber: parseInt(pageMatch[1]),
          title: pageMatch[2],
          content: [],
        }
      }
      // 匹配内容项:- 内容
      else if (trimmed.startsWith('- ') && currentPage) {
        currentPage.content.push(trimmed.substring(2))
      }
      // 匹配主标题:# 标题
      else if (trimmed.startsWith('# ')) {
        // 封面标题
        if (!currentPage) {
          currentPage = {
            pageNumber: 1,
            title: '封面',
            content: [trimmed.substring(2)],
          }
        }
      }
    }

    if (currentPage) pages.push(currentPage)
    return pages
  }
}

3. PPT内容生成

// services/core/llm.js (续)

class LLMService {
  // 根据大纲生成完整PPT内容
  async generatePPTContent(outline, template, style = 'academic') {
    const pages = this.parseOutline(outline)
    const slides = []

    for (const page of pages) {
      const slide = await this.generateSlide(page, template, style)
      slides.push(slide)
    }

    return {
      slides,
      theme: template.theme,
      viewport: { width: 1000, height: 562.5 },
    }
  }

  // 生成单页幻灯片
  async generateSlide(page, template, style) {
    const pageType = this.determinePageType(page)
    const prompt = `请为以下PPT页面生成详细内容:

页面标题:${page.title}
页面类型:${pageType}
内容要点:
${page.content.map(c => `- ${c}`).join('\n')}

风格:${style}

请生成JSON格式的幻灯片内容,包含:
1. 页面类型
2. 元素布局(文本框、图片位置)
3. 文本内容(HTML格式)
4. 样式设置

模板参考:
${JSON.stringify(template.slides[pageType] || template.slides.content, null, 2)}
`

    const response = await this.openai.chat.completions.create({
      model: 'gpt-4',
      messages: [
        { role: 'system', content: '你是一位PPT设计专家,擅长将大纲转换为精美的幻灯片JSON数据。' },
        { role: 'user', content: prompt },
      ],
      temperature: 0.7,
      response_format: { type: 'json_object' },
    })

    const content = JSON.parse(response.choices[0].message.content)
    return this.validateSlide(content)
  }

  // 判断页面类型
  determinePageType(page) {
    if (page.pageNumber === 1) return 'title'
    if (page.title.includes('目录')) return 'table_of_contents'
    if (page.title.includes('总结') || page.title.includes('结束')) return 'end'
    if (page.content.length >= 4) return 'content'
    return 'content'
  }

  // 验证和清理幻灯片数据
  validateSlide(slide) {
    // 确保必需的字段
    const validated = {
      id: generateUUID(),
      type: slide.type || 'content',
      elements: slide.elements || [],
      background: slide.background || { type: 'color', value: '#ffffff' },
    }

    // 验证元素
    validated.elements = validated.elements.map(el => ({
      id: el.id || generateUUID(),
      type: el.type || 'text',
      left: el.left || 50,
      top: el.top || 50,
      width: el.width || 400,
      height: el.height || 100,
      ...el,
    }))

    return validated
  }
}

4. Agent任务编排

// services/agent/index.js
class AgentExecutor {
  constructor(fastify) {
    this.fastify = fastify
    this.handlers = new Map()
    this.registerHandlers()
  }

  registerHandlers() {
    this.handlers.set('llm', new LLMHandler(this.fastify))
    this.handlers.set('ppt-compose', new PPTComposeHandler(this.fastify))
    this.handlers.set('grpc', new GRPCHandler(this.fastify))
  }

  // 执行AI PPT生成任务
  async executeAIPPTGeneration(agentTask) {
    const { subAgents } = agentTask

    for (const subAgent of subAgents) {
      const handler = this.handlers.get(subAgent.agentType)
      if (!handler) {
        throw new Error(`Unknown agent type: ${subAgent.agentType}`)
      }

      subAgent.status = 'running'
      await this.updateAgentStatus(agentTask.agentCode, subAgent)

      try {
        const result = await handler.execute(subAgent)
        subAgent.status = 'completed'
        subAgent.output = result
        subAgent.completedAt = new Date()
      } catch (error) {
        subAgent.status = 'failed'
        subAgent.error = error.message
        throw error
      }

      await this.updateAgentStatus(agentTask.agentCode, subAgent)
    }

    return agentTask
  }
}

// PPT合成处理器
class PPTComposeHandler {
  constructor(fastify) {
    this.fastify = fastify
    this.playwright = null
  }

  async execute(subAgent) {
    const { slides, template } = subAgent.input

    // 使用Playwright渲染PPT
    const pptData = await this.composePPT(slides, template)

    return pptData
  }

  async composePPT(slides, template) {
    // 启动Playwright
    const { chromium } = require('playwright')
    const browser = await chromium.launch()
    const context = await browser.newContext()
    const page = await context.newPage()

    // 设置视口
    await page.setViewportSize({ width: 1000, height: 562.5 })

    // 加载PPTist编辑器页面
    await page.goto(process.env.PPTIST_EDITOR_URL)

    // 注入PPT数据
    await page.evaluate((data) => {
      window.loadPPTData(data)
    }, { slides, theme: template.theme })

    // 等待渲染完成
    await page.waitForTimeout(1000)

    // 生成缩略图
    const thumbnails = []
    for (let i = 0; i < slides.length; i++) {
      await page.evaluate((index) => {
        window.setCurrentSlide(index)
      }, i)

      const screenshot = await page.screenshot({
        type: 'png',
        encoding: 'base64',
      })
      thumbnails.push(screenshot)
    }

    await browser.close()

    return {
      slides,
      thumbnails,
      slideCount: slides.length,
    }
  }
}

PPTX导入导出

1. PPTX导入

// services/core/importPptx.js
import { extractPptx } from 'pptxtojson'
import { chromium } from 'playwright'

class PPTXImporter {
  constructor(fastify) {
    this.fastify = fastify
  }

  async import(filePath) {
    // 第一步:使用pptxtojson解析PPTX
    const parsed = await extractPptx(filePath)

    // 第二步:使用Playwright处理复杂样式
    const processed = await this.processWithPlaywright(parsed)

    // 第三步:转换为内部格式
    const slides = this.convertToInternalFormat(processed)

    return {
      title: parsed.title || '导入的PPT',
      slideCount: slides.length,
      slides,
    }
  }

  async processWithPlaywright(parsed) {
    const browser = await chromium.launch()
    const page = await browser.newPage()

    const processedSlides = []

    for (const slide of parsed.slides) {
      // 构建HTML
      const html = this.buildSlideHTML(slide)

      // 使用Playwright渲染并计算样式
      await page.setContent(html)

      // 获取计算后的样式
      const computedStyles = await page.evaluate(() => {
        const elements = document.querySelectorAll('[data-ppt-element]')
        return Array.from(elements).map(el => {
          const rect = el.getBoundingClientRect()
          const style = window.getComputedStyle(el)
          return {
            id: el.dataset.id,
            left: rect.left,
            top: rect.top,
            width: rect.width,
            height: rect.height,
            fontSize: style.fontSize,
            color: style.color,
            backgroundColor: style.backgroundColor,
          }
        })
      })

      processedSlides.push({
        ...slide,
        computedStyles,
      })
    }

    await browser.close()
    return processedSlides
  }

  convertToInternalFormat(processedSlides) {
    return processedSlides.map((slide, index) => ({
      id: generateUUID(),
      type: this.determineSlideType(slide),
      elements: slide.elements.map(el => this.convertElement(el)),
      background: this.convertBackground(slide.background),
    }))
  }

  convertElement(element) {
    const base = {
      id: element.id || generateUUID(),
      left: element.left || 0,
      top: element.top || 0,
      width: element.width || 100,
      height: element.height || 50,
    }

    switch (element.type) {
      case 'text':
        return {
          ...base,
          type: 'text',
          content: element.text || '',
          defaultFontName: element.fontName || '微软雅黑',
          defaultColor: element.color || '#000000',
          fontSize: element.fontSize || 18,
        }
      case 'image':
        return {
          ...base,
          type: 'image',
          src: element.src || '',
        }
      case 'shape':
        return {
          ...base,
          type: 'shape',
          shape: element.shape || 'rect',
          fill: element.fill || '#ffffff',
        }
      default:
        return base
    }
  }
}

2. PPTX导出

// services/core/exportPptx.js
import PptxGenJS from 'pptxgenjs'

class PPTXExporter {
  async export(slides) {
    const pres = new PptxGenJS()

    // 设置默认主题
    pres.layout = 'LAYOUT_16x9'
    pres.author = 'PPTist'
    pres.company = 'Micro Platform'

    for (const slide of slides) {
      const pptxSlide = pres.addSlide()

      // 设置背景
      if (slide.background) {
        this.setBackground(pptxSlide, slide.background)
      }

      // 添加元素
      for (const element of slide.elements) {
        await this.addElement(pptxSlide, element)
      }
    }

    return pres
  }

  setBackground(pptxSlide, background) {
    if (background.type === 'color') {
      pptxSlide.background = { color: background.value }
    } else if (background.type === 'image') {
      pptxSlide.background = { path: background.src }
    }
  }

  async addElement(pptxSlide, element) {
    const { type, left, top, width, height } = element

    // 转换为PPTX单位(百分比)
    const x = left / 1000
    const y = top / 562.5
    const w = width / 1000
    const h = height / 562.5

    switch (type) {
      case 'text':
        pptxSlide.addText(element.content, {
          x, y, w, h,
          fontSize: element.fontSize || 18,
          fontFace: element.defaultFontName || 'Arial',
          color: this.rgbToHex(element.defaultColor),
          align: element.align || 'left',
        })
        break

      case 'image':
        // 下载图片
        const imageData = await this.downloadImage(element.src)
        pptxSlide.addImage({
          data: imageData,
          x, y, w, h,
        })
        break

      case 'shape':
        pptxSlide.addShape(element.shape, {
          x, y, w, h,
          fill: this.rgbToHex(element.fill),
        })
        break

      case 'chart':
        pptxSlide.addChart(element.chartType, element.data, {
          x, y, w, h,
          ...element.options,
        })
        break

      case 'table':
        pptxSlide.addTable(element.data, {
          x, y, w, h,
          fontSize: 12,
          border: { pt: 1, color: 'cccccc' },
        })
        break
    }
  }

  rgbToHex(color) {
    // 转换颜色格式
    if (color.startsWith('#')) return color.substring(1)
    if (color.startsWith('rgb')) {
      const matches = color.match(/\d+/g)
      if (matches) {
        const [r, g, b] = matches.map(Number)
        return ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')
      }
    }
    return '000000'
  }

  async downloadImage(url) {
    // 下载图片并转为base64
    const response = await fetch(url)
    const buffer = await response.arrayBuffer()
    return 'data:image/png;base64,' + Buffer.from(buffer).toString('base64')
  }
}

gRPC服务

PPT信息gRPC服务

// grpc/servers/pptInfo.js
import grpc from '@grpc/grpc-js'
import protoLoader from '@grpc/proto-loader'

const PROTO_PATH = './grpc/proto/pptInfo.proto'

const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
})

const pptProto = grpc.loadPackageDefinition(packageDefinition).ppt

export default async function pptInfoServer(fastify) {
  const server = new grpc.Server()

  server.addService(pptProto.PptInfoService.service, {
    // 创建PPT
    createPPT: async (call, callback) => {
      try {
        const { title, ownerId, createType } = call.request

        const ppt = await fastify.pptInfoService.create({
          pptCode: generateUUID(),
          title,
          ownerId,
          createType,
          content: {
            slides: [],
            theme: {},
            viewport: { width: 1000, height: 562.5 },
          },
        })

        callback(null, {
          pptCode: ppt.pptCode,
          title: ppt.title,
          status: 'success',
        })
      } catch (error) {
        callback(error)
      }
    },

    // 获取PPT
    getPPT: async (call, callback) => {
      try {
        const { pptCode } = call.request

        const ppt = await fastify.pptInfoService.getByCode(pptCode)
        if (!ppt) {
          return callback({
            code: grpc.status.NOT_FOUND,
            message: 'PPT not found',
          })
        }

        callback(null, {
          pptCode: ppt.pptCode,
          title: ppt.title,
          content: JSON.stringify(ppt.content),
          slideCount: ppt.slideCount,
          updatedAt: ppt.updatedAt.toISOString(),
        })
      } catch (error) {
        callback(error)
      }
    },

    // 复制PPT
    copyPPT: async (call, callback) => {
      try {
        const { pptCode, newOwnerId } = call.request

        const original = await fastify.pptInfoService.getByCode(pptCode)
        if (!original) {
          return callback({
            code: grpc.status.NOT_FOUND,
            message: 'Original PPT not found',
          })
        }

        const copy = await fastify.pptInfoService.create({
          pptCode: generateUUID(),
          title: `${original.title} (副本)`,
          ownerId: newOwnerId,
          createType: 'copy',
          content: original.content,
          slideCount: original.slideCount,
        })

        callback(null, {
          pptCode: copy.pptCode,
          title: copy.title,
          status: 'success',
        })
      } catch (error) {
        callback(error)
      }
    },

    // 导入PPT
    importPPT: async (call, callback) => {
      try {
        const { filePath, ownerId } = call.request

        const importer = new PPTXImporter(fastify)
        const result = await importer.import(filePath)

        const ppt = await fastify.pptInfoService.create({
          pptCode: generateUUID(),
          title: result.title,
          ownerId,
          createType: 'import',
          content: {
            slides: result.slides,
            theme: {},
            viewport: { width: 1000, height: 562.5 },
          },
          slideCount: result.slideCount,
        })

        callback(null, {
          pptCode: ppt.pptCode,
          title: ppt.title,
          slideCount: ppt.slideCount,
          status: 'success',
        })
      } catch (error) {
        callback(error)
      }
    },
  })

  return server
}

总结

PPT后端服务的设计要点:

  1. JSON-First架构:PPT数据完全以JSON格式存储和处理
  2. AI集成:从大纲生成到内容填充的完整AI工作流
  3. Playwright渲染:服务端浏览器渲染处理复杂样式
  4. Agent编排:多步骤任务的自动执行
  5. 格式转换:完整的PPTX导入导出支持
  6. gRPC通信:高性能服务间调用

下一篇将介绍企业级PDF阅读器的架构设计。

阅读更多

Skills系统:可扩展AI能力设计

Skills系统:可扩展AI能力设计

概述 Skills系统是AI-Native架构中的重要组件,它允许通过声明式配置扩展AI的能力。本文将介绍Skills系统的设计与实现,让大模型能够像人类专家一样具备特定领域的能力。 什么是Skills系统 概念 Skills(技能)是一种声明式的AI能力扩展机制,类似于人类的"专业技能": 通用AI助手 专业AI助手(带Skills) ┌──────────────────────┐ ┌──────────────────────────────┐ │ │ │ │ │ 用户:请帮我写代码 │ │ 用户:请帮我审查这段代码 │ │ │ │ │ │ AI:我是一个AI助手 │ │ AI:[激活

By 菱角
插件化架构设计模式

插件化架构设计模式

概述 插件化架构是一种将核心功能与扩展功能分离的设计模式,允许系统在运行时动态加载和卸载功能模块。本文将介绍如何在微服务平台中设计和实现插件化架构。 为什么需要插件化 插件化优势 1. 模块化:功能独立,边界清晰 2. 可扩展:按需加载,动态增删 3. 隔离性:插件间互不干扰 4. 可维护:独立开发、测试、部署 5. 可定制:用户按需选择功能 核心设计 架构概览 核心组件实现 1. 插件接口定义 // core/plugin.interface.ts // 插件接口 export interface IPlugin { // 插件名称 readonly name: string // 插件版本 readonly version: string // 插件配置 getConfig(): PluginConfig // 插件清单

By 菱角
gRPC服务通信设计与实践

gRPC服务通信设计与实践

概述 在微服务架构中,服务间通信是关键环节。相比REST API,gRPC提供了更高的性能和更强的类型安全。本文将介绍如何在微服务平台中设计和实现gRPC服务通信。 为什么选择gRPC gRPC vs REST对比 特性 gRPC REST 协议 HTTP/2 HTTP/1.1 序列化 Protocol Buffers (二进制) JSON (文本) 性能 高(二进制+压缩) 中(文本开销) 类型安全 强(代码生成) 弱(运行时检查) 流式通信 原生支持(双向流) 需额外实现(SSE/WebSocket) 代码生成 自动生成 手动编写 浏览器支持 需gRPC-Web 原生支持 调试难度

By 菱角
多语言微服务架构:Node.js与Python协作

多语言微服务架构:Node.js与Python协作

概述 在微服务架构中,根据场景选择最适合的编程语言是最佳实践。本文将介绍如何在微服务平台中实现Node.js与Python的协作,发挥各自技术优势。 技术选型策略 为什么混合使用 服务划分 Node.js服务(7个) 服务 功能 选择Node.js的原因 llm.api 大模型服务 高并发SSE流式响应 ucenter.api 用户中心 RESTful API标准实践 doc.api 文件服务 流式上传下载处理 resource.api 资源管理 gRPC高性能通信 rag.api 知识库服务 MongoDB集成便利 statistic.api 统计分析 事件驱动架构 pptonline.api PPT服务 与前端技术栈统一 Python服务(1个) 服务 功能 选择Python的原因

By 菱角