feat(C-04):AI图片放大最小闭环
新应用aitool:POST /api/aitool/upscale(登录扣2积分、事务行锁防并发超扣,失败退款+failed记录);产物存MEDIA_ROOT/aitool/upscale,返回可下载URL;provider默认本地Pillow,第三方网关走环境变量不落库。
This commit is contained in:
@@ -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']
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AitoolConfig(AppConfig):
|
||||||
|
name = 'aitool'
|
||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})'
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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()),
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .upscale_view import UpscaleView, AIToolRecordsView # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ['UpscaleView', 'AIToolRecordsView']
|
||||||
@@ -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})
|
||||||
@@ -145,6 +145,7 @@ INSTALLED_APPS = [
|
|||||||
'chat.apps.ChatConfig',
|
'chat.apps.ChatConfig',
|
||||||
'learn.apps.LearnConfig',
|
'learn.apps.LearnConfig',
|
||||||
'tool.apps.ToolConfig',
|
'tool.apps.ToolConfig',
|
||||||
|
'aitool.apps.AitoolConfig',
|
||||||
'search.apps.SearchConfig',
|
'search.apps.SearchConfig',
|
||||||
'weather.apps.WeatherConfig',
|
'weather.apps.WeatherConfig',
|
||||||
'air_quality.apps.AirQualityConfig',
|
'air_quality.apps.AirQualityConfig',
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ urlpatterns = [
|
|||||||
path('message/', include('message.urls')),
|
path('message/', include('message.urls')),
|
||||||
path('chat/', include('chat.urls')),
|
path('chat/', include('chat.urls')),
|
||||||
path('tool/', include('tool.urls')),
|
path('tool/', include('tool.urls')),
|
||||||
|
path('api/aitool/', include('aitool.urls')),
|
||||||
path('search/', include('search.urls')),
|
path('search/', include('search.urls')),
|
||||||
path('logs/', include('logs.urls')),
|
path('logs/', include('logs.urls')),
|
||||||
path('shorturl/', include('shorturl.urls')),
|
path('shorturl/', include('shorturl.urls')),
|
||||||
|
|||||||
Reference in New Issue
Block a user