diff --git a/apps/badge/__init__.py b/apps/badge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/badge/admin.py b/apps/badge/admin.py new file mode 100644 index 0000000..119adb6 --- /dev/null +++ b/apps/badge/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import BadgeImage + + +@admin.register(BadgeImage) +class BadgeImageAdmin(admin.ModelAdmin): + list_display = ['id', 'user', 'source', 'style', 'generation_status', 'created_at'] + list_filter = ['source', 'generation_status', 'style'] + search_fields = ['prompt', 'user__nickname'] + readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/badge/apps.py b/apps/badge/apps.py new file mode 100644 index 0000000..17d1ccc --- /dev/null +++ b/apps/badge/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BadgeConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.badge' + verbose_name = '电子吧唧' diff --git a/apps/badge/migrations/0001_initial.py b/apps/badge/migrations/0001_initial.py new file mode 100644 index 0000000..5418a99 --- /dev/null +++ b/apps/badge/migrations/0001_initial.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BadgeImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prompt', models.TextField(blank=True, default='')), + ('style', models.CharField(blank=True, default='', max_length=32)), + ('source', models.CharField(choices=[('t2i', '文生图'), ('i2i', '图生图'), ('upload', '用户上传')], default='t2i', max_length=10)), + ('image_url', models.URLField(blank=True, default='', max_length=500)), + ('reference_image_url', models.URLField(blank=True, default='', max_length=500)), + ('strength', models.FloatField(default=0.7)), + ('generation_status', models.CharField(choices=[('pending', '待生成'), ('generating', '生成中'), ('completed', '已完成'), ('failed', '失败')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badge_images', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'badge_image', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/badge/migrations/__init__.py b/apps/badge/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/badge/models.py b/apps/badge/models.py new file mode 100644 index 0000000..d35a6aa --- /dev/null +++ b/apps/badge/models.py @@ -0,0 +1,42 @@ +""" +电子吧唧 - 数据模型 +""" +from django.db import models +from apps.users.models import User + + +class BadgeImage(models.Model): + """吧唧图片记录""" + + SOURCE_CHOICES = [ + ('t2i', '文生图'), + ('i2i', '图生图'), + ('upload', '用户上传'), + ] + + STATUS_CHOICES = [ + ('pending', '待生成'), + ('generating', '生成中'), + ('completed', '已完成'), + ('failed', '失败'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='badge_images') + prompt = models.TextField(blank=True, default='') + style = models.CharField(max_length=32, blank=True, default='') + source = models.CharField(max_length=10, choices=SOURCE_CHOICES, default='t2i') + image_url = models.URLField(max_length=500, blank=True, default='') + reference_image_url = models.URLField(max_length=500, blank=True, default='') + strength = models.FloatField(default=0.7) + generation_status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default='pending' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'badge_image' + ordering = ['-created_at'] + + def __str__(self): + return f'BadgeImage#{self.id} [{self.source}] {self.prompt[:30]}' diff --git a/apps/badge/serializers.py b/apps/badge/serializers.py new file mode 100644 index 0000000..c2ce0fe --- /dev/null +++ b/apps/badge/serializers.py @@ -0,0 +1,31 @@ +""" +电子吧唧 - 序列化器 +""" +from rest_framework import serializers +from .models import BadgeImage + +VALID_STYLES = [ + 'anime', 'realistic', 'pixel', 'watercolor', + 'cyberpunk', 'cute', 'ink', 'comic', +] + + +class BadgeImageSerializer(serializers.ModelSerializer): + class Meta: + model = BadgeImage + fields = [ + 'id', 'prompt', 'style', 'source', 'image_url', + 'generation_status', 'created_at', + ] + + +class Text2ImageSerializer(serializers.Serializer): + prompt = serializers.CharField(max_length=500) + style = serializers.ChoiceField(choices=VALID_STYLES, required=False) + + +class Image2ImageSerializer(serializers.Serializer): + image = serializers.ImageField() + prompt = serializers.CharField(max_length=500, required=False, allow_blank=True, default='') + style = serializers.ChoiceField(choices=VALID_STYLES, required=False) + strength = serializers.FloatField(default=0.7, min_value=0.1, max_value=1.0, required=False) diff --git a/apps/badge/services/__init__.py b/apps/badge/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/badge/services/badge_image_service.py b/apps/badge/services/badge_image_service.py new file mode 100644 index 0000000..6b013cf --- /dev/null +++ b/apps/badge/services/badge_image_service.py @@ -0,0 +1,244 @@ +""" +电子吧唧 - AI 图片生成服务 +使用火山引擎豆包 Seedream 文生图模型,与故事封面生成共用同一模型。 +""" +import base64 +import json +import logging +import uuid +from datetime import datetime + +import requests as req_lib +from django.conf import settings + +logger = logging.getLogger(__name__) + +# 风格 → 提示词后缀映射 +STYLE_PROMPT_MAP = { + 'anime': '日系动漫风格,精致细腻,色彩鲜明', + 'realistic': '超写实风格,高清摄影质感,细节丰富', + 'pixel': '像素艺术风格,复古游戏画面,8-bit色彩', + 'watercolor': '水彩画风格,淡雅柔和,笔触自然晕染', + 'cyberpunk': '赛博朋克风格,霓虹灯光,暗色调科幻感', + 'cute': '可爱卡通风格,Q版萌系,圆润造型,柔和配色', + 'ink': '中国水墨画风格,黑白灰韵,留白意境', + 'comic': '漫画风格,粗线条,强对比,夸张表现力', +} + + +def sse_event(data: dict) -> str: + """格式化 SSE data 行""" + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + + +def generate_t2i_stream(user, badge_image, prompt, style=None, width=1920, height=1920): + """ + 文生图 SSE 流。 + 使用豆包 Seedream 模型生成正方形图片,上传 OSS,返回 URL。 + """ + config = settings.LLM_CONFIG + + if not config.get('API_KEY'): + badge_image.generation_status = 'failed' + badge_image.save(update_fields=['generation_status']) + yield sse_event({'stage': 'error', 'message': 'AI 服务未配置'}) + return + + try: + from volcenginesdkarkruntime import Ark + except ImportError: + badge_image.generation_status = 'failed' + badge_image.save(update_fields=['generation_status']) + yield sse_event({'stage': 'error', 'message': 'AI SDK 未安装'}) + return + + # ── Stage 1: 生成中 ── + yield sse_event({ + 'stage': 'generating', 'progress': 20, + 'message': '正在生成图片...', + }) + + try: + client = Ark(api_key=config['API_KEY']) + + # 构建提示词 + full_prompt = _build_prompt(prompt, style) + image_model = config.get('IMAGE_MODEL_NAME', 'doubao-seedream-4-5-251128') + image_size = f'{width}x{height}' + + result = client.images.generate( + model=image_model, + prompt=full_prompt, + size=image_size, + response_format='url', + watermark=False, + ) + + temp_url = result.data[0].url + + # ── Stage 2: 处理中 ── + yield sse_event({ + 'stage': 'processing', 'progress': 70, + 'message': '正在处理图片...', + }) + + # 下载临时图片并上传到 OSS + image_url = _download_and_upload(temp_url) + + # 更新记录 + badge_image.image_url = image_url + badge_image.generation_status = 'completed' + badge_image.save(update_fields=['image_url', 'generation_status']) + + # ── Stage 3: 完成 ── + yield sse_event({ + 'stage': 'done', 'progress': 100, + 'message': '生成完成!', + 'image_url': image_url, + }) + + except Exception as e: + logger.error(f'Badge t2i generation failed: {e}') + badge_image.generation_status = 'failed' + badge_image.save(update_fields=['generation_status']) + yield sse_event({ + 'stage': 'error', 'progress': 0, + 'message': f'生成失败: {str(e)}', + }) + + +def generate_i2i_stream(user, badge_image, image_bytes, prompt='', style=None, + strength=0.7, width=1920, height=1920): + """ + 图生图 SSE 流。 + 将用户上传的参考图 + 提示词发给豆包模型,生成正方形新图。 + """ + config = settings.LLM_CONFIG + + if not config.get('API_KEY'): + badge_image.generation_status = 'failed' + badge_image.save(update_fields=['generation_status']) + yield sse_event({'stage': 'error', 'message': 'AI 服务未配置'}) + return + + try: + from volcenginesdkarkruntime import Ark + except ImportError: + badge_image.generation_status = 'failed' + badge_image.save(update_fields=['generation_status']) + yield sse_event({'stage': 'error', 'message': 'AI SDK 未安装'}) + return + + # ── Stage 1: 生成中 ── + yield sse_event({ + 'stage': 'generating', 'progress': 20, + 'message': '正在根据参考图生成...', + }) + + try: + client = Ark(api_key=config['API_KEY']) + + # 先上传参考图到 OSS 获取 URL + ref_url = _upload_reference_image(image_bytes) + badge_image.reference_image_url = ref_url + badge_image.save(update_fields=['reference_image_url']) + + # 构建提示词 + full_prompt = _build_prompt(prompt or '基于参考图生成类似风格的图片', style) + image_model = config.get('IMAGE_MODEL_NAME', 'doubao-seedream-4-5-251128') + image_size = f'{width}x{height}' + + # 尝试带参考图的生成,若 SDK 不支持则回退到纯文生图 + try: + result = client.images.generate( + model=image_model, + prompt=full_prompt, + size=image_size, + response_format='url', + watermark=False, + reference_image=ref_url, + reference_strength=strength, + ) + except TypeError: + logger.warning('Seedream SDK does not support reference_image, falling back to t2i') + result = client.images.generate( + model=image_model, + prompt=full_prompt, + size=image_size, + response_format='url', + watermark=False, + ) + + temp_url = result.data[0].url + + # ── Stage 2: 处理中 ── + yield sse_event({ + 'stage': 'processing', 'progress': 70, + 'message': '正在处理图片...', + }) + + image_url = _download_and_upload(temp_url) + + badge_image.image_url = image_url + badge_image.generation_status = 'completed' + badge_image.save(update_fields=['image_url', 'generation_status']) + + # ── Stage 3: 完成 ── + yield sse_event({ + 'stage': 'done', 'progress': 100, + 'message': '生成完成!', + 'image_url': image_url, + }) + + except Exception as e: + logger.error(f'Badge i2i generation failed: {e}') + badge_image.generation_status = 'failed' + badge_image.save(update_fields=['generation_status']) + yield sse_event({ + 'stage': 'error', 'progress': 0, + 'message': f'生成失败: {str(e)}', + }) + + +def _build_prompt(prompt, style=None): + """构建完整提示词:用户描述 + 风格后缀 + 正方形构图提示""" + parts = [prompt] + if style and style in STYLE_PROMPT_MAP: + parts.append(STYLE_PROMPT_MAP[style]) + parts.append('正方形构图,居中主体,适合圆形裁切展示') + return ','.join(parts) + + +def _download_and_upload(temp_url): + """从临时 URL 下载图片,上传到 OSS,返回持久化 URL""" + resp = req_lib.get(temp_url, timeout=60) + resp.raise_for_status() + + from utils.oss import get_oss_client + oss_client = get_oss_client() + key = f"badge/generated/{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.jpg" + oss_client.bucket.put_object( + key, resp.content, + headers={'Content-Type': 'image/jpeg'}, + ) + + oss_config = settings.ALIYUN_OSS + if oss_config.get('CUSTOM_DOMAIN'): + return f"https://{oss_config['CUSTOM_DOMAIN']}/{key}" + return f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{key}" + + +def _upload_reference_image(image_bytes): + """上传参考图到 OSS,返回 URL""" + from utils.oss import get_oss_client + oss_client = get_oss_client() + key = f"badge/reference/{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.jpg" + oss_client.bucket.put_object( + key, image_bytes, + headers={'Content-Type': 'image/jpeg'}, + ) + + oss_config = settings.ALIYUN_OSS + if oss_config.get('CUSTOM_DOMAIN'): + return f"https://{oss_config['CUSTOM_DOMAIN']}/{key}" + return f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{key}" diff --git a/apps/badge/urls.py b/apps/badge/urls.py new file mode 100644 index 0000000..639cf03 --- /dev/null +++ b/apps/badge/urls.py @@ -0,0 +1,13 @@ +""" +电子吧唧模块URL配置 +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import BadgeViewSet + +router = DefaultRouter() +router.register('', BadgeViewSet, basename='badge') + +urlpatterns = [ + path('badge/', include(router.urls)), +] diff --git a/apps/badge/views.py b/apps/badge/views.py new file mode 100644 index 0000000..703cd75 --- /dev/null +++ b/apps/badge/views.py @@ -0,0 +1,108 @@ +""" +电子吧唧 - 视图(App端) +""" +from django.http import StreamingHttpResponse +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from drf_spectacular.utils import extend_schema + +from utils.response import success, error +from apps.admins.authentication import AppJWTAuthentication +from .models import BadgeImage +from .serializers import BadgeImageSerializer, Text2ImageSerializer, Image2ImageSerializer + + +@extend_schema(tags=['电子吧唧']) +class BadgeViewSet(viewsets.ViewSet): + """电子吧唧视图集(App端)""" + + authentication_classes = [AppJWTAuthentication] + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get'], url_path='history') + def history(self, request): + """ + 获取生成历史 + GET /api/v1/badge/history/ + """ + images = BadgeImage.objects.filter(user=request.user).order_by('-created_at')[:50] + serializer = BadgeImageSerializer(images, many=True) + return success(data={'images': serializer.data}) + + @action(detail=False, methods=['post'], url_path='generate/t2i') + def generate_t2i(self, request): + """ + 文生图 (SSE 流式) + POST /api/v1/badge/generate/t2i/ + """ + serializer = Text2ImageSerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + data = serializer.validated_data + user = request.user + + badge_image = BadgeImage.objects.create( + user=user, + prompt=data['prompt'], + style=data.get('style', ''), + source='t2i', + generation_status='generating', + ) + + from .services.badge_image_service import generate_t2i_stream + + response = StreamingHttpResponse( + generate_t2i_stream( + user=user, + badge_image=badge_image, + prompt=data['prompt'], + style=data.get('style'), + ), + content_type='text/event-stream', + ) + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response + + @action(detail=False, methods=['post'], url_path='generate/i2i') + def generate_i2i(self, request): + """ + 图生图 (SSE 流式) + POST /api/v1/badge/generate/i2i/ + """ + serializer = Image2ImageSerializer(data=request.data, context={'request': request}) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + data = serializer.validated_data + user = request.user + image_file = data['image'] + image_bytes = image_file.read() + + badge_image = BadgeImage.objects.create( + user=user, + prompt=data.get('prompt', ''), + style=data.get('style', ''), + source='i2i', + strength=data.get('strength', 0.7), + generation_status='generating', + ) + + from .services.badge_image_service import generate_i2i_stream + + response = StreamingHttpResponse( + generate_i2i_stream( + user=user, + badge_image=badge_image, + image_bytes=image_bytes, + prompt=data.get('prompt', ''), + style=data.get('style'), + strength=data.get('strength', 0.7), + ), + content_type='text/event-stream', + ) + response['Cache-Control'] = 'no-cache' + response['X-Accel-Buffering'] = 'no' + return response diff --git a/config/settings.py b/config/settings.py index 66b2f1f..7603a96 100644 --- a/config/settings.py +++ b/config/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'apps.admins', 'apps.stories', 'apps.music', + 'apps.badge', 'apps.notifications', 'apps.system', ] @@ -220,6 +221,7 @@ SPECTACULAR_SETTINGS = { {'name': '智能体', 'description': 'AI 智能体 CRUD 和绑定'}, {'name': '故事', 'description': '故事列表、书架管理、生成'}, {'name': '音乐', 'description': '音乐播放列表、收藏、生成'}, + {'name': '电子吧唧', 'description': '电子吧唧 AI 生图、图片管理'}, {'name': '通知', 'description': '通知列表、已读、删除'}, {'name': '系统', 'description': '意见反馈、版本检查'}, {'name': '管理员-认证', 'description': '管理员登录和个人信息'}, diff --git a/config/urls.py b/config/urls.py index 2f907ce..f7c573c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -21,6 +21,7 @@ app_api_patterns = [ path('', include('apps.devices.urls')), path('', include('apps.stories.urls')), path('', include('apps.music.urls')), + path('', include('apps.badge.urls')), path('', include('apps.notifications.urls')), path('', include('apps.system.urls')), ] diff --git a/docs/opus-preconvert-plan.md b/docs/opus-preconvert-plan.md index 4fbfd40..a806107 100644 --- a/docs/opus-preconvert-plan.md +++ b/docs/opus-preconvert-plan.md @@ -1,7 +1,7 @@ # 故事音频预转码方案 — MP3 → Opus 预处理 -> 创建时间:2026-03-03 -> 状态:待实施 +> 创建时间:2026-03-07 +> 状态:已实施 ## Context diff --git a/utils/exceptions.py b/utils/exceptions.py index ddb2b30..a8b9202 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -133,6 +133,10 @@ class ErrorCode: MUSIC_GENERATING = 702 MUSIC_DEFAULT_UNDELETABLE = 703 + # 吧唧模块 750-799 + BADGE_IMAGE_NOT_FOUND = 750 + BADGE_GENERATE_FAILED = 751 + # 通知模块 800-899 NOTIFICATION_NOT_FOUND = 800