feat: 生成脚本

This commit is contained in:
richarjiang
2026-03-16 08:52:44 +08:00
parent 8e9af19c88
commit a230122faf
8 changed files with 1345 additions and 5 deletions

View File

@@ -13,7 +13,12 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate", "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": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -52,6 +57,7 @@
"eslint-config-next": "^15.3.0", "eslint-config-next": "^15.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.0",
"tsx": "^4.21.0",
"typescript": "^5.8.0" "typescript": "^5.8.0"
}, },
"pnpm": { "pnpm": {

24
pnpm-lock.yaml generated
View File

@@ -111,6 +111,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.2.1 version: 4.2.1
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.9.3 version: 5.9.3
@@ -129,7 +132,7 @@ importers:
version: 22.19.15 version: 22.19.15
tsup: tsup:
specifier: ^8.3.0 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: typescript:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.9.3 version: 5.9.3
@@ -3756,6 +3759,11 @@ packages:
typescript: typescript:
optional: true 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: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -6716,12 +6724,13 @@ snapshots:
possible-typed-array-names@1.1.0: {} 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: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
optionalDependencies: optionalDependencies:
jiti: 2.6.1 jiti: 2.6.1
postcss: 8.5.8 postcss: 8.5.8
tsx: 4.21.0
postcss@8.4.31: postcss@8.4.31:
dependencies: dependencies:
@@ -7282,7 +7291,7 @@ snapshots:
tslib@2.8.1: {} 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: dependencies:
bundle-require: 5.1.0(esbuild@0.27.4) bundle-require: 5.1.0(esbuild@0.27.4)
cac: 6.7.14 cac: 6.7.14
@@ -7293,7 +7302,7 @@ snapshots:
fix-dts-default-cjs-exports: 1.0.1 fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1 joycon: 3.1.1
picocolors: 1.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 resolve-from: 5.0.0
rollup: 4.59.0 rollup: 4.59.0
source-map: 0.7.6 source-map: 0.7.6
@@ -7311,6 +7320,13 @@ snapshots:
- tsx - tsx
- yaml - 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: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1

122
scripts/README.md Normal file
View File

@@ -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

102
scripts/clear-mock-data.ts Normal file
View File

@@ -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);
});

View File

@@ -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<string, { name: string; lat: number; lng: number }[]> = {
// 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<string, string> = {
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<T extends { weight: number }>(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<string, string> = {
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<string, number> = {};
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<ActivityLevel, number> = {
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);
});

40
scripts/seed-mock-data.sh Executable file
View File

@@ -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 ""

View File

@@ -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<T>(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);
});

163
scripts/sync-redis-stats.ts Normal file
View File

@@ -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<number>`COUNT(*)` })
.from(claws);
const totalClaws = totalClawsResult[0].count;
// 总任务数
const totalTasksResult = await db
.select({ count: sql<number>`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<number>`COUNT(*)` })
.from(tasks)
.where(gte(tasks.timestamp, todayStart));
const todayTasks = todayTasksResult[0].count;
// 总 token
const totalTokensResult = await db
.select({
input: sql<number>`SUM(input_tokens)`,
output: sql<number>`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<number>`COUNT(*)`,
})
.from(claws)
.where(sql`region IS NOT NULL`)
.groupBy(claws.region);
const regionData: Record<string, string> = {};
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<string, number> = {};
// 生成过去 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<number>`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);
});