完成https 服务
This commit is contained in:
+337
-83
@@ -1,103 +1,357 @@
|
||||
import requests
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
虚拟换装网关 —— 统一 FastAPI 服务(HTTPS / 443)
|
||||
|
||||
路由规则(与原 change_app.py 保持一致):
|
||||
kuzi_img 存在 → service3 逻辑(模特 + 上衣 + 裤子)
|
||||
cloth_img 存在(无裤子)→ service2 逻辑(模特 + 上衣)
|
||||
仅 human_img → service1 逻辑(单图编辑)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
app = Flask(__name__, static_folder='static')
|
||||
import httpx
|
||||
import oss2
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ComfyUI 配置
|
||||
# ---------------------------------------------------------------------------
|
||||
COMFYUI_URL = "http://112.126.94.241:28188"
|
||||
COMFYUI_URL_BACKUP = "http://112.126.94.241:38188"
|
||||
|
||||
_root = Path(__file__).parent
|
||||
COMFYUI_USER = (_root / "user.txt").read_text(encoding="utf-8").strip()
|
||||
COMFYUI_PASS = (_root / "password.txt").read_text(encoding="utf-8").strip()
|
||||
COMFYUI_AUTH = (COMFYUI_USER, COMFYUI_PASS)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工作流路径
|
||||
# ---------------------------------------------------------------------------
|
||||
WORKFLOW1 = _root / "change1" / "change1.json" # 单图编辑
|
||||
WORKFLOW2 = _root / "change2" / "change2_1_ex.json" # 模特 + 上衣
|
||||
WORKFLOW3 = _root / "change3" / "change2_2_0308.json" # 模特 + 上衣 + 裤子
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工作流节点 ID
|
||||
# ---------------------------------------------------------------------------
|
||||
NODE_MODEL = "11" # 模特图(LoadImage)
|
||||
NODE_SHIRT = "10" # 上衣图(LoadImage)
|
||||
NODE_PANTS = "4" # 裤子图(LoadImage)
|
||||
NODE_OUTPUT = "33" # 输出(SaveImage)
|
||||
NODE_PROMPT = "31" # 文本提示(PrimitiveStringMultiline)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS 配置
|
||||
# ---------------------------------------------------------------------------
|
||||
OSS_KEY_ID = "LTAI5tGp1sLzedqxihcNC1eb"
|
||||
OSS_KEY_SECRET = "IFZE1b8YYreCP6zfA6GaZ9uBT678qO"
|
||||
OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"
|
||||
OSS_BUCKET = "xiangsilian"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTPS 证书路径
|
||||
# ---------------------------------------------------------------------------
|
||||
SSL_CERT = "/etc/letsencrypt/live/xiangsilian.com/fullchain.pem"
|
||||
SSL_KEY = "/etc/letsencrypt/live/xiangsilian.com/privkey.pem"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI 应用
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(title="虚拟换装服务")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""将 HTTPException 统一转换为业务错误格式"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"ret": -1, "state": -1, "msg": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
@app.route('/static/<path:filename>')
|
||||
def static_files(filename):
|
||||
return send_from_directory(app.static_folder, filename)
|
||||
# ---------------------------------------------------------------------------
|
||||
# OSS 工具
|
||||
# ---------------------------------------------------------------------------
|
||||
def upload_to_oss(image_path: str, object_name: str) -> str | None:
|
||||
auth = oss2.Auth(OSS_KEY_ID, OSS_KEY_SECRET)
|
||||
bucket = oss2.Bucket(auth, OSS_ENDPOINT, OSS_BUCKET)
|
||||
try:
|
||||
bucket.put_object_from_file(object_name, image_path)
|
||||
url = bucket.sign_url("GET", object_name, 3600 * 24 * 365 * 10)
|
||||
print(f"OSS 上传成功: {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
print(f"OSS 上传失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@app.route('/change_cloth_base64', methods=['POST'])
|
||||
def change_cloth_base64():
|
||||
# ---------------------------------------------------------------------------
|
||||
# ComfyUI 工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
def decode_base64_image(b64_str: str) -> bytes:
|
||||
"""解码 base64 图片,自动去除 data:image/... 前缀"""
|
||||
if "," in b64_str:
|
||||
b64_str = b64_str.split(",", 1)[1]
|
||||
return base64.b64decode(b64_str)
|
||||
|
||||
data = request.get_json()
|
||||
print(f"change_cloth_base64 input data keys:{list(data.keys()) if data else None}")
|
||||
if not data:
|
||||
return jsonify({"ret": -1, "state": -1, "msg": "No JSON data provided"}), 400
|
||||
|
||||
human_img = data.get('human_img')
|
||||
cloth_img = data.get('cloth_img')
|
||||
async def check_comfyui_alive(base_url: str, timeout: float = 3.0) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, auth=COMFYUI_AUTH) as client:
|
||||
resp = await client.get(f"{base_url}/system_stats")
|
||||
return resp.status_code < 500
|
||||
except Exception as e:
|
||||
print(f"ComfyUI 健康检查失败 {base_url}: {e}")
|
||||
return False
|
||||
|
||||
if not human_img:
|
||||
return jsonify({"ret": -1, "state": -1, "msg": "human_img is required"}), 400
|
||||
|
||||
kuzi_img = data.get('kuzi_img')
|
||||
async def pick_comfyui_url() -> str:
|
||||
if await check_comfyui_alive(COMFYUI_URL):
|
||||
print(f"使用正式服务器: {COMFYUI_URL}")
|
||||
return COMFYUI_URL
|
||||
print(f"正式服务器不可用,切换备份: {COMFYUI_URL_BACKUP}")
|
||||
if await check_comfyui_alive(COMFYUI_URL_BACKUP):
|
||||
print(f"使用备份服务器: {COMFYUI_URL_BACKUP}")
|
||||
return COMFYUI_URL_BACKUP
|
||||
raise HTTPException(status_code=503, detail="正式与备份 ComfyUI 服务器均不可用")
|
||||
|
||||
change_desc = data.get('change_desc')
|
||||
if change_desc is None:
|
||||
change_desc = ''
|
||||
elif not isinstance(change_desc, str):
|
||||
return jsonify({"ret": -1, "state": -1, "msg": "change_desc must be a string"}), 400
|
||||
|
||||
async def upload_image(client: httpx.AsyncClient, base_url: str, image_bytes: bytes, filename: str) -> str:
|
||||
files = {"image": (filename, io.BytesIO(image_bytes), "image/jpeg")}
|
||||
resp = await client.post(f"{base_url}/upload/image", files=files, data={"overwrite": "true"})
|
||||
resp.raise_for_status()
|
||||
return resp.json()["name"]
|
||||
|
||||
|
||||
async def queue_prompt(client: httpx.AsyncClient, base_url: str, workflow: dict) -> str:
|
||||
payload = {"prompt": workflow, "client_id": str(uuid.uuid4())}
|
||||
resp = await client.post(f"{base_url}/prompt", json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["prompt_id"]
|
||||
|
||||
|
||||
async def wait_for_result(
|
||||
client: httpx.AsyncClient, base_url: str, prompt_id: str, timeout: int = 300
|
||||
) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
resp = await client.get(f"{base_url}/history/{prompt_id}")
|
||||
resp.raise_for_status()
|
||||
history = resp.json()
|
||||
if prompt_id in history:
|
||||
entry = history[prompt_id]
|
||||
status = entry.get("status", {})
|
||||
if status.get("completed"):
|
||||
return entry.get("outputs", {})
|
||||
if status.get("status_str") == "error":
|
||||
messages = status.get("messages", [])
|
||||
raise HTTPException(status_code=500, detail=f"ComfyUI 工作流执行出错: {messages}")
|
||||
await asyncio.sleep(2)
|
||||
raise HTTPException(status_code=504, detail="等待 ComfyUI 超时(300秒)")
|
||||
|
||||
|
||||
async def fetch_image_bytes(
|
||||
client: httpx.AsyncClient, base_url: str, filename: str,
|
||||
subfolder: str = "", type_: str = "output"
|
||||
) -> bytes:
|
||||
params = {"filename": filename, "subfolder": subfolder, "type": type_}
|
||||
resp = await client.get(f"{base_url}/view", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通用工作流执行
|
||||
# ---------------------------------------------------------------------------
|
||||
async def run_workflow(
|
||||
workflow_path: Path,
|
||||
node_images: dict[str, bytes],
|
||||
change_desc: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
执行 ComfyUI 工作流并返回 OSS URL。
|
||||
|
||||
node_images: {节点ID: 图片字节},按顺序并发上传并注入工作流。
|
||||
"""
|
||||
comfyui_url = await pick_comfyui_url()
|
||||
workflow = json.loads(workflow_path.read_text(encoding="utf-8"))
|
||||
|
||||
desc = (change_desc or "").strip()
|
||||
if desc:
|
||||
workflow[NODE_PROMPT]["inputs"] = {"value": desc}
|
||||
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0, auth=COMFYUI_AUTH) as client:
|
||||
node_ids = list(node_images.keys())
|
||||
upload_tasks = [
|
||||
upload_image(client, comfyui_url, node_images[nid], f"{nid}_{uid}.jpg")
|
||||
for nid in node_ids
|
||||
]
|
||||
try:
|
||||
names = await asyncio.gather(*upload_tasks)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"上传图片到 ComfyUI 失败: {e}")
|
||||
|
||||
for node_id, name in zip(node_ids, names):
|
||||
workflow[node_id]["inputs"]["image"] = name
|
||||
|
||||
try:
|
||||
prompt_id = await queue_prompt(client, comfyui_url, workflow)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"提交工作流失败: {e}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, auth=COMFYUI_AUTH) as poll_client:
|
||||
outputs = await wait_for_result(poll_client, comfyui_url, prompt_id, timeout=300)
|
||||
|
||||
node_output = outputs.get(NODE_OUTPUT)
|
||||
if not node_output:
|
||||
raise HTTPException(status_code=500, detail=f"工作流未返回节点 {NODE_OUTPUT} 的输出")
|
||||
|
||||
images = node_output.get("images", [])
|
||||
if not images:
|
||||
raise HTTPException(status_code=500, detail="输出节点没有图片")
|
||||
|
||||
img_info = images[0]
|
||||
filename = img_info["filename"]
|
||||
subfolder = img_info.get("subfolder", "")
|
||||
type_ = img_info.get("type", "output")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, auth=COMFYUI_AUTH) as dl_client:
|
||||
result_bytes = await fetch_image_bytes(dl_client, comfyui_url, filename, subfolder, type_)
|
||||
|
||||
suffix = Path(filename).suffix or ".png"
|
||||
object_name = f"tryon/{uuid.uuid4().hex}{suffix}"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(result_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
if kuzi_img:
|
||||
payload = {
|
||||
'model_image': human_img,
|
||||
'shirt_image': cloth_img,
|
||||
'pants_image': kuzi_img,
|
||||
'change_desc': change_desc,
|
||||
}
|
||||
response = requests.post('http://127.0.0.1:12223/try-on', json=payload, timeout=360)
|
||||
elif cloth_img:
|
||||
payload = {
|
||||
'model_image': human_img,
|
||||
'shirt_image': cloth_img,
|
||||
'change_desc': change_desc,
|
||||
}
|
||||
response = requests.post('http://127.0.0.1:12222/try-on', json=payload, timeout=360)
|
||||
else:
|
||||
payload = {
|
||||
'model_image': human_img,
|
||||
'change_desc': change_desc,
|
||||
}
|
||||
response = requests.post('http://127.0.0.1:12221/try-on', json=payload, timeout=360)
|
||||
result_url = upload_to_oss(tmp_path, object_name)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
result_image = result.get('result_url')
|
||||
if not result_url:
|
||||
raise HTTPException(status_code=500, detail="上传结果图片到 OSS 失败")
|
||||
|
||||
if not result_image:
|
||||
return jsonify({"ret": -1, "state": -1, "msg": "Backend returned no image"}), 500
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return jsonify({"ret": -1, "state": -1, "msg": "Backend service timeout"}), 504
|
||||
except requests.exceptions.ConnectionError:
|
||||
return jsonify({"ret": -1, "state": -1, "msg": "Cannot connect to backend service"}), 502
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return jsonify({"ret": -1, "state": -1, "msg": f"Backend error: {e}"}), 502
|
||||
|
||||
return jsonify({
|
||||
"ret": 0,
|
||||
"state": 0,
|
||||
"msg": "success",
|
||||
"data": result_image,
|
||||
})
|
||||
return result_url
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host="0.0.0.0", port=28888, debug=True)
|
||||
# ---------------------------------------------------------------------------
|
||||
# API 模型
|
||||
# ---------------------------------------------------------------------------
|
||||
class ChangeClothRequest(BaseModel):
|
||||
human_img: str
|
||||
cloth_img: str = ""
|
||||
kuzi_img: str = ""
|
||||
change_desc: str = ""
|
||||
|
||||
# if __name__ == '__main__':
|
||||
# # 证书路径(替换为你的域名)
|
||||
# ssl_cert = '/etc/letsencrypt/live/change.xiangsilian.com/fullchain.pem'
|
||||
# ssl_key = '/etc/letsencrypt/live/change.xiangsilian.com/privkey.pem'
|
||||
|
||||
# # 检查证书文件是否存在
|
||||
# if not os.path.exists(ssl_cert) or not os.path.exists(ssl_key):
|
||||
# print("❌ 证书文件不存在,请检查路径是否正确!")
|
||||
# else:
|
||||
# # 启动HTTPS服务(443端口需要root权限,所以运行时要加sudo)
|
||||
# app.run(
|
||||
# host='0.0.0.0', # 允许外网访问
|
||||
# port=443, # HTTPS默认端口(非443需加端口访问,如8443)
|
||||
# ssl_context=(ssl_cert, ssl_key), # 加载Let's Encrypt证书
|
||||
# debug=True # 生产环境务必关闭debug
|
||||
# )
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.post("/change_cloth_base64")
|
||||
async def change_cloth_base64(req: ChangeClothRequest):
|
||||
print(f"收到请求,字段: human_img={'有' if req.human_img else '无'}, "
|
||||
f"cloth_img={'有' if req.cloth_img else '无'}, "
|
||||
f"kuzi_img={'有' if req.kuzi_img else '无'}")
|
||||
|
||||
if not req.human_img:
|
||||
raise HTTPException(status_code=400, detail="human_img is required")
|
||||
|
||||
try:
|
||||
model_bytes = decode_base64_image(req.human_img)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"human_img 解码失败: {e}")
|
||||
|
||||
if req.kuzi_img:
|
||||
try:
|
||||
shirt_bytes = decode_base64_image(req.cloth_img)
|
||||
pants_bytes = decode_base64_image(req.kuzi_img)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"图片解码失败: {e}")
|
||||
result_url = await run_workflow(
|
||||
WORKFLOW3,
|
||||
{NODE_MODEL: model_bytes, NODE_SHIRT: shirt_bytes, NODE_PANTS: pants_bytes},
|
||||
req.change_desc,
|
||||
)
|
||||
elif req.cloth_img:
|
||||
try:
|
||||
shirt_bytes = decode_base64_image(req.cloth_img)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"cloth_img 解码失败: {e}")
|
||||
result_url = await run_workflow(
|
||||
WORKFLOW2,
|
||||
{NODE_MODEL: model_bytes, NODE_SHIRT: shirt_bytes},
|
||||
req.change_desc,
|
||||
)
|
||||
else:
|
||||
result_url = await run_workflow(
|
||||
WORKFLOW1,
|
||||
{NODE_MODEL: model_bytes},
|
||||
req.change_desc,
|
||||
)
|
||||
|
||||
return {"ret": 0, "state": 0, "msg": "success", "data": result_url}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse(str(_root / "static" / "index.html"))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
primary_ok = await check_comfyui_alive(COMFYUI_URL)
|
||||
backup_ok = await check_comfyui_alive(COMFYUI_URL_BACKUP)
|
||||
active = COMFYUI_URL if primary_ok else (COMFYUI_URL_BACKUP if backup_ok else None)
|
||||
return {
|
||||
"status": "ok" if active else "degraded",
|
||||
"comfyui_active": active,
|
||||
"comfyui_primary": {"url": COMFYUI_URL, "alive": primary_ok},
|
||||
"comfyui_backup": {"url": COMFYUI_URL_BACKUP, "alive": backup_ok},
|
||||
}
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(_root / "static")), name="static")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 启动入口
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
use_https = os.path.exists(SSL_CERT) and os.path.exists(SSL_KEY)
|
||||
|
||||
if use_https:
|
||||
print(f"启动 HTTPS 服务,端口 443,证书: {SSL_CERT}")
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=443,
|
||||
ssl_certfile=SSL_CERT,
|
||||
ssl_keyfile=SSL_KEY,
|
||||
log_level="info",
|
||||
)
|
||||
else:
|
||||
print("⚠ 证书文件不存在,降级为 HTTP,端口 28888")
|
||||
uvicorn.run(app, host="0.0.0.0", port=28888, log_level="info")
|
||||
|
||||
Reference in New Issue
Block a user