feat(C-04):AI图片放大最小闭环

新应用aitool:POST /api/aitool/upscale(登录扣2积分、事务行锁防并发超扣,失败退款+failed记录);产物存MEDIA_ROOT/aitool/upscale,返回可下载URL;provider默认本地Pillow,第三方网关走环境变量不落库。
This commit is contained in:
chunyu
2026-09-15 15:20:31 +08:00
parent 24874aae23
commit 008b82055d
14 changed files with 430 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
"""C-04 Provider 抽象层:本地 Pillow 实现 + HTTP 预留。"""
from __future__ import annotations
import io
import os
from PIL import Image
class BaseProvider:
name = 'base'
def upscale(self, raw: bytes, scale: int) -> bytes: # pragma: no cover
raise NotImplementedError
class PillowProvider(BaseProvider):
"""本地放大:LANCZOS 重采样。零 key、零外部调用,mock 链路即生产链路。"""
name = 'pillow'
MAX_INPUT_BYTES = 10 * 1024 * 1024
MAX_DIM = 4096
def upscale(self, raw: bytes, scale: int) -> bytes:
if scale not in (2, 4):
raise ValueError('仅支持 2x / 4x')
if len(raw) > self.MAX_INPUT_BYTES:
raise ValueError('图片过大(>10MB)')
img = Image.open(io.BytesIO(raw))
img.load()
if img.width > self.MAX_DIM or img.height > self.MAX_DIM:
raise ValueError('图片尺寸过大')
out = img.resize((img.width * scale, img.height * scale), Image.Resampling.LANCZOS)
buf = io.BytesIO()
fmt = (img.format or 'PNG').upper()
if fmt in ('JPEG', 'JPG'):
if out.mode in ('RGBA', 'P'):
out = out.convert('RGB')
out.save(buf, 'JPEG', quality=95, optimize=True)
return buf.getvalue()
out.save(buf, 'PNG', optimize=True)
return buf.getvalue()
class HTTPProvider(BaseProvider):
"""预留第三方网关:baseURL/key 全走环境变量。未配置时直接抛错(由视图转 503)。"""
name = 'http'
def __init__(self) -> None:
self.base = os.environ.get('AITOOL_API_BASE', '')
self.key = os.environ.get('AITOOL_API_KEY', '')
def upscale(self, raw: bytes, scale: int) -> bytes: # pragma: no cover
import urllib.request
if not self.base or not self.key:
raise RuntimeError('AITOOL_API_BASE / AITOOL_API_KEY 未配置')
req = urllib.request.Request(
f'{self.base.rstrip("/")}/upscale',
data=raw,
headers={'Authorization': f'Bearer {self.key}', 'X-Scale': str(scale)},
method='POST',
)
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read()
def get_provider() -> BaseProvider:
which = os.environ.get('AITOOL_PROVIDER', 'pillow').lower()
if which == 'http':
return HTTPProvider()
return PillowProvider()