Compare commits

..

No commits in common. "84d4c6b061d74076b728662a260bb43a636f2de2" and "25a43042cfb1c9d98e9e58bd984f8fe492e5b82a" have entirely different histories.

7 changed files with 1 additions and 803 deletions

View File

@ -16,7 +16,7 @@ class Settings(BaseSettings):
upload_url: str = "http://images.jingrow.com:8080/api/v1/image"
# Jingrow Jcloud API 配置
jingrow_api_url: str = "https://console.jingrow.com"
jingrow_api_url: str = "https://cloud.jingrow.com"
jingrow_api_key: Optional[str] = None
jingrow_api_secret: Optional[str] = None

View File

@ -1,46 +0,0 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from service import MidjourneyService
from utils import jingrow_api_verify_and_billing
from settings import settings
import json
import asyncio
from typing import AsyncGenerator, List
router = APIRouter(prefix=settings.router_prefix)
service = MidjourneyService()
@router.post(settings.generate_route
@jingrow_api_verify_and_billing(api_name=settings.api_name)
async def ve_generate_image(data: dict, request: Request):
"""
使用 VectorEngine API 生成图片
Args:
data: 请求数据包含:
- prompt: 提示词必需
- config: 配置参数可选
- base64_array: base64 编码的图片数组
- notify_hook: 回调地址
- state: 自定义状态
- bot_type: 机器人类型默认 MID_JOURNEY
- split_image: 是否分割图片默认 True
Returns:
流式响应包含生成状态和结果
"""
if "prompt" not in data:
raise HTTPException(status_code=400, detail="缺少prompt参数")
prompt = data["prompt"]
config = data.get("config", {})
async def generate() -> AsyncGenerator[str, None]:
async for result in service.ve_generate_image(prompt, config):
yield json.dumps(result, ensure_ascii=False) + "\n"
return StreamingResponse(
generate(),
media_type="application/x-ndjson",
headers={"X-Content-Type-Options": "nosniff"}
)

View File

@ -1,21 +0,0 @@
from fastapi import FastAPI
from settings import settings
from api import router
app = FastAPI(
title="Midjourney",
description="Midjourney绘画服务API",
version="1.0.0"
)
# 注册路由
app.include_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app:app",
host=settings.host,
port=settings.port,
reload=settings.debug
)

View File

