修改了接受的格式

This commit is contained in:
jinye_huang 2025-08-04 13:43:52 +08:00
parent 9cae3ec061
commit dc639e8d39
13 changed files with 1110 additions and 36 deletions

View File

@ -14,7 +14,7 @@ from pydantic import BaseModel, Field
class PosterGenerateRequest(BaseModel):
"""海报生成请求模型"""
templateId: str = Field("vibrant", description="模板ID")
imagesBase64: Optional[str] = Field(None, description="图像base64编码")
imagesBase64: Optional[List[str]] = Field(None, description="图像base64编码列表")
posterContent: Optional[Dict[str, Any]] = Field(None, description="海报内容,如果提供则直接使用此内容")
contentId: Optional[str] = Field(None, description="内容ID用于AI生成内容")
productId: Optional[str] = Field(None, description="产品ID用于AI生成内容")
@ -28,7 +28,7 @@ class PosterGenerateRequest(BaseModel):
json_schema_extra = {
"example": {
"templateId": "vibrant",
"imagesBase64": "",
"imagesBase64": ["base64_encoded_image_1", "base64_encoded_image_2"],
"numVariations": 1,
"forceLlmGeneration":False,
"generatePsd": True,

View File

@ -69,7 +69,7 @@ class ContentRequest(BaseModel):
audienceIds: Optional[List[Union[int, str]]] = Field(None, description="受众ID列表")
scenicSpotIds: Optional[List[Union[int, str]]] = Field(None, description="景区ID列表")
productIds: Optional[List[Union[int, str]]] = Field(None, description="产品ID列表")
autoJudge: bool = Field(False, description="是否自动进行内容审核")
autoJudge: Optional[bool] = Field(True, description="是否自动进行内容审核")
class Config:
schema_extra = {
@ -185,7 +185,7 @@ class PipelineRequest(BaseModel):
scenicSpotIds: Optional[List[Union[int, str]]] = Field(None, description="景区ID列表")
productIds: Optional[List[Union[int, str]]] = Field(None, description="产品ID列表")
skipJudge: bool = Field(False, description="是否跳过内容审核步骤")
autoJudge: bool = Field(False, description="是否在内容生成时进行内嵌审核")
autoJudge: Optional[bool] = Field(None, description="是否在内容生成时进行内嵌审核")
class Config:
schema_extra = {

View File

@ -234,26 +234,37 @@ class PosterService:
# 获取模板的默认尺寸,如果获取不到则使用标准尺寸
template_size = getattr(template_handler, 'size', (900, 1200))
if images_base64 and images_base64.strip():
if images_base64 and len(images_base64) > 0:
try:
logger.info(f"🎯 接收到 {len(images_base64)} 张图片的base64数据")
# 处理第一张图片(目前模板只支持单张图片)
# 未来可以扩展为处理多张图片
first_image_base64 = images_base64[0] if len(images_base64) > 0 else ""
if not first_image_base64 or not first_image_base64.strip():
raise ValueError("第一张图片的base64数据为空")
logger.info(f"📊 处理第一张图片base64长度: {len(first_image_base64)}")
# 移除可能存在的MIME类型前缀
if images_base64.startswith("data:"):
images_base64 = images_base64.split(",", 1)[1]
if first_image_base64.startswith("data:"):
first_image_base64 = first_image_base64.split(",", 1)[1]
# 彻底清理base64字符串 - 移除所有空白字符
images_base64 = ''.join(images_base64.split())
first_image_base64 = ''.join(first_image_base64.split())
# 验证base64字符串长度应该是4的倍数
if len(images_base64) % 4 != 0:
if len(first_image_base64) % 4 != 0:
# 添加必要的填充
images_base64 += '=' * (4 - len(images_base64) % 4)
logger.info(f"为base64字符串添加了填充最终长度: {len(images_base64)}")
first_image_base64 += '=' * (4 - len(first_image_base64) % 4)
logger.info(f"为base64字符串添加了填充最终长度: {len(first_image_base64)}")
logger.info(f"准备解码base64数据长度: {len(images_base64)}, 前20字符: {images_base64[:20]}...")
logger.info(f"准备解码base64数据长度: {len(first_image_base64)}, 前20字符: {first_image_base64[:20]}...")
# 解码base64
image_bytes = base64.b64decode(images_base64)
logger.info(f"base64解码成功图片数据大小: {len(image_bytes)} bytes")
image_bytes = base64.b64decode(first_image_base64)
logger.info(f"base64解码成功图片数据大小: {len(image_bytes)} bytes")
# 验证解码后的数据不为空
if len(image_bytes) == 0:
@ -262,11 +273,11 @@ class PosterService:
# 检查文件头判断图片格式
file_header = image_bytes[:10]
if file_header.startswith(b'\xff\xd8\xff'):
logger.info("检测到JPEG格式图片")
logger.info("检测到JPEG格式图片")
elif file_header.startswith(b'\x89PNG'):
logger.info("检测到PNG格式图片")
logger.info("检测到PNG格式图片")
else:
logger.warning(f"未识别的图片格式,文件头: {file_header.hex()}")
logger.warning(f"⚠️ 未识别的图片格式,文件头: {file_header.hex()}")
# 创建PIL Image对象
image_io = BytesIO(image_bytes)
@ -279,16 +290,16 @@ class PosterService:
image_io.seek(0)
images = Image.open(image_io)
logger.info(f"图片解码成功,格式: {images.format}, 尺寸: {images.size}, 模式: {images.mode}")
logger.info(f"图片解码成功,格式: {images.format}, 尺寸: {images.size}, 模式: {images.mode}")
except binascii.Error as e:
logger.error(f"Base64解码失败: {e}")
logger.error(f"问题数据长度: {len(images_base64) if 'images_base64' in locals() else 'unknown'}")
logger.error(f"Base64解码失败: {e}")
logger.error(f"问题数据长度: {len(first_image_base64) if 'first_image_base64' in locals() else 'unknown'}")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")
except Exception as e:
logger.error(f"图片处理失败: {e}")
logger.error(f"图片处理失败: {e}")
logger.error(f"错误类型: {type(e).__name__}")
if 'image_bytes' in locals():
logger.error(f"图片数据大小: {len(image_bytes)} bytes, 前20字节: {image_bytes[:20].hex()}")
@ -296,7 +307,7 @@ class PosterService:
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")
else:
logger.warning("未提供图片数据,使用默认透明背景图")
logger.warning("⚠️ 未提供图片数据,使用默认透明背景图")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")

View File

@ -10,6 +10,8 @@ import logging
import uuid
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime
import re
from difflib import SequenceMatcher
from core.config import ConfigManager, GenerateTopicConfig, GenerateContentConfig
from core.ai import AIAgent
@ -24,6 +26,249 @@ from api.services.database_service import DatabaseService
logger = logging.getLogger(__name__)
class TopicIDMappingManager:
"""选题ID映射管理器 - 专门处理选题中的对象ID映射"""
def __init__(self):
"""初始化映射管理器"""
self.name_to_id = {} # 名称 -> ID映射
self.mapping_data = {
'styles': {},
'audiences': {},
'scenic_spots': {},
'products': {}
}
def add_objects_mapping(self,
style_objects: Optional[List[Dict[str, Any]]] = None,
audience_objects: Optional[List[Dict[str, Any]]] = None,
scenic_spot_objects: Optional[List[Dict[str, Any]]] = None,
product_objects: Optional[List[Dict[str, Any]]] = None):
"""
批量添加对象映射关系
Args:
style_objects: 风格对象列表
audience_objects: 受众对象列表
scenic_spot_objects: 景区对象列表
product_objects: 产品对象列表
"""
logger.info("开始建立选题ID映射关系")
mapping_count = 0
if style_objects:
logger.info(f"处理 {len(style_objects)} 个风格对象")
for obj in style_objects:
name = obj.get('styleName', '')
obj_id = obj.get('id')
if name and obj_id:
self.mapping_data['styles'][name] = str(obj_id)
self._add_name_variants('styles', name, str(obj_id))
mapping_count += 1
logger.info(f"添加风格映射: {name} -> ID {obj_id}")
if audience_objects:
logger.info(f"处理 {len(audience_objects)} 个受众对象")
for obj in audience_objects:
name = obj.get('audienceName', '')
obj_id = obj.get('id')
if name and obj_id:
self.mapping_data['audiences'][name] = str(obj_id)
self._add_name_variants('audiences', name, str(obj_id))
mapping_count += 1
logger.info(f"添加受众映射: {name} -> ID {obj_id}")
if scenic_spot_objects:
logger.info(f"处理 {len(scenic_spot_objects)} 个景区对象")
for obj in scenic_spot_objects:
name = obj.get('name', '')
obj_id = obj.get('id')
if name and obj_id:
self.mapping_data['scenic_spots'][name] = str(obj_id)
self._add_name_variants('scenic_spots', name, str(obj_id))
mapping_count += 1
logger.info(f"添加景区映射: {name} -> ID {obj_id}")
if product_objects:
logger.info(f"处理 {len(product_objects)} 个产品对象")
for obj in product_objects:
name = obj.get('productName', '')
obj_id = obj.get('id')
if name and obj_id:
self.mapping_data['products'][name] = str(obj_id)
self._add_name_variants('products', name, str(obj_id))
mapping_count += 1
logger.info(f"添加产品映射: {name} -> ID {obj_id}")
total_variants = len(self.name_to_id)
logger.info(f"ID映射关系建立完成: {mapping_count} 个对象,生成 {total_variants} 个名称变体")
def _add_name_variants(self, category: str, name: str, obj_id: str):
"""为名称添加各种变体以支持模糊匹配"""
variants = [name]
# 去除标点符号版本
clean_name = re.sub(r'[^\w\s]', '', name)
if clean_name != name:
variants.append(clean_name)
# 去除空格版本
no_space_name = name.replace(' ', '')
if no_space_name != name:
variants.append(no_space_name)
# 添加简化版本(去除常见后缀)
simplified = re.sub(r'(风格|类型|系列|产品|景区|公园)$', '', name)
if simplified != name and simplified:
variants.append(simplified)
# 为所有变体添加映射
for variant in variants:
self.name_to_id[variant.lower()] = {
'category': category,
'id': obj_id,
'original_name': name
}
# 记录生成的变体(仅在有多个变体时记录)
if len(variants) > 1:
variants_str = ', '.join(f"'{v}'" for v in variants)
logger.info(f"'{name}' 生成 {len(variants)} 个变体: {variants_str}")
def find_ids_in_topic(self, topic: Dict[str, Any]) -> Dict[str, List[str]]:
"""
在选题中查找相关的对象ID
Args:
topic: 选题字典
Returns:
按类型分组的ID列表
"""
topic_index = topic.get('index', 'N/A')
found_ids = {
'style_ids': [],
'audience_ids': [],
'scenic_spot_ids': [],
'product_ids': []
}
# 提取选题中的所有文本
topic_text = self._extract_topic_text(topic)
topic_text_lower = topic_text.lower()
logger.info(f"开始为选题 {topic_index} 进行ID匹配")
logger.info(f"选题 {topic_index} 提取的文本内容: '{topic_text}'")
logger.info(f"当前映射库包含 {len(self.name_to_id)} 个名称变体")
# 显示映射库内容
logger.info("映射库内容:")
for variant, info in self.name_to_id.items():
logger.info(f" '{variant}' -> {info['original_name']} ({info['category']}, ID: {info['id']})")
# 记录匹配过程
match_details = []
# 在文本中查找匹配的名称
for name_variant, mapping_info in self.name_to_id.items():
if name_variant in topic_text_lower:
category = mapping_info['category']
obj_id = mapping_info['id']
original_name = mapping_info['original_name']
# 记录匹配详情
match_details.append({
'variant': name_variant,
'original_name': original_name,
'category': category,
'id': obj_id
})
# 映射到返回格式
if category == 'styles' and obj_id not in found_ids['style_ids']:
found_ids['style_ids'].append(obj_id)
logger.info(f"选题 {topic_index} 匹配到风格: '{name_variant}' -> {original_name} (ID: {obj_id})")
elif category == 'audiences' and obj_id not in found_ids['audience_ids']:
found_ids['audience_ids'].append(obj_id)
logger.info(f"选题 {topic_index} 匹配到受众: '{name_variant}' -> {original_name} (ID: {obj_id})")
elif category == 'scenic_spots' and obj_id not in found_ids['scenic_spot_ids']:
found_ids['scenic_spot_ids'].append(obj_id)
logger.info(f"选题 {topic_index} 匹配到景区: '{name_variant}' -> {original_name} (ID: {obj_id})")
elif category == 'products' and obj_id not in found_ids['product_ids']:
found_ids['product_ids'].append(obj_id)
logger.info(f"选题 {topic_index} 匹配到产品: '{name_variant}' -> {original_name} (ID: {obj_id})")
# 统计匹配结果
total_found = sum(len(ids) for ids in found_ids.values())
total_available = len(set(info['category'] + '_' + info['id'] for info in self.name_to_id.values()))
match_rate = (total_found / total_available * 100) if total_available > 0 else 0
# 输出详细的匹配结果
if total_found > 0:
logger.info(f"选题 {topic_index} ID匹配完成: 找到 {total_found} 个相关对象,匹配率: {match_rate:.1f}%")
logger.info(f"选题 {topic_index} 匹配详情: {match_details}")
# 按类别显示匹配结果
for category, ids in found_ids.items():
if ids:
category_name = {
'style_ids': '风格',
'audience_ids': '受众',
'scenic_spot_ids': '景区',
'product_ids': '产品'
}.get(category, category)
# 获取匹配的原始名称
matched_names = []
# 建立正确的分类映射关系
category_mapping = {
'style_ids': 'styles',
'audience_ids': 'audiences',
'scenic_spot_ids': 'scenic_spots',
'product_ids': 'products'
}
target_category = category_mapping.get(category)
logger.info(f"选题 {topic_index} 处理分类 {category} -> {target_category}, IDs: {ids}")
for detail in match_details:
if detail['id'] in ids and detail['category'] == target_category:
matched_names.append(f"{detail['original_name']}({detail['id']})")
# 显示分类结果
names_str = ', '.join(matched_names) if matched_names else '(无匹配名称)'
logger.info(f" {category_name}: {names_str}")
# 如果没有匹配名称,输出调试信息
if not matched_names:
logger.info(f"选题 {topic_index} {category_name}无匹配名称,详情检查:")
logger.info(f" IDs: {ids}")
logger.info(f" 目标分类: {target_category}")
for detail in match_details:
if detail['id'] in ids:
logger.info(f" 详情: {detail}")
else:
logger.info(f"选题 {topic_index} 未匹配到任何相关对象ID")
return found_ids
def _extract_topic_text(self, topic: Dict[str, Any]) -> str:
"""提取选题中的所有文本内容"""
text_parts = []
# 提取主要字段的文本
text_fields = ['object', 'style', 'targetAudience', 'product', 'logic',
'productLogic', 'styleLogic', 'targetAudienceLogic']
for field in text_fields:
if field in topic and topic[field]:
text_parts.append(str(topic[field]))
return ' '.join(text_parts)
class TweetService:
"""文字内容服务类"""
@ -49,6 +294,9 @@ class TweetService:
self.prompt_service = PromptService(config_manager)
self.prompt_builder = PromptBuilderService(config_manager, self.prompt_service)
# 初始化选题ID映射管理器
self.topic_id_mapping_manager = TopicIDMappingManager()
async def generate_topics(self, dates: Optional[str] = None, numTopics: int = 5,
styles: Optional[List[str]] = None,
audiences: Optional[List[str]] = None,
@ -84,6 +332,14 @@ class TweetService:
topic_config.topic.date = dates
topic_config.topic.num = numTopics
# 建立ID映射关系用于后续的ID反向映射
self.topic_id_mapping_manager.add_objects_mapping(
style_objects=style_objects,
audience_objects=audience_objects,
scenic_spot_objects=scenic_spot_objects,
product_objects=product_objects
)
# 使用PromptBuilderService构建提示词
system_prompt, user_prompt = self.prompt_builder.build_topic_prompt(
products=products,
@ -103,12 +359,35 @@ class TweetService:
if not topics:
logger.error("未能生成任何选题")
return str(uuid.uuid4()), []
# 为每个选题添加相关的对象ID
logger.info(f"开始为 {len(topics)} 个选题进行ID映射分析")
enhanced_topics = []
total_mapped_topics = 0
for topic in topics:
enhanced_topic = topic.copy()
# 查找选题中涉及的对象ID
related_object_ids = self.topic_id_mapping_manager.find_ids_in_topic(topic)
# 只有当找到相关ID时才添加字段
if any(related_object_ids.values()):
enhanced_topic['related_object_ids'] = related_object_ids
total_mapped_topics += 1
logger.info(f"选题 {topic.get('index', 'N/A')} 找到相关ID: {related_object_ids}")
enhanced_topics.append(enhanced_topic)
# 统计总体映射情况
mapping_coverage = (total_mapped_topics / len(topics) * 100) if topics else 0
logger.info(f"选题ID映射完成: {total_mapped_topics}/{len(topics)} 个选题包含相关对象ID覆盖率: {mapping_coverage:.1f}%")
# 生成请求ID
requestId = f"topic-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{str(uuid.uuid4())[:8]}"
logger.info(f"选题生成完成请求ID: {requestId}, 数量: {len(topics)}")
return requestId, topics
logger.info(f"选题生成完成请求ID: {requestId}, 数量: {len(enhanced_topics)}")
return requestId, enhanced_topics
async def generate_content(self, topic: Optional[Dict[str, Any]] = None, autoJudge: bool = False,
style_objects: Optional[List[Dict[str, Any]]] = None,
@ -198,10 +477,10 @@ class TweetService:
async def _enhance_topic_with_database_data(self, topic: Dict[str, Any]) -> Dict[str, Any]:
"""
使用数据库数据增强选题信息
使用数据库数据增强选题信息优先使用related_object_ids中的反射ID
Args:
topic: 原始选题数据
topic: 原始选题数据可能包含related_object_ids字段
Returns:
增强后的选题数据
@ -216,46 +495,119 @@ class TweetService:
logger.warning("数据库服务不可用,无法增强选题数据")
return enhanced_topic
# 优先使用related_object_ids中的反射ID更精确
related_ids = topic.get('related_object_ids', {})
if related_ids:
logger.info(f"选题包含反射ID信息优先使用: {related_ids}")
enhanced_topic = await self._enhance_with_related_ids(enhanced_topic, db_service, related_ids)
return enhanced_topic
# 处理风格ID
if 'styleIds' in topic and topic['styleIds']:
style_id = topic['styleIds'][0] if isinstance(topic['styleIds'], list) else topic['styleIds']
style_data = db_service.get_style_by_id(style_id)
if style_data:
style_name = style_data.get('styleName')
enhanced_topic['style_object'] = style_data
enhanced_topic['style'] = style_data.get('styleName')
logger.info(f"从数据库加载风格数据: {style_data.get('styleName')} (ID: {style_id})")
enhanced_topic['style'] = style_name
logger.info(f"从数据库加载风格数据: {style_name} (ID: {style_id})")
# 处理受众ID
if 'audienceIds' in topic and topic['audienceIds']:
audience_id = topic['audienceIds'][0] if isinstance(topic['audienceIds'], list) else topic['audienceIds']
audience_data = db_service.get_audience_by_id(audience_id)
if audience_data:
audience_name = audience_data.get('audienceName')
enhanced_topic['audience_object'] = audience_data
enhanced_topic['targetAudience'] = audience_data.get('audienceName')
logger.info(f"从数据库加载受众数据: {audience_data.get('audienceName')} (ID: {audience_id})")
enhanced_topic['targetAudience'] = audience_name
logger.info(f"从数据库加载受众数据: {audience_name} (ID: {audience_id})")
# 处理景区ID
if 'scenicSpotIds' in topic and topic['scenicSpotIds']:
spot_id = topic['scenicSpotIds'][0] if isinstance(topic['scenicSpotIds'], list) else topic['scenicSpotIds']
spot_data = db_service.get_scenic_spot_by_id(spot_id)
if spot_data:
spot_name = spot_data.get('name')
enhanced_topic['scenic_spot_object'] = spot_data
enhanced_topic['object'] = spot_data.get('name')
logger.info(f"从数据库加载景区数据: {spot_data.get('name')} (ID: {spot_id})")
enhanced_topic['object'] = spot_name
logger.info(f"从数据库加载景区数据: {spot_name} (ID: {spot_id})")
# 处理产品ID
if 'productIds' in topic and topic['productIds']:
product_id = topic['productIds'][0] if isinstance(topic['productIds'], list) else topic['productIds']
product_data = db_service.get_product_by_id(product_id)
if product_data:
product_name = product_data.get('productName')
enhanced_topic['product_object'] = product_data
enhanced_topic['product'] = product_data.get('productName')
logger.info(f"从数据库加载产品数据: {product_data.get('productName')} (ID: {product_id})")
enhanced_topic['product'] = product_name
logger.info(f"从数据库加载产品数据: {product_name} (ID: {product_id})")
except Exception as e:
logger.error(f"增强选题数据时发生错误: {e}", exc_info=True)
return enhanced_topic
async def _enhance_with_related_ids(self, enhanced_topic: Dict[str, Any], db_service, related_ids: Dict[str, List[str]]) -> Dict[str, Any]:
"""
使用related_object_ids中的反射ID进行数据库查询和增强
Args:
enhanced_topic: 待增强的选题数据
db_service: 数据库服务实例
related_ids: 反射的ID字典
Returns:
增强后的选题数据
"""
logger.info("开始使用反射ID进行选题数据增强")
try:
# 处理风格ID
style_ids = related_ids.get('style_ids', [])
if style_ids:
style_id = int(style_ids[0]) # 取第一个ID
style_data = db_service.get_style_by_id(style_id)
if style_data:
enhanced_topic['style_object'] = style_data
enhanced_topic['style'] = style_data.get('styleName')
logger.info(f"通过反射ID加载风格数据: {style_data.get('styleName')} (ID: {style_id})")
# 处理受众ID
audience_ids = related_ids.get('audience_ids', [])
if audience_ids:
audience_id = int(audience_ids[0]) # 取第一个ID
audience_data = db_service.get_audience_by_id(audience_id)
if audience_data:
enhanced_topic['audience_object'] = audience_data
enhanced_topic['targetAudience'] = audience_data.get('audienceName')
logger.info(f"通过反射ID加载受众数据: {audience_data.get('audienceName')} (ID: {audience_id})")
# 处理景区ID
scenic_spot_ids = related_ids.get('scenic_spot_ids', [])
if scenic_spot_ids:
spot_id = int(scenic_spot_ids[0]) # 取第一个ID
spot_data = db_service.get_scenic_spot_by_id(spot_id)
if spot_data:
enhanced_topic['scenic_spot_object'] = spot_data
enhanced_topic['object'] = spot_data.get('name')
logger.info(f"通过反射ID加载景区数据: {spot_data.get('name')} (ID: {spot_id})")
# 处理产品ID
product_ids = related_ids.get('product_ids', [])
if product_ids:
product_id = int(product_ids[0]) # 取第一个ID
product_data = db_service.get_product_by_id(product_id)
if product_data:
enhanced_topic['product_object'] = product_data
enhanced_topic['product'] = product_data.get('productName')
logger.info(f"通过反射ID加载产品数据: {product_data.get('productName')} (ID: {product_id})")
logger.info("反射ID数据增强完成")
except Exception as e:
logger.error(f"使用反射ID增强选题数据时发生错误: {e}", exc_info=True)
return enhanced_topic
async def generate_content_with_prompt(self, topic: Dict[str, Any], system_prompt: str, user_prompt: str) -> Tuple[str, str, Dict[str, Any]]:
"""

View File

@ -1,7 +1,7 @@
{
"host": "localhost",
"user": "root",
"password": "civmek-rezTed-0hovre",
"password": "Kj#9mP2$",
"database": "travel_content",
"port": 3306,
"charset": "utf8mb4",

View File

@ -32,7 +32,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--port",
type=int,
default=int(os.getenv("PORT", 8000)),
default=int(os.getenv("PORT", 2714)),
help="监听端口",
)
parser.add_argument(

View File

@ -0,0 +1,711 @@
# 海报Fabric.js前端对接文档
## 📋 概述
本文档详细说明如何在前端使用新的海报生成API返回的Fabric.js JSON数据包括图层加载、图片替换、图层管理等功能。
## 🔄 核心变化
### **从PSD到Fabric.js JSON**
- **原先**: 返回PSD文件的base64编码
- **现在**: 返回Fabric.js JSON文件的base64编码 + 原始JSON数据
- **优势**: 可以直接在前端使用,支持实时编辑和图片替换
## 🎯 API接口说明
### 1. **生成海报接口**
#### **请求示例**
```javascript
const generatePosterRequest = {
posterNumber: 1,
posters: [{
templateId: "vibrant",
imagesBase64: "图片ID列表",
contentId: "内容ID",
productId: "产品ID",
generatePsd: true, // 现在实际生成JSON
numVariations: 1
}]
};
const response = await fetch('/poster/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(generatePosterRequest)
});
const result = await response.json();
```
#### **响应结构**
```javascript
{
"code": 0,
"message": "操作成功",
"data": [{
"requestId": "poster-20250104-123456",
"templateId": "vibrant",
"resultImagesBase64": [{
"id": "vibrant_v1",
"image": "base64编码的PNG图片",
"format": "PNG",
"size": [1350, 1800]
}],
"psdFiles": [{ // 现在是JSON文件
"id": "vibrant_v1_json",
"filename": "template_fabric_v1_20250104.json",
"data": "base64编码的JSON文件",
"size": 15672,
"format": "JSON",
"jsonData": { // 原始JSON数据可直接使用
"version": "5.3.0",
"objects": [...],
"background": "white",
"width": 1350,
"height": 1800
}
}]
}]
}
```
### 2. **获取Fabric.js JSON数据**
```javascript
// 获取指定海报的JSON数据
const fabricJson = await fetch(`/poster/fabric-json/${posterId}`)
.then(res => res.json())
.then(data => data.data);
```
### 3. **替换底层图片**
```javascript
// 单个图片替换
const replaceImage = async (posterId, newImageBase64) => {
const formData = new FormData();
formData.append('posterId', posterId);
formData.append('newImageBase64', newImageBase64);
const response = await fetch('/poster/replace-image', {
method: 'POST',
body: formData
});
return response.json();
};
```
### 4. **获取图层信息**
```javascript
// 获取图层管理信息
const layers = await fetch(`/poster/layers/${posterId}`)
.then(res => res.json())
.then(data => data.data);
// 图层信息结构
// [{
// "name": "图片层",
// "type": "image",
// "level": 0,
// "visible": true,
// "selectable": true,
// "replaceable": true
// }]
```
## 🛠️ 前端集成实现
### 1. **基础环境准备**
#### **HTML结构**
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>海报编辑器</title>
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
</head>
<body>
<div id="poster-editor">
<!-- 画布容器 -->
<div class="canvas-container">
<canvas id="poster-canvas" width="1350" height="1800"></canvas>
</div>
<!-- 图层管理面板 -->
<div class="layer-panel">
<h3>图层管理</h3>
<div id="layer-list"></div>
</div>
<!-- 图片替换面板 -->
<div class="image-replace-panel">
<h3>图片替换</h3>
<input type="file" id="image-upload" accept="image/*">
<button id="replace-btn">替换底层图片</button>
</div>
</div>
</body>
</html>
```
#### **CSS样式**
```css
#poster-editor {
display: flex;
gap: 20px;
padding: 20px;
}
.canvas-container {
flex: 1;
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
background: #f5f5f5;
}
.layer-panel, .image-replace-panel {
width: 250px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background: white;
}
.layer-item {
padding: 8px;
margin: 5px 0;
border: 1px solid #eee;
border-radius: 4px;
cursor: pointer;
}
.layer-item:hover {
background: #f0f0f0;
}
.layer-item.active {
background: #e3f2fd;
border-color: #2196f3;
}
```
### 2. **JavaScript核心实现**
#### **初始化画布**
```javascript
class PosterEditor {
constructor(canvasId) {
this.canvas = new fabric.Canvas(canvasId, {
preserveObjectStacking: true,
selection: true
});
this.currentPosterId = null;
this.layers = [];
this.initEventListeners();
}
// 加载海报JSON数据
async loadPosterFromJson(fabricJsonData, posterId) {
try {
console.log('开始加载Fabric.js JSON数据...');
// 清空画布
this.canvas.clear();
this.currentPosterId = posterId;
// 加载JSON数据到画布
await new Promise((resolve, reject) => {
this.canvas.loadFromJSON(fabricJsonData, () => {
console.log('Fabric.js JSON数据加载成功');
this.canvas.renderAll();
resolve();
}, (error) => {
console.error('加载JSON数据失败:', error);
reject(error);
});
});
// 加载图层信息
await this.loadLayerInfo(posterId);
console.log('海报加载完成');
} catch (error) {
console.error('加载海报失败:', error);
throw error;
}
}
// 从API获取并加载海报
async loadPosterFromApi(posterId) {
try {
const response = await fetch(`/poster/fabric-json/${posterId}`);
const result = await response.json();
if (result.code === 0) {
await this.loadPosterFromJson(result.data, posterId);
} else {
throw new Error(result.message || '获取海报数据失败');
}
} catch (error) {
console.error('从API加载海报失败:', error);
throw error;
}
}
// 加载图层信息
async loadLayerInfo(posterId) {
try {
const response = await fetch(`/poster/layers/${posterId}`);
const result = await response.json();
if (result.code === 0) {
this.layers = result.data;
this.renderLayerPanel();
}
} catch (error) {
console.error('加载图层信息失败:', error);
}
}
// 渲染图层管理面板
renderLayerPanel() {
const layerList = document.getElementById('layer-list');
layerList.innerHTML = '';
this.layers.forEach((layer, index) => {
const layerItem = document.createElement('div');
layerItem.className = 'layer-item';
layerItem.innerHTML = `
<div class="layer-info">
<span class="layer-name">${layer.name}</span>
<span class="layer-type">(${layer.type})</span>
</div>
<div class="layer-controls">
<label>
<input type="checkbox" ${layer.visible ? 'checked' : ''}
onchange="editor.toggleLayerVisibility(${index})">
显示
</label>
${layer.replaceable ? `
<button onclick="editor.selectLayerForReplace(${index})">
替换图片
</button>
` : ''}
</div>
`;
layerList.appendChild(layerItem);
});
}
// 替换底层图片
async replaceBackgroundImage(imageFile) {
try {
if (!this.currentPosterId) {
throw new Error('没有加载的海报');
}
// 转换图片为base64
const imageBase64 = await this.fileToBase64(imageFile);
// 调用后端API替换图片
const formData = new FormData();
formData.append('posterId', this.currentPosterId);
formData.append('newImageBase64', imageBase64);
const response = await fetch('/poster/replace-image', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.code === 0) {
// 重新加载画布
await this.loadPosterFromJson(result.data, this.currentPosterId);
console.log('图片替换成功');
} else {
throw new Error(result.message || '图片替换失败');
}
} catch (error) {
console.error('替换图片失败:', error);
alert('替换图片失败: ' + error.message);
}
}
// 文件转base64
fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// 移除data:image/xxx;base64,前缀
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 切换图层可见性
toggleLayerVisibility(layerIndex) {
const layer = this.layers[layerIndex];
if (layer) {
layer.visible = !layer.visible;
// 这里可以实现图层显示/隐藏的逻辑
// 需要根据具体的图层结构来实现
}
}
// 初始化事件监听
initEventListeners() {
// 图片上传事件
document.getElementById('image-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
this.replaceBackgroundImage(file);
}
});
// 替换按钮事件
document.getElementById('replace-btn').addEventListener('click', () => {
document.getElementById('image-upload').click();
});
}
// 导出当前画布为图片
exportAsImage(format = 'png', quality = 1.0) {
return this.canvas.toDataURL({
format: format,
quality: quality,
multiplier: 1
});
}
// 获取当前的Fabric.js JSON数据
getCurrentJson() {
return this.canvas.toJSON();
}
}
// 初始化编辑器
const editor = new PosterEditor('poster-canvas');
```
### 3. **完整使用示例**
#### **生成海报并加载到编辑器**
```javascript
// 完整的海报生成和加载流程
async function generateAndLoadPoster() {
try {
// 1. 生成海报
const generateRequest = {
posterNumber: 1,
posters: [{
templateId: "vibrant",
imagesBase64: "123,456", // 图片ID列表
generatePsd: true,
numVariations: 1
}]
};
console.log('正在生成海报...');
const generateResponse = await fetch('/poster/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(generateRequest)
});
const generateResult = await generateResponse.json();
if (generateResult.code !== 0) {
throw new Error('海报生成失败: ' + generateResult.message);
}
const posterData = generateResult.data[0];
const fabricJsonData = posterData.psdFiles[0].jsonData;
const posterId = posterData.psdFiles[0].id;
console.log('海报生成成功,开始加载到编辑器...');
// 2. 加载到编辑器
await editor.loadPosterFromJson(fabricJsonData, posterId);
console.log('海报加载到编辑器成功!');
// 3. 显示预览图
const previewImage = posterData.resultImagesBase64[0].image;
showPreviewImage(previewImage);
} catch (error) {
console.error('生成和加载海报失败:', error);
alert('操作失败: ' + error.message);
}
}
// 显示预览图
function showPreviewImage(base64Image) {
const previewDiv = document.createElement('div');
previewDiv.innerHTML = `
<h3>生成的海报预览</h3>
<img src="data:image/png;base64,${base64Image}"
style="max-width: 300px; border: 1px solid #ddd; border-radius: 8px;">
`;
document.body.appendChild(previewDiv);
}
```
#### **图片替换功能示例**
```javascript
// 高级图片替换功能
class ImageReplacer {
constructor(editor) {
this.editor = editor;
this.setupUI();
}
setupUI() {
const replacePanel = document.querySelector('.image-replace-panel');
replacePanel.innerHTML = `
<h3>图片替换</h3>
<div class="upload-area" id="upload-area">
<p>拖拽图片到这里或点击选择</p>
<input type="file" id="image-upload" accept="image/*" style="display: none;">
</div>
<div class="image-preview" id="image-preview" style="display: none;">
<img id="preview-img" style="max-width: 100%; border-radius: 4px;">
<button id="confirm-replace">确认替换</button>
<button id="cancel-replace">取消</button>
</div>
`;
this.bindEvents();
}
bindEvents() {
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('image-upload');
const previewDiv = document.getElementById('image-preview');
const previewImg = document.getElementById('preview-img');
// 点击上传
uploadArea.addEventListener('click', () => {
fileInput.click();
});
// 拖拽上传
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.style.backgroundColor = '#f0f0f0';
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.style.backgroundColor = '';
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.style.backgroundColor = '';
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFileSelect(files[0]);
}
});
// 文件选择
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
this.handleFileSelect(file);
}
});
// 确认替换
document.getElementById('confirm-replace').addEventListener('click', () => {
this.confirmReplace();
});
// 取消
document.getElementById('cancel-replace').addEventListener('click', () => {
this.cancelReplace();
});
}
handleFileSelect(file) {
const reader = new FileReader();
reader.onload = (e) => {
const previewImg = document.getElementById('preview-img');
const previewDiv = document.getElementById('image-preview');
const uploadArea = document.getElementById('upload-area');
previewImg.src = e.target.result;
previewDiv.style.display = 'block';
uploadArea.style.display = 'none';
this.selectedFile = file;
};
reader.readAsDataURL(file);
}
async confirmReplace() {
try {
await this.editor.replaceBackgroundImage(this.selectedFile);
this.cancelReplace();
alert('图片替换成功!');
} catch (error) {
alert('图片替换失败: ' + error.message);
}
}
cancelReplace() {
document.getElementById('image-preview').style.display = 'none';
document.getElementById('upload-area').style.display = 'block';
this.selectedFile = null;
}
}
// 初始化图片替换器
const imageReplacer = new ImageReplacer(editor);
```
## 🚀 快速开始
### **1. 基础集成**
```html
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
</head>
<body>
<canvas id="canvas" width="1350" height="1800"></canvas>
<button onclick="loadDemo()">加载示例海报</button>
<script>
const canvas = new fabric.Canvas('canvas');
async function loadDemo() {
// 从API获取海报数据
const response = await fetch('/poster/fabric-json/demo-poster-id');
const result = await response.json();
// 加载到画布
canvas.loadFromJSON(result.data, () => {
canvas.renderAll();
console.log('海报加载完成!');
});
}
</script>
</body>
</html>
```
### **2. 图片替换示例**
```javascript
// 简单的图片替换
async function replaceImage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = event.target.result.split(',')[1];
try {
const response = await fetch('/poster/replace-image', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `posterId=your-poster-id&newImageBase64=${encodeURIComponent(base64)}`
});
const result = await response.json();
if (result.code === 0) {
// 重新加载画布
canvas.loadFromJSON(result.data, () => {
canvas.renderAll();
});
}
} catch (error) {
console.error('替换失败:', error);
}
};
reader.readAsDataURL(file);
};
input.click();
}
```
## ⚠️ 注意事项
### **1. 浏览器兼容性**
- 需要支持Canvas API
- 需要支持File API
- 建议使用现代浏览器Chrome 80+, Firefox 75+, Safari 13+
### **2. 性能优化**
```javascript
// 优化Canvas性能
canvas.set({
renderOnAddRemove: false, // 添加/删除对象时不自动渲染
skipTargetFind: true // 跳过目标查找(如果不需要交互)
});
// 批量操作后手动渲染
canvas.renderAll();
```
### **3. 错误处理**
```javascript
// 完善的错误处理
try {
await editor.loadPosterFromApi(posterId);
} catch (error) {
if (error.message.includes('404')) {
console.error('海报不存在');
} else if (error.message.includes('网络')) {
console.error('网络错误,请重试');
} else {
console.error('未知错误:', error);
}
}
```
### **4. 内存管理**
```javascript
// 清理资源
function cleanup() {
canvas.dispose(); // 销毁画布
canvas = null;
}
// 页面卸载时清理
window.addEventListener('beforeunload', cleanup);
```
## 📞 技术支持
如在集成过程中遇到问题:
- **API问题**: 检查请求格式和响应状态码
- **Canvas问题**: 查看浏览器控制台错误信息
- **性能问题**: 检查图片大小和Canvas尺寸
- **兼容性问题**: 确认Fabric.js版本和浏览器支持
## 🔗 相关链接
- [Fabric.js官方文档](http://fabricjs.com/docs/)
- [Canvas API文档](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API)
- [File API文档](https://developer.mozilla.org/en-US/docs/Web/API/File)