fix: 修复云存储上传API路径及添加测试接口

This commit is contained in:
2026-03-13 15:19:47 +08:00
parent 4a69bf2711
commit 0da6f210a6
2 changed files with 669 additions and 5 deletions

View File

@@ -8,9 +8,12 @@ from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import os
import base64
from app.database import get_db
from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter
from app.config import get_settings
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
@@ -36,6 +39,148 @@ async def get_story_characters(db: AsyncSession, story_id: int) -> List[dict]:
]
async def upload_to_cloud_storage(image_bytes: bytes, cloud_path: str) -> str:
"""
上传图片到微信云存储(云托管容器内调用)
cloud_path: 云存储路径,如 stories/1/drafts/10/branch_1/background.jpg
返回: 文件访问路径
"""
import httpx
env_id = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
if not env_id:
# 尝试从配置获取
settings = get_settings()
env_id = getattr(settings, 'wx_cloud_env', None)
if not env_id:
raise Exception("未检测到云环境ID")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
# 云托管内网调用云开发 API不需要 access_token
# 参考: https://developers.weixin.qq.com/miniprogram/dev/wxcloudrun/src/development/storage/service/upload.html
# 1. 获取上传链接
resp = await client.post(
"http://api.weixin.qq.com/tcb/uploadfile",
json={
"env": env_id,
"path": cloud_path
},
headers={"Content-Type": "application/json"}
)
if resp.status_code != 200:
raise Exception(f"获取上传链接失败: {resp.status_code} - {resp.text[:200]}")
data = resp.json()
if data.get("errcode", 0) != 0:
raise Exception(f"获取上传链接失败: {data.get('errmsg')}")
upload_url = data.get("url")
authorization = data.get("authorization")
token = data.get("token")
cos_file_id = data.get("cos_file_id")
file_id = data.get("file_id")
# 2. 上传文件到 COS
form_data = {
"key": cloud_path,
"Signature": authorization,
"x-cos-security-token": token,
"x-cos-meta-fileid": cos_file_id,
}
files = {"file": ("background.jpg", image_bytes, "image/jpeg")}
upload_resp = await client.post(upload_url, data=form_data, files=files)
if upload_resp.status_code not in [200, 204]:
raise Exception(f"上传文件失败: {upload_resp.status_code} - {upload_resp.text[:200]}")
print(f" [CloudStorage] 文件上传成功: {file_id}")
return file_id
except Exception as e:
print(f"[upload_to_cloud_storage] 上传失败: {e}")
raise
async def generate_draft_images(story_id: int, draft_id: int, ai_nodes: dict, story_category: str):
"""
为草稿的 AI 生成节点生成背景图
本地环境:保存到文件系统 /uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg
云端环境:上传到云存储
"""
from app.services.image_gen import ImageGenService
if not ai_nodes:
return
settings = get_settings()
# 检测是否是云端环境TCB_ENV 或 CBR_ENV_ID 是云托管容器自动注入的)
is_cloud = os.environ.get('TCB_ENV') or os.environ.get('CBR_ENV_ID')
# 本地环境使用文件系统
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
draft_dir = os.path.join(base_dir, "stories", str(story_id), "drafts", str(draft_id))
service = ImageGenService()
for node_key, node_data in ai_nodes.items():
if not isinstance(node_data, dict):
continue
content = node_data.get('content', '')[:150]
if not content:
continue
try:
# 生成背景图 - 强调情绪表达
bg_prompt = f"Background scene for {story_category} story. Scene: {content}. Wide shot, atmospheric, no characters, anime style. Strong emotional expression, dramatic mood, vivid colors reflecting the scene's emotion."
result = await service.generate_image(bg_prompt, "background", "anime")
if result and result.get("success"):
image_bytes = base64.b64decode(result["image_data"])
# 路径格式和本地一致uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg
cloud_path = f"uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg"
# 云端环境:上传到云存储
if is_cloud:
try:
file_id = await upload_to_cloud_storage(image_bytes, cloud_path)
# 云存储返回的 file_id 格式: cloud://env-id.xxx/path
# 前端通过 CDN 地址访问: https://7072-prod-xxx.tcb.qcloud.la/uploads/...
node_data['background_url'] = f"/{cloud_path}"
print(f" ✓ 云端草稿节点 {node_key} 背景图上传成功")
except Exception as cloud_e:
print(f" ✗ 云端上传失败: {cloud_e}")
continue
# 本地环境:保存到文件系统
node_dir = os.path.join(draft_dir, node_key)
os.makedirs(node_dir, exist_ok=True)
bg_path = os.path.join(node_dir, "background.jpg")
with open(bg_path, "wb") as f:
f.write(image_bytes)
# 更新节点数据,添加图片路径
node_data['background_url'] = f"/uploads/stories/{story_id}/drafts/{draft_id}/{node_key}/background.jpg"
print(f" ✓ 草稿节点 {node_key} 背景图生成成功")
else:
print(f" ✗ 草稿节点 {node_key} 背景图生成失败: {result.get('error') if result else 'Unknown'}")
except Exception as e:
print(f" ✗ 草稿节点 {node_key} 图片生成异常: {e}")
# 避免请求过快
import asyncio
await asyncio.sleep(1)
# ============ 请求/响应模型 ============
class PathHistoryItem(BaseModel):
@@ -121,11 +266,14 @@ async def process_ai_rewrite(draft_id: int):
# 获取故事角色
characters = await get_story_characters(db, story.id)
print(f"[process_ai_rewrite] 获取到角色数: {len(characters)}")
# 转换路径历史格式
path_history = draft.path_history or []
print(f"[process_ai_rewrite] 路径历史长度: {len(path_history)}")
# 调用AI服务
print(f"[process_ai_rewrite] 开始调用 AI 服务...")
ai_result = await ai_service.rewrite_branch(
story_title=story.title,
story_category=story.category or "未知",
@@ -134,9 +282,21 @@ async def process_ai_rewrite(draft_id: int):
user_prompt=draft.user_prompt,
characters=characters
)
print(f"[process_ai_rewrite] AI 服务返回: {bool(ai_result)}")
if ai_result and ai_result.get("nodes"):
# 成功
# 成功 - 尝试生成配图(失败不影响改写结果)
try:
print(f"[process_ai_rewrite] AI生成成功开始生成配图...")
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft.id,
ai_nodes=ai_result["nodes"],
story_category=story.category or "都市言情"
)
except Exception as img_e:
print(f"[process_ai_rewrite] 配图生成失败(不影响改写结果): {img_e}")
draft.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "branch_1")
@@ -232,8 +392,7 @@ async def process_ai_rewrite_ending(draft_id: int):
pass
# 成功 - 存储为对象格式(与故事节点格式一致)
draft.status = DraftStatus.completed
draft.ai_nodes = {
ai_nodes = {
"ending_rewrite": {
"content": content,
"speaker": "旁白",
@@ -242,6 +401,21 @@ async def process_ai_rewrite_ending(draft_id: int):
"ending_type": "rewrite"
}
}
# 生成配图(失败不影响改写结果)
try:
print(f"[process_ai_rewrite_ending] AI生成成功开始生成配图...")
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft.id,
ai_nodes=ai_nodes,
story_category=story.category or "都市言情"
)
except Exception as img_e:
print(f"[process_ai_rewrite_ending] 配图生成失败(不影响改写结果): {img_e}")
draft.status = DraftStatus.completed
draft.ai_nodes = ai_nodes
draft.entry_node_key = "ending_rewrite"
draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-{new_ending_name}"
@@ -313,7 +487,18 @@ async def process_ai_continue_ending(draft_id: int):
)
if ai_result and ai_result.get("nodes"):
# 成功 - 存储多节点分支格式
# 成功 - 尝试生成配图(失败不影响续写结果)
try:
print(f"[process_ai_continue_ending] AI生成成功开始生成配图...")
await generate_draft_images(
story_id=draft.story_id,
draft_id=draft.id,
ai_nodes=ai_result["nodes"],
story_category=story.category or "都市言情"
)
except Exception as img_e:
print(f"[process_ai_continue_ending] 配图生成失败(不影响续写结果): {img_e}")
draft.status = DraftStatus.completed
draft.ai_nodes = ai_result["nodes"]
draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1")
@@ -648,7 +833,7 @@ async def delete_draft(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""删除草稿"""
"""删除草稿(同时清理图片文件)"""
result = await db.execute(
select(StoryDraft).where(
StoryDraft.id == draft_id,
@@ -660,6 +845,19 @@ async def delete_draft(
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
# 删除草稿对应的图片文件夹
try:
import shutil
settings = get_settings()
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
draft_dir = os.path.join(base_dir, "stories", str(draft.story_id), "drafts", str(draft_id))
if os.path.exists(draft_dir):
shutil.rmtree(draft_dir)
print(f"[delete_draft] 已清理图片目录: {draft_dir}")
except Exception as e:
print(f"[delete_draft] 清理图片失败: {e}")
# 图片清理失败不影响草稿删除
await db.delete(draft)
await db.commit()