323 lines
12 KiB
JavaScript
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 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,
|
|
};
|