From 862f9e57d1868289ecd9a4dcf96e9cac5a52c5ce Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 16 May 2026 23:27:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90https=20=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- change_app.py | 420 +++++++++++++++++++++++++++++++++++++--------- renew_cert.sh | 39 +++++ start_services.sh | 28 ++-- 3 files changed, 386 insertions(+), 101 deletions(-) create mode 100755 renew_cert.sh diff --git a/change_app.py b/change_app.py index 52ea3b2..9f67000 100644 --- a/change_app.py +++ b/change_app.py @@ -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/') -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") diff --git a/renew_cert.sh b/renew_cert.sh new file mode 100755 index 0000000..da75c15 --- /dev/null +++ b/renew_cert.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Let's Encrypt 证书续期脚本 +# 流程:停服 → certbot renew(standalone)→ 修正证书权限 → 重启服务 +# 由 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" diff --git a/start_services.sh b/start_services.sh index 6cfa17b..d883897 100755 --- a/start_services.sh +++ b/start_services.sh @@ -1,35 +1,27 @@ #!/bin/bash -# 启动本机换装网关与 service1 / service2 / service3。 -# ComfyUI 在其它机器运行;service1/service2/service3 内已配置远端 COMFYUI_URL。 +# 启动虚拟换装服务(统一 FastAPI,HTTPS 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_app(HTTPS 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"