Initial commit: OmniAI backend server
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
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 20
|
||||
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 = 20;
|
||||
} 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 20
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user