完成https 服务

This commit is contained in:
Ubuntu
2026-05-16 23:27:38 +08:00
parent 60123abfdb
commit 862f9e57d1
3 changed files with 386 additions and 101 deletions
+336 -82
View File
@@ -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")
Executable
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Let's Encrypt 证书续期脚本
# 流程:停服 → certbot renewstandalone)→ 修正证书权限 → 重启服务
# 由 crontab 每月执行一次(证书90天过期,提前30天续期)
ROOT="/home/ubuntu/change_cloth_server"
LOG="/home/ubuntu/log/renew_cert.log"
mkdir -p "$(dirname "$LOG")"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === 开始证书续期 ===" >> "$LOG"
# 停止服务,释放 443 端口(certbot standalone 需要 80 端口,实际上不需要停止443)
# 但 certbot --standalone 需要 80 端口空闲即可,无需停止 443 服务
# 保险起见仍记录当前状态
pkill -f "${ROOT}/change_app.py" 2>/dev/null
echo " 服务已停止" >> "$LOG"
sleep 2
# 续期(standalone 模式使用 80 端口验证)
certbot renew --standalone --non-interactive >> "$LOG" 2>&1
RENEW_EXIT=$?
if [ $RENEW_EXIT -eq 0 ]; then
echo " 证书续期成功" >> "$LOG"
else
echo " 证书续期失败,退出码: $RENEW_EXIT" >> "$LOG"
fi
# 修正证书文件权限(确保非 root 用户可读)
chmod 644 /etc/letsencrypt/archive/xiangsilian.com/privkey*.pem 2>/dev/null
chmod 755 /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null
# 重启服务
export PATH="/home/ubuntu/.local/bin:$PATH"
nohup python3 "${ROOT}/change_app.py" >> "/home/ubuntu/log/change_app.log" 2>&1 &
echo " 服务已重启 (PID: $!)" >> "$LOG"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] === 续期流程结束 ===" >> "$LOG"
+10 -18
View File
@@ -1,35 +1,27 @@
#!/bin/bash
# 启动本机换装网关与 service1 / service2 / service3。
# ComfyUI 在其它机器运行;service1/service2/service3 内已配置远端 COMFYUI_URL
# 启动虚拟换装服务(统一 FastAPIHTTPS 443 端口)
# ComfyUI 在外部机器运行;change_app.py 直接调用 service1/2/3 逻辑,无需单独启动子服务
ROOT="/home/ubuntu/change_cloth_server"
LOG_DIR="/home/ubuntu/log"
mkdir -p "$LOG_DIR"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始停止旧进程..."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 停止旧进程..."
# 按命令行中的脚本绝对路径结束旧实例(与下方 nohup 路径一致)
pkill -f "${ROOT}/change1/service1.py" 2>/dev/null && echo " 已停止 service1" || echo " service1 未在运行"
pkill -f "${ROOT}/change2/service2.py" 2>/dev/null && echo " 已停止 service2" || echo " service2 未在运行"
pkill -f "${ROOT}/change3/service3.py" 2>/dev/null && echo " 已停止 service3" || echo " service3 未在运行"
pkill -f "${ROOT}/change_app.py" 2>/dev/null && echo " 已停止 change_app" || echo " change_app 未在运行"
# 兼容:若旧的子服务仍在运行,一并停止
pkill -f "${ROOT}/change1/service1.py" 2>/dev/null && echo " 已停止 service1(旧)" || true
pkill -f "${ROOT}/change2/service2.py" 2>/dev/null && echo " 已停止 service2(旧)" || true
pkill -f "${ROOT}/change3/service3.py" 2>/dev/null && echo " 已停止 service3(旧)" || true
sleep 2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始启动服务..."
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 启动 change_appHTTPS 443..."
export PATH="/home/ubuntu/.local/bin:$PATH"
nohup python3 "${ROOT}/change1/service1.py" >> "$LOG_DIR/service1.log" 2>&1 &
echo " service1 已启动 (PID: $!)"
nohup python3 "${ROOT}/change2/service2.py" >> "$LOG_DIR/service2.log" 2>&1 &
echo " service2 已启动 (PID: $!)"
nohup python3 "${ROOT}/change3/service3.py" >> "$LOG_DIR/service3.log" 2>&1 &
echo " service3 已启动 (PID: $!)"
nohup python3 "${ROOT}/change_app.py" >> "$LOG_DIR/change_app.log" 2>&1 &
echo " change_app 已启动 (PID: $!)"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 启动完毕(ComfyUI 请在外部机器保证可用)。日志目录: $LOG_DIR"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 启动完毕。日志: $LOG_DIR/change_app.log"