feat: marketplace, économie à crédits, perks temps réel & pubs réelles
Transforme XIP en réseau social satirique complet : monnaie fictive, marketplace, cosmétiques visibles de tous, messages riches sandboxés, pubs pilotées par les données, et tous les compteurs mock rendus réels. Backend (Bun + Hono + Prisma + Redis) - Économie par IP : modèles Wallet/Purchase/Entitlement, lib/wallet.ts avec spend() atomique (point unique du paywall) + recharge gratuite. - isLocalhost() → mode gratuit (README « si localhost: pas de paywall »). - Marketplace : lib/catalog.ts (achat transactionnel, stock limité, limites par IP) + routes/shop.ts ; 10 produits seedés (idempotent). - Perks : lib/perks.ts (cache Redis busté à l'achat) ; authorPerks injecté dans les payloads messages + endpoint batch /api/perks ; frame WS « perks » global pour MAJ live des messages déjà affichés. - Messages riches : Message.richMode/richContent, gating par entitlement. - Pubs réelles : modèle Ad seedé avec les 4 pubs (ex-hardcodées), rotation par API, comptage d'impressions réel + réconciliation. - WebSocket : IP capturée par connexion → broadcastToIp / broadcast ; frames wallet/perks/ads/alert. - Pièces jointes : lib/storage.ts (UUID, jamais exécuté) + routes/uploads.ts (limite 1 Mo sauf déblocage/localhost, Content-Disposition: attachment). - Alerte audio : routes/alert.ts (cooldown serveur Redis NX, clamp durée). - Compteur « argent extorqué » réel : impressions×CPM + crédits dépensés. Frontend (Vue 3 + Vite) - /shop : ShopPage + ProductCard fidèles aux maquettes ; composables useWallet/useShop/usePerks/useAds/useAttachments/useAlert. - UI de réponse (bannière + sous-threads), solde + lien Shop dans le header. - Perks rendus : Style Doré (or), Pets autour de l'IP, NoAds masque les pubs. - RichContent.vue : iframe sandbox verrouillée (htmlcss sans script ; js allow-scripts seul, jamais allow-same-origin) + CSP. - AdBand/InlineCasinoAd pilotés par l'API ; barre de saisie avec 📎, compteur de caractères, composer riche et bouton alerte. Infra - Migration economy_ads_attachments_rich ; seed idempotent (produits+pubs). - vite.config : usePolling (HMR fiable sur /mnt/c via WSL). - backend/.gitignore : uploads/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
207
backend/src/lib/stats.ts
Normal file
207
backend/src/lib/stats.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { redis } from "./redis";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
/**
|
||||
* XIP live stats.
|
||||
*
|
||||
* Two kinds of metrics:
|
||||
* - PERSISTENT totals, stored in Redis (survive restarts): messages, replies,
|
||||
* characters sent, letters typed (even if never sent), unique IPs, longest message.
|
||||
* - LIVE metrics, kept in process memory (sliding windows): letters/sec, messages/min.
|
||||
*
|
||||
* The number of connected tabs and the "currently typing" count are owned by the
|
||||
* realtime module and injected when building a snapshot.
|
||||
*/
|
||||
|
||||
const K = {
|
||||
messages: "xip:stat:messages",
|
||||
replies: "xip:stat:replies",
|
||||
charsSent: "xip:stat:chars_sent",
|
||||
lettersTyped: "xip:stat:letters_typed",
|
||||
longest: "xip:stat:longest",
|
||||
ips: "xip:hll:ips",
|
||||
initialized: "xip:stat:initialized",
|
||||
creditsSpent: "xip:money:credits_spent", // centi-credits spent (set by wallet/catalog)
|
||||
impressionsTotal: "xip:money:impressions_total", // ad impressions (set by lib/ads)
|
||||
} as const;
|
||||
|
||||
// Satirical CPM: "€" earned per 1000 ad impressions.
|
||||
const FAKE_CPM = 12.5;
|
||||
|
||||
// ── Sliding-window live metrics (per process) ──────────────────────────────
|
||||
const LETTERS_WINDOW_MS = 4000; // smoothing window for letters/sec
|
||||
const MSGS_WINDOW_MS = 60000; // messages per minute
|
||||
|
||||
let letterEvents: { ts: number; n: number }[] = [];
|
||||
let messageEvents: number[] = [];
|
||||
|
||||
function prune(now: number): void {
|
||||
letterEvents = letterEvents.filter((e) => now - e.ts <= LETTERS_WINDOW_MS);
|
||||
messageEvents = messageEvents.filter((ts) => now - ts <= MSGS_WINDOW_MS);
|
||||
}
|
||||
|
||||
export function getLettersPerSec(): number {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
const total = letterEvents.reduce((sum, e) => sum + e.n, 0);
|
||||
return total / (LETTERS_WINDOW_MS / 1000);
|
||||
}
|
||||
|
||||
export function getMsgsPerMin(): number {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
return messageEvents.length;
|
||||
}
|
||||
|
||||
// ── First-boot backfill ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed the persistent counters from the database the first time the server runs
|
||||
* (guarded by a Redis sentinel, so it's a no-op on hot reloads / restarts).
|
||||
* Without this, totals would show 0 while seeded messages are already visible.
|
||||
* letters_typed is intentionally NOT backfilled — it has no DB source.
|
||||
*/
|
||||
export async function initStats(): Promise<void> {
|
||||
const first = await redis.set(K.initialized, "1", "NX").catch(() => null);
|
||||
if (first !== "OK") return; // already initialized
|
||||
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ messages: bigint; replies: bigint; chars: bigint; longest: bigint }[]
|
||||
>`
|
||||
SELECT
|
||||
COUNT(*) AS messages,
|
||||
COUNT(*) FILTER (WHERE "parentId" IS NOT NULL) AS replies,
|
||||
COALESCE(SUM(LENGTH(content)), 0) AS chars,
|
||||
COALESCE(MAX(LENGTH(content)), 0) AS longest
|
||||
FROM messages
|
||||
`;
|
||||
const r = rows[0];
|
||||
if (r) {
|
||||
const pipe = redis.pipeline();
|
||||
pipe.set(K.messages, String(Number(r.messages)));
|
||||
pipe.set(K.replies, String(Number(r.replies)));
|
||||
pipe.set(K.charsSent, String(Number(r.chars)));
|
||||
pipe.set(K.longest, String(Number(r.longest)));
|
||||
await pipe.exec();
|
||||
}
|
||||
|
||||
const ips = await prisma.message.findMany({
|
||||
distinct: ["authorIp"],
|
||||
select: { authorIp: true },
|
||||
});
|
||||
if (ips.length > 0) {
|
||||
await redis.pfadd(K.ips, ...ips.map((m) => m.authorIp));
|
||||
}
|
||||
console.log("📊 Stats backfilled from database.");
|
||||
} catch (err) {
|
||||
// Non-fatal: release the sentinel so a later boot can retry.
|
||||
await redis.del(K.initialized).catch(() => {});
|
||||
console.warn("⚠️ Stats backfill failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Record a freshly created message (top-level or reply). */
|
||||
export async function recordMessage(
|
||||
contentLength: number,
|
||||
isReply: boolean
|
||||
): Promise<void> {
|
||||
messageEvents.push(Date.now());
|
||||
const pipe = redis.pipeline();
|
||||
pipe.incr(K.messages);
|
||||
pipe.incrby(K.charsSent, contentLength);
|
||||
if (isReply) pipe.incr(K.replies);
|
||||
// Track longest message (read-modify-write is fine; contention is negligible).
|
||||
pipe.get(K.longest);
|
||||
const res = await pipe.exec().catch(() => null);
|
||||
if (res) {
|
||||
const current = Number(res[res.length - 1]?.[1] ?? 0);
|
||||
if (contentLength > current) {
|
||||
await redis.set(K.longest, String(contentLength)).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Record letters typed (sent or not). Feeds both the persistent total and letters/sec. */
|
||||
export async function recordLettersTyped(delta: number): Promise<void> {
|
||||
if (!Number.isFinite(delta) || delta <= 0) return;
|
||||
const n = Math.min(delta, 1000); // guard against bogus client payloads
|
||||
letterEvents.push({ ts: Date.now(), n });
|
||||
await redis.incrby(K.lettersTyped, n).catch(() => {});
|
||||
}
|
||||
|
||||
/** Register an IP in the HyperLogLog of unique visitors. */
|
||||
export async function recordIp(ip: string): Promise<void> {
|
||||
if (!ip) return;
|
||||
await redis.pfadd(K.ips, ip).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Snapshot ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StatsSnapshot {
|
||||
// live
|
||||
connectedTabs: number;
|
||||
typingNow: number;
|
||||
lettersPerSec: number;
|
||||
msgsPerMin: number;
|
||||
// totals
|
||||
messages: number;
|
||||
replies: number;
|
||||
charsSent: number;
|
||||
lettersTyped: number;
|
||||
uniqueIps: number;
|
||||
longestMsg: number;
|
||||
// derived
|
||||
abandonRate: number; // % of typed letters that were never sent
|
||||
avgLength: number; // average sent-message length
|
||||
moneyExtorted: number; // fake "€": impressions×CPM + credits spent
|
||||
}
|
||||
|
||||
export async function buildSnapshot(live: {
|
||||
connectedTabs: number;
|
||||
typingNow: number;
|
||||
}): Promise<StatsSnapshot> {
|
||||
const [messages, replies, charsSent, lettersTyped, longest, uniqueIps, creditsSpent, impressions] =
|
||||
await Promise.all([
|
||||
redis.get(K.messages).catch(() => "0"),
|
||||
redis.get(K.replies).catch(() => "0"),
|
||||
redis.get(K.charsSent).catch(() => "0"),
|
||||
redis.get(K.lettersTyped).catch(() => "0"),
|
||||
redis.get(K.longest).catch(() => "0"),
|
||||
redis.pfcount(K.ips).catch(() => 0),
|
||||
redis.get(K.creditsSpent).catch(() => "0"),
|
||||
redis.get(K.impressionsTotal).catch(() => "0"),
|
||||
]);
|
||||
|
||||
const nMessages = Number(messages ?? 0);
|
||||
const nCharsSent = Number(charsSent ?? 0);
|
||||
const nLettersTyped = Number(lettersTyped ?? 0);
|
||||
|
||||
const abandonRate =
|
||||
nLettersTyped > 0
|
||||
? Math.max(0, Math.min(100, ((nLettersTyped - nCharsSent) / nLettersTyped) * 100))
|
||||
: 0;
|
||||
const avgLength = nMessages > 0 ? nCharsSent / nMessages : 0;
|
||||
|
||||
// Fake revenue: ad impressions × CPM + credits spent (centi-credits → "€").
|
||||
const moneyExtorted =
|
||||
(Number(impressions ?? 0) / 1000) * FAKE_CPM + Number(creditsSpent ?? 0) / 100;
|
||||
|
||||
return {
|
||||
connectedTabs: live.connectedTabs,
|
||||
typingNow: live.typingNow,
|
||||
lettersPerSec: getLettersPerSec(),
|
||||
msgsPerMin: getMsgsPerMin(),
|
||||
messages: nMessages,
|
||||
replies: Number(replies ?? 0),
|
||||
charsSent: nCharsSent,
|
||||
lettersTyped: nLettersTyped,
|
||||
uniqueIps: Number(uniqueIps ?? 0),
|
||||
longestMsg: Number(longest ?? 0),
|
||||
abandonRate,
|
||||
avgLength,
|
||||
moneyExtorted,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user