Files
XIP/backend/src/lib/stats.ts
kerboul cf239ab95f 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>
2026-05-30 22:47:23 +02:00

208 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}