@ -1,450 +0,0 @@
import json
import os
import io
import requests
import uuid
import urllib3
import logging
from pathlib import Path
from urllib.parse import urlparse
from PIL import Image
import asyncio
import httpx
from typing import Dict, Any, List, AsyncGenerator, Optional
from settings import settings
# 设置日志记录器
logger = logging.getLogger("midjourney_service")
# 禁用不安全请求警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class MidjourneyService:
def __init__(self):
"""初始化 MidjourneyService"""
# 设置代理
self.proxies = {}
if settings.http_proxy and settings.http_proxy.strip():
self.proxies['http'] = settings.http_proxy
if settings.https_proxy and settings.https_proxy.strip():
self.proxies['https'] = settings.https_proxy
# 确保保存目录存在
os.makedirs(settings.save_dir, exist_ok=True)
async def split_image(self, image_url: str) -> Optional[List[str]]:
"""
将一张大图切割成四张子图保存到本地并返回 URLs
Args:
image_url: 图片URL
Returns:
分割后的图片URL列表失败返回 None
"""
try:
response = requests.get(image_url, proxies=self.proxies if self.proxies else None, timeout=30)
if response.status_code != 200:
logger.error(f"下载图像失败,状态码: {response.status_code}")
return None
image_data = response.content
img = Image.open(io.BytesIO(image_data))
width, height = img.size
original_format = img.format
if not original_format:
parsed_url = urlparse(image_url)
original_format = os.path.splitext(parsed_url.path)[1][1:].upper()
if not original_format:
original_format = 'PNG'
if width < 500 or height < 500:
logger.warning(f"图像尺寸较小: {width}x{height},跳过分割")
return [image_url]
half_width = width // 2
half_height = height // 2
quadrants = [
img.crop((0, 0, half_width, half_height)),
img.crop((half_width, 0, width, half_height)),
img.crop((0, half_height, half_width, height)),
img.crop((half_width, half_height, width, height))
]
image_id = uuid.uuid4().hex[:10]
save_dir = os.path.abspath(settings.save_dir)
os.makedirs(save_dir, exist_ok=True)
image_urls = []
for i, quadrant in enumerate(quadrants, 1):
try:
filename = f"split_{image_id}_{i}.{original_format.lower()}"
file_path = os.path.join(save_dir, filename)
save_params = {"format": original_format}
if original_format in ['PNG', 'JPEG', 'JPG']:
save_params["optimize"] = True
if original_format in ['JPEG', 'JPG']:
save_params["quality"] = 95
quadrant.save(file_path, **save_params)
if not os.path.exists(file_path):
raise Exception(f"文件保存失败: {file_path}")
file_size = os.path.getsize(file_path)
if file_size == 0:
raise Exception(f"保存的文件大小为0: {file_path}")
image_url = f"{settings.download_url}/{filename}"
image_urls.append(image_url)
logger.info(f"成功保存分割图片 {i}/4: {filename}")
except Exception as e:
logger.error(f"保存分割图片 {i}/4 失败: {str(e)}")
# 清理已保存的文件
for url in image_urls:
try:
file_path = os.path.join(save_dir, os.path.basename(url))
if os.path.exists(file_path):
os.remove(file_path)
except Exception as del_e:
logger.error(f"删除失败的图片文件时出错: {str(del_e)}")
return None
if len(image_urls) != 4:
logger.error(f"分割图片数量不正确: 期望4张实际{len(image_urls)}")
return None
logger.info("成功完成图片分割生成4张子图")
return image_urls
except Exception as e:
logger.error(f"分割图像失败: {str(e)}")
return None
# ==================== VectorEngine API 方法 ====================
async def ve_submit_imagine(
self,
prompt: str,
base64_array: List[str] = None,
notify_hook: str = "",
state: str = "",
bot_type: str = "MID_JOURNEY"
) -> Dict[str, Any]:
"""
提交 imagine 任务到 VectorEngine API
API 文档: https://vectorengine.apifox.cn/api-349239131
Args:
prompt: 提示词
base64_array: base64 编码的图片数组垫图
notify_hook: 回调地址
state: 自定义状态
bot_type: 机器人类型默认 MID_JOURNEY
Returns:
{"result": "任务ID"} {"success": False, "message": "错误信息"}
"""
if base64_array is None:
base64_array = []
payload = {
"base64Array": base64_array,
"notifyHook": notify_hook,
"prompt": prompt,
"state": state,
"botType": bot_type
}
headers = {
"Authorization": f"Bearer {settings.vectorengine_token}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{settings.vectorengine_api_url}/mj/submit/imagine",
json=payload,
headers=headers
)
if response.status_code != 200:
error_text = response.text
logger.error(f"VectorEngine 提交任务失败: {response.status_code} - {error_text}")
return {"success": False, "message": f"API请求失败: {response.status_code} - {error_text}"}
result = response.json()
logger.info(f"VectorEngine 提交任务成功: {result}")
return result
except Exception as e:
logger.error(f"VectorEngine 提交任务异常: {str(e)}")
return {"success": False, "message": str(e)}
async def ve_get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
"""
根据任务ID查询任务状态
API 文档: https://vectorengine.apifox.cn/api-349239132
Args:
task_id: 任务ID
Returns:
任务状态对象包含:
- id: 任务ID
- action: 动作类型 ( IMAGINE)
- status: 任务状态 (SUCCESS, IN_PROGRESS, FAILURE )
- progress: 进度字符串 ( "100%")
- imageUrl: 生成的图片URL
- buttons: 操作按钮列表
- failReason: 失败原因
等字段失败返回 None
"""
headers = {
"Authorization": f"Bearer {settings.vectorengine_token}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(
f"{settings.vectorengine_api_url}/mj/task/{task_id}/fetch",
headers=headers
)
if response.status_code != 200:
logger.error(f"VectorEngine 获取任务状态失败: {response.status_code} - {response.text}")
return None
result = response.json()
return result
except Exception as e:
logger.error(f"VectorEngine 获取任务状态异常: {str(e)}")
return None
async def ve_get_task_list(self, task_ids: List[str]) -> Optional[List[Dict[str, Any]]]:
"""
根据ID列表查询任务
API 文档: https://vectorengine.apifox.cn/api-349239133
Args:
task_ids: 任务ID列表
Returns:
任务对象列表失败返回 None
"""
headers = {
"Authorization": f"Bearer {settings.vectorengine_token}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{settings.vectorengine_api_url}/mj/task/list-by-condition",
json={"ids": task_ids},
headers=headers
)
if response.status_code != 200:
logger.error(f"VectorEngine 批量查询任务失败: {response.status_code} - {response.text}")
return None
result = response.json()
return result
except Exception as e:
logger.error(f"VectorEngine 批量查询任务异常: {str(e)}")
return None
async def ve_get_image_seed(self, task_id: str) -> Optional[Dict[str, Any]]:
"""
获取任务图片的seed
API 文档: https://vectorengine.apifox.cn/api-349239134
Args:
task_id: 任务ID
Returns:
包含 seed 信息的任务对象失败返回 None
"""
headers = {
"Authorization": f"Bearer {settings.vectorengine_token}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(
f"{settings.vectorengine_api_url}/mj/task/{task_id}/image-seed",
headers=headers
)
if response.status_code != 200:
logger.error(f"VectorEngine 获取seed失败: {response.status_code} - {response.text}")
return None
result = response.json()
return result
except Exception as e:
logger.error(f"VectorEngine 获取seed异常: {str(e)}")
return None
async def ve_generate_image(
self,
prompt: str,
config: Dict = None
) -> AsyncGenerator[Dict, None]:
"""
使用 VectorEngine API 生成图像并以流式方式返回结果
Args:
prompt: 提示词
config: 配置参数可包含:
- base64_array: base64 编码的图片数组垫图
- notify_hook: 回调地址
- state: 自定义状态
- bot_type: 机器人类型
- split_image: 是否分割图片 (默认 True)
Yields:
生成状态和结果:
- {"status": "submitted", "task_id": "..."} # 任务已提交
- {"status": "progress", "progress": 50, "task_id": "..."} # 进度更新
- {"status": "success", "image_urls": [...], ...} # 成功完成
- {"status": "error", "message": "..."} # 错误
"""
if not config:
config = {}
if not settings.vectorengine_token:
yield {
"status": "error",
"message": "未配置 VectorEngine Token"
}
return
# 1. 提交任务
submit_result = await self.ve_submit_imagine(
prompt=prompt,
base64_array=config.get("base64_array", []),
notify_hook=config.get("notify_hook", ""),
state=config.get("state", ""),
bot_type=config.get("bot_type", "MID_JOURNEY")
)
# 检查提交结果 - VectorEngine 返回 {"result": "任务ID"} 表示成功
task_id = submit_result.get("result")
if not task_id:
yield {
"status": "error",
"message": submit_result.get("message", "提交任务失败未获取到任务ID")
}
return
logger.info(f"VectorEngine 任务已提交任务ID: {task_id}")
yield {
"status": "submitted",
"task_id": task_id
}
# 2. 轮询任务状态
polling_count = 0
last_progress = -1
while polling_count < settings.vectorengine_max_polling_attempts:
await asyncio.sleep(settings.vectorengine_polling_interval)
polling_count += 1
task_status = await self.ve_get_task_status(task_id)
if not task_status:
logger.warning(f"{polling_count} 次轮询未获取到任务状态")
continue
status = task_status.get("status", "")
progress_str = task_status.get("progress", "0%")
# 解析进度值(可能是数字或带%的字符串如 '0%', '100%'
try:
if isinstance(progress_str, str):
progress_val = int(progress_str.strip().rstrip('%'))
else:
progress_val = int(progress_str)
except (ValueError, TypeError):
progress_val = 0
# 发送进度更新(仅当进度有变化时)
if progress_val > last_progress:
last_progress = progress_val
yield {
"status": "progress",
"progress": progress_val,
"task_id": task_id
}
# 检查任务完成状态
if status == "SUCCESS":
image_url = task_status.get("imageUrl")
image_urls = [image_url] if image_url else []
response = {
"status": "success",
"task_id": task_id,
"image_urls": image_urls,
"buttons": task_status.get("buttons", []),
"properties": task_status.get("properties", {})
}
# 如果配置了分割图片且有图片URL则处理分割
if config.get("split_image", True) and image_url:
try:
split_urls = await self.split_image(image_url)
if split_urls and len(split_urls) == 4:
response["image_urls"] = split_urls
logger.info(f"图片分割成功,生成 4 张子图")
except Exception as e:
logger.error(f"分割图像失败: {str(e)}")
yield response
return
elif status in ("FAILURE", "FAILED"):
fail_reason = task_status.get("failReason", "任务失败")
yield {
"status": "error",
"message": fail_reason,
"task_id": task_id
}
return
elif status == "CANCELLED":
yield {
"status": "error",
"message": "任务已取消",
"task_id": task_id
}
return
# 其他状态(如 IN_PROGRESS, SUBMITTED继续轮询
logger.debug(f"任务 {task_id} 状态: {status}, 进度: {progress_str}")
# 超时
yield {
"status": "error",
"message": f"任务超时,已轮询 {settings.vectorengine_max_polling_attempts}",
"task_id": task_id
}

