Files
omniai-server/src/routes/user.js
T
2026-06-08 15:46:28 +08:00

323 lines
12 KiB
JavaScript

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,
};