const { requireAuth, pool, getPeriodStart, buildDailyTrend, clampPositiveInteger, clampNonNegativeInteger, generateOrderNo, } = require("./context"); function registerUserRoutes(router) { // ── User: Recharge & Usage ─────────────────────────────────────────── router.post("/user/recharge", requireAuth, async (req, res) => { const { amountCents, paymentMethod = "wechat" } = req.body; if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "充值金额必须大于0" }); if (req.user.enterpriseId && !req.user.isEnterpriseAdmin) { return res.status(403).json({ error: "企业员工请向管理员申请积分" }); } const orderNo = generateOrderNo(); const enterpriseName = req.user.enterpriseName || null; try { await pool.query( ` INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, user_id, type, amount_cents, payment_method, status) VALUES ($1, $2, $3, $4, 'personal_recharge', $5, $6, 'pending') `, [ orderNo, req.user.enterpriseId || null, enterpriseName, req.user.id, Number(amountCents), paymentMethod, ], ); res.json({ orderNo, amountCents, paymentMethod }); } catch (error) { console.error("[user/recharge] failed", error); res.status(500).json({ error: "创建充值订单失败" }); } }); router.get("/user/usage/summary", requireAuth, async (req, res) => { const { period = "7d" } = req.query; const periodStart = getPeriodStart(period); const whereClause = periodStart ? `AND created_at >= ${periodStart}` : ""; const { rows } = await pool.query( ` SELECT COUNT(*) AS total_calls, COALESCE(SUM(prompt_tokens), 0) AS total_prompt_tokens, COALESCE(SUM(completion_tokens), 0) AS total_completion_tokens, COALESCE(SUM(duration_ms), 0) AS total_duration_ms, ROUND(COALESCE(SUM(cost_estimate), 0)::numeric, 4) AS total_cost, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error_count, MAX(created_at) AS last_active FROM api_call_logs WHERE user_id = $1 ${whereClause} `, [req.user.id], ); const summary = rows[0] || {}; const { rows: generationRows } = await pool.query( ` SELECT COUNT(*) AS total_generation_tasks, SUM(CASE WHEN type = 'image' AND status = 'completed' THEN 1 ELSE 0 END) AS image_used, SUM(CASE WHEN type = 'video' AND status = 'completed' THEN 1 ELSE 0 END) AS video_used, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS generation_failed FROM generation_tasks WHERE user_id = $1 ${whereClause} `, [req.user.id], ); const generationSummary = generationRows[0] || {}; const betaUnlimited = req.user.billingMode === "beta_unlimited" && (!req.user.betaExpiresAt || new Date(req.user.betaExpiresAt).getTime() > Date.now()); res.json({ ...summary, balanceCents: req.user.balanceCents || 0, enterpriseBalanceCents: req.user.enterpriseBalanceCents || 0, billingMode: req.user.billingMode || "credits", betaExpiresAt: req.user.betaExpiresAt || null, betaUnlimited, textUsed: Number(summary.total_prompt_tokens || 0) + Number(summary.total_completion_tokens || 0), imageUsed: Number(generationSummary.image_used || 0), videoUsed: Number(generationSummary.video_used || 0), generationTaskCount: Number(generationSummary.total_generation_tasks || 0), generationFailedCount: Number(generationSummary.generation_failed || 0), }); }); router.get("/user/usage/credits", requireAuth, async (req, res) => { const { rows: txRows } = await pool.query( `SELECT COALESCE(SUM(ABS(amount_cents)) FILTER (WHERE amount_cents < 0), 0) AS total_used_cents FROM transactions WHERE user_id = $1`, [req.user.id], ); const totalUsedCents = Number(txRows[0]?.total_used_cents || 0); const { rows: taskRows } = await pool.query( `SELECT COUNT(*) AS total_tasks, SUM(CASE WHEN type = 'image' AND status = 'completed' THEN 1 ELSE 0 END) AS image_tasks, SUM(CASE WHEN type = 'video' AND status = 'completed' THEN 1 ELSE 0 END) AS video_tasks FROM generation_tasks WHERE user_id = $1`, [req.user.id], ); const taskSummary = taskRows[0] || {}; const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200); const { rows: recordRows } = await pool.query( `SELECT id, type, status, params_json, cost_cents, billing_refunded, created_at, completed_at FROM generation_tasks WHERE user_id = $1 ORDER BY id DESC LIMIT $2`, [req.user.id, recordsLimit], ); const { rows: modelRows } = await pool.query( `SELECT COALESCE(params_json::jsonb->>'requestedModel', params_json::jsonb->>'model', type, 'unknown') AS model, COUNT(*) FILTER (WHERE status = 'completed') AS task_count, COALESCE(SUM( CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents WHEN status = 'completed' AND type = 'image' THEN 2000 WHEN status = 'completed' AND type = 'video' THEN 500 ELSE 0 END ), 0) AS used_cents FROM generation_tasks WHERE user_id = $1 AND status IN ('completed', 'running', 'failed') GROUP BY COALESCE(params_json::jsonb->>'requestedModel', params_json::jsonb->>'model', type, 'unknown') ORDER BY task_count DESC LIMIT 50`, [req.user.id], ); const records = recordRows.map((row) => { let modelName = ""; let prompt = ""; let resolution = null; let costCents = Number(row.cost_cents || 0); let estimatedCents = 0; try { const params = row.params_json ? JSON.parse(row.params_json) : {}; modelName = params.requestedModel || params.model || row.type || ""; prompt = params.prompt || ""; resolution = params.resolution || params.quality || params.ratio || null; if (row.status === "completed") { if (row.type === "image") { estimatedCents = 2000; } else if (row.type === "video") { const dur = params.duration || 5; const res = String(params.resolution || params.quality || "").toUpperCase(); const model = String(params.model || params.requestedModel || "").toLowerCase(); let rate = 1; if (model.includes("happyhorse")) rate = res === "720P" ? 0.72 : 1.28; else if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) rate = res === "720P" ? 0.6 : 1; else if (model.includes("animate-mix") || model.includes("s2v")) rate = res === "720P" ? 0.6 : 1; else if (model.includes("kling")) rate = res === "720P" ? 0.6 : 0.8; estimatedCents = Math.ceil(rate * dur * 100); } } } catch { modelName = row.type || ""; } // Prefer the real charged amount; fall back to fee-rate estimate for // historical tasks created before cost_cents was recorded. if (costCents === 0 && row.status === "completed") costCents = estimatedCents; // Refunded failures cost the user nothing. if (row.billing_refunded === 1) costCents = 0; const durationSeconds = row.completed_at && row.created_at ? Math.round((new Date(row.completed_at).getTime() - new Date(row.created_at).getTime()) / 1000) : null; return { id: String(row.id), userId: req.user.id, username: req.user.username || "", model: modelName, taskType: row.type || "unknown", resolution, durationSeconds, amountCents: costCents, prompt, status: row.status === "completed" ? "completed" : row.status === "failed" ? "failed" : row.status, createdAt: row.created_at, }; }); const { rows: trendRows } = await pool.query( `SELECT to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day, COUNT(*) FILTER (WHERE status = 'completed') AS task_count, COALESCE(SUM( CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents WHEN status = 'completed' AND type = 'image' THEN 2000 WHEN status = 'completed' AND type = 'video' THEN 500 ELSE 0 END ), 0) AS used_cents FROM generation_tasks WHERE user_id = $1 AND created_at >= NOW() - INTERVAL '6 days' GROUP BY day ORDER BY day ASC`, [req.user.id], ); const dailyTrend = buildDailyTrend(trendRows); res.json({ balanceCents: req.user.balanceCents || 0, enterpriseBalanceCents: req.user.enterpriseBalanceCents || 0, totalUsedCents, billingMode: req.user.billingMode || "credits", betaUnlimited: req.user.billingMode === "beta_unlimited" && (!req.user.betaExpiresAt || new Date(req.user.betaExpiresAt).getTime() > Date.now()), betaExpiresAt: req.user.betaExpiresAt || null, totalTasks: Number(taskSummary.total_tasks || 0), imageTasks: Number(taskSummary.image_tasks || 0), videoTasks: Number(taskSummary.video_tasks || 0), members: [], modelBreakdown: modelRows.map((r) => ({ model: r.model || "unknown", usedCents: Number(r.used_cents || 0), taskCount: Number(r.task_count || 0), })), dailyTrend, records, }); }); router.get("/user/usage/details", requireAuth, async (req, res) => { const { period = "7d", limit = 50, offset = 0, date_from, date_to } = req.query; const safeLimit = clampPositiveInteger(limit, 50, 200); const safeOffset = clampNonNegativeInteger(offset, 0, 100000); const periodStart = getPeriodStart(period); const whereClauses = ["user_id = $1"]; const params = [req.user.id]; if (periodStart) whereClauses.push(`created_at >= ${periodStart}`); if (date_from) { whereClauses.push(`created_at >= $${params.length + 1}`); params.push(`${date_from}T00:00:00.000Z`); } if (date_to) { whereClauses.push(`created_at <= $${params.length + 1}`); params.push(`${date_to}T23:59:59.999Z`); } const whereSql = `WHERE ${whereClauses.join(" AND ")}`; const { rows: [countRow], } = await pool.query( ` SELECT COUNT(*) AS total FROM api_call_logs ${whereSql} `, params, ); const { rows } = await pool.query( ` SELECT * FROM api_call_logs ${whereSql} ORDER BY id DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2} `, [...params, safeLimit, safeOffset], ); res.json({ items: rows, total: Number(countRow?.total || 0), limit: safeLimit, offset: safeOffset, }); }); router.get("/user/transactions", requireAuth, async (req, res) => { const { limit = 50 } = req.query; const safeLimit = clampPositiveInteger(limit, 50, 200); const { rows } = await pool.query( ` SELECT * FROM transactions WHERE user_id = $1 ORDER BY id DESC LIMIT $2 `, [req.user.id, safeLimit], ); res.json( rows.map((t) => ({ id: t.id, type: t.type, amountCents: t.amount_cents, balanceAfterCents: t.balance_after_cents, description: t.description, createdAt: t.created_at, })), ); }); } module.exports = { registerUserRoutes, };