View File

@ -1,52 +0,0 @@
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache
class Settings(BaseSettings):
# Japi Server 配置
host: str = "0.0.0.0"
port: int = 8113
debug: bool = False
# API路由配置
router_prefix: str = "/jmidjourney"
generate_route: str = "/generate" # 保留用于兼容
api_name: str = "jmidjourney" # 默认API名称
upload_url: str = "http://images.jingrow.com:8080/api/v1/image"
# VectorEngine API 配置
vectorengine_api_url: str = "https://api.vectorengine.ai"
vectorengine_token: Optional[str] = None
vectorengine_max_polling_attempts: int = 120 # 最大轮询次数
vectorengine_polling_interval: int = 3 # 轮询间隔(秒)
# 图片保存配置
save_dir: str = "../jfile/files"
# Japi 静态资源下载URL
download_url: str = "https://api.jingrow.com/files"
# Jingrow Jcloud API 配置
jingrow_api_url: str = "https://console.jingrow.com"
jingrow_api_key: Optional[str] = None
jingrow_api_secret: Optional[str] = None
# 代理配置(用于下载外部图片)
http_proxy: Optional[str] = None
https_proxy: Optional[str] = None
# 超时设置(秒)
request_timeout: int = 30
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()
# 创建全局配置实例
settings = get_settings()

