#!/usr/bin/env node import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { randomUUID } from 'node:crypto' import { fileURLToPath } from 'node:url' import { createClient } from '@supabase/supabase-js' import dotenv from 'dotenv' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) function loadEnv(envFile) { if (envFile) { const resolved = path.isAbsolute(envFile) ? envFile : path.resolve(process.cwd(), envFile) if (!fs.existsSync(resolved)) { console.warn(`[warn] .env file not found at ${resolved}, skipping explicit load`) } else { dotenv.config({ path: resolved }) return } } const defaultEnv = path.resolve(process.cwd(), '.env') if (fs.existsSync(defaultEnv)) { dotenv.config({ path: defaultEnv }) return } const rootEnv = path.resolve(__dirname, '..', '..', '.env') if (fs.existsSync(rootEnv)) { dotenv.config({ path: rootEnv }) } } function parseArgs(argv) { const args = { help: false, classId: null, trainingId: null, rosterPath: null, studentsInline: null, intervalSeconds: 5, cycles: 12, includeAck: true, dryRun: false, verbose: false, supabaseUrl: null, supabaseKey: null, ingestSource: 'simulator', ingestNote: null, envFile: null } for (let i = 0; i < argv.length; i++) { const token = argv[i] if (!token.startsWith('--')) continue const [rawKey, rawValue] = token.split('=') const key = rawKey.replace(/^--/, '') const nextValue = rawValue !== undefined ? rawValue : argv[i + 1] const consumeNext = rawValue === undefined && nextValue !== undefined && !nextValue.startsWith('--') switch (key) { case 'help': case 'h': args.help = true break case 'class-id': case 'classId': args.classId = consumeNext ? argv[++i] : nextValue break case 'training-id': case 'trainingId': args.trainingId = consumeNext ? argv[++i] : nextValue break case 'roster': case 'roster-path': args.rosterPath = consumeNext ? argv[++i] : nextValue break case 'students': args.studentsInline = consumeNext ? argv[++i] : nextValue break case 'interval': case 'interval-seconds': { const val = consumeNext ? argv[++i] : nextValue args.intervalSeconds = parseFloat(val) break } case 'cycles': case 'rounds': { const val = consumeNext ? argv[++i] : nextValue args.cycles = parseInt(val, 10) break } case 'no-ack': args.includeAck = false break case 'ack': args.includeAck = true break case 'dry-run': args.dryRun = true break case 'verbose': case 'v': args.verbose = true break case 'supabase-url': args.supabaseUrl = consumeNext ? argv[++i] : nextValue break case 'supabase-key': args.supabaseKey = consumeNext ? argv[++i] : nextValue break case 'source': case 'ingest-source': args.ingestSource = consumeNext ? argv[++i] : nextValue break case 'note': case 'ingest-note': args.ingestNote = consumeNext ? argv[++i] : nextValue break case 'env': case 'env-file': args.envFile = consumeNext ? argv[++i] : nextValue break default: console.warn(`[warn] Unknown option ${token}`) break } if (consumeNext && rawValue === undefined) { // already consumed } } if (Number.isNaN(args.intervalSeconds) || args.intervalSeconds <= 0) { args.intervalSeconds = 5 } if (!Number.isInteger(args.cycles) || args.cycles <= 0) { args.cycles = 12 } return args } function printHelp() { const usage = `simulate-training-events Usage: node simulate-training-events.mjs --class-id --roster ./roster.json [options] Options: --class-id 班级 ID(必填) --training-id 训练会话 ID,缺省自动生成 --roster |--students 学生清单,支持 JSON/CSV 文件或内联 "student:device" 列表 --interval 指标上报间隔(秒),默认 5 --cycles 指标上报轮数,默认 12 (约 1 分钟) --no-ack 跳过应答事件 --dry-run 仅打印,不写入数据库 --verbose 输出更详细的日志 --supabase-url 覆盖环境变量中的 Supabase URL --supabase-key 覆盖环境变量中的 Supabase Key(建议使用 service role key) --source