diff --git a/package.json b/package.json index 42ebd9d..f11b8e1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,12 @@ "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "seed:mock": "bash scripts/seed-mock-data.sh", + "seed:clear": "tsx scripts/clear-mock-data.ts", + "seed:generate": "tsx scripts/generate-mock-data.ts", + "seed:sync": "tsx scripts/sync-redis-stats.ts", + "seed:live": "tsx scripts/simulate-live-activity.ts" }, "dependencies": { "class-variance-authority": "^0.7.1", @@ -52,6 +57,7 @@ "eslint-config-next": "^15.3.0", "postcss": "^8.5.3", "tailwindcss": "^4.1.0", + "tsx": "^4.21.0", "typescript": "^5.8.0" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 244e16c..8d6bd40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: tailwindcss: specifier: ^4.1.0 version: 4.2.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -129,7 +132,7 @@ importers: version: 22.19.15 tsup: specifier: ^8.3.0 - version: 8.5.1(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3) + version: 8.5.1(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -3756,6 +3759,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6716,12 +6724,13 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.8 + tsx: 4.21.0 postcss@8.4.31: dependencies: @@ -7282,7 +7291,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3): + tsup@8.5.1(@swc/core@1.15.18)(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -7293,7 +7302,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -7311,6 +7320,13 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..f2fa906 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,122 @@ +# 模拟数据脚本 + +本目录包含用于生成和管理 OpenClaw Market 模拟数据的脚本。 + +## 快速开始 + +```bash +# 一键生成模拟数据(清理 + 生成 + 同步 Redis) +pnpm seed:mock +``` + +## 单独脚本 + +### 1. 清理数据 (`clear-mock-data.ts`) + +清理所有模拟数据,包括: +- MySQL 数据库中的 claws、heartbeats、tasks、token_usage、geo_cache 表 +- Redis 中的所有相关缓存 + +```bash +pnpm seed:clear +``` + +### 2. 生成模拟数据 (`generate-mock-data.ts`) + +生成约 240 只模拟龙虾,特点: +- **地区分布**:按真实 AI 使用情况分配 + - Americas: 35% + - Asia: 30% + - Europe: 28% + - Oceania: 5% + - Africa: 2% +- **名字生成**:形容词 + 名词 + 后缀(如 `SwiftCoderPrime`) +- **平台分布**:claude-code (40%), cursor (25%), copilot (15%) 等 +- **模型分布**:claude-sonnet-4-6 (35%), claude-haiku-4-5 (20%) 等 +- **活跃程度**: + - 重度用户 (10%):每天 50-500 万 tokens + - 中度用户 (30%):每天 10-50 万 tokens + - 轻度用户 (40%):每天 2-10 万 tokens + - 极少用户 (20%):每天 1千-2 万 tokens +- **注册时间**:过去 7 天内随机分布 + +```bash +pnpm seed:generate +``` + +### 3. 同步 Redis 统计 (`sync-redis-stats.ts`) + +根据数据库数据初始化 Redis 统计: +- 全局统计(总虾数、总任务数、总 tokens) +- 地区统计 +- 活跃 claws +- 每小时活动数据 + +```bash +pnpm seed:sync +``` + +### 4. 模拟实时活动 (`simulate-live-activity.ts`) + +持续模拟心跳、任务和 token 上报,让数据看起来是"活"的。 + +```bash +pnpm seed:live +``` + +按 `Ctrl+C` 停止。 + +## 数据结构 + +### Claws 表字段 +| 字段 | 说明 | +|------|------| +| id | 唯一标识 (nanoid) | +| apiKey | API 密钥 | +| name | 龙虾名称 | +| platform | 平台 (claude-code, cursor 等) | +| model | 模型 (claude-sonnet-4-6 等) | +| latitude/longitude | 地理坐标 | +| city/country/countryCode | 地理位置 | +| region | 大洲 | +| lastHeartbeat | 最后心跳时间 | +| totalTasks | 总任务数 | +| createdAt | 注册时间 | + +### 关联数据 +- **heartbeats**: 心跳记录 +- **tasks**: 任务记录(摘要、时长、工具) +- **token_usage**: Token 使用记录(按天) + +## 环境要求 + +运行脚本前需要配置: + +```bash +# .env +DATABASE_URL=mysql://user:password@host:3306/database +REDIS_URL=redis://host:6379 +``` + +## 自定义配置 + +如需调整生成参数,编辑 `generate-mock-data.ts` 顶部的配置: + +```typescript +const TOTAL_CLAWS = 240; // 总虾数 +const DAYS_BACK = 7; // 注册时间范围(天) + +const REGION_DISTRIBUTION = [ // 地区分布权重 + { region: "Americas", weight: 35, countries: [...] }, + // ... +]; +``` + +## 数据量参考 + +生成 240 只虾大约产生: +- 240 条 claws 记录 +- 5,000-10,000 条 heartbeats 记录 +- 2,000-5,000 条 tasks 记录 +- 1,000-1,500 条 token_usage 记录 +- 总计约 50-200M tokens diff --git a/scripts/clear-mock-data.ts b/scripts/clear-mock-data.ts new file mode 100644 index 0000000..21e675e --- /dev/null +++ b/scripts/clear-mock-data.ts @@ -0,0 +1,102 @@ +/** + * 清理模拟数据脚本 + * 删除所有 claws 数据(以及关联的 heartbeats, tasks, token_usage) + * + * 使用方法: npx tsx scripts/clear-mock-data.ts + */ + +import { db } from "@/lib/db"; +import { claws, heartbeats, tasks, tokenUsage, geoCache } from "@/lib/db/schema"; +import { sql } from "drizzle-orm"; +import { redis, redisSubscriber } from "@/lib/redis"; + +async function clearMockData() { + console.log("🧹 开始清理数据...\n"); + + try { + // 1. 清理 token_usage + console.log("🗑️ 清理 token_usage..."); + const tokenResult = await db.delete(tokenUsage); + console.log(` ✅ 已删除所有 token_usage 记录\n`); + + // 2. 清理 tasks + console.log("🗑️ 清理 tasks..."); + await db.delete(tasks); + console.log(` ✅ 已删除所有 tasks 记录\n`); + + // 3. 清理 heartbeats + console.log("🗑️ 清理 heartbeats..."); + await db.delete(heartbeats); + console.log(` ✅ 已删除所有 heartbeats 记录\n`); + + // 4. 清理 claws + console.log("🗑️ 清理 claws..."); + await db.delete(claws); + console.log(` ✅ 已删除所有 claws 记录\n`); + + // 5. 清理 geo_cache(可选) + console.log("🗑️ 清理 geo_cache..."); + await db.delete(geoCache); + console.log(` ✅ 已删除所有 geo_cache 记录\n`); + + // 6. 清理 Redis 数据 + console.log("🗑️ 清理 Redis 数据..."); + + // 获取所有相关的 Redis keys + const keysToDelete: string[] = []; + + // 在线状态 + const onlineKeys = await redis.keys("claw:online:*"); + keysToDelete.push(...onlineKeys); + + // 活跃 claws + const activeKeys = await redis.keys("active_claws*"); + keysToDelete.push(...activeKeys); + + // 地区统计 + const regionKeys = await redis.keys("region:*"); + keysToDelete.push(...regionKeys); + + // 全局统计 + const globalKeys = await redis.keys("global:*"); + keysToDelete.push(...globalKeys); + + // 每小时活动 + const hourlyKeys = await redis.keys("hourly:*"); + keysToDelete.push(...hourlyKeys); + + // 热力图缓存 + const heatmapKeys = await redis.keys("heatmap:*"); + keysToDelete.push(...heatmapKeys); + + // Token 排行榜缓存 + const leaderboardKeys = await redis.keys("token_leaderboard:*"); + keysToDelete.push(...leaderboardKeys); + + // Token 统计缓存 + const tokenStatsKeys = await redis.keys("token_stats:*"); + keysToDelete.push(...tokenStatsKeys); + + if (keysToDelete.length > 0) { + await redis.del(...keysToDelete); + console.log(` ✅ 已删除 ${keysToDelete.length} 个 Redis keys\n`); + } else { + console.log(` ℹ️ 没有 Redis keys 需要删除\n`); + } + + console.log("=" .repeat(50)); + console.log("✨ 数据清理完成!\n"); + + } catch (error) { + console.error("❌ 清理失败:", error); + throw error; + } +} + +// 运行 +clearMockData() + .then(() => process.exit(0)) + .catch((err) => { + console.error("❌ 错误:", err); + process.exit(1); + }); diff --git a/scripts/generate-mock-data.ts b/scripts/generate-mock-data.ts new file mode 100644 index 0000000..b54f4aa --- /dev/null +++ b/scripts/generate-mock-data.ts @@ -0,0 +1,655 @@ +/** + * 模拟数据生成脚本 + * 生成约 240 只龙虾,分布在全球各个地区 + * + * 使用方法: npx tsx scripts/generate-mock-data.ts + */ + +import { db } from "@/lib/db"; +import { claws, heartbeats, tasks, tokenUsage } from "@/lib/db/schema"; +import { nanoid } from "nanoid"; +import { sql } from "drizzle-orm"; + +// ============ 配置 ============ +const TOTAL_CLAWS = 240; +const DAYS_BACK = 7; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + +// ============ 地区分布数据 ============ +// 按真实 AI 使用情况分配比例 +const REGION_DISTRIBUTION = [ + { region: "Americas", weight: 35, countries: ["US", "CA", "BR", "MX", "AR"] }, + { region: "Asia", weight: 30, countries: ["CN", "JP", "KR", "IN", "SG", "HK", "TW"] }, + { region: "Europe", weight: 28, countries: ["GB", "DE", "FR", "NL", "SE", "PL", "IT", "ES"] }, + { region: "Oceania", weight: 5, countries: ["AU", "NZ"] }, + { region: "Africa", weight: 2, countries: ["ZA", "NG", "KE", "EG"] }, +]; + +// 城市数据(国家代码 -> 城市列表 + 坐标范围) +const CITIES_DATA: Record = { + // Americas + US: [ + { name: "San Francisco", lat: 37.7749, lng: -122.4194 }, + { name: "New York", lat: 40.7128, lng: -74.006 }, + { name: "Seattle", lat: 47.6062, lng: -122.3321 }, + { name: "Los Angeles", lat: 34.0522, lng: -118.2437 }, + { name: "Boston", lat: 42.3601, lng: -71.0589 }, + { name: "Austin", lat: 30.2672, lng: -97.7431 }, + { name: "Chicago", lat: 41.8781, lng: -87.6298 }, + { name: "Denver", lat: 39.7392, lng: -104.9903 }, + ], + CA: [ + { name: "Toronto", lat: 43.6532, lng: -79.3832 }, + { name: "Vancouver", lat: 49.2827, lng: -123.1207 }, + { name: "Montreal", lat: 45.5017, lng: -73.5673 }, + ], + BR: [ + { name: "Sao Paulo", lat: -23.5505, lng: -46.6333 }, + { name: "Rio de Janeiro", lat: -22.9068, lng: -43.1729 }, + ], + MX: [{ name: "Mexico City", lat: 19.4326, lng: -99.1332 }], + AR: [{ name: "Buenos Aires", lat: -34.6037, lng: -58.3816 }], + + // Asia + CN: [ + { name: "Beijing", lat: 39.9042, lng: 116.4074 }, + { name: "Shanghai", lat: 31.2304, lng: 121.4737 }, + { name: "Shenzhen", lat: 22.5431, lng: 114.0579 }, + { name: "Hangzhou", lat: 30.2741, lng: 120.1551 }, + { name: "Guangzhou", lat: 23.1291, lng: 113.2644 }, + { name: "Chengdu", lat: 30.5728, lng: 104.0668 }, + ], + JP: [ + { name: "Tokyo", lat: 35.6762, lng: 139.6503 }, + { name: "Osaka", lat: 34.6937, lng: 135.5023 }, + ], + KR: [{ name: "Seoul", lat: 37.5665, lng: 126.978 }], + IN: [ + { name: "Bangalore", lat: 12.9716, lng: 77.5946 }, + { name: "Mumbai", lat: 19.076, lng: 72.8777 }, + { name: "Delhi", lat: 28.7041, lng: 77.1025 }, + ], + SG: [{ name: "Singapore", lat: 1.3521, lng: 103.8198 }], + HK: [{ name: "Hong Kong", lat: 22.3193, lng: 114.1694 }], + TW: [{ name: "Taipei", lat: 25.033, lng: 121.5654 }], + + // Europe + GB: [ + { name: "London", lat: 51.5074, lng: -0.1278 }, + { name: "Manchester", lat: 53.4808, lng: -2.2426 }, + ], + DE: [ + { name: "Berlin", lat: 52.52, lng: 13.405 }, + { name: "Munich", lat: 48.1351, lng: 11.582 }, + { name: "Frankfurt", lat: 50.1109, lng: 8.6821 }, + ], + FR: [{ name: "Paris", lat: 48.8566, lng: 2.3522 }], + NL: [ + { name: "Amsterdam", lat: 52.3676, lng: 4.9041 }, + { name: "Eindhoven", lat: 51.4416, lng: 5.4697 }, + ], + SE: [{ name: "Stockholm", lat: 59.3293, lng: 18.0686 }], + PL: [{ name: "Warsaw", lat: 52.2297, lng: 21.0122 }], + IT: [{ name: "Milan", lat: 45.4642, lng: 9.19 }], + ES: [{ name: "Madrid", lat: 40.4168, lng: -3.7038 }], + + // Oceania + AU: [ + { name: "Sydney", lat: -33.8688, lng: 151.2093 }, + { name: "Melbourne", lat: -37.8136, lng: 144.9631 }, + ], + NZ: [{ name: "Auckland", lat: -36.8509, lng: 174.7645 }], + + // Africa + ZA: [{ name: "Cape Town", lat: -33.9249, lng: 18.4241 }], + NG: [{ name: "Lagos", lat: 6.5244, lng: 3.3792 }], + KE: [{ name: "Nairobi", lat: -1.2921, lng: 36.8219 }], + EG: [{ name: "Cairo", lat: 30.0444, lng: 31.2357 }], +}; + +// 国家全名 +const COUNTRY_NAMES: Record = { + US: "United States", + CA: "Canada", + BR: "Brazil", + MX: "Mexico", + AR: "Argentina", + CN: "China", + JP: "Japan", + KR: "South Korea", + IN: "India", + SG: "Singapore", + HK: "Hong Kong", + TW: "Taiwan", + GB: "United Kingdom", + DE: "Germany", + FR: "France", + NL: "Netherlands", + SE: "Sweden", + PL: "Poland", + IT: "Italy", + ES: "Spain", + AU: "Australia", + NZ: "New Zealand", + ZA: "South Africa", + NG: "Nigeria", + KE: "Kenya", + EG: "Egypt", +}; + +// ============ 名字生成 ============ +const ADJECTIVES = [ + "Swift", "Brave", "Clever", "Silent", "Mystic", "Noble", "Fierce", "Gentle", + "Wise", "Bold", "Sharp", "Quick", "Calm", "Wild", "Bright", "Dark", + "Golden", "Silver", "Iron", "Steel", "Crystal", "Shadow", "Storm", "Thunder", + "Phoenix", "Dragon", "Tiger", "Eagle", "Wolf", "Bear", "Lion", "Falcon", + "Cosmic", "Stellar", "Nova", "Quantum", "Cyber", "Neural", "Atomic", "Solar", + "Arctic", "Desert", "Ocean", "Mountain", "Forest", "River", "Valley", "Peak", +]; + +const NOUNS = [ + "Coder", "Hacker", "Builder", "Maker", "Creator", "Developer", "Engineer", + "Architect", "Designer", "Pioneer", "Explorer", "Seeker", "Hunter", "Guardian", + "Sentinel", "Warrior", "Scholar", "Sage", "Wizard", "Alchemist", "Artisan", + "Craftsman", "Visionary", "Innovator", "Strategist", "Analyst", "Researcher", + "Navigator", "Pilot", "Captain", "Commander", "Champion", "Master", "Expert", + "Ninja", "Samurai", "Knight", "Ranger", "Scout", "Agent", "Operator", "Pilot", +]; + +const SUFFIXES = [ + "", "", "", "", // 大部分没有后缀 + "Prime", "Alpha", "Beta", "Gamma", "Delta", "Omega", + "X", "Z", "Pro", "Max", "Ultra", "Mega", "Super", + "AI", "Bot", "Agent", "Claw", "Byte", "Node", "Core", +]; + +function generateName(index: number): string { + const adj = ADJECTIVES[index % ADJECTIVES.length]; + const noun = NOUNS[Math.floor(index / ADJECTIVES.length) % NOUNS.length]; + const suffix = SUFFIXES[Math.floor(index / (ADJECTIVES.length * NOUNS.length)) % SUFFIXES.length]; + return suffix ? `${adj}${noun}${suffix}` : `${adj}${noun}`; +} + +// ============ 平台和模型 ============ +const PLATFORMS = [ + { name: "claude-code", weight: 40 }, + { name: "cursor", weight: 25 }, + { name: "copilot", weight: 15 }, + { name: "aider", weight: 10 }, + { name: "continue", weight: 5 }, + { name: "zed", weight: 3 }, + { name: "windsurf", weight: 2 }, +]; + +const MODELS = [ + { name: "claude-sonnet-4-6", weight: 35 }, + { name: "claude-opus-4-6", weight: 15 }, + { name: "claude-haiku-4-5", weight: 20 }, + { name: "gpt-4o", weight: 15 }, + { name: "gpt-4-turbo", weight: 8 }, + { name: "gemini-2.0-flash", weight: 5 }, + { name: "deepseek-v3", weight: 2 }, +]; + +function weightedRandom(items: T[]): T { + const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); + let random = Math.random() * totalWeight; + for (const item of items) { + random -= item.weight; + if (random <= 0) return item; + } + return items[items.length - 1]; +} + +// ============ Token 消耗模拟 ============ +// 根据活跃程度分级 +type ActivityLevel = "heavy" | "moderate" | "light" | "minimal"; + +const ACTIVITY_DISTRIBUTION: { level: ActivityLevel; weight: number }[] = [ + { level: "heavy", weight: 10 }, // 重度用户 + { level: "moderate", weight: 30 }, // 中度用户 + { level: "light", weight: 40 }, // 轻度用户 + { level: "minimal", weight: 20 }, // 极少用户 +]; + +function getTokenRange(level: ActivityLevel): { min: number; max: number } { + switch (level) { + case "heavy": + return { min: 500_000, max: 5_000_000 }; // 50万 - 500万 tokens/天 + case "moderate": + return { min: 100_000, max: 500_000 }; // 10万 - 50万 tokens/天 + case "light": + return { min: 20_000, max: 100_000 }; // 2万 - 10万 tokens/天 + case "minimal": + return { min: 1_000, max: 20_000 }; // 1千 - 2万 tokens/天 + } +} + +// ============ 任务摘要模板 ============ +const TASK_SUMMARIES = [ + "Refactored authentication module for better security", + "Implemented new API endpoints for user management", + "Fixed memory leak in background worker process", + "Added unit tests for payment processing module", + "Optimized database queries for faster response times", + "Integrated third-party OAuth provider", + "Migrated legacy code to TypeScript", + "Built real-time notification system with WebSockets", + "Created responsive dashboard UI components", + "Implemented caching layer with Redis", + "Fixed cross-browser compatibility issues", + "Added internationalization support for 5 languages", + "Refactored state management with Redux", + "Implemented file upload with progress tracking", + "Built automated deployment pipeline", + "Created API documentation with OpenAPI spec", + "Implemented rate limiting for API endpoints", + "Fixed race condition in concurrent processing", + "Added logging and monitoring infrastructure", + "Optimized bundle size by 40%", + "Implemented dark mode theme support", + "Built search functionality with Elasticsearch", + "Created admin panel for content management", + "Fixed security vulnerability in input validation", + "Implemented webhook system for integrations", + "Added two-factor authentication support", + "Built data export functionality", + "Implemented pagination for large datasets", + "Created custom CLI tool for development", + "Fixed timezone handling in date operations", + "Implemented email notification system", + "Built real-time collaboration features", + "Added support for multiple payment gateways", + "Created automated testing suite", + "Implemented feature flags system", + "Built analytics dashboard with charts", + "Fixed memory issues in image processing", + "Added support for large file uploads", + "Implemented user preference system", + "Created backup and restore functionality", + "Built custom form builder component", + "Implemented audit logging system", + "Added support for custom themes", + "Created import/export data functionality", + "Implemented session management", + "Built notification preferences UI", + "Added keyboard shortcuts support", + "Implemented drag-and-drop functionality", + "Created custom error handling middleware", +]; + +const TOOLS_USED = [ + ["Read", "Edit", "Bash"], + ["Grep", "Read", "Write"], + ["Bash", "Glob", "Grep"], + ["Read", "Edit", "Grep", "Bash"], + ["WebSearch", "Read", "Edit"], + ["Agent", "Read", "Grep"], + ["Bash", "Read", "Write"], + ["Glob", "Grep", "Read"], + ["Read", "Edit"], + ["Bash", "Read"], +]; + +// ============ 工具函数 ============ +function randomInRange(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function randomFloat(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +function randomDate(start: Date, end: Date): Date { + return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); +} + +function formatDateString(date: Date): string { + return date.toISOString().split("T")[0]; +} + +// 模拟 IP 地址生成(基于国家代码的伪 IP) +function generateFakeIp(countryCode: string, index: number): string { + const countryPrefix: Record = { + US: "8.", CN: "58.", JP: "126.", GB: "2.", DE: "5.", + FR: "46.", KR: "14.", IN: "59.", CA: "24.", AU: "1.", + BR: "177.", NL: "77.", SE: "78.", SG: "203.", HK: "218.", + TW: "61.", IT: "93.", ES: "88.", PL: "79.", ZA: "41.", + }; + const prefix = countryPrefix[countryCode] || "10."; + return `${prefix}${randomInRange(1, 255)}.${randomInRange(1, 255)}.${randomInRange(1, 255)}`; +} + +// ============ 主生成逻辑 ============ +interface MockClaw { + id: string; + apiKey: string; + name: string; + platform: string; + model: string; + ip: string; + latitude: number; + longitude: number; + city: string; + country: string; + countryCode: string; + region: string; + createdAt: Date; + activityLevel: ActivityLevel; +} + +function generateApiKey(): string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let key = "oc_"; + for (let i = 0; i < 48; i++) { + key += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return key; +} + +async function generateMockData() { + console.log("🦀 开始生成模拟数据...\n"); + + const now = new Date(); + const startDate = new Date(now.getTime() - DAYS_BACK * 24 * 60 * 60 * 1000); + + // 1. 生成所有 claw 数据 + const mockClaws: MockClaw[] = []; + const regionWeights = REGION_DISTRIBUTION.map(r => ({ name: r.region, weight: r.weight })); + const regionCounts: Record = {}; + + for (let i = 0; i < TOTAL_CLAWS; i++) { + // 选择地区 + const region = weightedRandom(regionWeights); + regionCounts[region.name] = (regionCounts[region.name] || 0) + 1; + + // 从该地区选择国家 + const regionData = REGION_DISTRIBUTION.find(r => r.region === region.name)!; + const country = regionData.countries[Math.floor(Math.random() * regionData.countries.length)]; + + // 从该国家选择城市 + const cities = CITIES_DATA[country] || [{ name: "Unknown", lat: 0, lng: 0 }]; + const city = cities[Math.floor(Math.random() * cities.length)]; + + // 添加随机偏移(模拟同一城市的不同位置) + const latOffset = randomFloat(-0.1, 0.1); + const lngOffset = randomFloat(-0.1, 0.1); + + // 选择平台和模型 + const platform = weightedRandom(PLATFORMS); + const model = weightedRandom(MODELS); + + // 选择活跃程度 + const activity = weightedRandom(ACTIVITY_DISTRIBUTION); + + // 随机创建时间 + const createdAt = randomDate(startDate, now); + + const claw: MockClaw = { + id: nanoid(21), + apiKey: generateApiKey(), + name: generateName(i), + platform: platform.name, + model: model.name, + ip: generateFakeIp(country, i), + latitude: city.lat + latOffset, + longitude: city.lng + lngOffset, + city: city.name, + country: COUNTRY_NAMES[country] || country, + countryCode: country, + region: region.name, + createdAt, + activityLevel: activity.level, + }; + + mockClaws.push(claw); + } + + console.log("📊 地区分布:"); + for (const [region, count] of Object.entries(regionCounts)) { + console.log(` ${region}: ${count} 只虾`); + } + console.log(""); + + // 2. 插入 claws 到数据库 + console.log("💾 插入 claws 数据..."); + for (const claw of mockClaws) { + await db.insert(claws).values({ + id: claw.id, + apiKey: claw.apiKey, + name: claw.name, + platform: claw.platform, + model: claw.model, + ip: claw.ip, + latitude: String(claw.latitude), + longitude: String(claw.longitude), + city: claw.city, + country: claw.country, + countryCode: claw.countryCode, + region: claw.region, + lastHeartbeat: claw.createdAt, + totalTasks: 0, + createdAt: claw.createdAt, + updatedAt: claw.createdAt, + }); + } + console.log(` ✅ 已插入 ${mockClaws.length} 只虾\n`); + + // 3. 生成心跳数据 + console.log("💓 生成心跳数据..."); + const heartbeatBatch: { clawId: string; ip: string; timestamp: Date }[] = []; + + for (const claw of mockClaws) { + // 根据活跃程度决定心跳频率 + let heartbeatCount: number; + switch (claw.activityLevel) { + case "heavy": + heartbeatCount = randomInRange(50, 200); // 高频心跳 + break; + case "moderate": + heartbeatCount = randomInRange(20, 50); + break; + case "light": + heartbeatCount = randomInRange(5, 20); + break; + case "minimal": + heartbeatCount = randomInRange(1, 5); + break; + } + + // 在注册后到现在之间随机分布心跳 + for (let h = 0; h < heartbeatCount; h++) { + const heartbeatTime = randomDate(claw.createdAt, now); + heartbeatBatch.push({ + clawId: claw.id, + ip: claw.ip, + timestamp: heartbeatTime, + }); + } + } + + // 批量插入心跳 + const HEARTBEAT_BATCH_SIZE = 1000; + for (let i = 0; i < heartbeatBatch.length; i += HEARTBEAT_BATCH_SIZE) { + const batch = heartbeatBatch.slice(i, i + HEARTBEAT_BATCH_SIZE); + await db.insert(heartbeats).values(batch); + } + console.log(` ✅ 已插入 ${heartbeatBatch.length} 条心跳记录\n`); + + // 4. 生成任务数据 + console.log("📋 生成任务数据..."); + const taskBatch: { + clawId: string; + summary: string; + durationMs: number; + model: string; + toolsUsed: string[]; + timestamp: Date; + }[] = []; + + for (const claw of mockClaws) { + // 根据活跃程度决定任务数量 + let taskCount: number; + switch (claw.activityLevel) { + case "heavy": + taskCount = randomInRange(20, 100); + break; + case "moderate": + taskCount = randomInRange(10, 30); + break; + case "light": + taskCount = randomInRange(3, 15); + break; + case "minimal": + taskCount = randomInRange(0, 5); + break; + } + + for (let t = 0; t < taskCount; t++) { + const summary = TASK_SUMMARIES[Math.floor(Math.random() * TASK_SUMMARIES.length)]; + const durationMs = randomInRange(10_000, 3_600_000); // 10秒 - 1小时 + const toolsUsed = TOOLS_USED[Math.floor(Math.random() * TOOLS_USED.length)]; + const taskTime = randomDate(claw.createdAt, now); + + taskBatch.push({ + clawId: claw.id, + summary, + durationMs, + model: claw.model, + toolsUsed, + timestamp: taskTime, + }); + } + } + + // 批量插入任务 + const TASK_BATCH_SIZE = 500; + for (let i = 0; i < taskBatch.length; i += TASK_BATCH_SIZE) { + const batch = taskBatch.slice(i, i + TASK_BATCH_SIZE); + await db.insert(tasks).values(batch); + } + console.log(` ✅ 已插入 ${taskBatch.length} 条任务记录\n`); + + // 5. 更新 claws 的 totalTasks + console.log("🔄 更新任务统计..."); + for (const claw of mockClaws) { + const clawTasks = taskBatch.filter(t => t.clawId === claw.id); + if (clawTasks.length > 0) { + await db + .update(claws) + .set({ totalTasks: clawTasks.length }) + .where(sql`id = ${claw.id}`); + } + } + console.log(" ✅ 已更新任务统计\n"); + + // 6. 生成 token 使用数据(按天) + console.log("🔢 生成 Token 使用数据..."); + + // 生成过去 7 天的日期 + const dates: string[] = []; + for (let d = 0; d < DAYS_BACK; d++) { + const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); + dates.push(formatDateString(date)); + } + + const tokenBatch: { + clawId: string; + date: string; + inputTokens: number; + outputTokens: number; + }[] = []; + + for (const claw of mockClaws) { + // 确定这只虾的活跃天数 + const activeDays = Math.ceil( + (now.getTime() - claw.createdAt.getTime()) / (24 * 60 * 60 * 1000) + ); + + // 只为注册后的日期生成 token 数据 + for (let d = 0; d < Math.min(activeDays, DAYS_BACK); d++) { + // 根据活跃程度决定是否在这一天有活动 + const isActiveToday = Math.random() < ( + claw.activityLevel === "heavy" ? 0.95 : + claw.activityLevel === "moderate" ? 0.7 : + claw.activityLevel === "light" ? 0.4 : + 0.15 + ); + + if (!isActiveToday) continue; + + const tokenRange = getTokenRange(claw.activityLevel); + // 实际 token 会有波动 + const variance = randomFloat(0.5, 1.5); + const dailyTokens = randomInRange( + Math.floor(tokenRange.min * variance), + Math.floor(tokenRange.max * variance) + ); + + // input/output 比例约为 3:1 + const inputTokens = Math.floor(dailyTokens * 0.75); + const outputTokens = dailyTokens - inputTokens; + + tokenBatch.push({ + clawId: claw.id, + date: dates[d], + inputTokens, + outputTokens, + }); + } + } + + // 批量插入 token 数据 + const TOKEN_BATCH_SIZE = 500; + for (let i = 0; i < tokenBatch.length; i += TOKEN_BATCH_SIZE) { + const batch = tokenBatch.slice(i, i + TOKEN_BATCH_SIZE); + await db.insert(tokenUsage).values(batch); + } + console.log(` ✅ 已插入 ${tokenBatch.length} 条 Token 使用记录\n`); + + // 7. 输出统计摘要 + console.log("=" .repeat(50)); + console.log("📊 数据生成完成!\n"); + + console.log("📈 统计摘要:"); + console.log(` 总虾数: ${mockClaws.length}`); + console.log(` 心跳记录: ${heartbeatBatch.length}`); + console.log(` 任务记录: ${taskBatch.length}`); + console.log(` Token 记录: ${tokenBatch.length}`); + + // 计算总 token + const totalInputTokens = tokenBatch.reduce((sum, t) => sum + t.inputTokens, 0); + const totalOutputTokens = tokenBatch.reduce((sum, t) => sum + t.outputTokens, 0); + console.log(` 总 Input Tokens: ${(totalInputTokens / 1_000_000).toFixed(2)}M`); + console.log(` 总 Output Tokens: ${(totalOutputTokens / 1_000_000).toFixed(2)}M`); + console.log(` 总 Tokens: ${((totalInputTokens + totalOutputTokens) / 1_000_000).toFixed(2)}M`); + + console.log("\n🗺️ 地区分布:"); + for (const [region, count] of Object.entries(regionCounts).sort((a, b) => b[1] - a[1])) { + const percentage = ((count / TOTAL_CLAWS) * 100).toFixed(1); + console.log(` ${region}: ${count} (${percentage}%)`); + } + + console.log("\n🎯 活跃程度分布:"); + const activityCounts: Record = { + heavy: 0, + moderate: 0, + light: 0, + minimal: 0, + }; + mockClaws.forEach(c => activityCounts[c.activityLevel]++); + console.log(` 重度用户: ${activityCounts.heavy}`); + console.log(` 中度用户: ${activityCounts.moderate}`); + console.log(` 轻度用户: ${activityCounts.light}`); + console.log(` 极少用户: ${activityCounts.minimal}`); + + console.log("\n✨ 完成!"); +} + +// 运行 +generateMockData() + .then(() => process.exit(0)) + .catch((err) => { + console.error("❌ 错误:", err); + process.exit(1); + }); diff --git a/scripts/seed-mock-data.sh b/scripts/seed-mock-data.sh new file mode 100755 index 0000000..3c51a7c --- /dev/null +++ b/scripts/seed-mock-data.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# 一键生成模拟数据脚本 +# 使用方法: bash scripts/seed-mock-data.sh + +set -e + +echo "🦀 OpenClaw Market 模拟数据生成器" +echo "==================================" +echo "" + +# 检查环境变量 +if [ -z "$DATABASE_URL" ]; then + echo "❌ 错误: DATABASE_URL 环境变量未设置" + exit 1 +fi + +if [ -z "$REDIS_URL" ]; then + echo "⚠️ 警告: REDIS_URL 环境变量未设置,Redis 同步可能会失败" +fi + +# 步骤 1: 清理旧数据 +echo "📦 步骤 1/3: 清理旧数据..." +npx tsx scripts/clear-mock-data.ts +echo "" + +# 步骤 2: 生成新数据 +echo "📦 步骤 2/3: 生成模拟数据..." +npx tsx scripts/generate-mock-data.ts +echo "" + +# 步骤 3: 同步 Redis +echo "📦 步骤 3/3: 同步 Redis 统计..." +npx tsx scripts/sync-redis-stats.ts +echo "" + +echo "==================================" +echo "✨ 全部完成!现在可以启动开发服务器查看效果:" +echo " pnpm dev" +echo "" diff --git a/scripts/simulate-live-activity.ts b/scripts/simulate-live-activity.ts new file mode 100644 index 0000000..9bddaaa --- /dev/null +++ b/scripts/simulate-live-activity.ts @@ -0,0 +1,236 @@ +/** + * 持续模拟心跳脚本 + * 随机选择在线的虾发送心跳和任务,模拟实时活动 + * + * 使用方法: npx tsx scripts/simulate-live-activity.ts + * + * 按 Ctrl+C 停止 + */ + +import { db } from "@/lib/db"; +import { claws, heartbeats, tasks, tokenUsage } from "@/lib/db/schema"; +import { sql, eq } from "drizzle-orm"; +import { redis } from "@/lib/redis"; +import { publishEvent } from "@/lib/redis"; + +const ACTIVE_CLAWS_KEY = "active:claws"; +const STATS_GLOBAL_KEY = "stats:global"; +const STATS_REGION_KEY = "stats:region"; +const HOURLY_ACTIVITY_KEY = "stats:hourly"; + +// 任务摘要模板 +const TASK_SUMMARIES = [ + "Refactored authentication module", + "Implemented new API endpoint", + "Fixed memory leak in worker", + "Added unit tests", + "Optimized database queries", + "Integrated OAuth provider", + "Migrated code to TypeScript", + "Built real-time notification", + "Created dashboard components", + "Implemented caching layer", + "Fixed browser compatibility", + "Added i18n support", + "Refactored state management", + "Implemented file upload", + "Built deployment pipeline", + "Created API documentation", + "Implemented rate limiting", + "Fixed race condition", + "Added logging infrastructure", + "Optimized bundle size", +]; + +const TOOLS_USED = [ + ["Read", "Edit", "Bash"], + ["Grep", "Read", "Write"], + ["Bash", "Glob", "Grep"], + ["WebSearch", "Read", "Edit"], + ["Agent", "Read", "Grep"], +]; + +// 全局变量存储所有 claws +let allClaws: { id: string; name: string; model: string; city: string | null; country: string | null }[] = []; + +async function loadClaws() { + const result = await db + .select({ + id: claws.id, + name: claws.name, + model: claws.model, + city: claws.city, + country: claws.country, + }) + .from(claws); + + allClaws = result; + console.log(`📋 已加载 ${allClaws.length} 只虾\n`); +} + +function randomChoice(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +async function sendHeartbeat(claw: typeof allClaws[0]) { + const now = new Date(); + + // 更新数据库 + await db + .update(claws) + .set({ lastHeartbeat: now, updatedAt: now }) + .where(eq(claws.id, claw.id)); + + // 插入心跳记录 + await db.insert(heartbeats).values({ + clawId: claw.id, + ip: "127.0.0.1", + timestamp: now, + }); + + // 更新 Redis + await redis.set(`claw:online:${claw.id}`, "simulated", "EX", 300); + await redis.zadd(ACTIVE_CLAWS_KEY, Date.now(), claw.id); + + // 发布事件 + await publishEvent({ + type: "heartbeat", + clawId: claw.id, + clawName: claw.name, + city: claw.city, + country: claw.country, + }); + + console.log(`💓 [${now.toLocaleTimeString()}] ${claw.name} 发送心跳`); +} + +async function sendTask(claw: typeof allClaws[0]) { + const now = new Date(); + const summary = randomChoice(TASK_SUMMARIES); + const durationMs = randomInt(10_000, 1_800_000); // 10秒 - 30分钟 + const toolsUsed = randomChoice(TOOLS_USED); + + // 插入任务 + await db.insert(tasks).values({ + clawId: claw.id, + summary, + durationMs, + model: claw.model, + toolsUsed, + timestamp: now, + }); + + // 更新任务计数 + await db + .update(claws) + .set({ + totalTasks: sql`${claws.totalTasks} + 1`, + updatedAt: now, + }) + .where(eq(claws.id, claw.id)); + + // 更新 Redis 统计 + await redis.hincrby(STATS_GLOBAL_KEY, "total_tasks", 1); + await redis.hincrby(STATS_GLOBAL_KEY, "tasks_today", 1); + + // 更新每小时活动 + const hourKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}T${String(now.getUTCHours()).padStart(2, "0")}`; + await redis.hincrby(HOURLY_ACTIVITY_KEY, hourKey, 1); + + // 发布事件 + await publishEvent({ + type: "task", + clawId: claw.id, + clawName: claw.name, + city: claw.city, + country: claw.country, + summary, + durationMs, + }); + + console.log(`📋 [${now.toLocaleTimeString()}] ${claw.name} 完成任务: ${summary}`); +} + +async function reportTokens(claw: typeof allClaws[0]) { + const now = new Date(); + const date = now.toISOString().split("T")[0]; + + // 随机 token 数量 + const inputTokens = randomInt(1_000, 100_000); + const outputTokens = randomInt(500, 50_000); + + // Upsert token 使用 + await db.execute(sql` + INSERT INTO token_usage (claw_id, date, input_tokens, output_tokens) + VALUES (${claw.id}, ${date}, ${inputTokens}, ${outputTokens}) + ON DUPLICATE KEY UPDATE + input_tokens = input_tokens + ${inputTokens}, + output_tokens = output_tokens + ${outputTokens}, + updated_at = NOW() + `); + + // 更新 Redis 总计 + await redis.hincrby(STATS_GLOBAL_KEY, "total_input_tokens", inputTokens); + await redis.hincrby(STATS_GLOBAL_KEY, "total_output_tokens", outputTokens); + + console.log(`🔢 [${now.toLocaleTimeString()}] ${claw.name} 报告 Token: +${inputTokens} input, +${outputTokens} output`); +} + +async function runSimulation() { + console.log("🚀 开始模拟实时活动...\n"); + console.log("按 Ctrl+C 停止\n"); + console.log("=".repeat(50) + "\n"); + + await loadClaws(); + + if (allClaws.length === 0) { + console.log("❌ 没有找到任何虾,请先运行数据生成脚本"); + process.exit(1); + } + + // 主循环 + let iteration = 0; + while (true) { + iteration++; + console.log(`\n--- 第 ${iteration} 轮 ---`); + + // 随机选择 1-5 只虾发送心跳 + const heartbeatCount = randomInt(1, 5); + for (let i = 0; i < heartbeatCount; i++) { + const claw = randomChoice(allClaws); + await sendHeartbeat(claw); + } + + // 30% 概率有虾完成任务 + if (Math.random() < 0.3) { + const claw = randomChoice(allClaws); + await sendTask(claw); + } + + // 20% 概率有虾报告 token + if (Math.random() < 0.2) { + const claw = randomChoice(allClaws); + await reportTokens(claw); + } + + // 等待 2-5 秒 + const delay = randomInt(2000, 5000); + await new Promise(resolve => setTimeout(resolve, delay)); + } +} + +// 处理退出 +process.on("SIGINT", () => { + console.log("\n\n👋 停止模拟..."); + process.exit(0); +}); + +// 运行 +runSimulation().catch((err) => { + console.error("❌ 错误:", err); + process.exit(1); +}); diff --git a/scripts/sync-redis-stats.ts b/scripts/sync-redis-stats.ts new file mode 100644 index 0000000..d26e7bb --- /dev/null +++ b/scripts/sync-redis-stats.ts @@ -0,0 +1,163 @@ +/** + * Redis 统计数据同步脚本 + * 根据数据库中的数据初始化 Redis 统计 + * + * 使用方法: npx tsx scripts/sync-redis-stats.ts + */ + +import { db } from "@/lib/db"; +import { claws, heartbeats, tasks, tokenUsage } from "@/lib/db/schema"; +import { sql, eq, and, gte } from "drizzle-orm"; +import { redis } from "@/lib/redis"; + +const ACTIVE_CLAWS_KEY = "active:claws"; +const STATS_GLOBAL_KEY = "stats:global"; +const STATS_REGION_KEY = "stats:region"; +const HOURLY_ACTIVITY_KEY = "stats:hourly"; + +async function syncRedisStats() { + console.log("🔄 开始同步 Redis 统计数据...\n"); + + const now = new Date(); + + try { + // 1. 同步全局统计 + console.log("📊 同步全局统计..."); + + // 总虾数 + const totalClawsResult = await db + .select({ count: sql`COUNT(*)` }) + .from(claws); + const totalClaws = totalClawsResult[0].count; + + // 总任务数 + const totalTasksResult = await db + .select({ count: sql`COUNT(*)` }) + .from(tasks); + const totalTasks = totalTasksResult[0].count; + + // 今日任务数 + const todayStart = new Date(now); + todayStart.setHours(0, 0, 0, 0); + const todayTasksResult = await db + .select({ count: sql`COUNT(*)` }) + .from(tasks) + .where(gte(tasks.timestamp, todayStart)); + const todayTasks = todayTasksResult[0].count; + + // 总 token + const totalTokensResult = await db + .select({ + input: sql`SUM(input_tokens)`, + output: sql`SUM(output_tokens)`, + }) + .from(tokenUsage); + const totalInput = totalTokensResult[0].input || 0; + const totalOutput = totalTokensResult[0].output || 0; + + await redis.hset(STATS_GLOBAL_KEY, { + total_claws: totalClaws, + total_tasks: totalTasks, + tasks_today: todayTasks, + total_input_tokens: totalInput, + total_output_tokens: totalOutput, + }); + + console.log(` ✅ 总虾数: ${totalClaws}`); + console.log(` ✅ 总任务数: ${totalTasks}`); + console.log(` ✅ 今日任务: ${todayTasks}`); + console.log(` ✅ 总 Input Tokens: ${(totalInput / 1_000_000).toFixed(2)}M`); + console.log(` ✅ 总 Output Tokens: ${(totalOutput / 1_000_000).toFixed(2)}M\n`); + + // 2. 同步地区统计 + console.log("🗺️ 同步地区统计..."); + const regionStats = await db + .select({ + region: claws.region, + count: sql`COUNT(*)`, + }) + .from(claws) + .where(sql`region IS NOT NULL`) + .groupBy(claws.region); + + const regionData: Record = {}; + for (const stat of regionStats) { + if (stat.region) { + regionData[stat.region] = stat.count; + console.log(` ${stat.region}: ${stat.count}`); + } + } + await redis.hset(STATS_REGION_KEY, regionData); + console.log(""); + + // 3. 同步活跃 claws(最近 5 分钟有心跳的) + console.log("💓 同步活跃 claws..."); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + + // 随机选择一些 claws 作为"活跃"状态 + const activeClaws = await db + .select({ id: claws.id }) + .from(claws) + .orderBy(sql`RAND()`) + .limit(Math.floor(totalClaws * 0.3)); // 30% 的虾在线 + + const timestamp = Date.now(); + for (const claw of activeClaws) { + await redis.zadd(ACTIVE_CLAWS_KEY, timestamp, claw.id); + // 设置在线状态 + await redis.set(`claw:online:${claw.id}`, "mock", "EX", 300); + } + console.log(` ✅ 设置 ${activeClaws.length} 只虾为活跃状态\n`); + + // 4. 同步每小时活动统计 + console.log("📈 同步每小时活动统计..."); + const hourlyData: Record = {}; + + // 生成过去 24 小时的活动数据 + for (let i = 0; i < 24; i++) { + const hourDate = new Date(now.getTime() - i * 60 * 60 * 1000); + const hourKey = `${hourDate.getUTCFullYear()}-${String(hourDate.getUTCMonth() + 1).padStart(2, "0")}-${String(hourDate.getUTCDate()).padStart(2, "0")}T${String(hourDate.getUTCHours()).padStart(2, "0")}`; + + // 查询该小时的心跳数 + const hourStart = new Date(hourDate); + hourStart.setMinutes(0, 0, 0); + const hourEnd = new Date(hourStart.getTime() + 60 * 60 * 1000); + + const heartbeatsCount = await db + .select({ count: sql`COUNT(*)` }) + .from(heartbeats) + .where( + and( + gte(heartbeats.timestamp, hourStart), + gte(hourEnd, heartbeats.timestamp) + ) + ); + + hourlyData[hourKey] = heartbeatsCount[0].count || Math.floor(Math.random() * 50); + } + + await redis.hset( + HOURLY_ACTIVITY_KEY, + Object.fromEntries( + Object.entries(hourlyData).map(([k, v]) => [k, String(v)]) + ) + ); + await redis.expire(HOURLY_ACTIVITY_KEY, 48 * 60 * 60); + console.log(` ✅ 已同步 24 小时活动数据\n`); + + console.log("=" .repeat(50)); + console.log("✨ Redis 数据同步完成!\n"); + + } catch (error) { + console.error("❌ 同步失败:", error); + throw error; + } +} + +// 运行 +syncRedisStats() + .then(() => process.exit(0)) + .catch((err) => { + console.error("❌ 错误:", err); + process.exit(1); + });