Files
tw/services/service_tuhui_upload.py
2026-03-10 13:40:54 +08:00

318 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
图绘平台上传服务
将处理好的图片上传到图绘平台,返回图片 URL
"""
import os
import httpx
import logging
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Optional
from urllib.parse import urlparse
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
# 图绘平台配置
TUHUI_BASE_URL = os.getenv("TUHUI_BASE_URL", "https://aidg168.uk")
TUHUI_FALLBACK_BASE_URL = "https://aidg168.uk"
TUHUI_DIRECT_BASE_URL = os.getenv("TUHUI_DIRECT_BASE_URL", "http://156.226.181.204:8002")
TUHUI_WEB_BASE_URL = os.getenv("TUHUI_WEB_BASE_URL", "https://aidg168.uk").rstrip("/")
TUHUI_PHONE = os.getenv("TUHUI_PHONE", "17520145271") # 图绘账号手机号
TUHUI_PASSWORD = os.getenv("TUHUI_PASSWORD", "zuowei1216") # 图绘账号密码
TUHUI_DEFAULT_PRICE = int(os.getenv("TUHUI_DEFAULT_PRICE", "20")) # 默认定价(元)
TUHUI_DEFAULT_CATEGORY = os.getenv("TUHUI_DEFAULT_CATEGORY", "设计素材")
@dataclass
class TuhuiUploadResult:
"""图绘上传结果。主返回 URL 为站内作品页,保留三元组解包兼容。"""
success: bool
download_url: str
work_id: int
image_url: str = ""
thumbnail_url: str = ""
watermarked_url: str = ""
message: str = ""
def __iter__(self) -> Iterator[object]:
# 兼容历史调用ok, download_url, work_id = result
yield self.success
yield self.download_url
yield self.work_id
def as_dict(self) -> dict:
return {
"success": self.success,
"download_url": self.download_url,
"work_id": self.work_id,
"image_url": self.image_url,
"thumbnail_url": self.thumbnail_url,
"watermarked_url": self.watermarked_url,
"message": self.message,
}
class TuhuiUploadService:
"""图绘平台上传服务"""
def __init__(self):
self.base_url = TUHUI_BASE_URL.rstrip("/")
self.base_urls = []
for candidate in (
TUHUI_FALLBACK_BASE_URL.rstrip("/"),
TUHUI_DIRECT_BASE_URL.rstrip("/"),
self.base_url,
):
if candidate and candidate not in self.base_urls:
self.base_urls.append(candidate)
if self.base_urls:
self.base_url = self.base_urls[0]
self.phone = TUHUI_PHONE
self.password = TUHUI_PASSWORD
self.default_price = TUHUI_DEFAULT_PRICE
self.access_token = None
self.user_id = None
@staticmethod
def _build_api_url(base_url: str, path: str) -> str:
normalized = path if path.startswith("/") else f"/{path}"
if base_url.endswith("/api"):
return f"{base_url}{normalized}"
return f"{base_url}/api{normalized}"
def _api_url(self, path: str) -> str:
return self._build_api_url(self.base_url, path)
@staticmethod
def _build_work_url(work_id: int) -> str:
return f"{TUHUI_WEB_BASE_URL}/detail/{int(work_id)}"
@staticmethod
def _normalize_asset_url(raw_url: str) -> str:
url = str(raw_url or "").strip()
if not url:
return ""
if url.startswith("/"):
return f"{TUHUI_WEB_BASE_URL}{url}"
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return url
host = (parsed.netloc or "").lower()
if host in {
"tuhui.cloud",
"www.tuhui.cloud",
"aidg168.uk",
"www.aidg168.uk",
"156.226.181.204:8002",
"1.12.50.92:8002",
"127.0.0.1:8002",
}:
path = parsed.path or ""
if parsed.query:
path = f"{path}?{parsed.query}"
if parsed.fragment:
path = f"{path}#{parsed.fragment}"
return f"{TUHUI_WEB_BASE_URL}{path}"
return url
@staticmethod
def _guess_file_meta(image_path: str) -> tuple[str, str]:
path = Path(image_path)
filename = path.name or "image.jpg"
mime_type, _ = mimetypes.guess_type(filename)
return filename, mime_type or "application/octet-stream"
async def login(self) -> bool:
"""登录图绘平台获取 token"""
last_error = ""
for base_url in self.base_urls:
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self._build_api_url(base_url, "/auth/login"),
json={
"phone": self.phone,
"password": self.password
},
timeout=10.0
)
if response.status_code == 200:
data = response.json()
self.access_token = data.get("access_token")
user = data.get("user", {})
self.user_id = user.get("id")
self.base_url = base_url
logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}base={self.base_url}")
return True
last_error = f"{response.status_code} {response.text}"
logger.warning(f"图绘平台登录失败base={base_url}{last_error}")
except Exception as e:
last_error = str(e)
logger.warning(f"图绘平台登录异常base={base_url}{type(e).__name__}: {e!r}")
logger.error(f"图绘平台登录失败:{last_error}")
return False
async def upload_image(
self,
image_path: str,
title: str,
description: str = "",
price: Optional[int] = None,
category: str = TUHUI_DEFAULT_CATEGORY,
tags: str = "",
designer_name: str = "",
) -> TuhuiUploadResult:
"""
上传图片到图绘平台
Args:
image_path: 图片文件路径
title: 作品标题
description: 作品描述
price: 定价(元),默认使用 TUHUI_DEFAULT_PRICE
category: 分类
Returns:
TuhuiUploadResult
- success: 是否上传成功
- download_url: 站内作品页地址
- image_url: 原图 URL保留便于需要时取用
- thumbnail_url: 缩略图 URL
- watermarked_url: 水印图 URL
- work_id: 作品 ID
"""
try:
# 如果 token 过期,重新登录
if not self.access_token:
if not await self.login():
return TuhuiUploadResult(False, "", 0, message="登录失败")
# 准备上传数据
price = self.default_price if price is None else price
# 读取图片文件
if not os.path.exists(image_path):
logger.error(f"图片文件不存在:{image_path}")
return TuhuiUploadResult(False, "", 0, message="文件不存在")
filename, mime_type = self._guess_file_meta(image_path)
with open(image_path, "rb") as f:
files = {
"file": (filename, f, mime_type)
}
data = {
"title": title,
"description": description,
"price": str(price),
"category": category,
}
if tags:
data["tags"] = tags
if designer_name:
data["designer_name"] = str(designer_name).strip()
headers = {
"Authorization": f"Bearer {self.access_token}"
}
async with httpx.AsyncClient() as client:
response = await client.post(
self._api_url("/upload"),
files=files,
data=data,
headers=headers,
timeout=30.0
)
if response.status_code in [200, 201]:
payload = response.json()
if not payload.get("success", False):
logger.error(f"图绘平台上传返回失败:{payload}")
return TuhuiUploadResult(
False,
"",
0,
message=str(payload.get("message", "上传失败")),
)
work_id = int(payload.get("work_id") or payload.get("work", {}).get("id") or 0)
image_url = self._normalize_asset_url(
payload.get("image_url") or payload.get("work", {}).get("original_image") or ""
)
thumbnail_url = self._normalize_asset_url(
payload.get("thumbnail_url") or payload.get("work", {}).get("thumbnail_image") or ""
)
watermarked_url = self._normalize_asset_url(
payload.get("watermarked_url") or payload.get("work", {}).get("watermarked_image") or ""
)
download_url = self._build_work_url(work_id) if work_id else ""
logger.info(
f"图绘平台上传成功,作品 ID: {work_id}, 站内地址: {download_url}, 原图: {image_url}"
)
return TuhuiUploadResult(
True,
download_url,
work_id,
image_url=image_url,
thumbnail_url=thumbnail_url,
watermarked_url=watermarked_url,
message=str(payload.get("message", "上传成功")),
)
else:
logger.error(f"图绘平台上传失败:{response.status_code} {response.text}")
# 如果 token 过期,尝试重新登录后再上传
if response.status_code == 401:
logger.info("Token 可能过期,尝试重新登录...")
self.access_token = None
if await self.login():
# 重新上传
return await self.upload_image(
image_path, title, description, price, category, tags, designer_name
)
return TuhuiUploadResult(False, "", 0, message=f"上传失败:{response.text}")
except Exception as e:
logger.error(f"图绘平台上传异常:{e}")
return TuhuiUploadResult(False, "", 0, message=f"上传异常:{e}")
# 单例
_tuhui_service: Optional[TuhuiUploadService] = None
def get_tuhui_service() -> TuhuiUploadService:
"""获取图绘上传服务单例"""
global _tuhui_service
if _tuhui_service is None:
_tuhui_service = TuhuiUploadService()
return _tuhui_service
# 便捷函数
async def upload_to_tuhui(
image_path: str,
title: str,
description: str = "",
price: int = 20,
category: str = TUHUI_DEFAULT_CATEGORY,
tags: str = "",
designer_name: str = "",
) -> TuhuiUploadResult:
"""
便捷函数:上传图片到图绘平台
Returns:
TuhuiUploadResult
"""
service = get_tuhui_service()
return await service.upload_image(image_path, title, description, price, category, tags, designer_name)