fix of the day, payment + sub + music generation
This commit is contained in:
+191
-102
@@ -7,9 +7,12 @@ const axios = require("axios");
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { logger } = require("firebase-functions/logger");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { randomUUID } = require("crypto");
|
||||
const { ALERT_TYPE, refList } = require("../index");
|
||||
const { sendNotification } = require("./notifications");
|
||||
const { SUNO_API_KEY } = require("../config/keys");
|
||||
const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders");
|
||||
const {
|
||||
SUNO_MODEL,
|
||||
SUNO_CALLBACK_URL,
|
||||
@@ -18,6 +21,51 @@ const {
|
||||
SUNO_STATUS_PATH,
|
||||
} = require("../config/suno");
|
||||
|
||||
const MUSIC_GENERATION_CREDIT_COST = 8;
|
||||
const MUSIC_REFUND_SOURCE = "music_generation_refund";
|
||||
|
||||
const refundMusicCredits = async ({
|
||||
projectId,
|
||||
userId,
|
||||
reason = "music_generation_failed",
|
||||
context = {},
|
||||
}) => {
|
||||
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null;
|
||||
|
||||
try {
|
||||
const metadata = {
|
||||
source: MUSIC_REFUND_SOURCE,
|
||||
reason,
|
||||
projectId,
|
||||
...context,
|
||||
};
|
||||
|
||||
const { orderId } = await createOrderDocument({
|
||||
userId,
|
||||
type: ORDER_TYPES.SONG,
|
||||
amount: MUSIC_GENERATION_CREDIT_COST,
|
||||
songId: projectId,
|
||||
createdBy: "system",
|
||||
metadata,
|
||||
});
|
||||
|
||||
logger.log("💸 [Music] Crédits remboursés", {
|
||||
projectId,
|
||||
userId,
|
||||
orderId,
|
||||
});
|
||||
|
||||
return { orderId };
|
||||
} catch (error) {
|
||||
logger.error("❌ [Music] Échec remboursement crédits", {
|
||||
projectId,
|
||||
userId,
|
||||
error: error?.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marque un projet comme échoué suite à une erreur Suno
|
||||
* @param {string} projectId - Identifiant du projet
|
||||
@@ -42,21 +90,46 @@ async function markProjectMusicFailure(projectId, error) {
|
||||
if (status) errorPayload.status = status;
|
||||
if (error?.code) errorPayload.code = error.code;
|
||||
|
||||
await docRef.set(
|
||||
{
|
||||
musicStatus: "FAILED",
|
||||
sunoTaskId: FieldValue.delete(),
|
||||
generationStartAt: FieldValue.delete(),
|
||||
musicError: errorPayload,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
const receiverId = sanitizeField(projectData?.userId);
|
||||
const alreadyRefunded =
|
||||
projectData?.musicCreditsRefunded === true ||
|
||||
typeof projectData?.musicCreditsRefundOrderId === "string";
|
||||
|
||||
let refundResult = null;
|
||||
if (receiverId && !alreadyRefunded) {
|
||||
refundResult = await refundMusicCredits({
|
||||
projectId,
|
||||
userId: receiverId,
|
||||
reason: sunoMessage,
|
||||
context: {
|
||||
status: status || null,
|
||||
code: error?.code || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
musicStatus: "FAILED",
|
||||
sunoTaskId: FieldValue.delete(),
|
||||
generationStartAt: FieldValue.delete(),
|
||||
musicError: errorPayload,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
|
||||
if (refundResult?.orderId) {
|
||||
updatePayload.musicCreditsRefunded = true;
|
||||
updatePayload.musicCreditsRefundOrderId = refundResult.orderId;
|
||||
updatePayload.musicCreditsRefundedAt = FieldValue.serverTimestamp();
|
||||
}
|
||||
|
||||
await docRef.set(updatePayload, { merge: true });
|
||||
|
||||
if (receiverId) {
|
||||
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
||||
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
|
||||
const baseMessage = `La génération de musique pour "${projectTitle}" a échoué.`;
|
||||
const message = refundResult?.orderId
|
||||
? `${baseMessage} Tes crédits ont été remboursés.`
|
||||
: baseMessage;
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
@@ -267,12 +340,11 @@ const downloadTrackToStorage = async (
|
||||
if (!url) return null;
|
||||
try {
|
||||
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
||||
const resp = await axios.get(url, { responseType: "arraybuffer" });
|
||||
const buffer = Buffer.from(resp.data);
|
||||
const resp = await axios.get(url, { responseType: "stream" });
|
||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
||||
const token = require("crypto").randomUUID();
|
||||
const token = randomUUID();
|
||||
const file = bucket.file(path);
|
||||
await file.save(buffer, {
|
||||
const writeStream = file.createWriteStream({
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: "audio/mpeg",
|
||||
@@ -280,6 +352,7 @@ const downloadTrackToStorage = async (
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
await pipeline(resp.data, writeStream);
|
||||
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
path,
|
||||
)}?alt=media&token=${token}`;
|
||||
@@ -681,104 +754,120 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
||||
* Cette fonction est appelée par l'API Suno lorsque la génération
|
||||
* de musique est terminée
|
||||
*/
|
||||
exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
|
||||
return res.status(405).json({ error: "Méthode non autorisée" });
|
||||
}
|
||||
exports.sunoCallback = onRequest(
|
||||
{ methods: ["POST"], memory: "1GiB" },
|
||||
async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
|
||||
return res.status(405).json({ error: "Méthode non autorisée" });
|
||||
}
|
||||
|
||||
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
||||
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
||||
|
||||
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
||||
console.log("🎯 [SunoCallback] Détails:", {
|
||||
code,
|
||||
status,
|
||||
taskId,
|
||||
count: tracks.length,
|
||||
});
|
||||
|
||||
if (code !== 200 || status !== "complete") {
|
||||
console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
|
||||
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
||||
console.log("🎯 [SunoCallback] Détails:", {
|
||||
code,
|
||||
status,
|
||||
taskId,
|
||||
count: tracks.length,
|
||||
});
|
||||
return res.status(200).json({ success: true, ignored: true });
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
|
||||
return res.status(200).json({ success: true, ignored: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectId, projectData, projectRef } =
|
||||
await fetchProjectByTaskId(taskId);
|
||||
const { userId, projectTitle } = formatProjectMeta(projectData);
|
||||
|
||||
const audioUrls = extractAudioUrlsFromTracks(tracks);
|
||||
if (audioUrls.length < 2) {
|
||||
console.warn(
|
||||
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
||||
{
|
||||
found: audioUrls.length,
|
||||
},
|
||||
);
|
||||
if (code !== 200 || status !== "complete") {
|
||||
console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
|
||||
code,
|
||||
status,
|
||||
});
|
||||
return res.status(200).json({ success: true, ignored: true });
|
||||
}
|
||||
|
||||
const storedUrls = await saveTracksToStorage(audioUrls, {
|
||||
userId,
|
||||
projectId,
|
||||
taskId,
|
||||
});
|
||||
if (!taskId) {
|
||||
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
|
||||
return res.status(200).json({ success: true, ignored: true });
|
||||
}
|
||||
|
||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
||||
let projectIdForFailure = null;
|
||||
|
||||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||||
projectId,
|
||||
musicUrlsCount: musicUrls.length,
|
||||
});
|
||||
try {
|
||||
const { projectId, projectData, projectRef } =
|
||||
await fetchProjectByTaskId(taskId);
|
||||
projectIdForFailure = projectId;
|
||||
const { userId, projectTitle } = formatProjectMeta(projectData);
|
||||
|
||||
if (userId) {
|
||||
const successMessage =
|
||||
musicUrls.length > 0
|
||||
? `Ta musique pour "${projectTitle}" est prête.`
|
||||
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
receiver: userId,
|
||||
receiverCollection: "users",
|
||||
title: "Musique prête",
|
||||
message: successMessage,
|
||||
data: {
|
||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
projectTitle,
|
||||
musicUrls,
|
||||
taskId,
|
||||
const audioUrls = extractAudioUrlsFromTracks(tracks);
|
||||
if (audioUrls.length < 2) {
|
||||
console.warn(
|
||||
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
||||
{
|
||||
found: audioUrls.length,
|
||||
},
|
||||
});
|
||||
} catch (notifError) {
|
||||
console.error(
|
||||
"[sunoCallback] Failed to send success notification:",
|
||||
notifError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
projectId,
|
||||
savedCount: storedUrls.length,
|
||||
musicUrlsCount: musicUrls.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
|
||||
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error?.message || "Erreur interne du serveur",
|
||||
});
|
||||
}
|
||||
});
|
||||
const storedUrls = await saveTracksToStorage(audioUrls, {
|
||||
userId,
|
||||
projectId,
|
||||
taskId,
|
||||
});
|
||||
|
||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
||||
|
||||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||||
projectId,
|
||||
musicUrlsCount: musicUrls.length,
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
const successMessage =
|
||||
musicUrls.length > 0
|
||||
? `Ta musique pour "${projectTitle}" est prête.`
|
||||
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
receiver: userId,
|
||||
receiverCollection: "users",
|
||||
title: "Musique prête",
|
||||
message: successMessage,
|
||||
data: {
|
||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
projectTitle,
|
||||
musicUrls,
|
||||
taskId,
|
||||
},
|
||||
});
|
||||
} catch (notifError) {
|
||||
console.error(
|
||||
"[sunoCallback] Failed to send success notification:",
|
||||
notifError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
projectId,
|
||||
savedCount: storedUrls.length,
|
||||
musicUrlsCount: musicUrls.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
|
||||
if (statusCode !== 404 && projectIdForFailure) {
|
||||
try {
|
||||
await markProjectMusicFailure(projectIdForFailure, error);
|
||||
} catch (markError) {
|
||||
console.error(
|
||||
"⚠️ [SunoCallback] Impossible de marquer le projet en échec:",
|
||||
markError,
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error?.message || "Erreur interne du serveur",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user