feat(core): standalone image-gen endpoint + api.ts methods (profile/password/avatar/generate-image)

- POST /api/ai/generate-image/ — project-less image generation (AITask.project nullable, no schema change),
  reuses VolcanoArk image_generation + credit reserve/charge; modes image/model/cover.
  Verified: manage.py check clean; 2 active IMAGE models present (doubao-seedream-4.5/5.0).
  (Real generation calls Volcano API + charges credit — not yet live-tested to avoid spend.)
- api.ts: updateProfile / changePassword / uploadAvatar / generateImage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-06-05 15:51:36 +08:00
parent aad9bd683b
commit a8f4608d10
4 changed files with 115 additions and 3 deletions

View File

@ -419,3 +419,73 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
def create_export_job(*, timeline, user) -> ExportJob:
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
_STANDALONE_CATEGORY = {
"model": Asset.Category.PERSON,
"cover": Asset.Category.PRODUCT_IMAGE,
"image": Asset.Category.PRODUCT_IMAGE,
}
_STANDALONE_TASK_TYPE = {
"model": AITask.Type.PERSON_IMAGE,
"cover": AITask.Type.PRODUCT_IMAGE,
"image": AITask.Type.PRODUCT_IMAGE,
}
def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> list[Asset]:
"""不绑定项目的独立生图(图片创作 / 模特上身图 / 平台套图)。复用项目内生图链路,AITask.project=None。"""
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
count = max(1, min(int(count or 1), 4))
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
assets: list[Asset] = []
for index in range(count):
cost = estimate_cost(model_config)
task = AITask.objects.create(
team=team,
created_by=user,
project=None,
task_type=task_type,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode},
estimated_cost=cost,
)
reserve_credit(team=team, user=user, task=task, amount=cost)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
reservation = task.credit_reservation
try:
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png")
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
)
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
assets.append(asset)
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
return assets

View File

@ -1,9 +1,12 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import AITaskViewSet, ModelConfigViewSet
from .views import AITaskViewSet, GenerateImageView, ModelConfigViewSet
router = DefaultRouter()
router.register("tasks", AITaskViewSet, basename="ai-task")
router.register("models", ModelConfigViewSet, basename="model-config")
urlpatterns = router.urls
urlpatterns = [
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
] + router.urls

View File

@ -1,9 +1,36 @@
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ReadOnlyModelViewSet
from apps.common.api import TeamScopedViewSetMixin
from apps.assets.serializers import AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import AITask, ModelConfig
from .serializers import AITaskSerializer, ModelConfigSerializer
from .services import generate_standalone_image
class GenerateImageView(APIView):
"""POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。"""
def post(self, request):
prompt = str(request.data.get("prompt") or "").strip()
if not prompt:
return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
mode = str(request.data.get("mode") or "image")
try:
count = int(request.data.get("count") or 1)
except (TypeError, ValueError):
count = 1
team = get_current_team(request.user)
try:
assets = generate_standalone_image(team=team, user=request.user, prompt=prompt, mode=mode, count=count)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 生成失败已回滚额度,返回明确错误给前端
return Response({"detail": f"生成失败: {exc}"}, status=status.HTTP_502_BAD_GATEWAY)
return Response({"assets": AssetSerializer(assets, many=True).data}, status=status.HTTP_201_CREATED)
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):

View File

@ -66,6 +66,15 @@ export const api = {
me() {
return request<{ user: User; team: Team }>("/api/auth/me/");
},
updateProfile(payload: { name?: string; phone?: string; email?: string }) {
return request<{ user: User; team: Team }>("/api/auth/me/", { method: "PATCH", body: JSON.stringify(payload) });
},
changePassword(payload: { old_password: string; new_password: string }) {
return request<{ token: string }>("/api/auth/me/password/", { method: "POST", body: JSON.stringify(payload) });
},
uploadAvatar(formData: FormData) {
return request<User>("/api/auth/me/avatar/", { method: "POST", body: formData });
},
logout() {
return request<void>("/api/auth/logout/", { method: "POST" });
},
@ -183,6 +192,9 @@ export const api = {
aiTasks() {
return request<Paginated<AITask>>("/api/ai/tasks/");
},
generateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return request<{ assets: Asset[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},