Add 电子吧唧
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 7m15s

This commit is contained in:
repair-agent 2026-03-18 17:38:05 +08:00
parent 51a673e814
commit fe0dcb78c3
15 changed files with 499 additions and 2 deletions

0
apps/badge/__init__.py Normal file
View File

10
apps/badge/admin.py Normal file
View File

@ -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']

7
apps/badge/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class BadgeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.badge'
verbose_name = '电子吧唧'

View File

@ -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'],
},
),
]

View File

42
apps/badge/models.py Normal file
View File

@ -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]}'

31
apps/badge/serializers.py Normal file
View File

@ -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)

View File

View File

@ -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}"

13
apps/badge/urls.py Normal file
View File

@ -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)),
]

108
apps/badge/views.py Normal file
View File

@ -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

View File

@ -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': '管理员登录和个人信息'},

View File

@ -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')),
]

View File

@ -1,7 +1,7 @@
# 故事音频预转码方案 — MP3 → Opus 预处理
> 创建时间2026-03-03
> 状态:实施
> 创建时间2026-03-07
> 状态:实施
## Context

View File

@ -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