AI番茄短故事模块技术方案
1. 技术架构概览
1.1 整体架构
markdown
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 前端应用 │ │ 后端API │ │ AI服务 │
│ React + TS │◄──►│ Node.js │◄──►│ GPT-4/Claude │
│ Ant Design │ │ Express │ │ 通义千问 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 状态管理 │ │ 数据存储 │ │ 缓存服务 │
│ Context API │ │ MongoDB │ │ Redis │
│ useReducer │ │ Mongoose │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
1.2 技术选型理由
前端技术栈
- React 18: 与现有系统保持一致,支持并发特性
- TypeScript: 提供类型安全,提高代码质量
- Ant Design: 丰富的组件库,快速开发
- Context API: 轻量级状态管理,避免过度工程化
后端技术栈
- Node.js: 与现有系统技术栈一致
- Express.js: 成熟稳定的Web框架
- MongoDB: 文档型数据库,适合存储非结构化内容
- Redis: 高性能缓存,提升响应速度
2. 前端详细设计
2.1 项目结构
bash
src/
├── pages/
│ └── TomatoStory/
│ ├── Create/ # 创建故事页面
│ │ ├── index.tsx
│ │ ├── StoryForm.tsx # 故事配置表单
│ │ ├── TemplateSelector.tsx # 模板选择器
│ │ └── styles.css
│ ├── List/ # 故事列表页面
│ │ ├── index.tsx
│ │ ├── StoryCard.tsx # 故事卡片
│ │ ├── FilterBar.tsx # 筛选栏
│ │ └── styles.css
│ ├── Detail/ # 故事详情页面
│ │ ├── index.tsx
│ │ ├── ChapterList.tsx # 章节列表
│ │ ├── StoryViewer.tsx # 故事阅读器
│ │ └── styles.css
│ ├── Edit/ # 编辑故事页面
│ │ ├── index.tsx
│ │ ├── ChapterEditor.tsx # 章节编辑器
│ │ ├── OutlineEditor.tsx # 大纲编辑器
│ │ └── styles.css
│ └── Generate/ # 生成进度页面
│ ├── index.tsx
│ ├── ProgressBar.tsx # 进度条
│ ├── GenerationLog.tsx # 生成日志
│ └── styles.css
├── components/
│ └── TomatoStory/
│ ├── Common/
│ │ ├── StoryStyleSelector.tsx # 风格选择器
│ │ ├── CharacterForm.tsx # 人物表单
│ │ ├── WordCountSlider.tsx # 字数滑块
│ │ └── QualityIndicator.tsx # 质量指示器
│ ├── Templates/
│ │ ├── TemplateCard.tsx # 模板卡片
│ │ ├── TemplatePreview.tsx # 模板预览
│ │ └── CustomTemplate.tsx # 自定义模板
│ └── Generation/
│ ├── GenerationStatus.tsx # 生成状态
│ ├── ChapterProgress.tsx # 章节进度
│ └── ErrorHandler.tsx # 错误处理
├── hooks/
│ └── useTomatoStory/
│ ├── index.ts
│ ├── useStoryGeneration.ts # 故事生成钩子
│ ├── useStoryList.ts # 故事列表钩子
│ ├── useTemplates.ts # 模板钩子
│ └── useStoryEditor.ts # 编辑器钩子
├── context/
│ └── TomatoStoryContext.tsx # 全局状态管理
├── services/
│ └── tomatoStoryApi.ts # API服务
├── types/
│ └── tomatoStory.ts # 类型定义
└── utils/
├── storyValidation.ts # 故事验证
├── contentFilter.ts # 内容过滤
└── exportUtils.ts # 导出工具
2.2 核心组件实现
2.2.1 故事创建表单组件
typescript
// src/components/TomatoStory/Common/StoryForm.tsx
import React, { useState } from 'react';
import { Form, Input, Select, Slider, Button, Card, Space } from 'antd';
import { StoryConfig, StoryStyle, Character } from '../../../types/tomatoStory';
import StoryStyleSelector from './StoryStyleSelector';
import CharacterForm from './CharacterForm';
import WordCountSlider from './WordCountSlider';
interface StoryFormProps {
onSubmit: (config: StoryConfig) => void;
loading?: boolean;
initialValues?: Partial<StoryConfig>;
}
const StoryForm: React.FC<StoryFormProps> = ({ onSubmit, loading, initialValues }) => {
const [form] = Form.useForm();
const [characters, setCharacters] = useState<Character[]>([
{ name: '', role: 'female_lead', personality: '', background: '' },
{ name: '', role: 'male_lead', personality: '', background: '' }
]);
const handleSubmit = (values: any) => {
const config: StoryConfig = {
theme: values.theme,
description: values.description,
wordCount: values.wordCount,
style: values.style,
characters: characters.filter(c => c.name.trim()),
templateId: values.templateId
};
onSubmit(config);
};
return (
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
wordCount: 20000,
style: 'sweet',
...initialValues
}}
>
<Card title="基本信息" className="mb-4">
<Form.Item
name="theme"
label="故事主题"
rules={[{ required: true, message: '请输入故事主题' }]}
>
<Input
placeholder="如:霸道总裁爱上我、重生复仇记等"
maxLength={50}
showCount
/>
</Form.Item>
<Form.Item
name="description"
label="故事简介"
rules={[
{ required: true, message: '请输入故事简介' },
{ min: 50, message: '简介至少50字' },
{ max: 500, message: '简介不超过500字' }
]}
>
<Input.TextArea
placeholder="请详细描述故事背景、主要情节和人物关系..."
rows={4}
maxLength={500}
showCount
/>
</Form.Item>
<Form.Item name="style" label="故事风格">
<StoryStyleSelector />
</Form.Item>
<Form.Item name="wordCount" label="目标字数">
<WordCountSlider min={15000} max={30000} />
</Form.Item>
</Card>
<Card title="人物设定" className="mb-4">
<CharacterForm
characters={characters}
onChange={setCharacters}
/>
</Card>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={loading} size="large">
开始生成故事
</Button>
<Button onClick={() => form.resetFields()}>重置</Button>
</Space>
</Form.Item>
</Form>
);
};
export default StoryForm;
2.2.2 故事生成进度组件
typescript
// src/components/TomatoStory/Generation/GenerationStatus.tsx
import React, { useEffect, useState } from 'react';
import { Progress, Card, Steps, Alert, Button, Space } from 'antd';
import { CheckCircleOutlined, LoadingOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { GenerationProgress, GenerationStep } from '../../../types/tomatoStory';
interface GenerationStatusProps {
storyId: string;
onComplete: () => void;
onError: (error: string) => void;
}
const GenerationStatus: React.FC<GenerationStatusProps> = ({ storyId, onComplete, onError }) => {
const [progress, setProgress] = useState<GenerationProgress>({
currentChapter: 0,
totalChapters: 0,
currentStep: 'outline',
progress: 0,
status: 'running',
message: '正在初始化...'
});
const steps: GenerationStep[] = [
{ key: 'outline', title: '生成大纲', description: '分析主题,构建故事框架' },
{ key: 'content', title: '生成内容', description: '逐章节生成故事内容' },
{ key: 'polish', title: '优化润色', description: '检查逻辑,优化表达' },
{ key: 'complete', title: '生成完成', description: '故事生成完毕' }
];
useEffect(() => {
const eventSource = new EventSource(`/api/tomato-story/${storyId}/progress`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
setProgress(data);
if (data.status === 'completed') {
eventSource.close();
onComplete();
} else if (data.status === 'error') {
eventSource.close();
onError(data.error);
}
};
eventSource.onerror = () => {
eventSource.close();
onError('连接中断,请刷新页面重试');
};
return () => eventSource.close();
}, [storyId, onComplete, onError]);
const getCurrentStepIndex = () => {
return steps.findIndex(step => step.key === progress.currentStep);
};
const getStepStatus = (stepIndex: number) => {
const currentIndex = getCurrentStepIndex();
if (stepIndex < currentIndex) return 'finish';
if (stepIndex === currentIndex) {
return progress.status === 'error' ? 'error' : 'process';
}
return 'wait';
};
return (
<div className="generation-status">
<Card title="故事生成进度" className="mb-4">
<Steps
current={getCurrentStepIndex()}
status={progress.status === 'error' ? 'error' : 'process'}
items={steps.map((step, index) => ({
title: step.title,
description: step.description,
status: getStepStatus(index),
icon: getStepStatus(index) === 'process' ? <LoadingOutlined /> :
getStepStatus(index) === 'error' ? <ExclamationCircleOutlined /> :
getStepStatus(index) === 'finish' ? <CheckCircleOutlined /> : undefined
}))}
/>
</Card>
<Card title="详细进度" className="mb-4">
<div className="mb-3">
<div className="flex justify-between mb-2">
<span>总体进度</span>
<span>{Math.round(progress.progress)}%</span>
</div>
<Progress
percent={progress.progress}
status={progress.status === 'error' ? 'exception' : 'active'}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
</div>
{progress.currentStep === 'content' && progress.totalChapters > 0 && (
<div className="mb-3">
<div className="flex justify-between mb-2">
<span>章节进度</span>
<span>{progress.currentChapter}/{progress.totalChapters}</span>
</div>
<Progress
percent={(progress.currentChapter / progress.totalChapters) * 100}
showInfo={false}
/>
</div>
)}
<Alert
message={progress.message}
type={progress.status === 'error' ? 'error' : 'info'}
showIcon
/>
</Card>
{progress.status === 'error' && (
<Card>
<Space>
<Button type="primary" onClick={() => window.location.reload()}>
重新生成
</Button>
<Button onClick={() => window.history.back()}>
返回
</Button>
</Space>
</Card>
)}
</div>
);
};
export default GenerationStatus;
2.3 状态管理设计
typescript
// src/context/TomatoStoryContext.tsx
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { TomatoStory, StoryConfig, Template } from '../types/tomatoStory';
import * as api from '../services/tomatoStoryApi';
interface TomatoStoryState {
stories: TomatoStory[];
templates: Template[];
currentStory: TomatoStory | null;
loading: boolean;
error: string | null;
pagination: {
current: number;
pageSize: number;
total: number;
};
}
type TomatoStoryAction =
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_STORIES'; payload: TomatoStory[] }
| { type: 'SET_TEMPLATES'; payload: Template[] }
| { type: 'SET_CURRENT_STORY'; payload: TomatoStory | null }
| { type: 'ADD_STORY'; payload: TomatoStory }
| { type: 'UPDATE_STORY'; payload: { id: string; updates: Partial<TomatoStory> } }
| { type: 'DELETE_STORY'; payload: string }
| { type: 'SET_PAGINATION'; payload: Partial<TomatoStoryState['pagination']> };
const initialState: TomatoStoryState = {
stories: [],
templates: [],
currentStory: null,
loading: false,
error: null,
pagination: {
current: 1,
pageSize: 10,
total: 0
}
};
function tomatoStoryReducer(state: TomatoStoryState, action: TomatoStoryAction): TomatoStoryState {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'SET_STORIES':
return { ...state, stories: action.payload, loading: false };
case 'SET_TEMPLATES':
return { ...state, templates: action.payload };
case 'SET_CURRENT_STORY':
return { ...state, currentStory: action.payload };
case 'ADD_STORY':
return { ...state, stories: [action.payload, ...state.stories] };
case 'UPDATE_STORY':
return {
...state,
stories: state.stories.map(story =>
story.id === action.payload.id
? { ...story, ...action.payload.updates }
: story
),
currentStory: state.currentStory?.id === action.payload.id
? { ...state.currentStory, ...action.payload.updates }
: state.currentStory
};
case 'DELETE_STORY':
return {
...state,
stories: state.stories.filter(story => story.id !== action.payload),
currentStory: state.currentStory?.id === action.payload ? null : state.currentStory
};
case 'SET_PAGINATION':
return {
...state,
pagination: { ...state.pagination, ...action.payload }
};
default:
return state;
}
}
interface TomatoStoryContextType {
state: TomatoStoryState;
actions: {
createStory: (config: StoryConfig) => Promise<TomatoStory>;
loadStories: (params?: any) => Promise<void>;
loadStory: (id: string) => Promise<void>;
updateStory: (id: string, updates: Partial<TomatoStory>) => Promise<void>;
deleteStory: (id: string) => Promise<void>;
loadTemplates: () => Promise<void>;
clearError: () => void;
};
}
const TomatoStoryContext = createContext<TomatoStoryContextType | undefined>(undefined);
export const TomatoStoryProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(tomatoStoryReducer, initialState);
const actions = {
createStory: async (config: StoryConfig): Promise<TomatoStory> => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
dispatch({ type: 'SET_ERROR', payload: null });
const story = await api.createStory(config);
dispatch({ type: 'ADD_STORY', payload: story });
return story;
} catch (error) {
const message = error instanceof Error ? error.message : '创建故事失败';
dispatch({ type: 'SET_ERROR', payload: message });
throw error;
}
},
loadStories: async (params = {}) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
dispatch({ type: 'SET_ERROR', payload: null });
const response = await api.getStories({
page: state.pagination.current,
limit: state.pagination.pageSize,
...params
});
dispatch({ type: 'SET_STORIES', payload: response.stories });
dispatch({
type: 'SET_PAGINATION',
payload: {
total: response.total,
current: response.page
}
});
} catch (error) {
const message = error instanceof Error ? error.message : '加载故事列表失败';
dispatch({ type: 'SET_ERROR', payload: message });
}
},
loadStory: async (id: string) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
dispatch({ type: 'SET_ERROR', payload: null });
const story = await api.getStory(id);
dispatch({ type: 'SET_CURRENT_STORY', payload: story });
} catch (error) {
const message = error instanceof Error ? error.message : '加载故事详情失败';
dispatch({ type: 'SET_ERROR', payload: message });
}
},
updateStory: async (id: string, updates: Partial<TomatoStory>) => {
try {
dispatch({ type: 'SET_ERROR', payload: null });
const updatedStory = await api.updateStory(id, updates);
dispatch({ type: 'UPDATE_STORY', payload: { id, updates: updatedStory } });
} catch (error) {
const message = error instanceof Error ? error.message : '更新故事失败';
dispatch({ type: 'SET_ERROR', payload: message });
throw error;
}
},
deleteStory: async (id: string) => {
try {
dispatch({ type: 'SET_ERROR', payload: null });
await api.deleteStory(id);
dispatch({ type: 'DELETE_STORY', payload: id });
} catch (error) {
const message = error instanceof Error ? error.message : '删除故事失败';
dispatch({ type: 'SET_ERROR', payload: message });
throw error;
}
},
loadTemplates: async () => {
try {
dispatch({ type: 'SET_ERROR', payload: null });
const templates = await api.getTemplates();
dispatch({ type: 'SET_TEMPLATES', payload: templates });
} catch (error) {
const message = error instanceof Error ? error.message : '加载模板失败';
dispatch({ type: 'SET_ERROR', payload: message });
}
},
clearError: () => {
dispatch({ type: 'SET_ERROR', payload: null });
}
};
return (
<TomatoStoryContext.Provider value={{ state, actions }}>
{children}
</TomatoStoryContext.Provider>
);
};
export const useTomatoStory = () => {
const context = useContext(TomatoStoryContext);
if (!context) {
throw new Error('useTomatoStory must be used within a TomatoStoryProvider');
}
return context;
};
3. 后端详细设计
3.1 项目结构
bash
src/
├── controllers/
│ └── tomatoStoryController.js # 番茄故事控制器
├── models/
│ ├── TomatoStory.js # 故事模型
│ └── StoryTemplate.js # 模板模型
├── services/
│ ├── tomatoStoryService.js # 故事业务逻辑
│ ├── aiGenerationService.js # AI生成服务
│ ├── templateService.js # 模板服务
│ └── contentFilterService.js # 内容过滤服务
├── routes/
│ └── tomatoStory.js # 路由定义
├── middleware/
│ ├── validation.js # 参数验证
│ └── rateLimit.js # 频率限制
├── utils/
│ ├── promptBuilder.js # Prompt构建器
│ ├── contentParser.js # 内容解析器
│ └── qualityChecker.js # 质量检查器
└── config/
├── ai.js # AI配置
└── constants.js # 常量定义
3.2 数据模型设计
javascript
// src/models/TomatoStory.js
const mongoose = require('mongoose');
const characterSchema = new mongoose.Schema({
name: { type: String, required: true },
role: {
type: String,
enum: ['male_lead', 'female_lead', 'supporting', 'antagonist'],
required: true
},
personality: { type: String, required: true },
background: String,
appearance: String
});
const chapterSchema = new mongoose.Schema({
title: { type: String, required: true },
content: { type: String, required: true },
wordCount: { type: Number, required: true },
order: { type: Number, required: true },
summary: String,
keyPoints: [String], // 关键情节点
qualityScore: { type: Number, min: 0, max: 100 },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const tomatoStorySchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
title: { type: String, required: true },
theme: { type: String, required: true },
description: { type: String, required: true },
wordCount: { type: Number, required: true, min: 15000, max: 30000 },
style: {
type: String,
enum: ['sweet', 'abuse', 'cool', 'suspense', 'fantasy', 'urban', 'ancient'],
required: true
},
status: {
type: String,
enum: ['draft', 'generating', 'completed', 'error', 'paused'],
default: 'draft'
},
outline: String,
chapters: [chapterSchema],
characters: [characterSchema],
templateId: { type: mongoose.Schema.Types.ObjectId, ref: 'StoryTemplate' },
// 生成配置
generationConfig: {
aiModel: { type: String, default: 'gpt-4' },
temperature: { type: Number, default: 0.8, min: 0, max: 2 },
maxTokens: { type: Number, default: 4000 },
language: { type: String, default: 'zh-CN' }
},
// 质量指标
qualityMetrics: {
overallScore: { type: Number, min: 0, max: 100 },
plotCoherence: { type: Number, min: 0, max: 100 }, // 情节连贯性
characterConsistency: { type: Number, min: 0, max: 100 }, // 人物一致性
languageQuality: { type: Number, min: 0, max: 100 }, // 语言质量
paceRating: { type: Number, min: 0, max: 100 } // 节奏评分
},
// 生成进度
generationProgress: {
currentChapter: { type: Number, default: 0 },
totalChapters: { type: Number, default: 0 },
currentStep: {
type: String,
enum: ['outline', 'content', 'polish', 'complete'],
default: 'outline'
},
progress: { type: Number, default: 0, min: 0, max: 100 },
startTime: Date,
estimatedEndTime: Date,
actualEndTime: Date
},
// 统计信息
stats: {
viewCount: { type: Number, default: 0 },
likeCount: { type: Number, default: 0 },
shareCount: { type: Number, default: 0 },
downloadCount: { type: Number, default: 0 }
},
tags: [String],
isPublic: { type: Boolean, default: false },
publishedAt: Date,
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
// 索引
tomatoStorySchema.index({ userId: 1, createdAt: -1 });
tomatoStorySchema.index({ style: 1, status: 1 });
tomatoStorySchema.index({ tags: 1 });
tomatoStorySchema.index({ 'qualityMetrics.overallScore': -1 });
// 中间件
tomatoStorySchema.pre('save', function(next) {
this.updatedAt = new Date();
// 计算总字数
if (this.chapters && this.chapters.length > 0) {
this.wordCount = this.chapters.reduce((total, chapter) => total + chapter.wordCount, 0);
}
next();
});
// 虚拟字段
tomatoStorySchema.virtual('averageChapterLength').get(function() {
if (!this.chapters || this.chapters.length === 0) return 0;
return Math.round(this.wordCount / this.chapters.length);
});
tomatoStorySchema.virtual('estimatedReadingTime').get(function() {
// 假设每分钟阅读300字
return Math.ceil(this.wordCount / 300);
});
module.exports = mongoose.model('TomatoStory', tomatoStorySchema);
3.3 AI生成服务实现
javascript
// src/services/aiGenerationService.js
const OpenAI = require('openai');
const PromptBuilder = require('../utils/promptBuilder');
const ContentParser = require('../utils/contentParser');
const QualityChecker = require('../utils/qualityChecker');
const TomatoStory = require('../models/TomatoStory');
const EventEmitter = require('events');
class AIGenerationService extends EventEmitter {
constructor() {
super();
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
this.promptBuilder = new PromptBuilder();
this.contentParser = new ContentParser();
this.qualityChecker = new QualityChecker();
}
async generateStory(storyId, config) {
try {
// 更新状态为生成中
await this.updateStoryStatus(storyId, {
status: 'generating',
'generationProgress.startTime': new Date(),
'generationProgress.currentStep': 'outline'
});
// 第一步:生成故事大纲
this.emit('progress', storyId, {
currentStep: 'outline',
progress: 10,
message: '正在分析主题,生成故事大纲...'
});
const outline = await this.generateOutline(config);
await this.updateStoryStatus(storyId, {
outline: outline.content,
'generationProgress.totalChapters': outline.chapters.length
});
// 第二步:生成章节内容
this.emit('progress', storyId, {
currentStep: 'content',
progress: 20,
message: '开始生成章节内容...'
});
const chapters = [];
for (let i = 0; i < outline.chapters.length; i++) {
const chapterConfig = {
...config,
outline,
chapterIndex: i,
previousChapters: chapters
};
const chapter = await this.generateChapter(chapterConfig);
chapters.push(chapter);
// 更新进度
const progress = 20 + (i + 1) / outline.chapters.length * 60;
await this.updateStoryStatus(storyId, {
$push: { chapters: chapter },
'generationProgress.currentChapter': i + 1,
'generationProgress.progress': progress
});
this.emit('progress', storyId, {
currentStep: 'content',
currentChapter: i + 1,
totalChapters: outline.chapters.length,
progress,
message: `正在生成第${i + 1}章:${chapter.title}`
});
// 避免API频率限制
await this.delay(2000);
}
// 第三步:内容优化和质量检查
this.emit('progress', storyId, {
currentStep: 'polish',
progress: 85,
message: '正在优化内容,检查质量...'
});
const qualityMetrics = await this.checkQuality(storyId, chapters);
await this.updateStoryStatus(storyId, {
status: 'completed',
qualityMetrics,
'generationProgress.currentStep': 'complete',
'generationProgress.progress': 100,
'generationProgress.actualEndTime': new Date()
});
this.emit('progress', storyId, {
currentStep: 'complete',
progress: 100,
message: '故事生成完成!',
status: 'completed'
});
return await TomatoStory.findById(storyId);
} catch (error) {
console.error('Story generation error:', error);
await this.updateStoryStatus(storyId, {
status: 'error',
'generationProgress.progress': 0
});
this.emit('progress', storyId, {
status: 'error',
error: error.message,
message: '生成过程中出现错误,请重试'
});
throw error;
}
}
async generateOutline(config) {
const prompt = this.promptBuilder.buildOutlinePrompt(config);
const response = await this.openai.chat.completions.create({
model: config.generationConfig?.aiModel || 'gpt-4',
messages: [{ role: 'user', content: prompt }],
temperature: config.generationConfig?.temperature || 0.8,
max_tokens: 2000
});
const content = response.choices[0].message.content;
return this.contentParser.parseOutline(content);
}
async generateChapter(config) {
const prompt = this.promptBuilder.buildChapterPrompt(config);
const response = await this.openai.chat.completions.create({
model: config.generationConfig?.aiModel || 'gpt-4',
messages: [{ role: 'user', content: prompt }],
temperature: config.generationConfig?.temperature || 0.8,
max_tokens: config.generationConfig?.maxTokens || 4000
});
const content = response.choices[0].message.content;
const chapter = this.contentParser.parseChapter(content, config.chapterIndex);
// 计算字数
chapter.wordCount = this.countWords(chapter.content);
return chapter;
}
async checkQuality(storyId, chapters) {
const story = await TomatoStory.findById(storyId);
const metrics = {
overallScore: 0,
plotCoherence: 0,
characterConsistency: 0,
languageQuality: 0,
paceRating: 0
};
// 情节连贯性检查
metrics.plotCoherence = await this.qualityChecker.checkPlotCoherence(chapters);
// 人物一致性检查
metrics.characterConsistency = await this.qualityChecker.checkCharacterConsistency(
story.characters,
chapters
);
// 语言质量检查
metrics.languageQuality = await this.qualityChecker.checkLanguageQuality(chapters);
// 节奏评分
metrics.paceRating = await this.qualityChecker.checkPacing(chapters, story.style);
// 计算总分
metrics.overallScore = Math.round(
(metrics.plotCoherence + metrics.characterConsistency +
metrics.languageQuality + metrics.paceRating) / 4
);
return metrics;
}
async updateStoryStatus(storyId, updates) {
return await TomatoStory.findByIdAndUpdate(storyId, updates, { new: true });
}
countWords(text) {
// 中文字符计数
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
// 英文单词计数
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
return chineseChars + englishWords;
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = AIGenerationService;
3.4 API控制器实现
javascript
// src/controllers/tomatoStoryController.js
const TomatoStory = require('../models/TomatoStory');
const StoryTemplate = require('../models/StoryTemplate');
const AIGenerationService = require('../services/aiGenerationService');
const ContentFilterService = require('../services/contentFilterService');
const { validationResult } = require('express-validator');
class TomatoStoryController {
constructor() {
this.aiService = new AIGenerationService();
this.contentFilter = new ContentFilterService();
this.activeGenerations = new Map(); // 存储活跃的生成任务
}
// 创建故事
async createStory(req, res) {
try {
// 验证输入
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: '输入参数错误',
errors: errors.array()
});
}
const { theme, description, wordCount, style, characters, templateId } = req.body;
const userId = req.user.id;
// 内容过滤
const filterResult = await this.contentFilter.checkContent({
theme,
description,
characters: characters.map(c => c.name + c.personality).join(' ')
});
if (!filterResult.passed) {
return res.status(400).json({
success: false,
message: '内容包含敏感信息,请修改后重试',
details: filterResult.issues
});
}
// 创建故事记录
const story = new TomatoStory({
userId,
title: theme,
theme,
description,
wordCount,
style,
characters,
templateId,
status: 'draft'
});
await story.save();
// 异步开始生成
this.startGeneration(story._id, {
theme,
description,
wordCount,
style,
characters,
templateId,
generationConfig: {
aiModel: req.body.aiModel || 'gpt-4',
temperature: req.body.temperature || 0.8,
maxTokens: req.body.maxTokens || 4000
}
});
res.status(201).json({
success: true,
message: '故事创建成功,正在生成中',
data: {
id: story._id,
status: story.status,
generationProgress: story.generationProgress
}
});
} catch (error) {
console.error('Create story error:', error);
res.status(500).json({
success: false,
message: '创建故事失败',
error: error.message
});
}
}
// 获取生成进度(SSE)
async getGenerationProgress(req, res) {
const { id } = req.params;
// 设置SSE头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
// 发送初始状态
const story = await TomatoStory.findById(id);
if (!story) {
res.write(`data: ${JSON.stringify({ error: '故事不存在' })}\n\n`);
res.end();
return;
}
res.write(`data: ${JSON.stringify({
...story.generationProgress,
status: story.status,
message: this.getStatusMessage(story.status, story.generationProgress)
})}\n\n`);
// 监听进度更新
const progressHandler = (storyId, progress) => {
if (storyId === id) {
res.write(`data: ${JSON.stringify(progress)}\n\n`);
// 如果完成或出错,关闭连接
if (progress.status === 'completed' || progress.status === 'error') {
res.end();
}
}
};
this.aiService.on('progress', progressHandler);
// 客户端断开连接时清理
req.on('close', () => {
this.aiService.removeListener('progress', progressHandler);
});
}
// 获取故事列表
async getStories(req, res) {
try {
const {
page = 1,
limit = 10,
style,
status,
search,
sortBy = 'createdAt',
sortOrder = 'desc'
} = req.query;
const userId = req.user.id;
const skip = (page - 1) * limit;
// 构建查询条件
const query = { userId };
if (style) query.style = style;
if (status) query.status = status;
if (search) {
query.$or = [
{ title: { $regex: search, $options: 'i' } },
{ theme: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// 构建排序
const sort = {};
sort[sortBy] = sortOrder === 'desc' ? -1 : 1;
const [stories, total] = await Promise.all([
TomatoStory.find(query)
.sort(sort)
.skip(skip)
.limit(parseInt(limit))
.select('-chapters.content') // 列表不返回章节内容
.populate('templateId', 'name category'),
TomatoStory.countDocuments(query)
]);
res.json({
success: true,
data: {
stories,
pagination: {
current: parseInt(page),
pageSize: parseInt(limit),
total,
totalPages: Math.ceil(total / limit)
}
}
});
} catch (error) {
console.error('Get stories error:', error);
res.status(500).json({
success: false,
message: '获取故事列表失败',
error: error.message
});
}
}
// 获取故事详情
async getStory(req, res) {
try {
const { id } = req.params;
const userId = req.user.id;
const story = await TomatoStory.findOne({ _id: id, userId })
.populate('templateId')
.populate('userId', 'username avatar');
if (!story) {
return res.status(404).json({
success: false,
message: '故事不存在'
});
}
// 增加查看次数
await TomatoStory.findByIdAndUpdate(id, {
$inc: { 'stats.viewCount': 1 }
});
res.json({
success: true,
data: story
});
} catch (error) {
console.error('Get story error:', error);
res.status(500).json({
success: false,
message: '获取故事详情失败',
error: error.message
});
}
}
// 更新故事
async updateStory(req, res) {
try {
const { id } = req.params;
const userId = req.user.id;
const updates = req.body;
// 验证权限
const story = await TomatoStory.findOne({ _id: id, userId });
if (!story) {
return res.status(404).json({
success: false,
message: '故事不存在或无权限'
});
}
// 不允许修改某些字段
delete updates._id;
delete updates.userId;
delete updates.createdAt;
delete updates.stats;
const updatedStory = await TomatoStory.findByIdAndUpdate(
id,
{ ...updates, updatedAt: new Date() },
{ new: true, runValidators: true }
);
res.json({
success: true,
message: '故事更新成功',
data: updatedStory
});
} catch (error) {
console.error('Update story error:', error);
res.status(500).json({
success: false,
message: '更新故事失败',
error: error.message
});
}
}
// 删除故事
async deleteStory(req, res) {
try {
const { id } = req.params;
const userId = req.user.id;
const story = await TomatoStory.findOneAndDelete({ _id: id, userId });
if (!story) {
return res.status(404).json({
success: false,
message: '故事不存在或无权限'
});
}
res.json({
success: true,
message: '故事删除成功'
});
} catch (error) {
console.error('Delete story error:', error);
res.status(500).json({
success: false,
message: '删除故事失败',
error: error.message
});
}
}
// 开始生成任务
async startGeneration(storyId, config) {
try {
// 避免重复生成
if (this.activeGenerations.has(storyId)) {
return;
}
this.activeGenerations.set(storyId, true);
await this.aiService.generateStory(storyId, config);
} catch (error) {
console.error(`Generation failed for story ${storyId}:`, error);
} finally {
this.activeGenerations.delete(storyId);
}
}
// 获取状态消息
getStatusMessage(status, progress) {
switch (status) {
case 'draft':
return '准备开始生成';
case 'generating':
switch (progress.currentStep) {
case 'outline':
return '正在生成故事大纲...';
case 'content':
return `正在生成第${progress.currentChapter}章内容...`;
case 'polish':
return '正在优化和润色内容...';
default:
return '正在生成中...';
}
case 'completed':
return '故事生成完成';
case 'error':
return '生成过程中出现错误';
case 'paused':
return '生成已暂停';
default:
return '未知状态';
}
}
}
module.exports = new TomatoStoryController();
4. 部署和集成方案
4.1 前端集成
typescript
// src/App.tsx 中添加路由
import { Routes, Route } from 'react-router-dom';
import { TomatoStoryProvider } from './context/TomatoStoryContext';
import TomatoStoryRoutes from './pages/TomatoStory/routes';
function App() {
return (
<BrowserRouter>
<TomatoStoryProvider>
<Routes>
{/* 现有路由 */}
<Route path="/novel/*" element={<NovelRoutes />} />
<Route path="/chapter-generation" element={<ChapterGeneration />} />
{/* 新增番茄故事路由 */}
<Route path="/tomato-story/*" element={<TomatoStoryRoutes />} />
</Routes>
</TomatoStoryProvider>
</BrowserRouter>
);
}
typescript
// src/pages/TomatoStory/routes.tsx
import { Routes, Route } from 'react-router-dom';
import CreateStory from './Create';
import StoryList from './List';
import StoryDetail from './Detail';
import EditStory from './Edit';
import GenerateProgress from './Generate';
const TomatoStoryRoutes = () => {
return (
<Routes>
<Route index element={<StoryList />} />
<Route path="create" element={<CreateStory />} />
<Route path="list" element={<StoryList />} />
<Route path="detail/:id" element={<StoryDetail />} />
<Route path="edit/:id" element={<EditStory />} />
<Route path="generate/:id" element={<GenerateProgress />} />
</Routes>
);
};
export default TomatoStoryRoutes;
4.2 后端集成
javascript
// src/routes/index.js 中添加路由
const express = require('express');
const router = express.Router();
// 现有路由
router.use('/api/novels', require('./novels'));
router.use('/api/chapters', require('./chapters'));
// 新增番茄故事路由
router.use('/api/tomato-story', require('./tomatoStory'));
module.exports = router;
javascript
// src/routes/tomatoStory.js
const express = require('express');
const router = express.Router();
const tomatoStoryController = require('../controllers/tomatoStoryController');
const { body } = require('express-validator');
const auth = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
// 创建故事验证规则
const createStoryValidation = [
body('theme').notEmpty().withMessage('主题不能为空').isLength({ max: 50 }).withMessage('主题不超过50字'),
body('description').isLength({ min: 50, max: 500 }).withMessage('简介长度50-500字'),
body('wordCount').isInt({ min: 15000, max: 30000 }).withMessage('字数范围15000-30000'),
body('style').isIn(['sweet', 'abuse', 'cool', 'suspense', 'fantasy', 'urban', 'ancient']).withMessage('无效的故事风格'),
body('characters').isArray({ min: 1, max: 5 }).withMessage('人物数量1-5个')
];
// 路由定义
router.post('/create', auth, rateLimit.createStory, createStoryValidation, tomatoStoryController.createStory);
router.get('/list', auth, tomatoStoryController.getStories);
router.get('/:id', auth, tomatoStoryController.getStory);
router.put('/:id', auth, tomatoStoryController.updateStory);
router.delete('/:id', auth, tomatoStoryController.deleteStory);
router.get('/:id/progress', auth, tomatoStoryController.getGenerationProgress);
// 模板相关路由
router.get('/templates/list', auth, tomatoStoryController.getTemplates);
router.get('/templates/:id', auth, tomatoStoryController.getTemplate);
router.post('/templates', auth, tomatoStoryController.createTemplate);
module.exports = router;
4.3 导航集成
typescript
// src/components/Layout/Sidebar.tsx 中添加导航项
const menuItems = [
{
key: 'novel',
icon: <BookOutlined />,
label: '小说管理',
children: [
{ key: 'novel-list', label: '我的小说', path: '/novel/list' },
{ key: 'novel-create', label: '创建小说', path: '/novel/create' }
]
},
{
key: 'chapter-generation',
icon: <EditOutlined />,
label: '章节生成',
path: '/chapter-generation'
},
// 新增番茄故事导航
{
key: 'tomato-story',
icon: <ThunderboltOutlined />,
label: '番茄短故事',
children: [
{ key: 'tomato-story-list', label: '我的故事', path: '/tomato-story/list' },
{ key: 'tomato-story-create', label: '创建故事', path: '/tomato-story/create' }
]
}
];
5. 性能优化方案
5.1 前端优化
- 代码分割
typescript
// 懒加载番茄故事模块
const TomatoStoryRoutes = lazy(() => import('./pages/TomatoStory/routes'));
// 在App.tsx中使用Suspense
<Suspense fallback={<Loading />}>
<Route path="/tomato-story/*" element={<TomatoStoryRoutes />} />
</Suspense>
- 状态优化
typescript
// 使用useMemo优化计算
const filteredStories = useMemo(() => {
return stories.filter(story => {
if (filters.style && story.style !== filters.style) return false;
if (filters.status && story.status !== filters.status) return false;
return true;
});
}, [stories, filters]);
// 使用useCallback优化函数
const handleStoryCreate = useCallback(async (config: StoryConfig) => {
try {
const story = await actions.createStory(config);
navigate(`/tomato-story/generate/${story.id}`);
} catch (error) {
message.error(error.message);
}
}, [actions, navigate]);
5.2 后端优化
- 数据库优化
javascript
// 添加复合索引
tomatoStorySchema.index({ userId: 1, status: 1, createdAt: -1 });
tomatoStorySchema.index({ style: 1, 'qualityMetrics.overallScore': -1 });
// 分页查询优化
const getStoriesOptimized = async (query, options) => {
const { page, limit, sortBy, sortOrder } = options;
// 使用聚合管道优化查询
const pipeline = [
{ $match: query },
{ $sort: { [sortBy]: sortOrder === 'desc' ? -1 : 1 } },
{ $skip: (page - 1) * limit },
{ $limit: limit },
{
$project: {
'chapters.content': 0, // 排除大字段
'generationProgress.logs': 0
}
}
];
return await TomatoStory.aggregate(pipeline);
};
- 缓存策略
javascript
const Redis = require('redis');
const redis = Redis.createClient();
// 缓存模板数据
const getTemplatesWithCache = async () => {
const cacheKey = 'story_templates';
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const templates = await StoryTemplate.find({ isPublic: true });
await redis.setex(cacheKey, 3600, JSON.stringify(templates)); // 1小时缓存
return templates;
};
// 缓存用户故事列表
const getUserStoriesWithCache = async (userId, options) => {
const cacheKey = `user_stories:${userId}:${JSON.stringify(options)}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const stories = await getStoriesOptimized({ userId }, options);
await redis.setex(cacheKey, 300, JSON.stringify(stories)); // 5分钟缓存
return stories;
};
- AI服务优化
javascript
// 请求队列管理
class AIRequestQueue {
constructor(maxConcurrent = 3) {
this.queue = [];
this.running = 0;
this.maxConcurrent = maxConcurrent;
}
async add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
}
async process() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
return;
}
this.running++;
const { task, resolve, reject } = this.queue.shift();
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.process();
}
}
}
const aiQueue = new AIRequestQueue();
// 使用队列管理AI请求
const generateWithQueue = async (prompt, config) => {
return aiQueue.add(() => openai.chat.completions.create({
model: config.model,
messages: [{ role: 'user', content: prompt }],
...config
}));
};
6. 监控和日志
6.1 性能监控
javascript
// 生成性能监控
class GenerationMonitor {
constructor() {
this.metrics = {
totalGenerations: 0,
successfulGenerations: 0,
failedGenerations: 0,
averageGenerationTime: 0,
totalTokensUsed: 0
};
}
startGeneration(storyId) {
this.metrics.totalGenerations++;
return {
storyId,
startTime: Date.now(),
tokensUsed: 0
};
}
endGeneration(session, success, tokensUsed = 0) {
const duration = Date.now() - session.startTime;
if (success) {
this.metrics.successfulGenerations++;
} else {
this.metrics.failedGenerations++;
}
this.metrics.totalTokensUsed += tokensUsed;
// 更新平均生成时间
this.metrics.averageGenerationTime =
(this.metrics.averageGenerationTime * (this.metrics.totalGenerations - 1) + duration) /
this.metrics.totalGenerations;
// 记录日志
console.log(`Generation ${session.storyId}: ${success ? 'SUCCESS' : 'FAILED'}, Duration: ${duration}ms, Tokens: ${tokensUsed}`);
}
getMetrics() {
return {
...this.metrics,
successRate: this.metrics.successfulGenerations / this.metrics.totalGenerations * 100,
averageCost: this.calculateCost(this.metrics.totalTokensUsed)
};
}
calculateCost(tokens) {
// 基于GPT-4定价计算
const inputCost = 0.03 / 1000; // $0.03 per 1K tokens
const outputCost = 0.06 / 1000; // $0.06 per 1K tokens
return (tokens * inputCost + tokens * outputCost).toFixed(4);
}
}
const monitor = new GenerationMonitor();
6.2 错误处理和重试机制
javascript
// 智能重试机制
class RetryHandler {
constructor(maxRetries = 3, baseDelay = 1000) {
this.maxRetries = maxRetries;
this.baseDelay = baseDelay;
}
async executeWithRetry(fn, context = {}) {
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// 不重试的错误类型
if (this.isNonRetryableError(error)) {
throw error;
}
if (attempt < this.maxRetries) {
const delay = this.calculateDelay(attempt);
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms:`, error.message);
await this.delay(delay);
}
}
}
throw lastError;
}
isNonRetryableError(error) {
// 内容违规、认证失败等不应重试
const nonRetryableCodes = ['content_filter', 'invalid_api_key', 'insufficient_quota'];
return nonRetryableCodes.some(code => error.message.includes(code));
}
calculateDelay(attempt) {
// 指数退避
return this.baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
const retryHandler = new RetryHandler();
7. 安全和合规
7.1 内容安全
javascript
// 内容过滤服务
class ContentFilterService {
constructor() {
this.sensitiveWords = this.loadSensitiveWords();
this.patterns = this.loadPatterns();
}
async checkContent(content) {
const issues = [];
// 敏感词检查
const sensitiveWordIssues = this.checkSensitiveWords(content);
if (sensitiveWordIssues.length > 0) {
issues.push(...sensitiveWordIssues);
}
// 模式匹配检查
const patternIssues = this.checkPatterns(content);
if (patternIssues.length > 0) {
issues.push(...patternIssues);
}
// AI内容检查
const aiIssues = await this.checkWithAI(content);
if (aiIssues.length > 0) {
issues.push(...aiIssues);
}
return {
passed: issues.length === 0,
issues,
score: this.calculateSafetyScore(content, issues)
};
}
checkSensitiveWords(content) {
const issues = [];
const text = typeof content === 'string' ? content : JSON.stringify(content);
for (const word of this.sensitiveWords) {
if (text.includes(word)) {
issues.push({
type: 'sensitive_word',
word,
severity: 'high'
});
}
}
return issues;
}
async checkWithAI(content) {
try {
const prompt = `请检查以下内容是否包含不当信息(政治敏感、暴力、色情等):\n\n${content}\n\n请回答:安全/不安全,并说明原因。`;
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: prompt }],
temperature: 0.1
});
const result = response.choices[0].message.content;
if (result.includes('不安全')) {
return [{
type: 'ai_detection',
reason: result,
severity: 'high'
}];
}
return [];
} catch (error) {
console.error('AI content check failed:', error);
return []; // 检查失败时不阻止
}
}
}
7.2 API安全
javascript
// API频率限制
const rateLimit = {
createStory: require('express-rate-limit')({
windowMs: 15 * 60 * 1000, // 15分钟
max: 5, // 最多5个故事
message: {
success: false,
message: '创建故事过于频繁,请稍后再试'
}
}),
generateContent: require('express-rate-limit')({
windowMs: 60 * 1000, // 1分钟
max: 10, // 最多10次请求
message: {
success: false,
message: '请求过于频繁,请稍后再试'
}
})
};
// 输入验证中间件
const validateInput = (req, res, next) => {
const { body } = req;
// 检查必需字段
const requiredFields = ['theme', 'description', 'wordCount', 'style'];
for (const field of requiredFields) {
if (!body[field]) {
return res.status(400).json({
success: false,
message: `缺少必需字段: ${field}`
});
}
}
// 字符串长度检查
if (body.theme.length > 50) {
return res.status(400).json({
success: false,
message: '主题长度不能超过50字符'
});
}
// 数值范围检查
if (body.wordCount < 15000 || body.wordCount > 30000) {
return res.status(400).json({
success: false,
message: '字数必须在15000-30000之间'
});
}
next();
};
8. 测试策略
8.1 前端测试
typescript
// 组件测试示例
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TomatoStoryProvider } from '../context/TomatoStoryContext';
import StoryForm from '../components/TomatoStory/Common/StoryForm';
describe('StoryForm', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
const renderWithProvider = (component: React.ReactElement) => {
return render(
<TomatoStoryProvider>
{component}
</TomatoStoryProvider>
);
};
test('应该正确渲染表单字段', () => {
renderWithProvider(<StoryForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText('故事主题')).toBeInTheDocument();
expect(screen.getByLabelText('故事简介')).toBeInTheDocument();
expect(screen.getByLabelText('故事风格')).toBeInTheDocument();
expect(screen.getByLabelText('目标字数')).toBeInTheDocument();
});
test('应该验证必填字段', async () => {
renderWithProvider(<StoryForm onSubmit={mockOnSubmit} />);
const submitButton = screen.getByText('开始生成故事');
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('请输入故事主题')).toBeInTheDocument();
});
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test('应该正确提交表单数据', async () => {
renderWithProvider(<StoryForm onSubmit={mockOnSubmit} />);
// 填写表单
fireEvent.change(screen.getByLabelText('故事主题'), {
target: { value: '霸道总裁爱上我' }
});
fireEvent.change(screen.getByLabelText('故事简介'), {
target: { value: '一个平凡女孩意外成为总裁秘书,从此开始了一段甜蜜的爱情故事。女主角善良坚强,男主角冷酷霸道但内心深情。' }
});
const submitButton = screen.getByText('开始生成故事');
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
theme: '霸道总裁爱上我',
description: expect.stringContaining('平凡女孩'),
wordCount: 20000,
style: 'sweet',
characters: expect.any(Array)
});
});
});
});
8.2 后端测试
javascript
// API测试示例
const request = require('supertest');
const app = require('../app');
const TomatoStory = require('../models/TomatoStory');
const { generateToken } = require('../utils/auth');
describe('Tomato Story API', () => {
let authToken;
let userId;
beforeAll(async () => {
// 创建测试用户
const user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
userId = user._id;
authToken = generateToken(user._id);
});
afterAll(async () => {
// 清理测试数据
await TomatoStory.deleteMany({ userId });
await User.findByIdAndDelete(userId);
});
describe('POST /api/tomato-story/create', () => {
test('应该成功创建故事', async () => {
const storyData = {
theme: '重生复仇记',
description: '女主角重生回到十年前,决心报复那些伤害过她的人,同时收获真爱。',
wordCount: 25000,
style: 'cool',
characters: [
{
name: '苏晚晚',
role: 'female_lead',
personality: '聪明、坚强、有仇必报'
}
]
};
const response = await request(app)
.post('/api/tomato-story/create')
.set('Authorization', `Bearer ${authToken}`)
.send(storyData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.id).toBeDefined();
expect(response.body.data.status).toBe('draft');
});
test('应该验证输入参数', async () => {
const invalidData = {
theme: '', // 空主题
description: '太短', // 描述太短
wordCount: 5000, // 字数太少
style: 'invalid' // 无效风格
};
const response = await request(app)
.post('/api/tomato-story/create')
.set('Authorization', `Bearer ${authToken}`)
.send(invalidData)
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.errors).toBeDefined();
});
});
describe('GET /api/tomato-story/list', () => {
test('应该返回用户的故事列表', async () => {
// 先创建一些测试故事
await TomatoStory.create({
userId,
title: '测试故事1',
theme: '测试主题1',
description: '测试描述1',
wordCount: 20000,
style: 'sweet',
status: 'completed'
});
const response = await request(app)
.get('/api/tomato-story/list')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.stories).toBeInstanceOf(Array);
expect(response.body.data.pagination).toBeDefined();
});
});
});
9. 部署配置
9.1 环境配置
bash
# .env.production
NODE_ENV=production
PORT=3001
# 数据库
MONGODB_URI=mongodb://localhost:27017/novel_ai_prod
REDIS_URL=redis://localhost:6379
# AI服务
OPENAI_API_KEY=your_openai_api_key
OPENAI_ORG_ID=your_org_id
# 安全
JWT_SECRET=your_jwt_secret
ENCRYPTION_KEY=your_encryption_key
# 限制
MAX_CONCURRENT_GENERATIONS=5
MAX_STORIES_PER_USER=100
MAX_DAILY_GENERATIONS=10
# 监控
LOG_LEVEL=info
MONITOR_ENABLED=true
9.2 Docker配置
dockerfile
# Dockerfile
FROM node:18-alpine
WORKDIR /app
# 复制package文件
COPY package*.json ./
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建前端
RUN npm run build
# 暴露端口
EXPOSE 3001
# 启动应用
CMD ["npm", "start"]
yaml
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- MONGODB_URI=mongodb://mongo:27017/novel_ai
- REDIS_URL=redis://redis:6379
depends_on:
- mongo
- redis
volumes:
- ./logs:/app/logs
mongo:
image: mongo:6
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
mongo_data:
redis_data:
10. 总结
本技术方案详细描述了AI番茄短故事模块的完整实现方案,包括:
- 前端架构:基于React + TypeScript的现代化前端架构
- 后端架构:Node.js + Express + MongoDB的稳定后端架构
- AI集成:完整的AI生成服务和质量控制机制
- 性能优化:数据库优化、缓存策略、请求队列管理
- 安全保障:内容过滤、API安全、输入验证
- 监控测试:完整的监控体系和测试策略
- 部署方案:Docker化部署和环境配置
该方案确保了系统的可扩展性、稳定性和安全性,为用户提供高质量的AI番茄短故事生成服务。