View File

@ -1,233 +0,0 @@
import aiohttp
from functools import wraps
from fastapi import HTTPException
import os
from typing import Callable, Any, Dict, Optional, Tuple, List
from settings import settings
from fastapi.responses import StreamingResponse
import json
import requests
import io
import re
from pathlib import Path
from urllib.parse import urlparse
from PIL import Image
async def verify_api_credentials_and_balance(api_key: str, api_secret: str, api_name: str) -> Dict[str, Any]:
"""验证API密钥和团队余额"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{settings.jingrow_api_url}/api/action/jcloud.api.account.verify_api_credentials_and_balance",
headers={"Authorization": f"token {settings.jingrow_api_key}:{settings.jingrow_api_secret}"},
json={"api_key": api_key, "api_secret": api_secret, "api_name": api_name}
) as response:
if response.status != 200:
raise HTTPException(status_code=500, detail="验证服务暂时不可用")
result = await response.json()
if "message" in result and isinstance(result["message"], dict):
result = result["message"]
if not result.get("success"):
raise HTTPException(status_code=401, detail=result.get("message", "验证失败"))
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"验证服务暂时不可用: {str(e)}")
async def deduct_jingrow_api_usage_fee(api_key: str, api_secret: str, api_name: str, usage_count: int = 1) -> Dict[str, Any]:
"""从Jingrow平台扣除API使用费"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{settings.jingrow_api_url}/api/action/jcloud.api.account.deduct_api_usage_fee",
headers={"Authorization": f"token {settings.jingrow_api_key}:{settings.jingrow_api_secret}"},
json={
"api_key": api_key,
"api_secret": api_secret,
"api_name": api_name,
"usage_count": usage_count
}
) as response:
if response.status != 200:
raise HTTPException(status_code=500, detail="扣费服务暂时不可用")
result = await response.json()
if "message" in result and isinstance(result["message"], dict):
result = result["message"]
return result
except HTTPException:
raise
except Exception as e:
return {"success": False, "message": f"扣费服务暂时不可用: {str(e)}"}
def get_token_from_request(request) -> str:
"""从请求中获取访问令牌"""
if not request:
raise HTTPException(status_code=400, detail="无法获取请求信息")
auth_header = request.headers.get("Authorization", "")
if not auth_header or not auth_header.startswith("token "):
raise HTTPException(status_code=401, detail="无效的Authorization头格式")
token = auth_header[6:]
if ":" not in token:
raise HTTPException(status_code=401, detail="无效的令牌格式")
return token
def jingrow_api_verify_and_billing(api_name: str):
"""Jingrow API 验证装饰器(带余额检查和扣费)"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
try:
request = kwargs.get('request')
if not request:
raise HTTPException(status_code=400, detail="无法获取请求信息")
token = get_token_from_request(request)
api_key, api_secret = token.split(":", 1)
verify_result = await verify_api_credentials_and_balance(api_key, api_secret, api_name)
if not verify_result.get("success"):
raise HTTPException(status_code=401, detail=verify_result.get("message", "验证失败"))
result = await func(*args, **kwargs)
usage_count = 1
try:
body_data = await request.json()
if isinstance(body_data, dict):
for key in ["items", "urls", "images", "files"]:
if key in body_data and isinstance(body_data[key], list):
usage_count = len(body_data[key])
break
except Exception:
pass
if isinstance(result, StreamingResponse):
original_generator = result.body_iterator
success_count = 0
async def wrapped_generator():
nonlocal success_count
async for chunk in original_generator:
try:
data = json.loads(chunk)
if isinstance(data, dict) and data.get("status") == "success":
success_count += 1
except:
pass
yield chunk
if success_count > 0:
await deduct_jingrow_api_usage_fee(api_key, api_secret, api_name, success_count)
return StreamingResponse(
wrapped_generator(),
media_type=result.media_type,
headers=result.headers
)
if isinstance(result, dict) and result.get("success") is True:
actual_usage_count = result.get("success_count", usage_count)
await deduct_jingrow_api_usage_fee(api_key, api_secret, api_name, actual_usage_count)
return result
await deduct_jingrow_api_usage_fee(api_key, api_secret, api_name, usage_count)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"API验证过程发生异常: {str(e)}")
return wrapper
return decorator
def is_valid_image_url(url: str) -> bool:
if not url or not isinstance(url, str):
return False
try:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return False
# 检查文件扩展名
path = parsed.path.lower()
valid_extensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif']
return any(path.endswith(ext) for ext in valid_extensions)
except:
return False
def get_new_image_url(image_url: str) -> str:
try:
# 使用settings中的upload_url
upload_url = settings.upload_url
if not upload_url:
raise HTTPException(status_code=500, detail="未配置上传URL")
# 下载图片
response = requests.get(image_url, verify=False, timeout=30)
if response.status_code != 200:
raise HTTPException(status_code=400, detail=f"无法下载图片: HTTP {response.status_code}")
image_data = response.content
# 解析文件名和扩展名
parsed_url = urlparse(image_url)
file_name = Path(parsed_url.path).name
file_name = sanitize_filename(file_name)
file_ext = Path(file_name).suffix.lower()
# 如果图片是webp格式转换为png格式
if file_ext == '.webp':
image = Image.open(io.BytesIO(image_data))
png_buffer = io.BytesIO()
image.save(png_buffer, format='PNG')
image_data = png_buffer.getvalue()
file_name = file_name.replace('.webp', '.png')
# 准备文件上传
files = {"file": (file_name, image_data)}
# 上传图片
upload_response = requests.post(upload_url, files=files, verify=False, timeout=30)
if upload_response.status_code != 200:
error_message = f"图片URL转换失败: 状态码 {upload_response.status_code}, 响应: {upload_response.text[:200]}"
raise HTTPException(status_code=500, detail=error_message)
result = upload_response.json()
new_url = result.get("url")
if not new_url:
raise HTTPException(status_code=500, detail="上传成功但未返回URL")
return new_url
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"图片URL转换异常: {str(e)}")
def sanitize_filename(filename: str) -> str:
# 移除路径分隔符和空字符
filename = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '', filename)
# 移除首尾空白字符
filename = filename.strip()
# 如果文件名为空,使用默认名称
if not filename:
filename = "untitled"
# 限制文件名长度
if len(filename) > 255:
name, ext = os.path.splitext(filename)
filename = name[:255-len(ext)] + ext
return filename