feat(server): 添加图片生成服务和配置更新

This commit is contained in:
2026-03-13 22:20:10 +08:00
parent 5f94129236
commit c850623a48
5 changed files with 149 additions and 19 deletions

BIN
.gitignore vendored

Binary file not shown.

View File

@@ -36,6 +36,12 @@ class Settings(BaseSettings):
wx_appid: str = ""
wx_secret: str = ""
# 微信云托管配置
wx_cloud_env: str = ""
# Gemini 图片生成配置
gemini_api_key: str = ""
# JWT 配置
jwt_secret_key: str = "your-super-secret-key-change-in-production"
jwt_expire_hours: int = 168 # 7天

View File

@@ -35,8 +35,9 @@ app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
app.include_router(upload.router, prefix="/api", tags=["上传"])
# 静态文件服务(用于访问上传的图片)
os.makedirs(settings.upload_path, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads")
upload_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', settings.upload_path))
os.makedirs(upload_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
@app.get("/")

View File

@@ -0,0 +1,139 @@
"""
图片生成服务 - 使用 Gemini API 生图,存储到本地/云托管
"""
import httpx
import base64
import time
import hashlib
import os
from typing import Optional
from app.config import get_settings
# 图片尺寸规范(基于前端展示尺寸 × 3倍清晰度
IMAGE_SIZES = {
"cover": {"width": 240, "height": 330, "desc": "竖版封面图3:4比例"},
"avatar": {"width": 150, "height": 150, "desc": "正方形头像1:1比例"},
"background": {"width": 1120, "height": 840, "desc": "横版背景图4:3比例"},
"character": {"width": 512, "height": 768, "desc": "竖版角色立绘2:3比例透明背景"}
}
class ImageGenService:
def __init__(self):
settings = get_settings()
self.api_key = settings.gemini_api_key
self.base_url = "https://work.poloapi.com/v1beta"
# 计算绝对路径
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
self.upload_dir = os.path.join(base_dir, "images")
os.makedirs(self.upload_dir, exist_ok=True)
def get_size_prompt(self, image_type: str) -> str:
"""获取尺寸描述提示词"""
size = IMAGE_SIZES.get(image_type, IMAGE_SIZES["background"])
return f"Image size: {size['width']}x{size['height']} pixels, {size['desc']}."
async def generate_image(self, prompt: str, image_type: str = "background", style: str = "anime") -> Optional[dict]:
"""
调用 Gemini 生成图片
prompt: 图片描述
image_type: 图片类型cover/avatar/background/character
style: 风格anime/realistic/illustration
"""
style_prefix = {
"anime": "anime style, high quality illustration, vibrant colors, ",
"realistic": "photorealistic, high detail, cinematic lighting, ",
"illustration": "digital art illustration, beautiful artwork, "
}
# 组合完整提示词:风格 + 尺寸 + 内容
size_prompt = self.get_size_prompt(image_type)
full_prompt = f"{style_prefix.get(style, '')}{size_prompt} {prompt}"
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{self.base_url}/models/gemini-3-pro-image-preview:generateContent",
headers={
"Content-Type": "application/json",
"x-goog-api-key": self.api_key
},
json={
"contents": [{
"parts": [{
"text": f"Generate an image: {full_prompt}"
}]
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"]
}
}
)
if response.status_code == 200:
data = response.json()
candidates = data.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if "inlineData" in part:
return {
"success": True,
"image_data": part["inlineData"]["data"],
"mime_type": part["inlineData"].get("mimeType", "image/png")
}
return {"success": False, "error": "No image in response"}
else:
error_text = response.text[:200]
return {"success": False, "error": f"API error: {response.status_code} - {error_text}"}
except Exception as e:
return {"success": False, "error": str(e)}
async def save_image(self, image_data: str, filename: str) -> Optional[str]:
"""保存图片到本地返回访问URL"""
try:
image_bytes = base64.b64decode(image_data)
file_path = os.path.join(self.upload_dir, filename)
with open(file_path, "wb") as f:
f.write(image_bytes)
# 返回可访问的URL路径
return f"/uploads/images/{filename}"
except Exception as e:
print(f"Save error: {e}")
return None
async def generate_and_save(self, prompt: str, image_type: str = "background", style: str = "anime") -> dict:
"""生成图片并保存"""
result = await self.generate_image(prompt, image_type, style)
if not result or not result.get("success"):
return {
"success": False,
"error": result.get("error", "生成失败") if result else "生成失败"
}
# 生成文件名
timestamp = int(time.time() * 1000)
hash_str = hashlib.md5(prompt.encode()).hexdigest()[:8]
ext = "png" if "png" in result.get("mime_type", "") else "jpg"
filename = f"{image_type}_{timestamp}_{hash_str}.{ext}"
url = await self.save_image(result["image_data"], filename)
if url:
return {"success": True, "url": url, "filename": filename}
else:
return {"success": False, "error": "保存失败"}
# 延迟初始化单例
_service_instance = None
def get_image_gen_service():
global _service_instance
if _service_instance is None:
_service_instance = ImageGenService()
return _service_instance

View File

@@ -163,23 +163,7 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
-- ============================================
-- 8. 游玩记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `play_records` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`story_id` INT NOT NULL COMMENT '故事ID',
`draft_id` INT DEFAULT NULL COMMENT 'AI草稿ID原故事为空',
`ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称',
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型',
`path_history` JSON NOT NULL COMMENT '完整的选择路径',
`play_duration` INT DEFAULT 0 COMMENT '游玩时长(秒)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_story` (`user_id`, `story_id`),
KEY `idx_user` (`user_id`),
KEY `idx_story` (`story_id`),
CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表';
c
-- ============================================
-- 9. 故事角色表