Commit 232de8ef authored by yaoke.yk's avatar yaoke.yk

客户端设定-道具修改

parent 347882ac
import { useState } from "react";
import { useRef, useState, type ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router";
import {
ArrowRight, Box, Sparkles, Users, MapPin, Plus,
Edit2, Trash2, Upload, X, Check, RefreshCw, Loader2,
ArrowRight,
Box,
Sparkles,
Users,
MapPin,
Plus,
Edit2,
Trash2,
Upload,
X,
Check,
RefreshCw,
Loader2,
} from "lucide-react";
import { useGlobalAssets, useCreateAsset, useDeleteAsset } from "../../hooks/useAssets";
import {
useGlobalAssets,
useCreateAsset,
useUpdateAsset,
useUploadAssetImage,
useGenerateAssetImage,
useDeleteAsset,
} from "../../hooks/useAssets";
import type { GlobalAsset } from "../../lib/api/assets";
type SettingsTab = "characters" | "scenes" | "props";
......@@ -19,6 +37,19 @@ interface EditForm {
const CATEGORIES = ["首饰", "文件", "配饰", "文具", "饮品", "电子设备", "武器", "交通工具", "其他"];
const emptyForm = (): EditForm => ({
name: "",
category: "配饰",
description: "",
imageUrl: "",
});
function revokeObjectPreview(url: string | null) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function PropsGeneration() {
const navigate = useNavigate();
const { projectId } = useParams();
......@@ -26,11 +57,19 @@ export function PropsGeneration() {
const { data: props = [], isLoading } = useGlobalAssets("prop", "personal");
const createAsset = useCreateAsset("prop", "personal");
const updateAsset = useUpdateAsset("prop", "personal");
const uploadImage = useUploadAssetImage("prop", "personal");
const generateImage = useGenerateAssetImage("prop", "personal");
const deleteAsset = useDeleteAsset("prop", "personal");
const [activeTab, setActiveTab] = useState<SettingsTab>("props");
const [showModal, setShowModal] = useState(false);
const [editForm, setEditForm] = useState<EditForm | null>(null);
const [editForm, setEditForm] = useState<EditForm>(emptyForm());
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const settingsTabs = [
{ id: "characters" as SettingsTab, label: "角色", icon: Users, path: `/project/${projectId}/characters` },
......@@ -38,12 +77,21 @@ export function PropsGeneration() {
{ id: "props" as SettingsTab, label: "道具", icon: Box, path: `/project/${projectId}/props` },
];
const resetImageInput = () => {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const openAdd = () => {
setEditForm({ name: "", category: "配饰", description: "", imageUrl: "" });
revokeObjectPreview(imagePreview);
setEditForm(emptyForm());
setImagePreview(null);
setPendingFile(null);
resetImageInput();
setShowModal(true);
};
const openEdit = (p: GlobalAsset) => {
revokeObjectPreview(imagePreview);
setEditForm({
id: p.id,
name: p.name,
......@@ -51,23 +99,107 @@ export function PropsGeneration() {
description: p.description ?? "",
imageUrl: p.imageUrl ?? "",
});
setImagePreview(p.imageUrl ?? null);
setPendingFile(null);
resetImageInput();
setShowModal(true);
};
const closeModal = () => { setShowModal(false); setEditForm(null); };
const closeModal = () => {
revokeObjectPreview(imagePreview);
setShowModal(false);
setEditForm(emptyForm());
setImagePreview(null);
setPendingFile(null);
resetImageInput();
};
const handleSave = async () => {
if (!editForm || !editForm.name.trim()) return;
await createAsset.mutateAsync({
const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
revokeObjectPreview(imagePreview);
setPendingFile(file);
setImagePreview(URL.createObjectURL(file));
event.target.value = "";
};
const clearSelectedImage = () => {
revokeObjectPreview(imagePreview);
setPendingFile(null);
setImagePreview(editForm.imageUrl || null);
resetImageInput();
};
const saveAsset = async (uploadPendingImage = true) => {
if (!editForm.name.trim()) return null;
const payload = {
assetType: "prop",
libraryType: "personal",
name: editForm.name,
description: editForm.description,
name: editForm.name.trim(),
description: editForm.description.trim(),
tags: editForm.category,
imageUrl: editForm.imageUrl || undefined,
sourceProjectId: numProjectId || undefined,
});
};
let saved = editForm.id
? await updateAsset.mutateAsync({ id: editForm.id, patch: payload })
: await createAsset.mutateAsync(payload);
if (uploadPendingImage && pendingFile) {
saved = await uploadImage.mutateAsync({ id: saved.id, file: pendingFile });
}
return saved;
};
const handleSave = async () => {
if (!editForm.name.trim()) return;
setSaving(true);
try {
await saveAsset(true);
closeModal();
} catch (error) {
alert("保存失败: " + (error as Error).message);
} finally {
setSaving(false);
}
};
const handleGenerateImage = async (prop: GlobalAsset) => {
setGeneratingImageId(prop.id);
try {
await generateImage.mutateAsync(prop.id);
} catch (error) {
alert("道具图生成失败: " + (error as Error).message);
} finally {
setGeneratingImageId(null);
}
};
const handleGenerateFromModal = async () => {
if (!editForm.name.trim()) return;
setSaving(true);
setGeneratingImageId(editForm.id ?? "draft");
try {
const saved = await saveAsset(false);
if (!saved) return;
const generated = await generateImage.mutateAsync(saved.id);
revokeObjectPreview(imagePreview);
setEditForm({
id: generated.id,
name: generated.name,
category: generated.tags?.[0] ?? editForm.category,
description: generated.description ?? editForm.description,
imageUrl: generated.imageUrl ?? "",
});
setImagePreview(generated.imageUrl ?? null);
setPendingFile(null);
resetImageInput();
} catch (error) {
alert("道具图生成失败: " + (error as Error).message);
} finally {
setSaving(false);
setGeneratingImageId(null);
}
};
const handleDelete = async (id: string) => {
......@@ -76,6 +208,8 @@ export function PropsGeneration() {
};
const withImage = props.filter((p) => p.imageUrl).length;
const isSaving = saving || createAsset.isPending || updateAsset.isPending || uploadImage.isPending;
const isBusy = isSaving || generateImage.isPending;
return (
<div className="h-full overflow-auto bg-background relative">
......@@ -91,7 +225,6 @@ export function PropsGeneration() {
<p className="text-sm text-muted-foreground">管理角色、场景和道具设定</p>
</div>
{/* Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
{settingsTabs.map((tab) => {
......@@ -121,7 +254,6 @@ export function PropsGeneration() {
</button>
</div>
{/* Stats bar */}
<div className="flex items-center justify-between mb-6 rounded-lg border border-border bg-card p-4">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
......@@ -145,7 +277,6 @@ export function PropsGeneration() {
</button>
</div>
{/* Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
......@@ -153,7 +284,7 @@ export function PropsGeneration() {
) : props.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Box className="w-12 h-12 text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground mb-4">暂无道具,点击"新增道具"开始添加</p>
<p className="text-muted-foreground mb-4">暂无道具,点击“新增道具”开始添加</p>
<button onClick={openAdd} className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4" />新增道具
</button>
......@@ -172,12 +303,32 @@ export function PropsGeneration() {
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
<button
onClick={() => handleGenerateImage(prop)}
disabled={generatingImageId === prop.id || generateImage.isPending}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title={prop.imageUrl ? "AI重新生成道具图" : "AI生成道具图"}
>
{generatingImageId === prop.id ? (
<Loader2 className="w-4 h-4 text-white animate-spin" />
) : prop.imageUrl ? (
<RefreshCw className="w-4 h-4 text-white" />
) : (
<Sparkles className="w-4 h-4 text-white" />
)}
</button>
<button
onClick={() => openEdit(prop)}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
>
<Edit2 className="w-4 h-4 text-white" />
</button>
</div>
{generatingImageId === prop.id && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center gap-2">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="text-xs text-foreground">生成中</span>
</div>
)}
</div>
<div className="p-3">
<div className="flex items-center justify-between mb-1">
......@@ -206,7 +357,6 @@ export function PropsGeneration() {
</div>
</div>
{/* Floating bottom bar */}
<div className="fixed bottom-0 left-0 right-0 border-t border-border bg-card/95 backdrop-blur-sm px-6 py-3 z-10">
<div className="max-w-7xl mx-auto flex items-center justify-end gap-3">
<button
......@@ -219,12 +369,11 @@ export function PropsGeneration() {
</div>
</div>
{/* Add / Edit Modal */}
{showModal && editForm && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-8">
<div className="bg-card rounded-2xl border border-border p-6 max-w-lg w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-foreground">
{showModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold text-foreground">
{editForm.id ? "编辑道具" : "新增道具"}
</h2>
<button onClick={closeModal} className="p-2 hover:bg-muted rounded-lg transition-colors">
......@@ -232,10 +381,78 @@ export function PropsGeneration() {
</button>
</div>
<div className="space-y-4">
<div className="overflow-auto p-6 space-y-4 flex-1">
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<label className="block text-sm font-medium text-foreground">道具图片(可选)</label>
<p className="mt-1 text-xs text-muted-foreground">可以上传参考图,也可以根据道具名称和描述 AI 生成。</p>
</div>
{pendingFile && (
<button onClick={clearSelectedImage} className="text-xs text-destructive hover:underline">
撤销更换
</button>
)}
</div>
<div
className="w-full aspect-video rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
onClick={() => fileInputRef.current?.click()}
>
{imagePreview ? (
<img src={imagePreview} alt="道具图片预览" className="w-full h-full object-cover" />
) : (
<div className="text-center px-4">
<Upload className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<span className="text-sm text-muted-foreground">点击上传道具参考图</span>
</div>
)}
</div>
{imagePreview && (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editForm.name.trim() || isBusy}
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editForm.id ?? "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI重新生成
</button>
</div>
)}
{!imagePreview && (
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editForm.name.trim() || isBusy}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editForm.id ?? "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{editForm.id ? "AI生成道具图" : "保存并AI生成"}
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">道具名称</label>
<label className="block text-sm font-medium text-foreground mb-2">道具名称 <span className="text-destructive">*</span></label>
<input
type="text"
value={editForm.name}
......@@ -262,33 +479,22 @@ export function PropsGeneration() {
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="描述道具的外观、用途和重要性"
rows={3}
rows={4}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">图片 URL(可选)</label>
<input
type="text"
value={editForm.imageUrl}
onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
placeholder="https://example.com/prop.jpg"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 font-mono text-sm"
/>
</div>
</div>
<div className="flex gap-3 mt-6 pt-6 border-t border-border">
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<button onClick={closeModal} className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors">
取消
</button>
<button
onClick={handleSave}
disabled={!editForm.name.trim() || createAsset.isPending}
disabled={!editForm.name.trim() || isBusy}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{createAsset.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{isBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
保存
</button>
</div>
......
......@@ -27,6 +27,31 @@ export function useToggleFavorite(type: string, library: string) {
});
}
export function useUpdateAsset(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<CreateAssetPayload> }) =>
assetsApi.update(id, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: assetsKey(type, library) }),
});
}
export function useUploadAssetImage(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => assetsApi.uploadImage(id, file),
onSuccess: () => qc.invalidateQueries({ queryKey: assetsKey(type, library) }),
});
}
export function useGenerateAssetImage(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => assetsApi.generateImage(id),
onSuccess: () => qc.invalidateQueries({ queryKey: assetsKey(type, library) }),
});
}
export function useDeleteAsset(type: string, library: string) {
const qc = useQueryClient();
return useMutation({
......
......@@ -42,6 +42,21 @@ export const assetsApi = {
const r = await apiClient.put(`/assets/${id}`, patch);
return r.data.data;
},
uploadImage: async (id: string, file: File): Promise<GlobalAsset> => {
const form = new FormData();
form.append("file", file);
const r = await apiClient.post(`/assets/${id}/upload-image`, form, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 600_000,
});
return r.data.data;
},
generateImage: async (id: string): Promise<GlobalAsset> => {
const r = await apiClient.post(`/assets/${id}/generate-image`, undefined, {
timeout: 180_000,
});
return r.data.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/assets/${id}`);
},
......
......@@ -7,12 +7,17 @@ import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.GlobalAsset;
import com.yaoai.domain.mapper.GlobalAssetMapper;
import com.yaoai.pipeline.service.ImageGenPipelineService;
import com.yaoai.security.context.TenantContext;
import com.yaoai.storage.service.TosService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
......@@ -23,6 +28,8 @@ import java.util.Map;
public class GlobalAssetsController {
private final GlobalAssetMapper globalAssetMapper;
private final TosService tosService;
private final ImageGenPipelineService imageGenPipelineService;
@Operation(summary = "获取资产列表")
@GetMapping
......@@ -54,7 +61,11 @@ public class GlobalAssetsController {
asset.setDescription((String) body.get("description"));
asset.setIsFavorite(0);
Object srcProject = body.get("sourceProjectId");
if (srcProject instanceof Number) asset.setSourceProjectId(((Number) srcProject).longValue());
if (srcProject instanceof Number) {
asset.setSourceProjectId(((Number) srcProject).longValue());
} else if (srcProject instanceof String srcProjectText && !srcProjectText.isBlank()) {
asset.setSourceProjectId(Long.valueOf(srcProjectText));
}
globalAssetMapper.insert(asset);
return ApiResponse.success(GlobalAssetDTO.from(asset));
......@@ -90,6 +101,65 @@ public class GlobalAssetsController {
return ApiResponse.success(GlobalAssetDTO.from(asset));
}
@Operation(summary = "AI 生成资产图片")
@PostMapping("/{id}/generate-image")
public ApiResponse<GlobalAssetDTO> generateImage(@PathVariable Long id) {
Long tenantId = TenantContext.get();
Long userId = StpUtil.getLoginIdAsLong();
GlobalAsset asset = globalAssetMapper.selectById(id);
if (asset == null || !tenantId.equals(asset.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "资产不存在");
}
if (!"prop".equals(asset.getAssetType())) {
throw new BizException(ErrorCode.INVALID_PARAM, "当前仅支持生成道具图片");
}
Long projectKeyId = asset.getSourceProjectId() != null ? asset.getSourceProjectId() : 0L;
String key = imageGenPipelineService.generateAndStore(
tenantId,
userId,
projectKeyId,
"global-assets/" + asset.getAssetType(),
buildPropImagePrompt(asset)
);
asset.setImageTosKey(key);
asset.setImageUrl(tosService.publicUrl(key));
globalAssetMapper.updateById(asset);
return ApiResponse.success(GlobalAssetDTO.from(asset));
}
@Operation(summary = "上传资产图片")
@PostMapping(value = "/{id}/upload-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<GlobalAssetDTO> uploadImage(@PathVariable Long id,
@RequestPart("file") MultipartFile file) throws IOException {
Long tenantId = TenantContext.get();
GlobalAsset asset = globalAssetMapper.selectById(id);
if (asset == null || !tenantId.equals(asset.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "资产不存在");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "图片文件不能为空");
}
String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream";
if (!contentType.startsWith("image/")) {
throw new BizException(ErrorCode.INVALID_PARAM, "请上传图片文件");
}
Long projectKeyId = asset.getSourceProjectId() != null ? asset.getSourceProjectId() : 0L;
String key = TosService.buildKey(
tenantId,
projectKeyId,
"global-assets/" + asset.getAssetType(),
file.getOriginalFilename()
);
tosService.upload(key, file.getInputStream(), file.getSize(), contentType);
asset.setImageTosKey(key);
asset.setImageUrl(tosService.publicUrl(key));
globalAssetMapper.updateById(asset);
return ApiResponse.success(GlobalAssetDTO.from(asset));
}
@Operation(summary = "删除资产")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
......@@ -101,4 +171,20 @@ public class GlobalAssetsController {
globalAssetMapper.deleteById(id);
return ApiResponse.success();
}
private String buildPropImagePrompt(GlobalAsset asset) {
String category = asset.getTags() != null && !asset.getTags().isBlank() ? asset.getTags() : "道具";
String description = asset.getDescription() != null && !asset.getDescription().isBlank()
? asset.getDescription()
: "根据名称和分类设计清晰可识别的影视道具";
return """
Cinematic AI short-drama prop reference image, single object only.
Create one clear hero reference image for a prop used in video generation.
Neutral clean studio background, three-quarter view, accurate material, scale cues, sharp silhouette, realistic lighting.
No people, no hands, no environment clutter, no text, no labels, no watermark, no logo, no duplicated objects.
Prop name: %s
Prop category: %s
Prop description: %s
""".formatted(asset.getName(), category, description);
}
}
......@@ -13,4 +13,6 @@ public interface ImageGenPipelineService {
String generateAndStore(Long tenantId, Long projectId, String prompt);
String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt);
String generateAndStore(Long tenantId, Long userId, Long projectId, String assetType, String prompt);
}
......@@ -39,6 +39,11 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
@Override
public String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt) {
return generateAndStore(tenantId, userId, projectId, "scene", prompt);
}
@Override
public String generateAndStore(Long tenantId, Long userId, Long projectId, String assetType, String prompt) {
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(userId)
......@@ -60,9 +65,10 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
byte[] imageBytes = downloadImage(imageUrl);
// 3. 上传到 TOS
String key = TosService.buildKey(tenantId, projectId, "scene", "scene.jpg");
String normalizedAssetType = assetType != null && !assetType.isBlank() ? assetType : "image";
String key = TosService.buildKey(tenantId, projectId, normalizedAssetType, normalizedAssetType + ".jpg");
tosService.upload(key, new ByteArrayInputStream(imageBytes), imageBytes.length, "image/jpeg");
log.info("Scene image stored: key={}", key);
log.info("Generated image stored: key={}", key);
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(userId)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment