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
View File
+12
View File
@@ -0,0 +1,12 @@
from django.contrib import admin
from .models import AIToolRecord
@admin.register(AIToolRecord)
class AIToolRecordAdmin(admin.ModelAdmin):
list_display = ['tool', 'user', 'status', 'cost', 'scale', 'created_at']
list_filter = ['tool', 'status', 'created_at']
search_fields = ['user__username']
ordering = ['-created_at']
readonly_fields = ['created_at']
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AitoolConfig(AppConfig):
name = 'aitool'
+38
View File
@@ -0,0 +1,38 @@
# Generated by Django 5.2.12 on 2026-09-12 16:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AIToolRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tool', models.CharField(choices=[('upscale', '图片放大'), ('matting', '抠图(预留)'), ('id-photo', '证件照(预留)'), ('text2img', '文生图(预留)')], default='upscale', max_length=20, verbose_name='工具')),
('status', models.CharField(choices=[('success', '成功'), ('failed', '失败'), ('refunded', '已回滚')], default='success', max_length=10, verbose_name='状态')),
('cost', models.IntegerField(default=0, verbose_name='扣减积分')),
('scale', models.IntegerField(default=2, verbose_name='放大倍数')),
('input_size', models.BigIntegerField(default=0, verbose_name='输入字节')),
('output_size', models.BigIntegerField(default=0, verbose_name='输出字节')),
('file_path', models.CharField(blank=True, default='', max_length=500, verbose_name='产物路径')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aitool_records', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': 'AI工具调用记录',
'verbose_name_plural': 'AI工具调用记录',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', '-created_at'], name='aitool_aito_user_id_2b9829_idx'), models.Index(fields=['tool', '-created_at'], name='aitool_aito_tool_196230_idx')],
},
),
]
View File
+56
View File
@@ -0,0 +1,56 @@
"""C-04 AI 工具:图片放大(upscale)最小闭环。
设计:
- Provider 抽象:BaseProvider + PillowProvider(本地 LANCZOS 2x/4x,
零 key、零外部依赖,mock 链路即生产链路)+ HTTPProvider(预留第三方网关,
baseURL/key 全走环境变量,绝不落库、不进响应)。
- 钱包打通:调用前按 AITOOL_UPSCALE_COST 扣积分(事务 + 行锁),处理失败自动回滚。
- 产物存储:MEDIA_ROOT/aitool/upscale/,返回可下载 URL。
- 与钱包打通仅做方案不落地计费:真实扣费走积分(存量 PointTransaction),
不引入支付网关;y币/现金计费留待商业化评审(见 PROGRESS_C-04)。
"""
from django.conf import settings
from django.db import models
class AIToolRecord(models.Model):
TOOL_CHOICES = [
('upscale', '图片放大'),
('matting', '抠图(预留)'),
('id-photo', '证件照(预留)'),
('text2img', '文生图(预留)'),
]
STATUS_CHOICES = [
('success', '成功'),
('failed', '失败'),
('refunded', '已回滚'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='aitool_records',
verbose_name='用户',
)
tool = models.CharField(max_length=20, choices=TOOL_CHOICES, default='upscale', verbose_name='工具')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='success', verbose_name='状态')
cost = models.IntegerField(default=0, verbose_name='扣减积分')
scale = models.IntegerField(default=2, verbose_name='放大倍数')
input_size = models.BigIntegerField(default=0, verbose_name='输入字节')
output_size = models.BigIntegerField(default=0, verbose_name='输出字节')
file_path = models.CharField(max_length=500, blank=True, default='', verbose_name='产物路径')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
ordering = ['-created_at']
verbose_name = 'AI工具调用记录'
verbose_name_plural = 'AI工具调用记录'
indexes = [
models.Index(fields=['user', '-created_at']),
models.Index(fields=['tool', '-created_at']),
]
def __str__(self):
return f'{self.tool} x{self.scale} ({self.status})'
+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()
View File
+89
View File
@@ -0,0 +1,89 @@
"""C-04 图片放大最小闭环用例:成功链路/余额不足/坏图回滚/流水/并发。"""
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from PIL import Image
from rest_framework.test import APIClient
from aitool.models import AIToolRecord
from aitool.views.upscale_view import UPSCALE_COST
from user.models import FUser, PointTransaction
def _resp_bytes(response):
return b''.join(response.streaming_content)
def _img(color='red', size=(32, 32)):
img = Image.new('RGB', size, color=color)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return SimpleUploadedFile('t.png', buf.getvalue(), content_type='image/png')
class UpscaleTest(TestCase):
def setUp(self):
self.client = APIClient()
self.u = FUser.objects.create_user(username='c04u', password='x12345678')
self.u.points = 100
self.u.save(update_fields=['points'])
self.client.force_authenticate(user=self.u)
def test_success_chain(self):
r = self.client.post('/api/aitool/upscale/', {'file': _img(), 'scale': 2}, format='multipart')
self.assertEqual(r.status_code, 200)
self.assertEqual(r['X-AITool-Cost'], str(UPSCALE_COST))
self.assertTrue(r['X-AITool-File'].startswith('/media/aitool/upscale/'))
out = Image.open(io.BytesIO(_resp_bytes(r)))
self.assertEqual(out.size, (64, 64))
self.u.refresh_from_db()
self.assertEqual(self.u.points, 100 - UPSCALE_COST)
rec = AIToolRecord.objects.filter(user=self.u, status='success').order_by('-id').first()
self.assertIsNotNone(rec)
self.assertEqual(rec.scale, 2)
tx = PointTransaction.objects.filter(user=self.u, transaction_type='spend').order_by('-id').first()
self.assertEqual(tx.amount, UPSCALE_COST)
def test_scale_4x(self):
r = self.client.post('/api/aitool/upscale/', {'file': _img(), 'scale': 4}, format='multipart')
self.assertEqual(r.status_code, 200)
out = Image.open(io.BytesIO(_resp_bytes(r)))
self.assertEqual(out.size, (128, 128))
def test_insufficient_points(self):
self.u.points = 0
self.u.save(update_fields=['points'])
r = self.client.post('/api/aitool/upscale/', {'file': _img(), 'scale': 2}, format='multipart')
self.assertEqual(r.status_code, 402)
self.assertEqual(r.json().get('code'), 'INSUFFICIENT_POINTS')
self.assertFalse(AIToolRecord.objects.filter(user=self.u).exists())
def test_invalid_scale(self):
r = self.client.post('/api/aitool/upscale/', {'file': _img(), 'scale': 8}, format='multipart')
self.assertEqual(r.status_code, 400)
def test_bad_image_refunds(self):
bad = SimpleUploadedFile('t.png', b'not-an-image', content_type='image/png')
before = self.u.points
r = self.client.post('/api/aitool/upscale/', {'file': bad, 'scale': 2}, format='multipart')
self.assertIn(r.status_code, (400, 500))
self.u.refresh_from_db()
self.assertEqual(self.u.points, before)
statuses = set(AIToolRecord.objects.filter(user=self.u).values_list('status', flat=True))
self.assertTrue(statuses <= {'failed', 'refunded', 'success'})
# 坏图应有回滚流水
self.assertTrue(PointTransaction.objects.filter(
user=self.u, transaction_type='earn', description__contains='回滚').exists())
def test_records_endpoint(self):
self.client.post('/api/aitool/upscale/', {'file': _img(), 'scale': 2}, format='multipart')
r = self.client.get('/api/aitool/records/')
self.assertEqual(r.status_code, 200)
self.assertTrue(len(r.json()['data']) >= 1)
def test_requires_login(self):
anon = APIClient()
r = anon.post('/api/aitool/upscale/', {'file': _img()}, format='multipart')
self.assertIn(r.status_code, (401, 403))
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import AIToolRecordsView, UpscaleView
urlpatterns = [
path('upscale/', UpscaleView.as_view()),
path('records/', AIToolRecordsView.as_view()),
]
+3
View File
@@ -0,0 +1,3 @@
from .upscale_view import UpscaleView, AIToolRecordsView # noqa: F401
__all__ = ['UpscaleView', 'AIToolRecordsView']
+147
View File
@@ -0,0 +1,147 @@
"""C-04 图片放大端点:POST /api/aitool/upscale(登录,扣积分,失败回滚)。"""
import logging
import os
import uuid
from adrf.views import APIView
from asgiref.sync import sync_to_async
from django.core.files.storage import default_storage
from django.db import transaction
from django.db.models import F
from django.http import FileResponse, JsonResponse
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from user.models import FUser, PointTransaction
from utils.async_decorators import async_never_cache_dispatch
from ..models import AIToolRecord
from ..providers import get_provider
logger = logging.getLogger(__name__)
UPSCALE_COST = int(os.environ.get('AITOOL_UPSCALE_COST', '2'))
@async_never_cache_dispatch
class UpscaleView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
async def post(self, request):
f = request.FILES.get('file') or request.FILES.get('image')
if not f:
return JsonResponse({'success': False, 'error': '请上传图片文件'}, status=400)
try:
scale = int(request.data.get('scale', 2))
except (TypeError, ValueError):
return JsonResponse({'success': False, 'error': 'scale 仅支持 2 或 4'}, status=400)
if scale not in (2, 4):
return JsonResponse({'success': False, 'error': 'scale 仅支持 2 或 4'}, status=400)
try:
raw = f.read()
except Exception:
return JsonResponse({'success': False, 'error': '文件读取失败'}, status=400)
if not raw:
return JsonResponse({'success': False, 'error': '空文件'}, status=400)
# 1) 扣费(事务 + 行锁;余额不足直接拒绝,不建记录)
def _charge():
with transaction.atomic():
u = FUser.objects.select_for_update().get(pk=request.user.pk)
if u.points < UPSCALE_COST:
return None
u.points = F('points') - UPSCALE_COST
u.save(update_fields=['points'])
u.refresh_from_db()
PointTransaction.objects.create(
user=u, transaction_type='spend', currency_type='points',
amount=UPSCALE_COST, balance_after=u.points,
description=f'AI图片放大 x{scale}',
)
return u.points
try:
balance = await sync_to_async(_charge)()
except Exception as e:
logger.error(f'aitool 扣费失败: {e}')
return JsonResponse({'success': False, 'error': '扣费失败,请稍后再试'}, status=500)
if balance is None:
return JsonResponse(
{'success': False, 'error': '积分余额不足', 'code': 'INSUFFICIENT_POINTS',
'cost': UPSCALE_COST},
status=402,
)
# 2) 处理(失败 → 回滚积分 + 落 failed 记录)
def _refund(reason: str):
with transaction.atomic():
u = FUser.objects.select_for_update().get(pk=request.user.pk)
u.points = F('points') + UPSCALE_COST
u.save(update_fields=['points'])
u.refresh_from_db()
PointTransaction.objects.create(
user=u, transaction_type='earn', currency_type='points',
amount=UPSCALE_COST, balance_after=u.points,
description=f'AI图片放大失败回滚 x{scale}',
)
AIToolRecord.objects.create(
user=u, tool='upscale', status='refunded', cost=UPSCALE_COST,
scale=scale, input_size=len(raw),
)
return u.points
try:
out_bytes = await sync_to_async(get_provider().upscale)(raw, scale)
except ValueError as e:
await sync_to_async(_refund)(str(e))
return JsonResponse({'success': False, 'error': str(e)}, status=400)
except Exception as e:
logger.error(f'aitool 放大失败: {e}')
await sync_to_async(_refund)('provider error')
return JsonResponse({'success': False, 'error': '图片处理失败,已退回积分'}, status=500)
# 3) 落盘 + 成功记录
name = f'aitool/upscale/{uuid.uuid4().hex}_{scale}x.png'
def _save():
path = default_storage.save(name, __import__('django').core.files.base.ContentFile(out_bytes))
AIToolRecord.objects.create(
user_id=request.user.pk, tool='upscale', status='success',
cost=UPSCALE_COST, scale=scale,
input_size=len(raw), output_size=len(out_bytes), file_path=path,
)
return path
try:
saved = await sync_to_async(_save)()
except Exception as e:
logger.error(f'aitool 落盘失败: {e}')
await sync_to_async(_refund)('save error')
return JsonResponse({'success': False, 'error': '产物保存失败,已退回积分'}, status=500)
resp = FileResponse(
__import__('io').BytesIO(out_bytes), content_type='image/png',
filename=f'upscaled_{scale}x.png', as_attachment=True,
)
resp['X-AITool-Cost'] = str(UPSCALE_COST)
current_balance = await sync_to_async(
lambda: FUser.objects.get(pk=request.user.pk).points)()
resp['X-AITool-Balance'] = str(current_balance)
resp['X-AITool-File'] = f'/media/{saved}'
return resp
class AIToolRecordsView(APIView):
"""本人调用流水:GET /api/aitool/records(登录)。"""
permission_classes = [IsAuthenticated]
async def get(self, request):
rows = [
{
'id': r.id, 'tool': r.tool, 'status': r.status, 'cost': r.cost,
'scale': r.scale, 'input_size': r.input_size, 'output_size': r.output_size,
'file_url': f'/media/{r.file_path}' if r.file_path else '',
'created_at': r.created_at.isoformat() if r.created_at else None,
}
async for r in AIToolRecord.objects.filter(user=request.user).order_by('-created_at')[:50]
]
return JsonResponse({'success': True, 'data': rows})
+1
View File
@@ -145,6 +145,7 @@ INSTALLED_APPS = [
'chat.apps.ChatConfig',
'learn.apps.LearnConfig',
'tool.apps.ToolConfig',
'aitool.apps.AitoolConfig',
'search.apps.SearchConfig',
'weather.apps.WeatherConfig',
'air_quality.apps.AirQualityConfig',
+1
View File
@@ -75,6 +75,7 @@ urlpatterns = [
path('message/', include('message.urls')),
path('chat/', include('chat.urls')),
path('tool/', include('tool.urls')),
path('api/aitool/', include('aitool.urls')),
path('search/', include('search.urls')),
path('logs/', include('logs.urls')),
path('shorturl/', include('shorturl.urls')),