feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates
This commit is contained in:
commit
e04672db2a
104 files changed
+8262
No files matched your search
+464
@@ -0,0 +1,464 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { validateAndSanitizeImage } from "@/lib/security";
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
timestamp: string; // ISO 8601 string
|
||||
dateStr: string; // YYYY-MM-DD for fast grouping
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
|
||||
model: string;
|
||||
durationMs: number;
|
||||
status: "success" | "refused" | "error";
|
||||
imageUrl?: string;
|
||||
imageFilename?: string;
|
||||
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
|
||||
errorMessage?: string;
|
||||
refusalReason?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
period: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
totalAttempts: number;
|
||||
successCount: number;
|
||||
refusedCount: number;
|
||||
errorCount: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
dailyTrends: {
|
||||
date: string;
|
||||
total: number;
|
||||
success: number;
|
||||
refused: number;
|
||||
error: number;
|
||||
}[];
|
||||
styleBreakdown: {
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
providerBreakdown: {
|
||||
provider: string;
|
||||
model: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}[];
|
||||
images: GenerationRecord[];
|
||||
errorLogs: GenerationRecord[];
|
||||
}
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const IMAGES_DIR = path.join(DATA_DIR, "images");
|
||||
const RECORDS_FILE = path.join(DATA_DIR, "records.json");
|
||||
const BACKUP_FILE = path.join(DATA_DIR, "records.json.bak");
|
||||
|
||||
function ensureDirectories() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(IMAGES_DIR)) {
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECORDS_FILE)) {
|
||||
fs.writeFileSync(RECORDS_FILE, JSON.stringify([], null, 2), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutex / Write Queue to prevent concurrent write collisions
|
||||
*/
|
||||
let writeQueuePromise: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueWrite(task: () => Promise<void> | void): Promise<void> {
|
||||
const next = writeQueuePromise.then(() => task()).catch((err) => {
|
||||
console.error("[Storage Mutex] Write task error:", err);
|
||||
});
|
||||
writeQueuePromise = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all records from disk safely with automatic backup recovery
|
||||
*/
|
||||
export function getAllRecords(): GenerationRecord[] {
|
||||
ensureDirectories();
|
||||
try {
|
||||
const raw = fs.readFileSync(RECORDS_FILE, "utf8");
|
||||
if (!raw.trim()) {
|
||||
return recoverFromBackup();
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.warn("[Storage] records.json is not an array, attempting backup recovery...");
|
||||
return recoverFromBackup();
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.error("[Storage] JSON parse error in records.json, attempting backup recovery:", err);
|
||||
return recoverFromBackup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover from backup file if primary file was corrupted
|
||||
*/
|
||||
function recoverFromBackup(): GenerationRecord[] {
|
||||
try {
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
const backupRaw = fs.readFileSync(BACKUP_FILE, "utf8");
|
||||
const parsed = JSON.parse(backupRaw);
|
||||
if (Array.isArray(parsed)) {
|
||||
console.warn("[Storage] Successfully recovered records from records.json.bak!");
|
||||
// Re-write valid records back to records.json
|
||||
fs.writeFileSync(RECORDS_FILE, backupRaw, "utf8");
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (backupErr) {
|
||||
console.error("[Storage] Backup recovery failed:", backupErr);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist records to disk atomically with fsync and backup rotation
|
||||
*/
|
||||
async function saveAllRecordsAtomic(records: GenerationRecord[]): Promise<void> {
|
||||
await queueWrite(() => {
|
||||
ensureDirectories();
|
||||
|
||||
const serialized = JSON.stringify(records, null, 2);
|
||||
// Safety check: Never write invalid or empty string if we had records
|
||||
if (!serialized || serialized.length < 2) {
|
||||
console.error("[Storage] Refusing to write invalid empty JSON payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Maintain backup copy of current good records file before replacing
|
||||
if (fs.existsSync(RECORDS_FILE)) {
|
||||
try {
|
||||
const currentContent = fs.readFileSync(RECORDS_FILE, "utf8");
|
||||
if (currentContent.trim().length > 2) {
|
||||
fs.writeFileSync(BACKUP_FILE, currentContent, "utf8");
|
||||
}
|
||||
} catch (backupErr) {
|
||||
console.warn("[Storage] Failed to update backup copy:", backupErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write to unique temporary file
|
||||
const tempFile = path.join(
|
||||
DATA_DIR,
|
||||
`records.${Date.now()}_${Math.random().toString(36).substring(2, 8)}.tmp`
|
||||
);
|
||||
|
||||
const fd = fs.openSync(tempFile, "w");
|
||||
fs.writeSync(fd, serialized, 0, "utf8");
|
||||
// Ensure data is physically flushed to disk platter/SSD before rename
|
||||
fs.fsyncSync(fd);
|
||||
fs.closeSync(fd);
|
||||
|
||||
// 3. Atomic rename replaces the destination file instantaneously
|
||||
fs.renameSync(tempFile, RECORDS_FILE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an image file from base64 or URL buffer safely with binary validation
|
||||
*/
|
||||
export async function persistImageFile(
|
||||
id: string,
|
||||
imageInput: string
|
||||
): Promise<{ imageUrl: string; filename: string }> {
|
||||
ensureDirectories();
|
||||
|
||||
// Sanitize id to alphanumeric characters, hyphens, and underscores only
|
||||
const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!safeId) {
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
// If it's a data URL or raw base64
|
||||
if (imageInput.startsWith("data:image/") || imageInput.startsWith("data:application/")) {
|
||||
const validation = validateAndSanitizeImage(imageInput, 15 * 1024 * 1024);
|
||||
if (validation.valid && validation.extension) {
|
||||
const ext = validation.extension; // Strictly "jpg" | "png" | "webp"
|
||||
const filename = `${safeId}.${ext}`;
|
||||
const filePath = path.join(IMAGES_DIR, filename);
|
||||
|
||||
const base64Data = validation.sanitizedBase64!.split(",")[1];
|
||||
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
||||
|
||||
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
return { imageUrl: `/api/images/${filename}`, filename };
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a remote URL
|
||||
if (imageInput.startsWith("http://") || imageInput.startsWith("https://")) {
|
||||
try {
|
||||
const parsedUrl = new URL(imageInput);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
|
||||
// Block SSRF loopback / private addresses
|
||||
const isPrivateOrLoopback =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
||||
hostname.startsWith("169.254.");
|
||||
|
||||
if (isPrivateOrLoopback) {
|
||||
console.warn(`[Storage SSRF Blocked] Attempted fetch to private/local address: ${hostname}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
const res = await fetch(imageInput);
|
||||
if (res.ok) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
// Validate downloaded buffer via data URL representation
|
||||
const validation = validateAndSanitizeImage(
|
||||
`data:image/png;base64,${buffer.toString("base64")}`,
|
||||
15 * 1024 * 1024
|
||||
);
|
||||
|
||||
if (validation.valid && validation.extension) {
|
||||
const filename = `${safeId}.${validation.extension}`;
|
||||
const filePath = path.join(IMAGES_DIR, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
return { imageUrl: `/api/images/${filename}`, filename };
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Storage] Could not fetch remote image to cache locally, storing direct URL:", e);
|
||||
}
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
// Local sample asset
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a generation event (both successes, safety refusals, and server errors)
|
||||
*/
|
||||
export async function logGenerationEvent(data: {
|
||||
styleId: string;
|
||||
styleName: string;
|
||||
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
|
||||
model: string;
|
||||
durationMs: number;
|
||||
status: "success" | "refused" | "error";
|
||||
rawImageUrl?: string;
|
||||
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
|
||||
errorMessage?: string;
|
||||
refusalReason?: string;
|
||||
clientId?: string;
|
||||
}): Promise<GenerationRecord> {
|
||||
const now = new Date();
|
||||
const id = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
let imageUrl = data.rawImageUrl;
|
||||
let imageFilename = "";
|
||||
|
||||
if (data.status === "success" && data.rawImageUrl) {
|
||||
const persisted = await persistImageFile(id, data.rawImageUrl);
|
||||
imageUrl = persisted.imageUrl;
|
||||
imageFilename = persisted.filename;
|
||||
}
|
||||
|
||||
const record: GenerationRecord = {
|
||||
id,
|
||||
timestamp: now.toISOString(),
|
||||
dateStr,
|
||||
styleId: data.styleId,
|
||||
styleName: data.styleName,
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
durationMs: data.durationMs,
|
||||
status: data.status,
|
||||
imageUrl,
|
||||
imageFilename,
|
||||
errorType: data.errorType,
|
||||
errorMessage: data.errorMessage,
|
||||
refusalReason: data.refusalReason,
|
||||
clientId: data.clientId,
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = getAllRecords();
|
||||
existing.unshift(record); // Prepend so newest is first
|
||||
await saveAllRecordsAtomic(existing);
|
||||
} catch (err) {
|
||||
console.error("[Storage] Failed to append record:", err);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query statistics with date filtering
|
||||
*/
|
||||
export function getGenerationStats(query: {
|
||||
period?: "day" | "week" | "month" | "all" | "custom";
|
||||
fromDate?: string; // YYYY-MM-DD
|
||||
toDate?: string; // YYYY-MM-DD
|
||||
}): StatsSummary {
|
||||
const records = getAllRecords();
|
||||
const now = new Date();
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
|
||||
const period = query.period || "week";
|
||||
let filtered = records;
|
||||
|
||||
let computedFrom = query.fromDate;
|
||||
let computedTo = query.toDate;
|
||||
|
||||
if (period === "day") {
|
||||
computedFrom = todayStr;
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr === todayStr);
|
||||
} else if (period === "week") {
|
||||
const sevenDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
|
||||
computedFrom = sevenDaysAgo.toISOString().slice(0, 10);
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
|
||||
} else if (period === "month") {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
computedFrom = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
computedTo = todayStr;
|
||||
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
|
||||
} else if (period === "custom") {
|
||||
if (query.fromDate) {
|
||||
filtered = filtered.filter((r) => r.dateStr >= query.fromDate!);
|
||||
}
|
||||
if (query.toDate) {
|
||||
filtered = filtered.filter((r) => r.dateStr <= query.toDate!);
|
||||
}
|
||||
}
|
||||
|
||||
const totalAttempts = filtered.length;
|
||||
const successCount = filtered.filter((r) => r.status === "success").length;
|
||||
const refusedCount = filtered.filter((r) => r.status === "refused").length;
|
||||
const errorCount = filtered.filter((r) => r.status === "error").length;
|
||||
|
||||
const successRate = totalAttempts > 0 ? Math.round((successCount / totalAttempts) * 1000) / 10 : 0;
|
||||
|
||||
const successfulRecords = filtered.filter((r) => r.status === "success");
|
||||
const totalDuration = successfulRecords.reduce((sum, r) => sum + (r.durationMs || 0), 0);
|
||||
const avgDurationMs = successfulRecords.length > 0 ? Math.round(totalDuration / successfulRecords.length) : 0;
|
||||
|
||||
// Daily Trends aggregation
|
||||
const dailyMap = new Map<string, { total: number; success: number; refused: number; error: number }>();
|
||||
|
||||
// If week or day or month, pre-populate all dates in range so charts are continuous
|
||||
if (computedFrom && computedTo) {
|
||||
const start = new Date(computedFrom);
|
||||
const end = new Date(computedTo);
|
||||
const cur = new Date(start);
|
||||
while (cur <= end) {
|
||||
const d = cur.toISOString().slice(0, 10);
|
||||
dailyMap.set(d, { total: 0, success: 0, refused: 0, error: 0 });
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of filtered) {
|
||||
const entry = dailyMap.get(r.dateStr) || { total: 0, success: 0, refused: 0, error: 0 };
|
||||
entry.total += 1;
|
||||
if (r.status === "success") entry.success += 1;
|
||||
else if (r.status === "refused") entry.refused += 1;
|
||||
else if (r.status === "error") entry.error += 1;
|
||||
dailyMap.set(r.dateStr, entry);
|
||||
}
|
||||
|
||||
const dailyTrends = Array.from(dailyMap.entries())
|
||||
.map(([date, counts]) => ({ date, ...counts }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Style Breakdown
|
||||
const styleCountMap = new Map<string, { name: string; count: number }>();
|
||||
for (const r of filtered) {
|
||||
if (r.styleId) {
|
||||
const existing = styleCountMap.get(r.styleId) || { name: r.styleName || r.styleId, count: 0 };
|
||||
existing.count += 1;
|
||||
styleCountMap.set(r.styleId, existing);
|
||||
}
|
||||
}
|
||||
const styleBreakdown = Array.from(styleCountMap.entries())
|
||||
.map(([styleId, { name, count }]) => ({
|
||||
styleId,
|
||||
styleName: name,
|
||||
count,
|
||||
percentage: totalAttempts > 0 ? Math.round((count / totalAttempts) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Provider Breakdown
|
||||
const providerCountMap = new Map<string, { provider: string; model: string; count: number }>();
|
||||
for (const r of filtered) {
|
||||
const key = `${r.provider}::${r.model || "default"}`;
|
||||
const existing = providerCountMap.get(key) || { provider: r.provider, model: r.model || "default", count: 0 };
|
||||
existing.count += 1;
|
||||
providerCountMap.set(key, existing);
|
||||
}
|
||||
const providerBreakdown = Array.from(providerCountMap.values())
|
||||
.map((item) => ({
|
||||
...item,
|
||||
percentage: totalAttempts > 0 ? Math.round((item.count / totalAttempts) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Filtered Images Gallery (success only)
|
||||
const images = filtered.filter((r) => r.status === "success" && r.imageUrl);
|
||||
|
||||
// Filtered Error & Refusal Logs
|
||||
const errorLogs = filtered.filter((r) => r.status !== "success");
|
||||
|
||||
return {
|
||||
period,
|
||||
fromDate: computedFrom,
|
||||
toDate: computedTo,
|
||||
totalAttempts,
|
||||
successCount,
|
||||
refusedCount,
|
||||
errorCount,
|
||||
successRate,
|
||||
avgDurationMs,
|
||||
dailyTrends,
|
||||
styleBreakdown,
|
||||
providerBreakdown,
|
||||
images,
|
||||
errorLogs,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user