Initial commit: OmniAI backend server

This commit is contained in:
stringadmin
2026-06-02 13:14:10 +08:00
commit 56955e32f7
73 changed files with 25834 additions and 0 deletions
+728
View File
@@ -0,0 +1,728 @@
const {
bcrypt,
requireAuth,
requireAdmin,
requireEnterpriseAdmin,
listModelPrices,
loadPriceCache,
creditUserBalance,
pool,
validateUsername,
validatePassword,
validateEnterpriseName,
ensureEnterpriseExists,
formatUserRow,
readModelPricePayload,
getModelPriceById,
} = require("./context");
function registerAdminRoutes(router) {
// ── Admin: Users ─────────────────────────────────────────────────────
router.get("/admin/users", requireAuth, requireAdmin, async (_req, res) => {
const { rows } = await pool.query(`
SELECT
u.id, u.username, u.role, u.max_concurrency, u.enabled,
u.avatar_url, u.enterprise_id, u.is_enterprise_admin, u.balance_cents,
u.billing_mode, u.beta_expires_at, u.created_at,
e.name AS enterprise_name
FROM users u
LEFT JOIN enterprises e ON e.id = u.enterprise_id
ORDER BY u.id
`);
res.json(rows.map(formatUserRow));
});
router.post("/admin/users", requireAuth, requireAdmin, async (req, res) => {
const {
username,
password,
role = "user",
maxConcurrency = 30,
enterpriseId = null,
isEnterpriseAdmin = false,
} = req.body;
const usernameError = validateUsername(username);
if (usernameError) return res.status(400).json({ error: usernameError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
if (enterpriseId != null && !(await ensureEnterpriseExists(enterpriseId))) {
return res.status(400).json({ error: "企业不存在" });
}
try {
const hash = await bcrypt.hash(password, 10);
const {
rows: [row],
} = await pool.query(
`
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, $3, $4, $5, $6, 0)
RETURNING id
`,
[
username,
hash,
role,
Number(maxConcurrency) || 30,
enterpriseId,
isEnterpriseAdmin ? 1 : 0,
],
);
const {
rows: [userRow],
} = await pool.query(
`
SELECT u.*, e.name AS enterprise_name
FROM users u LEFT JOIN enterprises e ON e.id = u.enterprise_id
WHERE u.id = $1
`,
[row.id],
);
res.json(formatUserRow(userRow));
} catch (error) {
console.error("[admin/users:create] failed", error);
res.status(409).json({ error: "用户名已存在" });
}
});
router.put("/admin/users/:id", requireAuth, requireAdmin, async (req, res) => {
const {
enabled,
role,
maxConcurrency,
password,
enterpriseId,
isEnterpriseAdmin,
billingMode,
betaExpiresAt,
} = req.body;
const updates = [];
const params = [];
let idx = 1;
if (enabled !== undefined) {
updates.push(`enabled = $${idx++}`);
params.push(enabled ? 1 : 0);
}
if (role) {
updates.push(`role = $${idx++}`);
params.push(role);
}
if (maxConcurrency !== undefined) {
updates.push(`max_concurrency = $${idx++}`);
params.push(Number(maxConcurrency) || 30);
}
if (password) {
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
updates.push(`password_hash = $${idx++}`);
params.push(await bcrypt.hash(password, 10));
}
if (enterpriseId !== undefined) {
if (enterpriseId != null && !(await ensureEnterpriseExists(enterpriseId)))
return res.status(400).json({ error: "企业不存在" });
updates.push(`enterprise_id = $${idx++}`);
params.push(enterpriseId);
}
if (isEnterpriseAdmin !== undefined) {
updates.push(`is_enterprise_admin = $${idx++}`);
params.push(isEnterpriseAdmin ? 1 : 0);
}
if (billingMode !== undefined) {
const mode = String(billingMode || "credits").trim();
if (!["credits", "beta_unlimited"].includes(mode)) {
return res.status(400).json({ error: "billingMode 无效" });
}
updates.push(`billing_mode = $${idx++}`);
params.push(mode);
}
if (betaExpiresAt !== undefined) {
updates.push(`beta_expires_at = $${idx++}`);
params.push(betaExpiresAt || null);
}
if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" });
updates.push(`updated_at = NOW()`);
params.push(req.params.id);
await pool.query(`UPDATE users SET ${updates.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true });
});
router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => {
const targetUserId = Number(req.params.id);
const { amountCents } = req.body;
if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" });
try {
const newBalance = await creditUserBalance(
targetUserId,
amountCents,
`管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`,
);
res.json({ success: true, newBalanceCents: newBalance });
} catch (err) {
res.status(400).json({ error: err.message || "发放积分失败" });
}
});
// ── Admin: Sub-accounts (enterprise admin) ───────────────────────────
router.get("/admin/sub-accounts", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { rows } = await pool.query(
`
SELECT u.id, u.username, u.role, u.max_concurrency, u.enabled,
u.avatar_url, u.enterprise_id, u.is_enterprise_admin, u.balance_cents,
u.billing_mode, u.beta_expires_at, u.created_at,
e.name AS enterprise_name
FROM users u LEFT JOIN enterprises e ON e.id = u.enterprise_id
WHERE u.enterprise_id = $1
ORDER BY u.is_enterprise_admin DESC, u.id ASC
`,
[req.user.enterpriseId],
);
res.json(rows.map(formatUserRow));
});
router.post("/admin/sub-accounts", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { username, password, maxConcurrency = 30 } = req.body;
const usernameError = validateUsername(username);
if (usernameError) return res.status(400).json({ error: usernameError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
try {
const hash = await bcrypt.hash(password, 10);
const {
rows: [row],
} = await pool.query(
`
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, 'user', $3, $4, 0, 0)
RETURNING id
`,
[username, hash, Number(maxConcurrency) || 30, req.user.enterpriseId],
);
const {
rows: [userRow],
} = await pool.query(
`
SELECT u.id, u.username, u.role, u.max_concurrency, u.enabled,
u.avatar_url, u.enterprise_id, u.is_enterprise_admin, u.balance_cents,
u.billing_mode, u.beta_expires_at, u.created_at,
e.name AS enterprise_name
FROM users u LEFT JOIN enterprises e ON e.id = u.enterprise_id
WHERE u.id = $1
`,
[row.id],
);
res.json(formatUserRow(userRow));
} catch (error) {
console.error("[admin/sub-accounts:create] failed", error);
res.status(409).json({ error: "用户名已存在" });
}
});
router.put("/admin/sub-accounts/:id", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const {
rows: [target],
} = await pool.query("SELECT id, enterprise_id, is_enterprise_admin FROM users WHERE id = $1", [
req.params.id,
]);
if (!target || Number(target.enterprise_id || 0) !== Number(req.user.enterpriseId || 0)) {
return res.status(404).json({ error: "子账号不存在" });
}
const { enabled, maxConcurrency, password } = req.body;
const updates = [];
const params = [];
let idx = 1;
if (enabled !== undefined) {
if (Number(target.id) === Number(req.user.id) && !enabled)
return res.status(400).json({ error: "不能禁用当前企业管理员账号" });
updates.push(`enabled = $${idx++}`);
params.push(enabled ? 1 : 0);
}
if (maxConcurrency !== undefined) {
updates.push(`max_concurrency = $${idx++}`);
params.push(Number(maxConcurrency) || 30);
}
if (password) {
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
updates.push(`password_hash = $${idx++}`);
params.push(await bcrypt.hash(password, 10));
}
if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" });
updates.push("updated_at = NOW()");
params.push(req.params.id);
await pool.query(`UPDATE users SET ${updates.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true });
});
// ── Admin: Provider Health ────────────────────────────────────────────
router.get('/admin/providers/status', requireAuth, requireAdmin, async (_req, res) => {
const { getProviderHealthCache } = require('../providerHealthMonitor');
const { pool } = require('./context');
const health = getProviderHealthCache();
const { rows: callStats } = await pool.query(
'SELECT provider, model, status, COUNT(*) AS count, AVG(duration_ms) AS avg_ms, SUM(cost_estimate) AS total_cost FROM api_call_logs WHERE created_at > NOW() - INTERVAL \$1 GROUP BY provider, model, status ORDER BY provider, model',
['1 hour']
);
const { rows: keyStats } = await pool.query(
'SELECT provider, COUNT(*) AS total_keys, SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS active_keys, SUM(active_count) AS current_load FROM api_keys GROUP BY provider ORDER BY provider'
);
res.json({ health, callStats, keyStats, checkedAt: new Date().toISOString() });
});
// ── Admin: Prices ────────────────────────────────────────────────────
router.get("/admin/prices", requireAuth, requireAdmin, async (_req, res) => {
const prices = await listModelPrices();
res.json(prices);
});
router.post("/admin/prices", requireAuth, requireAdmin, async (req, res) => {
const payload = readModelPricePayload(req.body);
if (payload.error) return res.status(400).json({ error: payload.error });
try {
const {
rows: [row],
} = await pool.query(
`
INSERT INTO model_prices (model_key, display_name, category, pricing_type, input_price_mills, output_price_mills, flat_price_mills, currency, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`,
[
payload.value.modelKey,
payload.value.displayName,
payload.value.category,
payload.value.pricingType,
payload.value.inputPriceMills,
payload.value.outputPriceMills,
payload.value.flatPriceMills,
payload.value.currency,
payload.value.enabled ? 1 : 0,
],
);
await loadPriceCache();
res.json(await getModelPriceById(row.id));
} catch (error) {
console.error("[admin/prices:create] failed", error);
res.status(409).json({ error: "模型标识已存在" });
}
});
router.put("/admin/prices/:id", requireAuth, requireAdmin, async (req, res) => {
const existing = await getModelPriceById(req.params.id);
if (!existing) return res.status(404).json({ error: "定价不存在" });
const payload = readModelPricePayload(req.body, existing);
if (payload.error) return res.status(400).json({ error: payload.error });
try {
await pool.query(
`
UPDATE model_prices SET model_key = $1, display_name = $2, category = $3, pricing_type = $4,
input_price_mills = $5, output_price_mills = $6, flat_price_mills = $7, currency = $8, enabled = $9
WHERE id = $10
`,
[
payload.value.modelKey,
payload.value.displayName,
payload.value.category,
payload.value.pricingType,
payload.value.inputPriceMills,
payload.value.outputPriceMills,
payload.value.flatPriceMills,
payload.value.currency,
payload.value.enabled ? 1 : 0,
req.params.id,
],
);
await loadPriceCache();
res.json(await getModelPriceById(req.params.id));
} catch (error) {
console.error("[admin/prices:update] failed", error);
res.status(409).json({ error: "模型标识已存在" });
}
});
// ── Admin: Keys ──────────────────────────────────────────────────────
router.get("/admin/keys", requireAuth, requireAdmin, async (_req, res) => {
const { rows } = await pool.query(
"SELECT id, provider, label, max_concurrency, active_count, total_used, enabled, created_at FROM api_keys ORDER BY provider, id",
);
res.json(rows);
});
router.post("/admin/keys", requireAuth, requireAdmin, async (req, res) => {
const { provider, api_key, label = "", max_concurrency = 10 } = req.body;
if (!provider || !api_key) return res.status(400).json({ error: "缺少 provider 或 api_key" });
const {
rows: [row],
} = await pool.query(
"INSERT INTO api_keys (provider, api_key, label, max_concurrency) VALUES ($1, $2, $3, $4) RETURNING id",
[provider, api_key, label, Number(max_concurrency) || 10],
);
res.json({ id: row.id, provider, label });
});
router.put("/admin/keys/:id", requireAuth, requireAdmin, async (req, res) => {
const { enabled, label, max_concurrency, api_key } = req.body;
const updates = [];
const params = [];
let idx = 1;
if (enabled !== undefined) {
updates.push(`enabled = $${idx++}`);
params.push(enabled ? 1 : 0);
}
if (label !== undefined) {
updates.push(`label = $${idx++}`);
params.push(label);
}
if (max_concurrency !== undefined) {
updates.push(`max_concurrency = $${idx++}`);
params.push(Number(max_concurrency) || 10);
}
if (api_key) {
updates.push(`api_key = $${idx++}`);
params.push(api_key);
}
if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" });
params.push(req.params.id);
await pool.query(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true });
});
router.delete("/admin/keys/:id", requireAuth, requireAdmin, async (req, res) => {
await pool.query("DELETE FROM api_keys WHERE id = $1", [req.params.id]);
res.json({ success: true });
});
// ── Admin: Enterprises ───────────────────────────────────────────────
router.get("/admin/enterprises", requireAuth, requireAdmin, async (_req, res) => {
const { rows } = await pool.query(`
SELECT e.*, COUNT(u.id) AS user_count
FROM enterprises e
LEFT JOIN users u ON u.enterprise_id = e.id AND u.enabled = 1
GROUP BY e.id
ORDER BY e.id
`);
res.json(
rows.map((row) => ({
id: Number(row.id),
name: row.name,
contactName: row.contact_name,
contactPhone: row.contact_phone,
taxId: row.tax_id,
legalPersonName: row.legal_person_name,
legalPersonPhone: row.legal_person_phone,
enterpriseCode: row.enterprise_code,
balanceCents: row.balance_cents,
enabled: !!row.enabled,
userCount: Number(row.user_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
);
});
router.get("/admin/enterprises/:id", requireAuth, requireAdmin, async (req, res) => {
const {
rows: [row],
} = await pool.query("SELECT * FROM enterprises WHERE id = $1", [req.params.id]);
if (!row) return res.status(404).json({ error: "企业不存在" });
res.json({
id: Number(row.id),
name: row.name,
contactName: row.contact_name,
contactPhone: row.contact_phone,
taxId: row.tax_id,
legalPersonName: row.legal_person_name,
legalPersonPhone: row.legal_person_phone,
enterpriseCode: row.enterprise_code,
balanceCents: row.balance_cents,
enabled: !!row.enabled,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
});
router.put("/admin/enterprises/:id", requireAuth, requireAdmin, async (req, res) => {
const { name, contactName, contactPhone, taxId, legalPersonName, legalPersonPhone, enabled } =
req.body;
const updates = [];
const params = [];
let idx = 1;
if (name !== undefined) {
const enterpriseError = validateEnterpriseName(name);
if (enterpriseError) return res.status(400).json({ error: enterpriseError });
updates.push(`name = $${idx++}`);
params.push(name.trim());
}
if (contactName !== undefined) {
updates.push(`contact_name = $${idx++}`);
params.push(String(contactName || "").trim());
}
if (contactPhone !== undefined) {
updates.push(`contact_phone = $${idx++}`);
params.push(String(contactPhone || "").trim());
}
if (taxId !== undefined) {
updates.push(`tax_id = $${idx++}`);
params.push(String(taxId || "").trim() || null);
}
if (legalPersonName !== undefined) {
updates.push(`legal_person_name = $${idx++}`);
params.push(String(legalPersonName || "").trim() || null);
}
if (legalPersonPhone !== undefined) {
updates.push(`legal_person_phone = $${idx++}`);
params.push(String(legalPersonPhone || "").trim() || null);
}
if (enabled !== undefined) {
updates.push(`enabled = $${idx++}`);
params.push(enabled ? 1 : 0);
}
if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" });
updates.push("updated_at = NOW()");
params.push(req.params.id);
await pool.query(`UPDATE enterprises SET ${updates.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true });
});
// ── Admin: Packages ──────────────────────────────────────────────────
router.get("/admin/packages", requireAuth, requireAdmin, async (_req, res) => {
const { rows } = await pool.query("SELECT * FROM packages ORDER BY sort_order, id");
res.json(
rows.map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description,
priceCents: row.price_cents,
creditsCents: row.credits_cents,
imageQuota: row.image_quota,
videoQuota: row.video_quota,
textQuota: row.text_quota,
durationDays: row.duration_days,
enabled: !!row.enabled,
sortOrder: row.sort_order,
createdAt: row.created_at,
})),
);
});
router.post("/admin/packages", requireAuth, requireAdmin, async (req, res) => {
const {
name,
description = "",
priceCents,
creditsCents = 0,
imageQuota = 0,
videoQuota = 0,
textQuota = 0,
durationDays = 365,
enabled = true,
sortOrder = 0,
} = req.body;
if (!name) return res.status(400).json({ error: "缺少套餐名称" });
if (priceCents == null || priceCents <= 0)
return res.status(400).json({ error: "售价必须大于0" });
try {
const {
rows: [row],
} = await pool.query(
`
INSERT INTO packages (name, description, price_cents, credits_cents, image_quota, video_quota, text_quota, duration_days, enabled, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`,
[
name,
description,
Number(priceCents),
Number(creditsCents || 0),
Number(imageQuota || 0),
Number(videoQuota || 0),
Number(textQuota || 0),
Number(durationDays || 365),
enabled ? 1 : 0,
Number(sortOrder || 0),
],
);
res.json({ id: row.id, success: true });
} catch (error) {
console.error("[admin/packages:create] failed", error);
res.status(500).json({ error: "创建套餐失败" });
}
});
router.put("/admin/packages/:id", requireAuth, requireAdmin, async (req, res) => {
const {
rows: [pkg],
} = await pool.query("SELECT * FROM packages WHERE id = $1", [req.params.id]);
if (!pkg) return res.status(404).json({ error: "套餐不存在" });
const {
name,
description,
priceCents,
creditsCents,
imageQuota,
videoQuota,
textQuota,
durationDays,
enabled,
sortOrder,
} = req.body;
const updates = [];
const params = [];
let idx = 1;
if (name !== undefined) {
updates.push(`name = $${idx++}`);
params.push(name);
}
if (description !== undefined) {
updates.push(`description = $${idx++}`);
params.push(description);
}
if (priceCents !== undefined) {
updates.push(`price_cents = $${idx++}`);
params.push(Number(priceCents));
}
if (creditsCents !== undefined) {
updates.push(`credits_cents = $${idx++}`);
params.push(Number(creditsCents));
}
if (imageQuota !== undefined) {
updates.push(`image_quota = $${idx++}`);
params.push(Number(imageQuota));
}
if (videoQuota !== undefined) {
updates.push(`video_quota = $${idx++}`);
params.push(Number(videoQuota));
}
if (textQuota !== undefined) {
updates.push(`text_quota = $${idx++}`);
params.push(Number(textQuota));
}
if (durationDays !== undefined) {
updates.push(`duration_days = $${idx++}`);
params.push(Number(durationDays));
}
if (enabled !== undefined) {
updates.push(`enabled = $${idx++}`);
params.push(enabled ? 1 : 0);
}
if (sortOrder !== undefined) {
updates.push(`sort_order = $${idx++}`);
params.push(Number(sortOrder));
}
if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" });
params.push(req.params.id);
await pool.query(`UPDATE packages SET ${updates.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true });
});
router.delete("/admin/packages/:id", requireAuth, requireAdmin, async (req, res) => {
await pool.query("UPDATE packages SET enabled = 0 WHERE id = $1", [req.params.id]);
res.json({ success: true });
});
}
function registerAdminInvoiceRoutes(router) {
// ── Admin: Invoices ──────────────────────────────────────────────────
router.get("/admin/invoices", requireAuth, requireAdmin, async (_req, res) => {
const { rows } = await pool.query(`
SELECT i.*, e.name AS enterprise_name
FROM invoices i LEFT JOIN enterprises e ON e.id = i.enterprise_id
ORDER BY i.id DESC
`);
res.json(
rows.map((row) => ({
id: Number(row.id),
enterpriseId: Number(row.enterprise_id),
enterpriseName: row.enterprise_name,
type: row.type,
title: row.title,
taxNo: row.tax_no,
amountCents: row.amount_cents,
status: row.status,
invoiceNo: row.invoice_no,
invoiceUrl: row.invoice_url,
issuedAt: row.issued_at,
createdAt: row.created_at,
})),
);
});
router.put("/admin/invoices/:id", requireAuth, requireAdmin, async (req, res) => {
const { invoiceNo, invoiceUrl, status } = req.body;
const updates = [];
const params = [];
let idx = 1;
if (invoiceNo !== undefined) {
updates.push(`invoice_no = $${idx++}`);
params.push(invoiceNo);
}
if (invoiceUrl !== undefined) {
updates.push(`invoice_url = $${idx++}`);
params.push(invoiceUrl);
}
if (status) {
updates.push(`status = $${idx++}`);
params.push(status);
}
if (status === "issued") {
updates.push("issued_at = NOW()");
}
if (updates.length === 0) return res.status(400).json({ error: "没有可更新内容" });
params.push(req.params.id);
await pool.query(`UPDATE invoices SET ${updates.join(", ")} WHERE id = $${idx}`, params);
res.json({ success: true });
});
}
module.exports = {
registerAdminRoutes,
registerAdminInvoiceRoutes,
};
+2276
View File
File diff suppressed because it is too large Load Diff
+1956
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
"use strict";
const { requireAuth, pool } = require("./context");
const ASSET_TYPES = new Set(["character", "scene", "prop", "video", "image", "asset", "other"]);
const ASSET_STATUSES = new Set(["ready", "draft", "reviewing", "pending", "failed"]);
function cleanText(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function safeJsonString(value, fallback) {
if (value === undefined) return JSON.stringify(fallback);
try {
return JSON.stringify(value ?? fallback);
} catch {
return JSON.stringify(fallback);
}
}
function parseJson(value, fallback) {
if (!value || typeof value !== "string") return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function normalizeTags(value) {
return Array.isArray(value)
? value.map((item) => cleanText(item, 40)).filter(Boolean).slice(0, 20)
: [];
}
function normalizeAssetPayload(body = {}, partial = false) {
const name = cleanText(body.name ?? body.title, 200);
if (!partial && !name) return { error: "Missing asset name" };
const type = cleanText(body.type ?? body.assetType, 32);
const status = cleanText(body.status, 32);
const sourceTaskId = Number(body.sourceTaskId ?? body.source_task_id);
return {
value: {
name: name || undefined,
type: ASSET_TYPES.has(type) ? type : partial ? undefined : "asset",
description: cleanText(body.description, 5000) || null,
url: cleanText(body.url ?? body.imageUrl, 1000) || null,
ossKey: cleanText(body.ossKey ?? body.oss_key, 500) || null,
tagsJson: safeJsonString(normalizeTags(body.tags), []),
status: ASSET_STATUSES.has(status) ? status : partial ? undefined : "ready",
sourceTaskId: Number.isFinite(sourceTaskId) ? sourceTaskId : null,
sourceProjectId: cleanText(body.sourceProjectId ?? body.projectId ?? body.source_project_id, 64) || null,
metadataJson: safeJsonString(body.metadata, {}),
},
};
}
function formatAsset(row) {
return {
id: Number(row.id),
type: row.type,
name: row.name,
description: row.description || "",
url: row.url || null,
ossKey: row.oss_key || null,
imageUrl: row.url || "",
tags: parseJson(row.tags_json, []),
status: row.status,
sourceTaskId: row.source_task_id == null ? null : String(row.source_task_id),
sourceProjectId: row.source_project_id || null,
metadata: parseJson(row.metadata_json, {}),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function registerAssetRoutes(router) {
router.get("/assets", requireAuth, async (req, res) => {
try {
const type = cleanText(req.query.type, 32);
const status = cleanText(req.query.status, 32);
const q = cleanText(req.query.q, 120).toLowerCase();
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200);
const params = [req.user.id];
const where = ["user_id = $1"];
if (ASSET_TYPES.has(type)) {
params.push(type);
where.push(`type = $${params.length}`);
}
if (ASSET_STATUSES.has(status)) {
params.push(status);
where.push(`status = $${params.length}`);
}
if (q) {
params.push(`%${q}%`);
where.push(`(LOWER(name) LIKE $${params.length} OR LOWER(COALESCE(description, '')) LIKE $${params.length} OR LOWER(tags_json) LIKE $${params.length})`);
}
params.push(limit);
const { rows } = await pool.query(
`
SELECT *
FROM web_assets
WHERE ${where.join(" AND ")}
ORDER BY updated_at DESC
LIMIT $${params.length}
`,
params,
);
res.json({ assets: rows.map(formatAsset) });
} catch (err) {
console.error("[assets] list failed:", err.message);
res.status(500).json({ error: "Failed to load assets" });
}
});
router.post("/assets", requireAuth, async (req, res) => {
const payload = normalizeAssetPayload(req.body || {});
if (payload.error) return res.status(400).json({ error: payload.error });
try {
const asset = payload.value;
const { rows } = await pool.query(
`
INSERT INTO web_assets (
user_id, type, name, description, url, oss_key, tags_json,
status, source_task_id, source_project_id, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`,
[
req.user.id,
asset.type,
asset.name,
asset.description,
asset.url,
asset.ossKey,
asset.tagsJson,
asset.status,
asset.sourceTaskId,
asset.sourceProjectId,
asset.metadataJson,
],
);
res.status(201).json({ asset: formatAsset(rows[0]) });
} catch (err) {
console.error("[assets] create failed:", err.message);
res.status(500).json({ error: "Failed to create asset" });
}
});
router.patch("/assets/:id", requireAuth, async (req, res) => {
const payload = normalizeAssetPayload(req.body || {}, true);
if (payload.error) return res.status(400).json({ error: payload.error });
const fieldMap = {
name: "name",
type: "type",
description: "description",
url: "url",
ossKey: "oss_key",
tagsJson: "tags_json",
status: "status",
sourceTaskId: "source_task_id",
sourceProjectId: "source_project_id",
metadataJson: "metadata_json",
};
const values = [];
const updates = [];
Object.entries(payload.value).forEach(([key, value]) => {
if (value === undefined) return;
values.push(value);
updates.push(`${fieldMap[key]} = $${values.length}`);
});
if (!updates.length) return res.status(400).json({ error: "No asset fields to update" });
values.push(req.params.id, req.user.id);
try {
const { rows } = await pool.query(
`
UPDATE web_assets
SET ${updates.join(", ")}, updated_at = NOW()
WHERE id = $${values.length - 1} AND user_id = $${values.length}
RETURNING *
`,
values,
);
if (!rows[0]) return res.status(404).json({ error: "Asset not found" });
res.json({ asset: formatAsset(rows[0]) });
} catch (err) {
console.error("[assets] update failed:", err.message);
res.status(500).json({ error: "Failed to update asset" });
}
});
router.delete("/assets/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query("DELETE FROM web_assets WHERE id = $1 AND user_id = $2", [
req.params.id,
req.user.id,
]);
if (!rowCount) return res.status(404).json({ error: "Asset not found" });
res.json({ success: true });
} catch (err) {
console.error("[assets] delete failed:", err.message);
res.status(500).json({ error: "Failed to delete asset" });
}
});
}
module.exports = { registerAssetRoutes };
+758
View File
@@ -0,0 +1,758 @@
const {
bcrypt,
requireAuth,
login,
getUserContextById,
clearUserSession,
generateUniqueEnterpriseCode,
crypto,
pool,
withTransaction,
SMS_PURPOSES,
SMS_CODE_TTL_MINUTES,
SMS_CODE_COOLDOWN_SECONDS,
validateUsername,
validatePassword,
normalizePhone,
validatePhone,
normalizeEmail,
validateEmail,
hashSmsCode,
generateSmsCode,
sendSmsCode,
createLoginResultForUserId,
generateUniqueUsername,
consumeSmsCode,
getWechatLoginConfig,
exchangeWechatCode,
findOrCreateWechatUser,
validateEnterpriseName,
buildOssPublicUrl,
normalizeAvatarOssKey,
normalizeProfileMediaUrl,
} = require("./context");
const {
checkBetaInviteCodeForRegistration,
consumeBetaInviteCode,
findEnterpriseBetaAccountByInviteCode,
getBetaInviteCodeFromBody,
} = require("../betaInviteCodes");
async function ensureRegistrationInvite(req, res, client = pool) {
const result = await checkBetaInviteCodeForRegistration(getBetaInviteCodeFromBody(req.body), client);
if (result.ok) return result;
res.status(result.status || 403).json({ error: result.error || "内测码无效或缺失" });
return null;
}
async function ensureBetaInviteCode(req, res, client = pool) {
const result = await ensureRegistrationInvite(req, res, client);
return result ? result.code : null;
}
function createBetaInviteCodeError(result) {
const error = new Error(result.error || "内测码无效或缺失");
error.status = result.status || 403;
return error;
}
async function consumeBetaInviteCodeForUser(client, code, userId) {
const result = await consumeBetaInviteCode(code, userId, client);
if (!result.ok) throw createBetaInviteCodeError(result);
}
function hashRegistrationInviteCode(code) {
const normalized = String(code || "")
.trim()
.replace(/[\s-]/g, "")
.toUpperCase();
return crypto.createHash("sha256").update(normalized).digest("hex");
}
async function resolveEnterpriseBetaRegistrationTarget(invite, client) {
const account =
invite?.account || findEnterpriseBetaAccountByInviteCode(invite?.code || invite);
if (!account) return { enterpriseId: null, isEnterpriseBeta: false };
const { rows } = await client.query(
"SELECT id, enabled FROM enterprises WHERE enterprise_code = $1 LIMIT 1",
[account.enterpriseId],
);
if (rows.length === 0 || !rows[0].enabled) {
const error = new Error("企业内测账号尚未初始化,请先运行服务端数据库初始化");
error.status = 503;
throw error;
}
return {
enterpriseId: rows[0].id,
isEnterpriseBeta: true,
account,
};
}
async function consumeRegistrationInviteForUser(client, invite, userId, enterpriseTarget) {
if (enterpriseTarget && enterpriseTarget.isEnterpriseBeta) {
await client.query(
`
INSERT INTO enterprise_members (enterprise_id, user_id, role)
VALUES ($1, $2, 'employee')
ON CONFLICT (enterprise_id, user_id) DO UPDATE SET role = EXCLUDED.role
`,
[enterpriseTarget.enterpriseId, userId],
);
await client.query(
`
UPDATE enterprise_invites
SET used_at = COALESCE(used_at, NOW())
WHERE enterprise_id = $1 AND code_hash = $2
`,
[enterpriseTarget.enterpriseId, hashRegistrationInviteCode(invite.code)],
);
return;
}
await consumeBetaInviteCodeForUser(client, invite.code, userId);
}
function sendAuthRouteError(res, error, fallback) {
const status = Number.isInteger(error?.status) ? error.status : 500;
if (status >= 400 && status < 500) {
res.status(status).json({ error: error.message || fallback });
return;
}
res.status(500).json({ error: fallback });
}
function registerAuthRoutes(router) {
// ── Auth ─────────────────────────────────────────────────────────────
router.post("/auth/login", async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: "缺少用户名或密码" });
try {
const result = await login(username, password, req.headers["user-agent"]);
if (!result) return res.status(401).json({ error: "用户名或密码错误" });
res.json(result);
} catch (err) {
console.error("[auth/login] failed", err);
res.status(500).json({ error: "登录失败" });
}
});
router.post("/auth/login-email", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const password = String(req.body?.password || "");
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
if (!password) return res.status(400).json({ error: "缺少密码" });
try {
const { rows } = await pool.query(
"SELECT username FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1",
[email],
);
if (rows.length === 0) return res.status(401).json({ error: "邮箱或密码错误" });
const result = await login(rows[0].username, password, req.headers["user-agent"]);
if (!result) return res.status(401).json({ error: "邮箱或密码错误" });
res.json(result);
} catch (err) {
console.error("[auth/login-email] failed", err);
res.status(500).json({ error: "邮箱登录失败" });
}
});
router.post("/auth/register", async (req, res) => {
const { username, password } = req.body;
const usernameError = validateUsername(username);
if (usernameError) return res.status(400).json({ error: usernameError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const registrationInvite = await ensureRegistrationInvite(req, res);
if (!registrationInvite) return;
const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [
username,
]);
if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" });
try {
const hash = await bcrypt.hash(password, 10);
await withTransaction(async (client) => {
const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget(
registrationInvite,
client,
);
const { rows } = await client.query(
`
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
[username, hash, "user", 30, enterpriseTarget.enterpriseId, 0, 0],
);
await consumeRegistrationInviteForUser(client, registrationInvite, rows[0].id, enterpriseTarget);
});
const loginResult = await login(username, password, req.headers["user-agent"]);
res.json(loginResult);
} catch (error) {
console.error("[auth/register] failed", error);
sendAuthRouteError(res, error, "Register failed");
}
});
router.post("/auth/register-email", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const usernameInput = String(req.body?.username || "").trim();
const password = String(req.body?.password || "");
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const registrationInvite = await ensureRegistrationInvite(req, res);
if (!registrationInvite) return;
try {
const { rows: existingEmail } = await pool.query(
"SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1",
[email],
);
if (existingEmail.length > 0) return res.status(409).json({ error: "该邮箱已注册" });
let username = usernameInput;
if (username) {
const usernameError = validateUsername(username);
if (usernameError) return res.status(400).json({ error: usernameError });
const { rows: existingUsername } = await pool.query("SELECT id FROM users WHERE username = $1", [
username,
]);
if (existingUsername.length > 0) return res.status(409).json({ error: "用户名已被注册" });
} else {
username = await generateUniqueUsername(email.split("@")[0], "email");
}
const hash = await bcrypt.hash(password, 10);
const { rows } = await withTransaction(async (client) => {
const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget(
registrationInvite,
client,
);
const insertResult = await client.query(
`
INSERT INTO users (username, password_hash, email, email_verified, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, $3, 0, 'email', 'user', 30, $4, 0, 0)
RETURNING id
`,
[username, hash, email, enterpriseTarget.enterpriseId],
);
await consumeRegistrationInviteForUser(
client,
registrationInvite,
insertResult.rows[0].id,
enterpriseTarget,
);
return insertResult;
});
const loginResult = await createLoginResultForUserId(rows[0].id, req);
res.json(loginResult);
} catch (error) {
console.error("[auth/register-email] failed", error);
sendAuthRouteError(res, error, "Email register failed");
}
});
router.post("/auth/sms/send", async (req, res) => {
const phone = normalizePhone(req.body?.phone);
const purpose = String(req.body?.purpose || "register");
const phoneError = validatePhone(phone);
if (phoneError) return res.status(400).json({ error: phoneError });
if (!SMS_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" });
if (purpose === "register" && !(await ensureBetaInviteCode(req, res))) return;
try {
const { rows: existingUsers } = await pool.query(
"SELECT id FROM users WHERE phone = $1 LIMIT 1",
[phone],
);
if (purpose === "register" && existingUsers.length > 0) {
return res.status(409).json({ error: "该手机号已注册" });
}
if (purpose === "login" && existingUsers.length === 0) {
return res.status(404).json({ error: "该手机号尚未注册" });
}
const { rows: recentCodes } = await pool.query(
`
SELECT created_at
FROM sms_verification_codes
WHERE phone = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval
ORDER BY created_at DESC
LIMIT 1
`,
[phone, purpose, SMS_CODE_COOLDOWN_SECONDS],
);
if (recentCodes.length > 0) {
return res
.status(429)
.json({ error: `验证码发送太频繁,请 ${SMS_CODE_COOLDOWN_SECONDS} 秒后再试` });
}
const code = generateSmsCode();
const codeHash = hashSmsCode(phone, code);
await pool.query(
`
INSERT INTO sms_verification_codes (phone, purpose, code_hash, expires_at)
VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)
`,
[phone, purpose, codeHash, SMS_CODE_TTL_MINUTES],
);
const sendResult = await sendSmsCode(phone, code, purpose);
res.json({
success: true,
provider: sendResult.provider,
ttlSeconds: SMS_CODE_TTL_MINUTES * 60,
cooldownSeconds: SMS_CODE_COOLDOWN_SECONDS,
...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
});
} catch (error) {
console.error("[auth/sms/send] failed", error);
res.status(500).json({ error: "验证码发送失败" });
}
});
router.post("/auth/register-phone", async (req, res) => {
const phone = normalizePhone(req.body?.phone);
const code = String(req.body?.code || "").trim();
const password = String(req.body?.password || "");
const phoneError = validatePhone(phone);
if (phoneError) return res.status(400).json({ error: phoneError });
if (!code) return res.status(400).json({ error: "缺少验证码" });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const registrationInvite = await ensureRegistrationInvite(req, res);
if (!registrationInvite) return;
try {
const { rows: existing } = await pool.query("SELECT id FROM users WHERE phone = $1 LIMIT 1", [
phone,
]);
if (existing.length > 0) return res.status(409).json({ error: "该手机号已注册" });
const verified = await consumeSmsCode(phone, code, "register");
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
const username = await generateUniqueUsername(`u${phone.slice(-4)}`, "phone");
const hash = await bcrypt.hash(password, 10);
const { rows } = await withTransaction(async (client) => {
const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget(
registrationInvite,
client,
);
const insertResult = await client.query(
`
INSERT INTO users (username, password_hash, phone, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, $3, 'phone', 'user', 30, $4, 0, 0)
RETURNING id
`,
[username, hash, phone, enterpriseTarget.enterpriseId],
);
await consumeRegistrationInviteForUser(
client,
registrationInvite,
insertResult.rows[0].id,
enterpriseTarget,
);
return insertResult;
});
const loginResult = await createLoginResultForUserId(rows[0].id, req);
res.json(loginResult);
} catch (error) {
console.error("[auth/register-phone] failed", error);
sendAuthRouteError(res, error, "Phone register failed");
}
});
router.post("/auth/login-phone", async (req, res) => {
const phone = normalizePhone(req.body?.phone);
const code = String(req.body?.code || "").trim();
const phoneError = validatePhone(phone);
if (phoneError) return res.status(400).json({ error: phoneError });
if (!code) return res.status(400).json({ error: "缺少验证码" });
try {
const verified = await consumeSmsCode(phone, code, "login");
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
const { rows } = await pool.query("SELECT id, enabled FROM users WHERE phone = $1 LIMIT 1", [
phone,
]);
if (rows.length === 0) return res.status(404).json({ error: "该手机号尚未注册" });
if (!rows[0].enabled) return res.status(403).json({ error: "账号已禁用" });
const loginResult = await createLoginResultForUserId(rows[0].id, req);
res.json(loginResult);
} catch (error) {
console.error("[auth/login-phone] failed", error);
res.status(500).json({ error: "手机号登录失败" });
}
});
router.get("/auth/wechat/login-url", async (req, res) => {
const { appId, redirectUri } = getWechatLoginConfig();
if (!appId || !redirectUri) {
return res.json({
configured: false,
message: "微信开放平台 AppID 或回调地址未配置",
});
}
const state = String(req.query.state || crypto.randomBytes(8).toString("hex"));
try {
await pool.query(
`
INSERT INTO wechat_login_sessions (state, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '10 minutes')
ON CONFLICT (state) DO UPDATE
SET status = 'pending',
user_id = NULL,
error = NULL,
consumed_at = NULL,
expires_at = NOW() + INTERVAL '10 minutes',
updated_at = NOW()
`,
[state],
);
} catch (error) {
console.error("[auth/wechat/login-url] failed to create session", error);
return res.status(500).json({ error: "微信登录会话创建失败" });
}
const url = new URL("https://open.weixin.qq.com/connect/qrconnect");
url.searchParams.set("appid", appId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "snsapi_login");
url.searchParams.set("state", state);
res.json({
configured: true,
url: `${url.toString()}#wechat_redirect`,
state,
});
});
router.get("/auth/wechat/callback", async (req, res) => {
const code = String(req.query?.code || "").trim();
const state = String(req.query?.state || "").trim();
if (!code || !state) {
return res.status(400).send('<meta charset="utf-8"><h3>微信授权参数缺失</h3>');
}
try {
const { rows: sessions } = await pool.query(
`
SELECT state
FROM wechat_login_sessions
WHERE state = $1 AND expires_at > NOW() AND consumed_at IS NULL
LIMIT 1
`,
[state],
);
if (sessions.length === 0) {
return res
.status(400)
.send('<meta charset="utf-8"><h3>微信登录会话已过期,请回到应用重新扫码</h3>');
}
const wechatUser = await exchangeWechatCode(code);
const userId = await findOrCreateWechatUser(wechatUser);
await pool.query(
`
UPDATE wechat_login_sessions
SET status = 'completed', user_id = $2, error = NULL, updated_at = NOW()
WHERE state = $1
`,
[state, userId],
);
res.send('<meta charset="utf-8"><h3>微信登录成功</h3><p>请回到 OmniAI 应用继续。</p>');
} catch (error) {
console.error("[auth/wechat/callback] failed", error);
await pool
.query(
`
UPDATE wechat_login_sessions
SET status = 'failed', error = $2, updated_at = NOW()
WHERE state = $1
`,
[state, error instanceof Error ? error.message : "微信登录失败"],
)
.catch(() => {});
res.status(500).send('<meta charset="utf-8"><h3>微信登录失败</h3><p>请回到应用重试。</p>');
}
});
router.get("/auth/wechat/session", async (req, res) => {
const state = String(req.query?.state || "").trim();
if (!state) return res.status(400).json({ error: "缺少微信登录 state" });
try {
const { rows } = await pool.query(
`
SELECT state, status, user_id, error, consumed_at, expires_at
FROM wechat_login_sessions
WHERE state = $1
LIMIT 1
`,
[state],
);
const session = rows[0];
if (!session) return res.status(404).json({ status: "missing" });
if (session.consumed_at) return res.status(409).json({ status: "consumed" });
if (new Date(session.expires_at).getTime() < Date.now())
return res.status(410).json({ status: "expired" });
if (session.status === "failed")
return res.status(400).json({ status: "failed", error: session.error || "微信登录失败" });
if (session.status !== "completed") return res.json({ status: "pending" });
const loginResult = await createLoginResultForUserId(session.user_id, req);
if (!loginResult) return res.status(403).json({ status: "failed", error: "账号不可用" });
await pool.query(
"UPDATE wechat_login_sessions SET consumed_at = NOW(), updated_at = NOW() WHERE state = $1",
[state],
);
res.json({ status: "completed", ...loginResult });
} catch (error) {
console.error("[auth/wechat/session] failed", error);
res.status(500).json({ error: "微信登录状态查询失败" });
}
});
router.post("/auth/wechat/login", async (req, res) => {
const code = String(req.body?.code || "").trim();
if (!code) return res.status(400).json({ error: "缺少微信授权 code" });
try {
const wechatUser = await exchangeWechatCode(code);
const userId = await findOrCreateWechatUser(wechatUser);
const loginResult = await createLoginResultForUserId(userId, req);
res.json(loginResult);
} catch (error) {
console.error("[auth/wechat/login] failed", error);
const status = typeof error?.status === "number" ? error.status : 500;
res.status(status).json({ error: "微信登录失败" });
}
});
router.post("/auth/register-enterprise", async (req, res) => {
const {
companyName,
contactName = "",
contactPhone = "",
taxId = "",
legalPersonName = "",
legalPersonPhone = "",
username,
password,
} = req.body;
const enterpriseError = validateEnterpriseName(companyName);
if (enterpriseError) return res.status(400).json({ error: enterpriseError });
const usernameError = validateUsername(username);
if (usernameError) return res.status(400).json({ error: usernameError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const betaInviteCode = await ensureBetaInviteCode(req, res);
if (!betaInviteCode) return;
const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [
username,
]);
if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" });
try {
const enterpriseCode = await generateUniqueEnterpriseCode();
const hash = await bcrypt.hash(password, 10);
await withTransaction(async (client) => {
const eResult = await client.query(
`
INSERT INTO enterprises (name, contact_name, contact_phone, tax_id, legal_person_name, legal_person_phone, enterprise_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
[
companyName.trim(),
String(contactName || "").trim(),
String(contactPhone || "").trim(),
String(taxId || "").trim() || null,
String(legalPersonName || "").trim() || null,
String(legalPersonPhone || "").trim() || null,
enterpriseCode,
],
);
const enterpriseId = eResult.rows[0].id;
const userResult = await client.query(
`
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, 'user', $3, $4, 1, 0)
RETURNING id
`,
[username, hash, 30, enterpriseId],
);
await consumeBetaInviteCodeForUser(client, betaInviteCode, userResult.rows[0].id);
});
const loginResult = await login(username, password, req.headers["user-agent"]);
res.json(loginResult);
} catch (error) {
console.error("[auth/register-enterprise] failed", error);
sendAuthRouteError(res, error, "Enterprise register failed");
}
});
router.post("/auth/register-employee", async (req, res) => {
const { enterpriseCode, username, password } = req.body;
if (!enterpriseCode) return res.status(400).json({ error: "缺少企业ID" });
const usernameError = validateUsername(username);
if (usernameError) return res.status(400).json({ error: usernameError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const betaInviteCode = await ensureBetaInviteCode(req, res);
if (!betaInviteCode) return;
const { rows: entRows } = await pool.query(
"SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1",
[enterpriseCode],
);
if (entRows.length === 0) return res.status(404).json({ error: "企业ID不存在" });
if (!entRows[0].enabled) return res.status(403).json({ error: "该企业已被禁用" });
const enterpriseId = entRows[0].id;
const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [
username,
]);
if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" });
try {
const hash = await bcrypt.hash(password, 10);
await withTransaction(async (client) => {
const { rows } = await client.query(
`
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, 'user', $3, $4, 0, 0)
RETURNING id
`,
[username, hash, 30, enterpriseId],
);
await consumeBetaInviteCodeForUser(client, betaInviteCode, rows[0].id);
});
const loginResult = await login(username, password, req.headers["user-agent"]);
res.json(loginResult);
} catch (error) {
console.error("[auth/register-employee] failed", error);
sendAuthRouteError(res, error, "Employee register failed");
}
});
router.get("/auth/enterprise-lookup", async (req, res) => {
const { code } = req.query;
if (!code) return res.status(400).json({ error: "缺少企业ID" });
const { rows } = await pool.query(
"SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1",
[code],
);
if (rows.length === 0 || !rows[0].enabled) return res.json({ valid: false });
res.json({ valid: true, enterpriseName: rows[0].name });
});
router.get("/auth/me", requireAuth, (req, res) => {
res.json({ user: req.user });
});
router.post("/auth/logout", requireAuth, async (req, res) => {
try {
await clearUserSession(req.user.id, req.auth?.sessionId);
res.json({ success: true });
} catch (error) {
console.error("[auth/logout] failed", error);
res.status(500).json({ error: "退出登录失败" });
}
});
router.put("/auth/profile", requireAuth, async (req, res) => {
try {
const normalizedAvatar = normalizeAvatarOssKey(req.body?.avatarOssKey, req.user.id);
if (normalizedAvatar.error) {
return res.status(400).json({ error: normalizedAvatar.error });
}
const hasAvatarUrl = req.body && Object.prototype.hasOwnProperty.call(req.body, "avatarUrl");
const avatarUrlInput =
normalizedAvatar.value !== undefined
? { value: normalizedAvatar.value ? `${buildOssPublicUrl(normalizedAvatar.value)}?v=${Date.now()}` : null }
: hasAvatarUrl
? normalizeProfileMediaUrl(req.body?.avatarUrl)
: { value: undefined };
if (avatarUrlInput.error) {
return res.status(400).json({ error: avatarUrlInput.error });
}
const backgroundUrlInput = normalizeProfileMediaUrl(req.body?.profileBackgroundUrl);
if (backgroundUrlInput.error) {
return res.status(400).json({ error: backgroundUrlInput.error });
}
const fields = [];
const values = [];
if (avatarUrlInput.value !== undefined) {
values.push(avatarUrlInput.value);
fields.push(`avatar_url = $${values.length}`);
}
if (req.body && Object.prototype.hasOwnProperty.call(req.body, "bio")) {
const bio = String(req.body.bio || "").trim().slice(0, 160) || null;
values.push(bio);
fields.push(`bio = $${values.length}`);
}
if (backgroundUrlInput.value !== undefined) {
values.push(backgroundUrlInput.value);
fields.push(`profile_background_url = $${values.length}`);
}
if (fields.length > 0) {
values.push(req.user.id);
await pool.query(
`UPDATE users SET ${fields.join(", ")}, updated_at = NOW() WHERE id = $${values.length}`,
values,
);
}
const user = await getUserContextById(req.user.id);
res.json({ user });
} catch (err) {
console.error("[auth/profile] update failed", err);
res.status(500).json({ error: "更新个人资料失败" });
}
});
}
module.exports = {
registerAuthRoutes,
};
+685
View File
@@ -0,0 +1,685 @@
const {
requireAuth,
pool,
withTransaction,
clampPositiveInteger,
clampNonNegativeInteger,
normalizeProjectOssKey,
} = require("./context");
const { getUserContextById, verifyToken } = require("../auth");
const CASE_STATUSES = new Set(["pending", "approved", "rejected"]);
const ASSET_TYPES = new Set(["image", "video", "project", "workflow", "asset", "cover", "other"]);
const REACTION_TYPES = new Set(["favorite", "like"]);
const COMMUNITY_REVIEW_ROLES = new Set(["admin", "staff", "reviewer", "moderator"]);
async function optionalAuth(req, _res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
next();
return;
}
try {
const payload = verifyToken(authHeader.slice(7));
const user = await getUserContextById(payload.userId);
if (user?.enabled) req.user = user;
} catch {
// Public community browsing still works with an expired or missing token.
}
next();
}
function requireCommunityReviewer(req, res, next) {
const role = String(req.user?.role || "").toLowerCase();
if (!COMMUNITY_REVIEW_ROLES.has(role)) {
return res.status(403).json({ error: "Community review access required" });
}
next();
}
function safeJsonString(value, fallback) {
if (value === undefined) return JSON.stringify(fallback);
try {
return JSON.stringify(value ?? fallback);
} catch {
return JSON.stringify(fallback);
}
}
function parseJson(value, fallback) {
if (!value || typeof value !== "string") return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function readCasePayload(body = {}) {
const title = String(body.title || "").trim().slice(0, 200);
if (!title) return { error: "Missing title" };
const assets = Array.isArray(body.assets) ? body.assets.slice(0, 100) : [];
return {
value: {
projectId: body.projectId || body.project_id || null,
title,
description: String(body.description || "").trim() || null,
coverUrl: String(body.coverUrl || body.cover_url || "").trim() || null,
tagsJson: safeJsonString(Array.isArray(body.tags) ? body.tags.slice(0, 20) : [], []),
metadataJson: safeJsonString(body.metadata, {}),
assets: assets.map((asset, index) => ({
assetType: ASSET_TYPES.has(String(asset.assetType || asset.asset_type || asset.type || ""))
? String(asset.assetType || asset.asset_type || asset.type)
: "other",
title: String(asset.title || "").trim().slice(0, 200) || null,
url: String(asset.url || "").trim() || null,
ossKey: String(asset.ossKey || asset.oss_key || "").trim() || null,
metadataJson: safeJsonString(asset.metadata, {}),
sortOrder: Number.isFinite(Number(asset.sortOrder ?? asset.sort_order))
? Number(asset.sortOrder ?? asset.sort_order)
: index,
})),
},
};
}
function formatCaseRow(row, assets = [], reactionStats) {
const stats = reactionStats || {};
return {
id: Number(row.id),
userId: Number(row.user_id),
username: row.username || null,
projectId: row.project_id || null,
title: row.title,
description: row.description || null,
coverUrl: row.cover_url || null,
tags: parseJson(row.tags_json, []),
metadata: parseJson(row.metadata_json, {}),
status: row.status,
reviewNote: row.review_note || null,
reviewedBy: row.reviewed_by == null ? null : Number(row.reviewed_by),
reviewedAt: row.reviewed_at || null,
publishedAt: row.published_at || null,
copyCount: Number(row.copy_count || 0),
favoriteCount: Number(stats.favoriteCount ?? row.favorite_count ?? 0),
likeCount: Number(stats.likeCount ?? row.like_count ?? 0),
isFavorited: Boolean(stats.isFavorited ?? row.is_favorited),
isLiked: Boolean(stats.isLiked ?? row.is_liked),
createdAt: row.created_at,
updatedAt: row.updated_at,
assets: assets.map((asset) => ({
id: Number(asset.id),
assetType: asset.asset_type,
title: asset.title || null,
url: asset.url || null,
ossKey: asset.oss_key || null,
metadata: parseJson(asset.metadata_json, {}),
sortOrder: Number(asset.sort_order || 0),
})),
};
}
async function loadCaseReactionStats(caseIds, userId) {
if (!caseIds.length) return new Map();
const { rows } = await pool.query(
`
SELECT case_id, reaction_type, COUNT(*)::int AS count
FROM community_case_reactions
WHERE case_id = ANY($1::int[])
GROUP BY case_id, reaction_type
`,
[caseIds],
);
const stats = new Map();
for (const id of caseIds) {
stats.set(Number(id), {
favoriteCount: 0,
likeCount: 0,
isFavorited: false,
isLiked: false,
});
}
rows.forEach((row) => {
const entry = stats.get(Number(row.case_id));
if (!entry) return;
if (row.reaction_type === "favorite") entry.favoriteCount = Number(row.count || 0);
if (row.reaction_type === "like") entry.likeCount = Number(row.count || 0);
});
if (userId) {
const mine = await pool.query(
`
SELECT case_id, reaction_type
FROM community_case_reactions
WHERE case_id = ANY($1::int[]) AND user_id = $2
`,
[caseIds, userId],
);
mine.rows.forEach((row) => {
const entry = stats.get(Number(row.case_id));
if (!entry) return;
if (row.reaction_type === "favorite") entry.isFavorited = true;
if (row.reaction_type === "like") entry.isLiked = true;
});
}
return stats;
}
async function createUserNotification(client, userId, input) {
await client.query(
`
INSERT INTO web_notifications (
user_id, type, title, description, target_type, target_id, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
userId,
input.type,
input.title,
input.description || null,
input.targetType || null,
input.targetId || null,
safeJsonString(input.metadata, {}),
],
);
}
async function tryCreateUserNotification(client, userId, input, contextLabel) {
try {
await createUserNotification(client, userId, input);
} catch (err) {
console.warn(`[community] notification skipped${contextLabel ? ` for ${contextLabel}` : ""}:`, err.message);
}
}
async function loadCaseAssets(caseIds) {
if (!caseIds.length) return new Map();
const { rows } = await pool.query(
`
SELECT *
FROM community_case_assets
WHERE case_id = ANY($1::int[])
ORDER BY case_id, sort_order, id
`,
[caseIds],
);
const byCase = new Map();
for (const row of rows) {
if (!byCase.has(row.case_id)) byCase.set(row.case_id, []);
byCase.get(row.case_id).push(row);
}
return byCase;
}
async function assertOwnedProject(client, userId, projectId) {
if (!projectId) return;
const { rows } = await client.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [
projectId,
userId,
]);
if (rows.length === 0) {
const error = new Error("Project not found");
error.status = 404;
throw error;
}
}
async function writeCaseAssets(client, caseId, assets) {
for (const asset of assets) {
await client.query(
`
INSERT INTO community_case_assets (
case_id, asset_type, title, url, oss_key, metadata_json, sort_order
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
caseId,
asset.assetType,
asset.title,
asset.url,
asset.ossKey,
asset.metadataJson,
asset.sortOrder,
],
);
}
}
function registerCommunityRoutes(router) {
router.get("/community/cases", optionalAuth, async (req, res) => {
try {
const limit = clampPositiveInteger(req.query.limit, 20, 100);
const offset = clampNonNegativeInteger(req.query.offset, 0, 100000);
const q = String(req.query.q || "").trim().toLowerCase().slice(0, 120);
const category = String(req.query.category || "").trim().toLowerCase().slice(0, 60);
const tag = String(req.query.tag || req.query.tags || "").trim().toLowerCase().slice(0, 60);
const sort = String(req.query.sort || "latest").trim().toLowerCase();
const params = [];
const where = ["cc.status = 'approved'"];
if (q) {
params.push(`%${q}%`);
where.push(`(LOWER(cc.title) LIKE $${params.length} OR LOWER(COALESCE(cc.description, '')) LIKE $${params.length} OR LOWER(cc.tags_json) LIKE $${params.length})`);
}
if (category && category !== "all") {
params.push(`%${category}%`);
where.push(`LOWER(cc.tags_json) LIKE $${params.length}`);
}
if (tag) {
params.push(`%${tag}%`);
where.push(`LOWER(cc.tags_json) LIKE $${params.length}`);
}
const orderBy =
sort === "popular"
? "cc.copy_count DESC, cc.published_at DESC NULLS LAST, cc.updated_at DESC"
: "cc.published_at DESC NULLS LAST, cc.updated_at DESC";
params.push(limit, offset);
const { rows } = await pool.query(
`
SELECT cc.*, u.username
FROM community_cases cc
JOIN users u ON u.id = cc.user_id
WHERE ${where.join(" AND ")}
ORDER BY ${orderBy}
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
);
const assetsByCase = await loadCaseAssets(rows.map((row) => row.id));
const statsByCase = await loadCaseReactionStats(rows.map((row) => row.id), req.user?.id);
res.json({
cases: rows.map((row) =>
formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))),
),
limit,
offset,
});
} catch (err) {
console.error("[community/cases] list failed:", err.message);
res.status(500).json({ error: "Failed to load community cases" });
}
});
router.get("/community/cases/:id", async (req, res) => {
try {
const { rows } = await pool.query(
`
SELECT cc.*, u.username
FROM community_cases cc
JOIN users u ON u.id = cc.user_id
WHERE cc.id = $1
LIMIT 1
`,
[req.params.id],
);
const row = rows[0];
if (!row) return res.status(404).json({ error: "Case not found" });
if (row.status !== "approved") {
return res.status(404).json({ error: "Case not found" });
}
const assetsByCase = await loadCaseAssets([row.id]);
const statsByCase = await loadCaseReactionStats([row.id], null);
res.json({ case: formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))) });
} catch (err) {
console.error("[community/cases] get failed:", err.message);
res.status(500).json({ error: "Failed to load community case" });
}
});
router.post("/community/cases", requireAuth, async (req, res) => {
const payload = readCasePayload(req.body || {});
if (payload.error) return res.status(400).json({ error: payload.error });
try {
const created = await withTransaction(async (client) => {
await assertOwnedProject(client, req.user.id, payload.value.projectId);
const {
rows: [row],
} = await client.query(
`
INSERT INTO community_cases (
user_id, project_id, title, description, cover_url, tags_json, metadata_json, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
RETURNING *
`,
[
req.user.id,
payload.value.projectId,
payload.value.title,
payload.value.description,
payload.value.coverUrl,
payload.value.tagsJson,
payload.value.metadataJson,
],
);
await writeCaseAssets(client, row.id, payload.value.assets);
return row;
});
await tryCreateUserNotification(pool, req.user.id, {
type: "review_pending",
title: "作品已提交审核",
description: `${payload.value.title}」已进入社区审核队列。`,
targetType: "community_case",
targetId: String(created.id),
}, `case ${created.id} submission`);
const assetsByCase = await loadCaseAssets([created.id]);
res.status(201).json({ case: formatCaseRow(created, assetsByCase.get(created.id) || []) });
} catch (err) {
const status = err.status || 500;
console.error("[community/cases] create failed:", err.message);
res.status(status).json({ error: err.message || "Failed to create community case" });
}
});
router.post("/community/cases/:id/copy", requireAuth, async (req, res) => {
try {
const result = await withTransaction(async (client) => {
const {
rows: [caseRow],
} = await client.query(
"SELECT * FROM community_cases WHERE id = $1 AND status = 'approved' FOR UPDATE",
[req.params.id],
);
if (!caseRow) {
const error = new Error("Case not found");
error.status = 404;
throw error;
}
let projectId = req.body?.projectId || null;
const ossKey = req.body?.ossKey || req.body?.oss_key || null;
const name = String(req.body?.name || caseRow.title || "").trim();
let projectRow = null;
if (projectId || ossKey) {
if (!projectId || !ossKey || !name) {
const error = new Error("Missing projectId, name or ossKey");
error.status = 400;
throw error;
}
const normalizedOssKey = normalizeProjectOssKey(ossKey, req.user.id, projectId);
if (normalizedOssKey.error) {
const error = new Error(normalizedOssKey.error);
error.status = 400;
throw error;
}
const {
rows: [createdProject],
} = await client.query(
`
INSERT INTO projects (
id, user_id, name, description, oss_key, thumbnail_url,
storyboard_count, image_count, video_count, file_size,
current_revision, current_fingerprint, updated_by_device_id,
source_case_id, origin_type, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1, $11, $12, $13, 'community_copy', NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
oss_key = EXCLUDED.oss_key,
thumbnail_url = EXCLUDED.thumbnail_url,
storyboard_count = EXCLUDED.storyboard_count,
image_count = EXCLUDED.image_count,
video_count = EXCLUDED.video_count,
file_size = EXCLUDED.file_size,
current_fingerprint = EXCLUDED.current_fingerprint,
updated_by_device_id = EXCLUDED.updated_by_device_id,
source_case_id = EXCLUDED.source_case_id,
origin_type = EXCLUDED.origin_type,
updated_at = NOW()
WHERE projects.user_id = EXCLUDED.user_id
RETURNING
id,
name,
description,
oss_key,
thumbnail_url,
storyboard_count,
image_count,
video_count,
file_size,
current_revision AS revision,
current_fingerprint AS fingerprint,
updated_by_device_id,
source_case_id,
origin_type,
created_at,
updated_at
`,
[
projectId,
req.user.id,
name,
req.body?.description || caseRow.description || null,
normalizedOssKey.value,
req.body?.thumbnailUrl || req.body?.thumbnail_url || caseRow.cover_url || null,
Number(req.body?.storyboardCount ?? req.body?.storyboard_count) || 0,
Number(req.body?.imageCount ?? req.body?.image_count) || 0,
Number(req.body?.videoCount ?? req.body?.video_count) || 0,
Number(req.body?.fileSize ?? req.body?.file_size) || 0,
req.body?.fingerprint || null,
req.body?.deviceId || req.body?.device_id || "web",
caseRow.id,
],
);
projectRow = createdProject || null;
if (!projectRow) {
const error = new Error("Project id is already used by another user");
error.status = 403;
throw error;
}
await client.query(
`
INSERT INTO project_revisions (
project_id, revision_number, oss_key, content_fingerprint, source_device_id, save_reason
)
VALUES ($1, 1, $2, $3, $4, 'community_copy')
ON CONFLICT (project_id, revision_number) DO UPDATE SET
oss_key = EXCLUDED.oss_key,
content_fingerprint = EXCLUDED.content_fingerprint,
source_device_id = EXCLUDED.source_device_id,
save_reason = EXCLUDED.save_reason
`,
[
projectId,
normalizedOssKey.value,
req.body?.fingerprint || null,
req.body?.deviceId || req.body?.device_id || "web",
],
);
}
const {
rows: [copyRow],
} = await client.query(
`
INSERT INTO community_case_copies (case_id, user_id, project_id)
VALUES ($1, $2, $3)
RETURNING *
`,
[caseRow.id, req.user.id, projectId],
);
await client.query(
"UPDATE community_cases SET copy_count = copy_count + 1, updated_at = NOW() WHERE id = $1",
[caseRow.id],
);
return { caseRow, copyRow, projectRow };
});
res.status(201).json({
project: result.projectRow || null,
copy: {
id: Number(result.copyRow.id),
caseId: Number(result.copyRow.case_id),
projectId: result.copyRow.project_id || null,
createdAt: result.copyRow.created_at,
},
});
} catch (err) {
const status = err.status || 500;
console.error("[community/cases] copy failed:", err.message);
res.status(status).json({ error: err.message || "Failed to copy community case" });
}
});
router.post("/community/cases/:id/reactions", requireAuth, async (req, res) => {
const reactionType = String(req.body?.reactionType || req.body?.type || "").trim();
const active = req.body?.active !== false;
if (!REACTION_TYPES.has(reactionType)) {
return res.status(400).json({ error: "Invalid reaction type" });
}
try {
const { rows: caseRows } = await pool.query(
"SELECT id FROM community_cases WHERE id = $1 AND status = 'approved'",
[req.params.id],
);
if (!caseRows[0]) return res.status(404).json({ error: "Case not found" });
if (active) {
await pool.query(
`
INSERT INTO community_case_reactions (case_id, user_id, reaction_type)
VALUES ($1, $2, $3)
ON CONFLICT (case_id, user_id, reaction_type) DO NOTHING
`,
[req.params.id, req.user.id, reactionType],
);
} else {
await pool.query(
"DELETE FROM community_case_reactions WHERE case_id = $1 AND user_id = $2 AND reaction_type = $3",
[req.params.id, req.user.id, reactionType],
);
}
const statsByCase = await loadCaseReactionStats([Number(req.params.id)], req.user.id);
res.json({ stats: statsByCase.get(Number(req.params.id)) });
} catch (err) {
console.error("[community/cases] reaction failed:", err.message);
res.status(500).json({ error: "Failed to update reaction" });
}
});
router.get("/community/me/cases", requireAuth, async (req, res) => {
try {
const { rows } = await pool.query(
`
SELECT cc.*, u.username
FROM community_cases cc
JOIN users u ON u.id = cc.user_id
WHERE cc.user_id = $1
ORDER BY cc.updated_at DESC
`,
[req.user.id],
);
const assetsByCase = await loadCaseAssets(rows.map((row) => row.id));
const statsByCase = await loadCaseReactionStats(rows.map((row) => row.id), req.user.id);
res.json({
cases: rows.map((row) =>
formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))),
),
});
} catch (err) {
console.error("[community/me/cases] list failed:", err.message);
res.status(500).json({ error: "Failed to load my community cases" });
}
});
}
function registerAdminCommunityRoutes(router) {
router.get("/admin/community/cases", requireAuth, requireCommunityReviewer, async (req, res) => {
try {
const status = String(req.query.status || "").trim();
const where = CASE_STATUSES.has(status) ? "WHERE cc.status = $1" : "";
const params = CASE_STATUSES.has(status) ? [status] : [];
const { rows } = await pool.query(
`
SELECT cc.*, u.username
FROM community_cases cc
JOIN users u ON u.id = cc.user_id
${where}
ORDER BY cc.updated_at DESC
LIMIT 200
`,
params,
);
const assetsByCase = await loadCaseAssets(rows.map((row) => row.id));
const statsByCase = await loadCaseReactionStats(rows.map((row) => row.id), null);
res.json({
cases: rows.map((row) =>
formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))),
),
});
} catch (err) {
console.error("[admin/community/cases] list failed:", err.message);
res.status(500).json({ error: "Failed to load community cases" });
}
});
const updateCaseStatus = async (req, res) => {
const status = String(req.body?.status || "").trim();
if (!CASE_STATUSES.has(status)) return res.status(400).json({ error: "Invalid status" });
try {
const {
rows: [row],
} = await pool.query(
`
UPDATE community_cases
SET status = $1::varchar(24),
review_note = $2,
reviewed_by = $3,
reviewed_at = NOW(),
published_at = CASE WHEN $1::varchar(24) = 'approved' THEN COALESCE(published_at, NOW()) ELSE NULL END,
updated_at = NOW()
WHERE id = $4
RETURNING *
`,
[status, req.body?.reviewNote || req.body?.review_note || null, req.user.id, req.params.id],
);
if (!row) return res.status(404).json({ error: "Case not found" });
await tryCreateUserNotification(pool, row.user_id, {
type: status === "approved" ? "review_passed" : status === "rejected" ? "review_rejected" : "review_pending",
title: status === "approved" ? "作品审核通过" : status === "rejected" ? "作品审核未通过" : "作品审核状态更新",
description:
status === "approved"
? `${row.title}」已发布到社区。`
: status === "rejected"
? req.body?.reviewNote || req.body?.review_note || "作品未通过审核,请修改后重新提交。"
: `${row.title}」仍在审核中。`,
targetType: "community_case",
targetId: String(row.id),
}, `case ${row.id} review status`);
const assetsByCase = await loadCaseAssets([row.id]);
const statsByCase = await loadCaseReactionStats([row.id], null);
res.json({ case: formatCaseRow(row, assetsByCase.get(row.id) || [], statsByCase.get(Number(row.id))) });
} catch (err) {
console.error("[admin/community/cases] status failed:", err.message);
res.status(500).json({ error: "Failed to update community case status" });
}
};
router.patch("/admin/community/cases/:id/status", requireAuth, requireCommunityReviewer, updateCaseStatus);
router.put("/admin/community/cases/:id/status", requireAuth, requireCommunityReviewer, updateCaseStatus);
}
module.exports = {
registerCommunityRoutes,
registerAdminCommunityRoutes,
};
+82
View File
@@ -0,0 +1,82 @@
const { requireAuth, requireAdmin, pool } = require("./context");
function registerConfigRoutes(router) {
// ── Config ───────────────────────────────────────────────────────────
router.get("/config/profile", requireAuth, async (req, res) => {
const { name = "default" } = req.query;
const {
rows: [row],
} = await pool.query(
"SELECT config_json, description, updated_at FROM config_profiles WHERE name = $1",
[name],
);
if (!row && name === "web-model-capabilities") {
return res.json({
name,
config: {
imageModels: [],
videoModels: [],
chatModels: [],
},
description: "",
updatedAt: null,
});
}
if (!row) return res.status(404).json({ error: `配置 "${name}" 不存在` });
res.json({
name,
config: JSON.parse(row.config_json),
description: row.description,
updatedAt: row.updated_at,
});
});
router.get("/config/profiles", requireAuth, async (_req, res) => {
const { rows } = await pool.query(
"SELECT name, description, updated_at FROM config_profiles ORDER BY name",
);
res.json(rows);
});
router.put("/config/profile", requireAuth, requireAdmin, async (req, res) => {
const { name = "default", config, description } = req.body;
if (!config || typeof config !== "object")
return res.status(400).json({ error: "缺少 config 对象" });
const {
rows: [existing],
} = await pool.query("SELECT id FROM config_profiles WHERE name = $1", [name]);
if (existing) {
const updates = ["config_json = $1", "updated_at = NOW()", "updated_by = $2"];
const params = [JSON.stringify(config), req.user.id];
let idx = 3;
if (description !== undefined) {
updates.push(`description = $${idx++}`);
params.push(description);
}
params.push(name);
await pool.query(
`UPDATE config_profiles SET ${updates.join(", ")} WHERE name = $${idx}`,
params,
);
} else {
await pool.query(
"INSERT INTO config_profiles (name, config_json, description, updated_by) VALUES ($1, $2, $3, $4)",
[name, JSON.stringify(config), description || "", req.user.id],
);
}
res.json({ success: true, name });
});
router.delete("/config/profile/:name", requireAuth, requireAdmin, async (req, res) => {
await pool.query("DELETE FROM config_profiles WHERE name = $1", [req.params.name]);
res.json({ success: true });
});
}
module.exports = {
registerConfigRoutes,
};
+793
View File
@@ -0,0 +1,793 @@
const express = require("express");
const bcrypt = require("bcryptjs");
const {
requireAuth,
requireAdmin,
requireEnterpriseAdmin,
requireManagementAccess,
login,
generateToken,
startUserSession,
getUserContextById,
isSystemAdmin,
generateUniqueEnterpriseCode,
} = require("../auth");
const keyManager = require("../keyManager");
const {
calculateCost,
calculateCostMills,
listModelPrices,
normalizeModelPriceRow,
getAverageCostCents,
loadPriceCache,
} = require("../pricing");
const {
deductForApiCall,
deductImageGenerationCredits,
creditBalance,
creditUserBalance,
activatePackage,
distributeCredits,
getEnterpriseFinancials,
getUserEnterpriseId,
getEnterpriseName,
preauthorizeCall,
} = require("../billing");
const wechatPay = require("../paymentWechat");
const alipay = require("../paymentAlipay");
const crypto = require("node:crypto");
const { pool, withTransaction } = require("../db");
const {
computeNextRevision,
normalizeRevisionValue,
shouldRejectStaleRevision,
} = require("../projectRevisionLogic");
const { loadBetaInviteCodes } = require("../betaInviteCodes");
const USERNAME_PATTERN = /^[a-zA-Z0-9_\u4e00-\u9fa5]+$/;
const PRICE_CATEGORIES = new Set(["text", "image", "video"]);
const PRICE_TYPES = new Set(["token", "flat"]);
const PHONE_PATTERN = /^\+?[0-9]{6,20}$/;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SMS_PURPOSES = new Set(["register", "login"]);
const SMS_CODE_TTL_MINUTES = Math.max(1, Number(process.env.SMS_CODE_TTL_MINUTES) || 10);
const SMS_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.SMS_CODE_COOLDOWN_SECONDS) || 60);
const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);
function validateUsername(username) {
if (!username) return "缺少用户名";
if (username.length < 2 || username.length > 30) return "用户名长度必须在 2 到 30 之间";
if (!USERNAME_PATTERN.test(username)) return "用户名只能包含字母、数字、下划线或中文";
return null;
}
function validatePassword(password) {
if (!password) return "缺少密码";
if (password.length < 6) return "密码至少 6 位";
return null;
}
function normalizePhone(phone) {
return String(phone || "")
.trim()
.replace(/[\s-]/g, "");
}
function validatePhone(phone) {
const normalized = normalizePhone(phone);
if (!normalized) return "缺少手机号";
if (!PHONE_PATTERN.test(normalized)) return "手机号格式不正确";
return null;
}
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function validateEmail(email) {
const normalized = normalizeEmail(email);
if (!normalized) return "缺少邮箱";
if (normalized.length > 200 || !EMAIL_PATTERN.test(normalized)) return "邮箱格式不正确";
return null;
}
function hashSmsCode(phone, code) {
const secret = process.env.SMS_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-sms-secret";
return crypto.createHash("sha256").update(`${phone}:${code}:${secret}`).digest("hex");
}
function generateSmsCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}
async function sendSmsCode(phone, code, purpose) {
const provider = String(process.env.SMS_PROVIDER || "mock")
.trim()
.toLowerCase();
if (provider === "http") {
const endpoint = process.env.SMS_HTTP_ENDPOINT;
if (!endpoint) throw new Error("SMS_HTTP_ENDPOINT 未配置");
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.SMS_HTTP_TOKEN
? { Authorization: `Bearer ${process.env.SMS_HTTP_TOKEN}` }
: {}),
},
body: JSON.stringify({ phone, code, purpose }),
});
if (!response.ok) {
throw new Error(`短信平台返回 HTTP ${response.status}`);
}
return { provider };
}
console.log(`[sms:${purpose}] ${phone} verification sent (mock provider)`);
return {
provider: "mock",
devCode: process.env.SMS_DEV_RETURN_CODE === "1" ? code : undefined,
};
}
async function createLoginResultForUserId(userId, req) {
const user = await getUserContextById(userId);
if (!user?.enabled) return null;
const userAgent = req?.headers?.["user-agent"] || null;
const sessionId = await startUserSession(user.id, userAgent);
const userWithSession = {
...user,
sessionId,
sessionStartedAt: new Date().toISOString(),
};
return {
token: generateToken(userWithSession, sessionId),
user: userWithSession,
};
}
function sanitizeUsernameSeed(seed, fallback) {
const normalized = String(seed || "")
.trim()
.replace(/[^\w\u4e00-\u9fa5]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const safe = normalized || fallback;
return safe.length > 24 ? safe.slice(0, 24) : safe;
}
async function generateUniqueUsername(seed, fallback) {
const base = sanitizeUsernameSeed(seed, fallback);
for (let attempt = 0; attempt < 10; attempt++) {
const suffix = crypto.randomBytes(3).toString("hex");
const username = `${base}_${suffix}`.slice(0, 30);
const { rows } = await pool.query("SELECT 1 FROM users WHERE username = $1", [username]);
if (rows.length === 0) return username;
}
return `${fallback}_${Date.now().toString(36)}`.slice(0, 30);
}
async function consumeSmsCode(phone, code, purpose) {
const { rows } = await pool.query(
`
SELECT id, code_hash, attempts
FROM sms_verification_codes
WHERE phone = $1
AND purpose = $2
AND consumed_at IS NULL
AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
`,
[phone, purpose],
);
const row = rows[0];
if (!row) return false;
if (Number(row.attempts || 0) >= SMS_CODE_MAX_ATTEMPTS) {
return false;
}
const expectedHash = hashSmsCode(phone, String(code || "").trim());
if (row.code_hash !== expectedHash) {
await pool.query("UPDATE sms_verification_codes SET attempts = attempts + 1 WHERE id = $1", [
row.id,
]);
return false;
}
await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
return true;
}
function getWechatLoginConfig() {
const appId = process.env.WECHAT_LOGIN_APP_ID || process.env.WECHAT_APP_ID || "";
const appSecret = process.env.WECHAT_LOGIN_APP_SECRET || process.env.WECHAT_APP_SECRET || "";
const redirectUri = process.env.WECHAT_LOGIN_REDIRECT_URI || "";
return { appId, appSecret, redirectUri };
}
async function fetchWechatJson(url) {
const response = await fetch(url);
const payload = await response.json();
if (!response.ok || payload.errcode) {
throw new Error(payload.errmsg || `微信接口返回 HTTP ${response.status}`);
}
return payload;
}
async function exchangeWechatCode(code) {
const { appId, appSecret } = getWechatLoginConfig();
if (!appId || !appSecret) {
throw new Error("微信开放平台 AppID/AppSecret 未配置");
}
const tokenUrl = new URL("https://api.weixin.qq.com/sns/oauth2/access_token");
tokenUrl.searchParams.set("appid", appId);
tokenUrl.searchParams.set("secret", appSecret);
tokenUrl.searchParams.set("code", code);
tokenUrl.searchParams.set("grant_type", "authorization_code");
const tokenPayload = await fetchWechatJson(tokenUrl.toString());
const accessToken = tokenPayload.access_token;
const openid = tokenPayload.openid;
if (!accessToken || !openid) {
throw new Error("微信登录未返回 openid");
}
let profile = {};
try {
const userInfoUrl = new URL("https://api.weixin.qq.com/sns/userinfo");
userInfoUrl.searchParams.set("access_token", accessToken);
userInfoUrl.searchParams.set("openid", openid);
userInfoUrl.searchParams.set("lang", "zh_CN");
profile = await fetchWechatJson(userInfoUrl.toString());
} catch (error) {
console.warn(
"[auth/wechat] userinfo failed",
error instanceof Error ? error.message : String(error),
);
}
return {
openid,
unionid: profile.unionid || tokenPayload.unionid || null,
nickname: profile.nickname || null,
};
}
async function findOrCreateWechatUser(wechatUser) {
const { rows: existingRows } = await pool.query(
"SELECT id, enabled FROM users WHERE wechat_openid = $1 LIMIT 1",
[wechatUser.openid],
);
if (existingRows.length > 0) {
if (!existingRows[0].enabled) {
const error = new Error("账号已禁用");
error.status = 403;
throw error;
}
return existingRows[0].id;
}
if (loadBetaInviteCodes().size > 0) {
const error = new Error("内测阶段请先使用内测码注册账号后再使用微信登录");
error.status = 403;
throw error;
}
const username = await generateUniqueUsername(
wechatUser.nickname || `wx${wechatUser.openid.slice(-6)}`,
"wechat",
);
const randomPasswordHash = await bcrypt.hash(crypto.randomBytes(32).toString("hex"), 10);
const { rows } = await pool.query(
`
INSERT INTO users (username, password_hash, wechat_openid, wechat_unionid, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
VALUES ($1, $2, $3, $4, 'wechat', 'user', 30, null, 0, 0)
RETURNING id
`,
[username, randomPasswordHash, wechatUser.openid, wechatUser.unionid],
);
return rows[0].id;
}
function validateEnterpriseName(name) {
if (!name) return "缺少企业名称";
if (name.trim().length < 2 || name.trim().length > 80) return "企业名称长度必须在 2 到 80 之间";
return null;
}
function parseNumericValue(value, fieldLabel, { allowNull = true } = {}) {
if (value === undefined) return { ok: true, value: undefined };
if (value === null || value === "") {
return allowNull ? { ok: true, value: null } : { ok: false, error: `${fieldLabel}不能为空` };
}
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0)
return { ok: false, error: `${fieldLabel}必须是非负数字` };
return { ok: true, value: numeric };
}
async function ensureEnterpriseExists(enterpriseId) {
if (enterpriseId == null) return null;
const { rows } = await pool.query("SELECT id, name FROM enterprises WHERE id = $1", [
enterpriseId,
]);
return rows[0] || null;
}
function formatUserRow(row) {
return {
id: Number(row.id),
username: row.username,
role: row.role,
avatarUrl: row.avatar_url || null,
maxConcurrency: Number(row.max_concurrency || 0),
enabled: !!row.enabled,
enterpriseId: row.enterprise_id == null ? null : Number(row.enterprise_id),
enterpriseName: row.enterprise_name || null,
isEnterpriseAdmin: !!row.is_enterprise_admin,
balanceCents: row.balance_cents != null ? Number(row.balance_cents) : 0,
billingMode: row.billing_mode || "credits",
betaExpiresAt: row.beta_expires_at || null,
createdAt: row.created_at,
};
}
function normalizeOssRegion(region) {
const trimmed = String(region || "").trim();
return trimmed.startsWith("oss-") ? trimmed.slice(4) : trimmed;
}
function buildOssPublicUrl(ossKey) {
const publicBaseUrl = String(process.env.OSS_PUBLIC_BASE_URL || "")
.trim()
.replace(/\/+$/, "");
if (publicBaseUrl) {
return `${publicBaseUrl}/${ossKey}`;
}
const bucket = String(process.env.OSS_BUCKET || "").trim();
const region = normalizeOssRegion(process.env.OSS_REGION || "");
if (!bucket || !region) {
throw new Error("OSS bucket or region is not configured");
}
return `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`;
}
function normalizeAvatarOssKey(value, userId) {
if (value === undefined) return { value: undefined };
if (value === null) return { value: null };
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
const ossKey = String(value || "")
.trim()
.replace(/^\/+/, "");
if (!ossKey) return { value: null };
const expectedPrefix = `users/${safeUserId}/profile/avatar/`;
const allowedPattern = new RegExp(
`^users/${safeUserId}/profile/avatar/avatar\\.(jpg|jpeg|png|webp)$`,
"i",
);
if (!ossKey.startsWith(expectedPrefix) || !allowedPattern.test(ossKey)) {
return { error: "Invalid avatar OSS key" };
}
return { value: ossKey };
}
function normalizeProfileMediaUrl(value) {
if (value === undefined) return { value: undefined };
if (value === null || value === "") return { value: null };
const url = String(value || "").trim();
if (!url) return { value: null };
if (url.length > 2000) return { error: "资料图片地址过长" };
if (url.startsWith("data:")) return { error: "资料图片请先上传到 OSS" };
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { error: "资料图片地址格式不正确" };
}
} catch {
return { error: "资料图片地址格式不正确" };
}
return { value: url };
}
function normalizeProjectOssKey(value, userId, projectId) {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
const safeProjectId = String(projectId || "")
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "");
const ossKey = String(value || "")
.trim()
.replace(/^\/+/, "");
if (!safeUserId || !safeProjectId || safeProjectId !== String(projectId || "").trim()) {
return { error: "Invalid project OSS key scope" };
}
const expectedKey = `users/${safeUserId}/projects/${safeProjectId}/current/project.json`;
if (ossKey !== expectedKey) {
return { error: "Invalid project OSS key scope" };
}
return { value: ossKey };
}
function getManagementEnterpriseId(user) {
if (!user || isSystemAdmin(user)) return null;
return user.enterpriseId || null;
}
function appendEnterpriseScope(whereClauses, params, user, expression, paramIdx) {
const enterpriseId = getManagementEnterpriseId(user);
if (enterpriseId != null) {
whereClauses.push(`${expression} = $${paramIdx}`);
params.push(enterpriseId);
return paramIdx + 1;
}
return paramIdx;
}
function readModelPricePayload(body, existing = null) {
const modelKey = String(body.modelKey ?? existing?.modelKey ?? "").trim();
const displayName = String(body.displayName ?? existing?.displayName ?? "").trim();
const category = String(body.category ?? existing?.category ?? "text").trim();
const pricingType = String(body.pricingType ?? existing?.pricingType ?? "token").trim();
const currency = String(body.currency ?? existing?.currency ?? "CNY").trim() || "CNY";
const enabled = body.enabled === undefined ? (existing?.enabled ?? true) : !!body.enabled;
if (!modelKey) return { error: "缺少模型标识" };
if (!displayName) return { error: "缺少显示名称" };
if (!PRICE_CATEGORIES.has(category)) return { error: "模型分类无效" };
if (!PRICE_TYPES.has(pricingType)) return { error: "计费类型无效" };
const inputPriceMills = parseNumericValue(body.inputPriceMills, "输入价格(厘)");
if (!inputPriceMills.ok) return { error: inputPriceMills.error };
const outputPriceMills = parseNumericValue(body.outputPriceMills, "输出价格(厘)");
if (!outputPriceMills.ok) return { error: outputPriceMills.error };
const flatPriceMills = parseNumericValue(body.flatPriceMills, "固定价格(厘)");
if (!flatPriceMills.ok) return { error: flatPriceMills.error };
const merged = {
modelKey,
displayName,
category,
pricingType,
currency,
enabled,
inputPriceMills:
inputPriceMills.value !== undefined
? inputPriceMills.value
: (existing?.inputPriceMills ?? null),
outputPriceMills:
outputPriceMills.value !== undefined
? outputPriceMills.value
: (existing?.outputPriceMills ?? null),
flatPriceMills:
flatPriceMills.value !== undefined
? flatPriceMills.value
: (existing?.flatPriceMills ?? null),
};
if (pricingType === "token") {
if (merged.inputPriceMills == null || merged.outputPriceMills == null)
return { error: "按 Token 计费时必须提供输入和输出价格(厘)" };
merged.flatPriceMills = null;
} else {
if (merged.flatPriceMills == null) return { error: "固定计费时必须提供固定价格(厘)" };
merged.inputPriceMills = null;
merged.outputPriceMills = null;
}
return { value: merged };
}
async function getModelPriceById(id) {
const { rows } = await pool.query("SELECT * FROM model_prices WHERE id = $1", [id]);
return normalizeModelPriceRow(rows[0]);
}
function getPeriodStart(period) {
switch (period) {
case "7d":
return "NOW() - INTERVAL '7 days'";
case "30d":
return "NOW() - INTERVAL '30 days'";
case "all":
return null;
default:
return "NOW() - INTERVAL '7 days'";
}
}
// Fills a SQL day-aggregation result into a continuous 7-day series ending
// today, padding missing days with zeros so the trend chart has no gaps.
function buildDailyTrend(rows, days = 7) {
const byDay = new Map();
for (const row of rows || []) {
byDay.set(String(row.day), {
usedCents: Number(row.used_cents || 0),
taskCount: Number(row.task_count || 0),
});
}
const series = [];
const today = new Date();
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = d.toISOString().slice(0, 10);
const hit = byDay.get(key) || { usedCents: 0, taskCount: 0 };
series.push({ date: key, usedCents: hit.usedCents, taskCount: hit.taskCount });
}
return series;
}
function clampPositiveInteger(value, fallback, max) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
return Math.min(Math.trunc(numeric), max);
}
function clampNonNegativeInteger(value, fallback, max) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0) return fallback;
return Math.min(Math.trunc(numeric), max);
}
function generateOrderNo() {
const timestamp = Date.now().toString(36).toUpperCase();
const random = crypto.randomBytes(4).toString("hex").toUpperCase();
return `ORD${timestamp}${random}`;
}
const GENERATION_TASK_STATUSES = new Set([
"pending",
"running",
"completed",
"failed",
"cancelled",
]);
const GENERATION_TASK_TYPES = new Set(["image", "video"]);
function clampTaskProgress(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(100, Math.trunc(numeric)));
}
function serializeTaskParams(value) {
if (!value || typeof value !== "object") return "{}";
return JSON.stringify(value);
}
function parseTaskParams(value) {
if (!value || typeof value !== "string") return {};
try {
return JSON.parse(value);
} catch {
return {};
}
}
function formatGenerationTaskRow(row) {
return {
id: Number(row.id),
projectId: row.project_id,
clientQueueId: row.client_queue_id,
type: row.type,
status: row.status,
providerTaskId: row.provider_task_id || null,
params: parseTaskParams(row.params_json),
resultUrl: row.result_url || null,
progress: Number(row.progress || 0),
error: row.error || null,
dedupeKey: row.dedupe_key || null,
sourceDeviceId: row.source_device_id || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
completedAt: row.completed_at || null,
};
}
function normalizeGenerationTaskPayload(body) {
const clientQueueId = String(body.clientQueueId || body.client_queue_id || "")
.trim()
.slice(0, 128);
const type = String(body.type || "").trim();
const status = String(body.status || "pending").trim();
if (!clientQueueId) return { error: "Missing clientQueueId" };
if (!GENERATION_TASK_TYPES.has(type)) return { error: "Invalid task type" };
if (!GENERATION_TASK_STATUSES.has(status)) return { error: "Invalid task status" };
return {
value: {
clientQueueId,
type,
status,
providerTaskId: body.providerTaskId || body.provider_task_id || null,
paramsJson: serializeTaskParams(body.params || body.paramsJson || body.params_json),
resultUrl: body.resultUrl || body.result_url || null,
progress: clampTaskProgress(body.progress),
error: body.error || null,
dedupeKey: body.dedupeKey || body.dedupe_key || null,
sourceDeviceId: body.sourceDeviceId || body.source_device_id || null,
createdAt: body.createdAt || body.created_at || null,
completedAt: body.completedAt || body.completed_at || null,
},
};
}
async function requireOwnedProject(client, userId, projectId) {
const { rows } = await client.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [
projectId,
userId,
]);
return rows.length > 0;
}
async function upsertGenerationTask(client, userId, projectId, payload) {
const {
rows: [row],
} = await client.query(
`
INSERT INTO generation_tasks (
user_id,
project_id,
client_queue_id,
type,
status,
provider_task_id,
params_json,
result_url,
progress,
error,
dedupe_key,
source_device_id,
created_at,
updated_at,
completed_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
COALESCE($13::timestamptz, NOW()),
NOW(),
$14::timestamptz
)
ON CONFLICT (project_id, client_queue_id) WHERE project_id IS NOT NULL DO UPDATE SET
type = EXCLUDED.type,
status = EXCLUDED.status,
provider_task_id = EXCLUDED.provider_task_id,
params_json = EXCLUDED.params_json,
result_url = EXCLUDED.result_url,
progress = EXCLUDED.progress,
error = EXCLUDED.error,
dedupe_key = EXCLUDED.dedupe_key,
source_device_id = EXCLUDED.source_device_id,
updated_at = NOW(),
completed_at = EXCLUDED.completed_at
RETURNING *
`,
[
userId,
projectId,
payload.clientQueueId,
payload.type,
payload.status,
payload.providerTaskId,
payload.paramsJson,
payload.resultUrl,
payload.progress,
payload.error,
payload.dedupeKey,
payload.sourceDeviceId,
payload.createdAt,
payload.completedAt,
],
);
return row;
}
module.exports = {
express,
bcrypt,
requireAuth,
requireAdmin,
requireEnterpriseAdmin,
requireManagementAccess,
login,
generateToken,
startUserSession,
getUserContextById,
isSystemAdmin,
generateUniqueEnterpriseCode,
keyManager,
calculateCost,
calculateCostMills,
listModelPrices,
normalizeModelPriceRow,
getAverageCostCents,
loadPriceCache,
deductForApiCall,
deductImageGenerationCredits,
creditBalance,
creditUserBalance,
activatePackage,
distributeCredits,
getEnterpriseFinancials,
getUserEnterpriseId,
getEnterpriseName,
preauthorizeCall,
wechatPay,
alipay,
crypto,
pool,
withTransaction,
computeNextRevision,
normalizeRevisionValue,
shouldRejectStaleRevision,
USERNAME_PATTERN,
PRICE_CATEGORIES,
PRICE_TYPES,
PHONE_PATTERN,
EMAIL_PATTERN,
SMS_PURPOSES,
SMS_CODE_TTL_MINUTES,
SMS_CODE_COOLDOWN_SECONDS,
SMS_CODE_MAX_ATTEMPTS,
validateUsername,
validatePassword,
normalizePhone,
validatePhone,
normalizeEmail,
validateEmail,
hashSmsCode,
generateSmsCode,
sendSmsCode,
createLoginResultForUserId,
sanitizeUsernameSeed,
generateUniqueUsername,
consumeSmsCode,
getWechatLoginConfig,
fetchWechatJson,
exchangeWechatCode,
findOrCreateWechatUser,
validateEnterpriseName,
parseNumericValue,
ensureEnterpriseExists,
formatUserRow,
normalizeOssRegion,
buildOssPublicUrl,
normalizeAvatarOssKey,
normalizeProfileMediaUrl,
normalizeProjectOssKey,
getManagementEnterpriseId,
appendEnterpriseScope,
readModelPricePayload,
getModelPriceById,
getPeriodStart,
buildDailyTrend,
clampPositiveInteger,
clampNonNegativeInteger,
generateOrderNo,
GENERATION_TASK_STATUSES,
GENERATION_TASK_TYPES,
clampTaskProgress,
serializeTaskParams,
parseTaskParams,
formatGenerationTaskRow,
normalizeGenerationTaskPayload,
requireOwnedProject,
upsertGenerationTask,
};
+141
View File
@@ -0,0 +1,141 @@
const { requireAuth } = require("../auth");
const { pool } = require("../db");
function registerConversationRoutes(router) {
router.get("/conversations", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const { rows } = await pool.query(
`SELECT id, title, mode, created_at, updated_at
FROM conversations
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT 100`,
[userId]
);
res.json({ conversations: rows.map(formatRow) });
} catch (err) {
console.error("[conversations] list failed:", err.message);
res.status(500).json({ error: "获取对话列表失败" });
}
});
router.post("/conversations", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const { title = "新对话", mode = "chat", messages } = req.body || {};
const safeMessages = Array.isArray(messages) ? JSON.stringify(messages) : "[]";
const { rows } = await pool.query(
`INSERT INTO conversations (user_id, title, mode, messages_json)
VALUES ($1, $2, $3, $4)
RETURNING id, title, mode, created_at, updated_at`,
[userId, String(title).slice(0, 200), String(mode || "chat").slice(0, 20), safeMessages]
);
res.status(201).json({ conversation: formatRow(rows[0]) });
} catch (err) {
console.error("[conversations] create failed:", err.message);
res.status(500).json({ error: "创建对话失败" });
}
});
router.get("/conversations/:id", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const convId = Number(req.params.id);
if (!Number.isFinite(convId)) {
return res.status(400).json({ error: "无效的对话 ID" });
}
const { rows } = await pool.query(
`SELECT id, title, mode, messages_json, created_at, updated_at
FROM conversations
WHERE id = $1 AND user_id = $2`,
[convId, userId]
);
if (rows.length === 0) {
return res.status(404).json({ error: "对话不存在" });
}
const row = rows[0];
let messages = [];
try { messages = JSON.parse(row.messages_json || "[]"); } catch {}
res.json({
conversation: {
...formatRow(row),
messages,
},
});
} catch (err) {
console.error("[conversations] get failed:", err.message);
res.status(500).json({ error: "获取对话失败" });
}
});
router.put("/conversations/:id", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const convId = Number(req.params.id);
if (!Number.isFinite(convId)) {
return res.status(400).json({ error: "无效的对话 ID" });
}
const { title, messages } = req.body || {};
const sets = [];
const params = [convId, userId];
let idx = 3;
if (title !== undefined) {
sets.push(`title = $${idx++}`);
params.push(String(title).slice(0, 200));
}
if (messages !== undefined) {
sets.push(`messages_json = $${idx++}`);
params.push(JSON.stringify(messages));
}
if (sets.length === 0) {
return res.status(400).json({ error: "没有需要更新的字段" });
}
sets.push("updated_at = NOW()");
const { rowCount } = await pool.query(
`UPDATE conversations SET ${sets.join(", ")} WHERE id = $1 AND user_id = $2`,
params
);
if (rowCount === 0) {
return res.status(404).json({ error: "对话不存在" });
}
res.json({ success: true });
} catch (err) {
console.error("[conversations] update failed:", err.message);
res.status(500).json({ error: "更新对话失败" });
}
});
router.delete("/conversations/:id", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const convId = Number(req.params.id);
if (!Number.isFinite(convId)) {
return res.status(400).json({ error: "无效的对话 ID" });
}
const { rowCount } = await pool.query(
"DELETE FROM conversations WHERE id = $1 AND user_id = $2",
[convId, userId]
);
if (rowCount === 0) {
return res.status(404).json({ error: "对话不存在" });
}
res.json({ success: true });
} catch (err) {
console.error("[conversations] delete failed:", err.message);
res.status(500).json({ error: "删除对话失败" });
}
});
}
function formatRow(row) {
return {
id: row.id,
title: row.title,
mode: row.mode,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
module.exports = { registerConversationRoutes };
+109
View File
@@ -0,0 +1,109 @@
"use strict";
const { requireAuth, pool } = require("./context");
function cleanText(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function safeJsonString(value, fallback) {
if (value === undefined) return JSON.stringify(fallback);
try {
return JSON.stringify(value ?? fallback);
} catch {
return JSON.stringify(fallback);
}
}
function parseJson(value, fallback) {
if (!value || typeof value !== "string") return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function formatDraft(row) {
return {
id: Number(row.id),
scope: row.scope,
targetId: row.target_id,
payload: parseJson(row.payload_json, {}),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function registerDraftRoutes(router) {
router.get("/drafts", requireAuth, async (req, res) => {
try {
const scope = cleanText(req.query.scope, 64);
const targetId = cleanText(req.query.targetId ?? req.query.target_id, 128);
const params = [req.user.id];
const where = ["user_id = $1"];
if (scope) {
params.push(scope);
where.push(`scope = $${params.length}`);
}
if (targetId) {
params.push(targetId);
where.push(`target_id = $${params.length}`);
}
const { rows } = await pool.query(
`
SELECT *
FROM web_drafts
WHERE ${where.join(" AND ")}
ORDER BY updated_at DESC
LIMIT 100
`,
params,
);
res.json({ drafts: rows.map(formatDraft) });
} catch (err) {
console.error("[drafts] list failed:", err.message);
res.status(500).json({ error: "Failed to load drafts" });
}
});
router.put("/drafts", requireAuth, async (req, res) => {
const scope = cleanText(req.body?.scope, 64);
const targetId = cleanText(req.body?.targetId ?? req.body?.target_id, 128) || "default";
if (!scope) return res.status(400).json({ error: "Missing draft scope" });
try {
const { rows } = await pool.query(
`
INSERT INTO web_drafts (user_id, scope, target_id, payload_json)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, scope, target_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = NOW()
RETURNING *
`,
[req.user.id, scope, targetId, safeJsonString(req.body?.payload, {})],
);
res.json({ draft: formatDraft(rows[0]) });
} catch (err) {
console.error("[drafts] upsert failed:", err.message);
res.status(500).json({ error: "Failed to save draft" });
}
});
router.delete("/drafts/:id", requireAuth, async (req, res) => {
try {
const { rowCount } = await pool.query("DELETE FROM web_drafts WHERE id = $1 AND user_id = $2", [
req.params.id,
req.user.id,
]);
if (!rowCount) return res.status(404).json({ error: "Draft not found" });
res.json({ success: true });
} catch (err) {
console.error("[drafts] delete failed:", err.message);
res.status(500).json({ error: "Failed to delete draft" });
}
});
}
module.exports = { registerDraftRoutes };
+133
View File
@@ -0,0 +1,133 @@
const { requireAuth, pool } = require("./context");
const { resolveTextProvider } = require("../aiProviderRouter");
const { keyManager, releaseLease } = require("../keyManager");
const { preauthorizeCall } = require("../billing");
function extractJson(text) {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = fenced ? fenced[1].trim() : text.trim();
const start = raw.search(/[\[{]/);
try { return JSON.parse(start >= 0 ? raw.slice(start) : raw); }
catch { throw new Error("AI 返回内容不是有效的 JSON"); }
}
function registerEcommerceRoutes(router) {
router.post("/ai/ecommerce/video-plan", requireAuth, async (req, res) => {
const { productImageUrls, manualText, platform, market, language, aspectRatio, durationSeconds, style, needVoiceover, needSubtitle, conversionFocus } = req.body;
if (!productImageUrls?.length && !manualText) {
return res.status(400).json({ error: "Missing productImageUrls or manualText" });
}
const config = {
platform: platform || "拖音电商",
market: market || "中国",
language: language || "中文",
aspectRatio: aspectRatio || "9:16",
durationSeconds: durationSeconds || 10,
style: style || "痛点解决",
needVoiceover: needVoiceover !== false,
needSubtitle: needSubtitle !== false,
conversionFocus: conversionFocus || "conversion",
};
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const abortController = new AbortController();
req.on("close", () => abortController.abort());
const sendStep = (step, data) => res.write("data: " + JSON.stringify({ step, ...data }) + "\n\n");
let slotResult;
try {
const providerConfig = resolveTextProvider("qwen-max");
const preauth = await preauthorizeCall(req.user.id, providerConfig.provider);
if (!preauth.authorized) {
res.write("data: " + JSON.stringify({ error: preauth.message }) + "\n\n");
res.end(); return;
}
slotResult = await keyManager.acquireKey(providerConfig.provider, req.user, preauth, { waitTimeoutMs: 15000 });
if (!slotResult) {
res.write("data: " + JSON.stringify({ error: "AI 服务繁忙" }) + "\n\n");
res.end(); return;
}
const chatOnce = async (sys, user, model) => {
const p = resolveTextProvider(model || "qwen-max");
const r = await fetch(p.baseUrl + p.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey },
body: JSON.stringify({ model: p.model, messages: [{ role: "system", content: sys }, { role: "user", content: user }], stream: false, temperature: 0.4, max_tokens: 4096 }),
signal: abortController.signal,
});
const j = await r.json();
return j.choices?.[0]?.message?.content || j.content || "";
};
const visionChat = async (sys, text, imageUrls) => {
const p = resolveTextProvider("qwen3.6-plus");
const content = [...imageUrls.map(u => ({ type: "image_url", image_url: { url: u } })), { type: "text", text }];
const r = await fetch(p.baseUrl + p.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey },
body: JSON.stringify({ model: p.model, messages: [{ role: "system", content: sys }, { role: "user", content }], stream: false, temperature: 0.3, max_tokens: 4096 }),
signal: abortController.signal,
});
const j = await r.json();
return j.choices?.[0]?.message?.content || j.content || "";
};
const cfgStr = "平台:" + config.platform + " 比例:" + config.aspectRatio + " 时长:" + config.durationSeconds + "s 风格:" + config.style;
sendStep("analyze", { status: "running" });
let imageDesc = "";
if (productImageUrls?.length) {
imageDesc = await visionChat("你是电商产品图片分析专家。分析产品图片,识别主体、外观、颜色、材质、卖点。简洁中文描述。", "分析这些产品图片。", productImageUrls);
}
sendStep("analyze", { status: "done" });
sendStep("summary", { status: "running" });
const summaryRaw = await chatOnce('商品信息理解专家。输出JSON{"product_name":"","category":"","appearance":"","materials":[],"colors":[],"core_features":[],"target_users":[],"usage_scenarios":[],"selling_points":[],"risk_notes":[]}', "图片描述:" + (imageDesc || "无") + "\n说明:" + (manualText || "无"));
const summary = extractJson(summaryRaw);
sendStep("summary", { status: "done", data: summary });
sendStep("selling", { status: "running" });
const sellingRaw = await chatOnce('卖点提炼专家。JSON{"primary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"secondary_selling_points":[],"unsupported_claims":[],"compliance_warnings":[]}', JSON.stringify(summary));
const selling = extractJson(sellingRaw);
sendStep("selling", { status: "done", data: selling });
sendStep("creative", { status: "running" });
const creativeRaw = await chatOnce('广告创意专家。生成3个方向。JSON{"creative_options":[{"creative_id":"A","creative_type":"","hook":"","target_user":"","main_message":"","emotional_tone":"","recommended_platform":"","reason":""}]}', "卖点:" + JSON.stringify(selling.primary_selling_points) + "\n配置:" + cfgStr);
const creativeOptions = (extractJson(creativeRaw).creative_options || []);
sendStep("creative", { status: "done", data: creativeOptions });
sendStep("storyboard", { status: "running" });
const sbRaw = await chatOnce('分镜师。JSON{"video_title":"","duration":"","aspect_ratio":"","target_platform":"","language":"","scenes":[{"scene_id":1,"duration":"3s","scene_goal":"","visual_description":"","product_focus":"","camera_movement":"","background":"","lighting":"","subtitle":"","voiceover":"","transition":""}]}', "创意:" + JSON.stringify(creativeOptions[0] || {}) + "\n商品:" + JSON.stringify(summary) + "\n配置:" + cfgStr);
const storyboard = extractJson(sbRaw);
sendStep("storyboard", { status: "done", data: storyboard });
sendStep("prompts", { status: "running" });
const promptsRaw = await chatOnce('视频提示词工程师。JSON数组:[{"scene_id":1,"positive_prompt":"","negative_prompt":"","reference_requirements":"","consistency_rules":"","text_overlay":""}]', "分镜:" + JSON.stringify(storyboard.scenes) + "\n外观:" + (summary.appearance || ""));
const videoPrompts = extractJson(promptsRaw);
sendStep("prompts", { status: "done", data: videoPrompts });
sendStep("compliance", { status: "running" });
const compRaw = await chatOnce('合规专家。JSON{"risk_level":"low","issues":[{"field":"","problem":"","suggestion":""}],"allow_video_generation":true}', "卖点:" + JSON.stringify(selling) + "\n文案:" + JSON.stringify((storyboard.scenes || []).map(s => s.subtitle)));
const compliance = extractJson(compRaw);
sendStep("compliance", { status: "done", data: compliance });
res.write("data: " + JSON.stringify({ step: "done", plan: { summary, selling, creatives: creativeOptions, storyboard, videoPrompts, compliance } }) + "\n\n");
res.end();
releaseLease(slotResult);
} catch (err) {
releaseLease(slotResult);
if (err.name === "AbortError") { res.end(); return; }
console.error("[ai/ecommerce/video-plan] error:", err.message);
res.write("data: " + JSON.stringify({ error: err.message || "策划失败" }) + "\n\n");
res.end();
}
});
}
module.exports = { registerEcommerceRoutes };
+492
View File
@@ -0,0 +1,492 @@
const {
requireAuth,
requireEnterpriseAdmin,
distributeCredits,
getEnterpriseFinancials,
getEnterpriseName,
pool,
getPeriodStart,
buildDailyTrend,
generateOrderNo,
clampPositiveInteger,
clampNonNegativeInteger,
} = require("./context");
function registerEnterpriseRoutes(router) {
// ── Enterprise: Dashboard & Financials ────────────────────────────────
router.get("/enterprise/dashboard", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const financials = await getEnterpriseFinancials(req.user.enterpriseId);
const {
rows: [countRow],
} = await pool.query(
"SELECT COUNT(*) AS count FROM users WHERE enterprise_id = $1 AND enabled = 1 AND is_enterprise_admin = 0",
[req.user.enterpriseId],
);
res.json({
enterpriseName: req.user.enterpriseName,
enterpriseCode: req.user.enterpriseCode,
balanceCents: financials.balanceCents,
activePackages: financials.activePackages,
subAccountCount: Number(countRow?.count || 0),
});
});
router.get("/enterprise/financials", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const financials = await getEnterpriseFinancials(req.user.enterpriseId);
res.json({
balanceCents: financials.balanceCents,
activePackages: financials.activePackages.map((p) => ({
id: p.id,
packageName: p.package_name,
remainingImage: p.remaining_image,
remainingVideo: p.remaining_video,
remainingText: p.remaining_text,
expiresAt: p.expires_at,
activatedAt: p.activated_at,
})),
recentTransactions: financials.recentTransactions.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amount_cents,
balanceAfterCents: t.balance_after_cents,
description: t.description,
createdAt: t.created_at,
})),
});
});
router.get("/enterprise/usage/summary", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { period = "30d" } = req.query;
const periodStart = getPeriodStart(period);
const ledgerDateJoin = periodStart ? `AND cl.created_at >= ${periodStart}` : "";
const ledgerDateWhere = periodStart ? `AND created_at >= ${periodStart}` : "";
const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200);
try {
const {
rows: [enterprise],
} = await pool.query(
"SELECT id, name, balance_cents FROM enterprises WHERE id = $1 AND enabled = 1 LIMIT 1",
[req.user.enterpriseId],
);
if (!enterprise) return res.status(404).json({ error: "企业不存在或已禁用" });
const { rows: members } = await pool.query(
`
SELECT
u.id AS user_id,
u.username,
COALESCE(em.role, CASE WHEN u.is_enterprise_admin = 1 THEN 'admin' ELSE 'employee' END) AS member_role,
COALESCE(SUM(CASE WHEN cl.status IN ('reserved', 'charged') THEN cl.amount_cents ELSE 0 END), 0) AS used_cents,
COUNT(CASE WHEN cl.status IN ('reserved', 'charged') THEN 1 END) AS task_count,
MAX(cl.created_at) AS last_used_at
FROM users u
LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id
LEFT JOIN credit_ledger cl ON cl.enterprise_id = u.enterprise_id AND cl.user_id = u.id ${ledgerDateJoin}
WHERE u.enterprise_id = $1 AND u.enabled = 1
GROUP BY u.id, u.username, em.role, u.is_enterprise_admin
ORDER BY used_cents DESC, u.id ASC
`,
[req.user.enterpriseId],
);
const { rows: modelBreakdown } = await pool.query(
`
SELECT
COALESCE(model, 'unknown') AS model,
COALESCE(SUM(amount_cents), 0) AS used_cents,
COUNT(*) AS task_count
FROM credit_ledger
WHERE enterprise_id = $1
AND status IN ('reserved', 'charged')
${ledgerDateWhere}
GROUP BY COALESCE(model, 'unknown')
ORDER BY used_cents DESC
LIMIT 50
`,
[req.user.enterpriseId],
);
const {
rows: [totalRow],
} = await pool.query(
`
SELECT COALESCE(SUM(amount_cents), 0) AS total_used_cents
FROM credit_ledger
WHERE enterprise_id = $1
AND status IN ('reserved', 'charged')
${ledgerDateWhere}
`,
[req.user.enterpriseId],
);
const { rows: records } = await pool.query(
`
SELECT cl.*, u.username, gt.params_json AS task_params_json
FROM credit_ledger cl
LEFT JOIN users u ON u.id = cl.user_id
LEFT JOIN generation_tasks gt ON gt.id = cl.task_id
WHERE cl.enterprise_id = $1
AND cl.status IN ('reserved', 'charged')
${periodStart ? `AND cl.created_at >= ${periodStart}` : ""}
ORDER BY cl.created_at DESC
LIMIT $2
`,
[req.user.enterpriseId, recordsLimit],
);
const { rows: trendRows } = await pool.query(
`
SELECT
to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day,
COUNT(*) AS task_count,
COALESCE(SUM(amount_cents), 0) AS used_cents
FROM credit_ledger
WHERE enterprise_id = $1
AND status IN ('reserved', 'charged')
AND created_at >= NOW() - INTERVAL '6 days'
GROUP BY day
ORDER BY day ASC
`,
[req.user.enterpriseId],
);
const dailyTrend = buildDailyTrend(trendRows);
res.json({
enterpriseId: String(enterprise.id),
enterpriseName: enterprise.name,
balanceCents: Number(enterprise.balance_cents || 0),
totalUsedCents: Number(totalRow?.total_used_cents || 0),
members: members.map((row) => ({
userId: Number(row.user_id),
username: row.username,
role: row.member_role || "employee",
usedCents: Number(row.used_cents || 0),
taskCount: Number(row.task_count || 0),
lastUsedAt: row.last_used_at || null,
})),
modelBreakdown: modelBreakdown.map((row) => ({
model: row.model,
usedCents: Number(row.used_cents || 0),
taskCount: Number(row.task_count || 0),
})),
records: records.map((row) => {
let prompt = "";
try {
const params = row.task_params_json ? JSON.parse(row.task_params_json) : {};
prompt = params.prompt || "";
} catch {}
return {
id: String(row.id),
userId: row.user_id == null ? "" : Number(row.user_id),
username: row.username || "",
model: row.model || "",
taskType: row.task_type,
resolution: row.resolution || null,
durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds),
amountCents: Number(row.amount_cents || 0),
prompt,
status: row.status,
createdAt: row.created_at,
};
}),
});
} catch (error) {
console.error("[enterprise/usage/summary] failed", error);
res.status(500).json({ error: "企业用量汇总加载失败" });
}
});
router.get("/enterprise/usage/records", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const limit = clampPositiveInteger(req.query.limit, 50, 200);
const offset = clampNonNegativeInteger(req.query.offset, 0, 100000);
const userId = req.query.userId || req.query.user_id;
const model = String(req.query.model || "").trim();
const dateFrom = req.query.from || req.query.date_from;
const dateTo = req.query.to || req.query.date_to;
const where = ["cl.enterprise_id = $1"];
const params = [req.user.enterpriseId];
if (userId) {
params.push(userId);
where.push(`cl.user_id = $${params.length}`);
}
if (model) {
params.push(model);
where.push(`cl.model = $${params.length}`);
}
if (dateFrom) {
params.push(`${dateFrom}T00:00:00.000Z`);
where.push(`cl.created_at >= $${params.length}`);
}
if (dateTo) {
params.push(`${dateTo}T23:59:59.999Z`);
where.push(`cl.created_at <= $${params.length}`);
}
const whereSql = `WHERE ${where.join(" AND ")}`;
try {
const {
rows: [countRow],
} = await pool.query(`SELECT COUNT(*) AS total FROM credit_ledger cl ${whereSql}`, params);
const { rows } = await pool.query(
`
SELECT cl.*, u.username, gt.params_json AS task_params_json
FROM credit_ledger cl
LEFT JOIN users u ON u.id = cl.user_id
LEFT JOIN generation_tasks gt ON gt.id = cl.task_id
${whereSql}
ORDER BY cl.created_at DESC
LIMIT $${params.length + 1}
OFFSET $${params.length + 2}
`,
[...params, limit, offset],
);
res.json({
items: rows.map((row) => {
let prompt = "";
try {
const params = row.task_params_json ? JSON.parse(row.task_params_json) : {};
prompt = params.prompt || "";
} catch {}
return {
id: String(row.id),
userId: row.user_id == null ? "" : Number(row.user_id),
username: row.username || "",
model: row.model || "",
taskType: row.task_type,
resolution: row.resolution || null,
durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds),
amountCents: Number(row.amount_cents || 0),
prompt,
status: row.status,
createdAt: row.created_at,
};
}),
total: Number(countRow?.total || 0),
limit,
offset,
});
} catch (error) {
console.error("[enterprise/usage/records] failed", error);
res.status(500).json({ error: "企业用量记录加载失败" });
}
});
router.post("/enterprise/recharge", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { amountCents, paymentMethod = "wechat" } = req.body;
if (!amountCents || amountCents <= 0)
return res.status(400).json({ error: "充值金额必须大于0" });
const orderNo = generateOrderNo();
const enterpriseName = await getEnterpriseName(req.user.enterpriseId);
try {
await pool.query(
`
INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, payment_method, status)
VALUES ($1, $2, $3, 'recharge', $4, $5, 'pending')
`,
[orderNo, req.user.enterpriseId, enterpriseName, Number(amountCents), paymentMethod],
);
res.json({ orderNo, amountCents, paymentMethod });
} catch (error) {
console.error("[enterprise/recharge] failed", error);
res.status(500).json({ error: "创建充值订单失败" });
}
});
router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { userId, amountCents, distributions } = req.body;
try {
if (distributions && Array.isArray(distributions)) {
for (const d of distributions) {
if (!d.userId || !d.amountCents || d.amountCents <= 0) {
return res
.status(400)
.json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" });
}
await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id);
}
res.json({ success: true, count: distributions.length });
} else if (userId && amountCents) {
if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" });
const result = await distributeCredits(
req.user.enterpriseId,
userId,
amountCents,
req.user.id,
);
res.json({ success: true, ...result });
} else {
return res.status(400).json({ error: "缺少分发参数" });
}
} catch (error) {
console.error("[enterprise/distribute] failed", error);
res.status(400).json({ error: "分发参数处理失败" });
}
});
router.get(
"/enterprise/employee-consumption",
requireAuth,
requireEnterpriseAdmin,
async (req, res) => {
const { period = "30d" } = req.query;
const periodStart = getPeriodStart(period);
const whereClause = periodStart ? `AND acl.created_at >= ${periodStart}` : "";
const { rows } = await pool.query(
`
SELECT
u.id AS user_id,
u.username,
u.balance_cents AS current_balance_cents,
COUNT(acl.id) AS total_calls,
COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents,
MAX(acl.created_at) AS last_active
FROM users u
LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success'
WHERE u.enterprise_id = $1 AND u.enabled = 1 AND u.is_enterprise_admin = 0 ${whereClause}
GROUP BY u.id, u.username, u.balance_cents
ORDER BY total_cost_cents DESC
`,
[req.user.enterpriseId],
);
res.json(
rows.map((r) => ({
userId: Number(r.user_id),
username: r.username,
currentBalanceCents: Number(r.current_balance_cents),
totalCalls: Number(r.total_calls),
totalCostCents: Number(r.total_cost_cents),
lastActive: r.last_active,
})),
);
},
);
router.post(
"/enterprise/purchase-package",
requireAuth,
requireEnterpriseAdmin,
async (req, res) => {
const { packageId, paymentMethod = "wechat" } = req.body;
if (!packageId) return res.status(400).json({ error: "缺少套餐ID" });
const {
rows: [pkg],
} = await pool.query("SELECT * FROM packages WHERE id = $1 AND enabled = 1", [packageId]);
if (!pkg) return res.status(404).json({ error: "套餐不存在或已下架" });
const orderNo = generateOrderNo();
const enterpriseName = await getEnterpriseName(req.user.enterpriseId);
try {
await pool.query(
`
INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, package_id, payment_method, status)
VALUES ($1, $2, $3, 'package', $4, $5, $6, 'pending')
`,
[
orderNo,
req.user.enterpriseId,
enterpriseName,
pkg.price_cents,
packageId,
paymentMethod,
],
);
res.json({ orderNo, amountCents: pkg.price_cents, packageId, paymentMethod });
} catch (error) {
console.error("[enterprise/purchase-package] failed", error);
res.status(500).json({ error: "创建套餐订单失败" });
}
},
);
// ── Enterprise: Invoices ──────────────────────────────────────────────
router.post(
"/enterprise/invoice-apply",
requireAuth,
requireEnterpriseAdmin,
async (req, res) => {
const { paymentOrderId, type = "general", title, taxNo } = req.body;
if (!title) return res.status(400).json({ error: "缺少发票抬头" });
let amountCents = 0;
if (paymentOrderId) {
const {
rows: [order],
} = await pool.query(
"SELECT * FROM payment_orders WHERE id = $1 AND enterprise_id = $2 AND status = $3",
[paymentOrderId, req.user.enterpriseId, "paid"],
);
if (!order) return res.status(404).json({ error: "支付订单不存在或未支付" });
amountCents = order.amount_cents;
}
const enterpriseName = await getEnterpriseName(req.user.enterpriseId);
try {
const {
rows: [row],
} = await pool.query(
`
INSERT INTO invoices (enterprise_id, enterprise_name, payment_order_id, type, title, tax_no, amount_cents, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
RETURNING id
`,
[
req.user.enterpriseId,
enterpriseName,
paymentOrderId || null,
type,
title,
taxNo || null,
amountCents,
],
);
res.json({ id: row.id, success: true });
} catch (error) {
console.error("[enterprise/invoice-apply] failed", error);
res.status(500).json({ error: "申请发票失败" });
}
},
);
router.get("/enterprise/invoices", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { rows } = await pool.query(
"SELECT * FROM invoices WHERE enterprise_id = $1 ORDER BY id DESC",
[req.user.enterpriseId],
);
res.json(
rows.map((row) => ({
id: Number(row.id),
type: row.type,
title: row.title,
taxNo: row.tax_no,
amountCents: row.amount_cents,
status: row.status,
invoiceNo: row.invoice_no,
invoiceUrl: row.invoice_url,
issuedAt: row.issued_at,
createdAt: row.created_at,
})),
);
});
}
module.exports = {
registerEnterpriseRoutes,
};
@@ -0,0 +1,456 @@
const {
requireAuth,
requireEnterpriseAdmin,
distributeCredits,
getEnterpriseFinancials,
getEnterpriseName,
pool,
getPeriodStart,
generateOrderNo,
clampPositiveInteger,
clampNonNegativeInteger,
} = require("./context");
function registerEnterpriseRoutes(router) {
// ── Enterprise: Dashboard & Financials ────────────────────────────────
router.get("/enterprise/dashboard", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const financials = await getEnterpriseFinancials(req.user.enterpriseId);
const {
rows: [countRow],
} = await pool.query(
"SELECT COUNT(*) AS count FROM users WHERE enterprise_id = $1 AND enabled = 1 AND is_enterprise_admin = 0",
[req.user.enterpriseId],
);
res.json({
enterpriseName: req.user.enterpriseName,
enterpriseCode: req.user.enterpriseCode,
balanceCents: financials.balanceCents,
activePackages: financials.activePackages,
subAccountCount: Number(countRow?.count || 0),
});
});
router.get("/enterprise/financials", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const financials = await getEnterpriseFinancials(req.user.enterpriseId);
res.json({
balanceCents: financials.balanceCents,
activePackages: financials.activePackages.map((p) => ({
id: p.id,
packageName: p.package_name,
remainingImage: p.remaining_image,
remainingVideo: p.remaining_video,
remainingText: p.remaining_text,
expiresAt: p.expires_at,
activatedAt: p.activated_at,
})),
recentTransactions: financials.recentTransactions.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amount_cents,
balanceAfterCents: t.balance_after_cents,
description: t.description,
createdAt: t.created_at,
})),
});
});
router.get("/enterprise/usage/summary", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { period = "30d" } = req.query;
const periodStart = getPeriodStart(period);
const ledgerDateJoin = periodStart ? `AND cl.created_at >= ${periodStart}` : "";
const ledgerDateWhere = periodStart ? `AND created_at >= ${periodStart}` : "";
const recordsLimit = clampPositiveInteger(req.query.limit, 50, 200);
try {
const {
rows: [enterprise],
} = await pool.query(
"SELECT id, name, balance_cents FROM enterprises WHERE id = $1 AND enabled = 1 LIMIT 1",
[req.user.enterpriseId],
);
if (!enterprise) return res.status(404).json({ error: "企业不存在或已禁用" });
const { rows: members } = await pool.query(
`
SELECT
u.id AS user_id,
u.username,
COALESCE(em.role, CASE WHEN u.is_enterprise_admin = 1 THEN 'admin' ELSE 'employee' END) AS role,
COALESCE(SUM(CASE WHEN cl.status IN ('reserved', 'charged') THEN cl.amount_cents ELSE 0 END), 0) AS used_cents,
COUNT(CASE WHEN cl.status IN ('reserved', 'charged') THEN 1 END) AS task_count,
MAX(cl.created_at) AS last_used_at
FROM users u
LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id
LEFT JOIN credit_ledger cl ON cl.enterprise_id = u.enterprise_id AND cl.user_id = u.id ${ledgerDateJoin}
WHERE u.enterprise_id = $1 AND u.enabled = 1
GROUP BY u.id, u.username, role
ORDER BY used_cents DESC, u.id ASC
`,
[req.user.enterpriseId],
);
const { rows: modelBreakdown } = await pool.query(
`
SELECT
COALESCE(model, 'unknown') AS model,
COALESCE(SUM(amount_cents), 0) AS used_cents,
COUNT(*) AS task_count
FROM credit_ledger
WHERE enterprise_id = $1
AND status IN ('reserved', 'charged')
${ledgerDateWhere}
GROUP BY COALESCE(model, 'unknown')
ORDER BY used_cents DESC
LIMIT 50
`,
[req.user.enterpriseId],
);
const {
rows: [totalRow],
} = await pool.query(
`
SELECT COALESCE(SUM(amount_cents), 0) AS total_used_cents
FROM credit_ledger
WHERE enterprise_id = $1
AND status IN ('reserved', 'charged')
${ledgerDateWhere}
`,
[req.user.enterpriseId],
);
const { rows: records } = await pool.query(
`
SELECT cl.*, u.username
FROM credit_ledger cl
LEFT JOIN users u ON u.id = cl.user_id
WHERE cl.enterprise_id = $1
AND cl.status IN ('reserved', 'charged')
${periodStart ? `AND cl.created_at >= ${periodStart}` : ""}
ORDER BY cl.created_at DESC
LIMIT $2
`,
[req.user.enterpriseId, recordsLimit],
);
res.json({
enterpriseId: String(enterprise.id),
enterpriseName: enterprise.name,
balanceCents: Number(enterprise.balance_cents || 0),
totalUsedCents: Number(totalRow?.total_used_cents || 0),
members: members.map((row) => ({
userId: Number(row.user_id),
username: row.username,
role: row.role || "employee",
usedCents: Number(row.used_cents || 0),
taskCount: Number(row.task_count || 0),
lastUsedAt: row.last_used_at || null,
})),
modelBreakdown: modelBreakdown.map((row) => ({
model: row.model,
usedCents: Number(row.used_cents || 0),
taskCount: Number(row.task_count || 0),
})),
records: records.map((row) => ({
id: String(row.id),
userId: row.user_id == null ? "" : Number(row.user_id),
username: row.username || "",
model: row.model || "",
taskType: row.task_type,
resolution: row.resolution || null,
durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds),
amountCents: Number(row.amount_cents || 0),
status: row.status,
createdAt: row.created_at,
})),
});
} catch (error) {
console.error("[enterprise/usage/summary] failed", error);
res.status(500).json({ error: "企业用量汇总加载失败" });
}
});
router.get("/enterprise/usage/records", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const limit = clampPositiveInteger(req.query.limit, 50, 200);
const offset = clampNonNegativeInteger(req.query.offset, 0, 100000);
const userId = req.query.userId || req.query.user_id;
const model = String(req.query.model || "").trim();
const dateFrom = req.query.from || req.query.date_from;
const dateTo = req.query.to || req.query.date_to;
const where = ["cl.enterprise_id = $1"];
const params = [req.user.enterpriseId];
if (userId) {
params.push(userId);
where.push(`cl.user_id = $${params.length}`);
}
if (model) {
params.push(model);
where.push(`cl.model = $${params.length}`);
}
if (dateFrom) {
params.push(`${dateFrom}T00:00:00.000Z`);
where.push(`cl.created_at >= $${params.length}`);
}
if (dateTo) {
params.push(`${dateTo}T23:59:59.999Z`);
where.push(`cl.created_at <= $${params.length}`);
}
const whereSql = `WHERE ${where.join(" AND ")}`;
try {
const {
rows: [countRow],
} = await pool.query(`SELECT COUNT(*) AS total FROM credit_ledger cl ${whereSql}`, params);
const { rows } = await pool.query(
`
SELECT cl.*, u.username
FROM credit_ledger cl
LEFT JOIN users u ON u.id = cl.user_id
${whereSql}
ORDER BY cl.created_at DESC
LIMIT $${params.length + 1}
OFFSET $${params.length + 2}
`,
[...params, limit, offset],
);
res.json({
items: rows.map((row) => ({
id: String(row.id),
userId: row.user_id == null ? "" : Number(row.user_id),
username: row.username || "",
model: row.model || "",
taskType: row.task_type,
resolution: row.resolution || null,
durationSeconds: row.duration_seconds == null ? null : Number(row.duration_seconds),
amountCents: Number(row.amount_cents || 0),
status: row.status,
createdAt: row.created_at,
})),
total: Number(countRow?.total || 0),
limit,
offset,
});
} catch (error) {
console.error("[enterprise/usage/records] failed", error);
res.status(500).json({ error: "企业用量记录加载失败" });
}
});
router.post("/enterprise/recharge", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { amountCents, paymentMethod = "wechat" } = req.body;
if (!amountCents || amountCents <= 0)
return res.status(400).json({ error: "充值金额必须大于0" });
const orderNo = generateOrderNo();
const enterpriseName = await getEnterpriseName(req.user.enterpriseId);
try {
await pool.query(
`
INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, payment_method, status)
VALUES ($1, $2, $3, 'recharge', $4, $5, 'pending')
`,
[orderNo, req.user.enterpriseId, enterpriseName, Number(amountCents), paymentMethod],
);
res.json({ orderNo, amountCents, paymentMethod });
} catch (error) {
console.error("[enterprise/recharge] failed", error);
res.status(500).json({ error: "创建充值订单失败" });
}
});
router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { userId, amountCents, distributions } = req.body;
try {
if (distributions && Array.isArray(distributions)) {
for (const d of distributions) {
if (!d.userId || !d.amountCents || d.amountCents <= 0) {
return res
.status(400)
.json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" });
}
await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id);
}
res.json({ success: true, count: distributions.length });
} else if (userId && amountCents) {
if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" });
const result = await distributeCredits(
req.user.enterpriseId,
userId,
amountCents,
req.user.id,
);
res.json({ success: true, ...result });
} else {
return res.status(400).json({ error: "缺少分发参数" });
}
} catch (error) {
console.error("[enterprise/distribute] failed", error);
res.status(400).json({ error: "分发参数处理失败" });
}
});
router.get(
"/enterprise/employee-consumption",
requireAuth,
requireEnterpriseAdmin,
async (req, res) => {
const { period = "30d" } = req.query;
const periodStart = getPeriodStart(period);
const whereClause = periodStart ? `AND acl.created_at >= ${periodStart}` : "";
const { rows } = await pool.query(
`
SELECT
u.id AS user_id,
u.username,
u.balance_cents AS current_balance_cents,
COUNT(acl.id) AS total_calls,
COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents,
MAX(acl.created_at) AS last_active
FROM users u
LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success'
WHERE u.enterprise_id = $1 AND u.enabled = 1 AND u.is_enterprise_admin = 0 ${whereClause}
GROUP BY u.id, u.username, u.balance_cents
ORDER BY total_cost_cents DESC
`,
[req.user.enterpriseId],
);
res.json(
rows.map((r) => ({
userId: Number(r.user_id),
username: r.username,
currentBalanceCents: Number(r.current_balance_cents),
totalCalls: Number(r.total_calls),
totalCostCents: Number(r.total_cost_cents),
lastActive: r.last_active,
})),
);
},
);
router.post(
"/enterprise/purchase-package",
requireAuth,
requireEnterpriseAdmin,
async (req, res) => {
const { packageId, paymentMethod = "wechat" } = req.body;
if (!packageId) return res.status(400).json({ error: "缺少套餐ID" });
const {
rows: [pkg],
} = await pool.query("SELECT * FROM packages WHERE id = $1 AND enabled = 1", [packageId]);
if (!pkg) return res.status(404).json({ error: "套餐不存在或已下架" });
const orderNo = generateOrderNo();
const enterpriseName = await getEnterpriseName(req.user.enterpriseId);
try {
await pool.query(
`
INSERT INTO payment_orders (order_no, enterprise_id, enterprise_name, type, amount_cents, package_id, payment_method, status)
VALUES ($1, $2, $3, 'package', $4, $5, $6, 'pending')
`,
[
orderNo,
req.user.enterpriseId,
enterpriseName,
pkg.price_cents,
packageId,
paymentMethod,
],
);
res.json({ orderNo, amountCents: pkg.price_cents, packageId, paymentMethod });
} catch (error) {
console.error("[enterprise/purchase-package] failed", error);
res.status(500).json({ error: "创建套餐订单失败" });
}
},
);
// ── Enterprise: Invoices ──────────────────────────────────────────────
router.post(
"/enterprise/invoice-apply",
requireAuth,
requireEnterpriseAdmin,
async (req, res) => {
const { paymentOrderId, type = "general", title, taxNo } = req.body;
if (!title) return res.status(400).json({ error: "缺少发票抬头" });
let amountCents = 0;
if (paymentOrderId) {
const {
rows: [order],
} = await pool.query(
"SELECT * FROM payment_orders WHERE id = $1 AND enterprise_id = $2 AND status = $3",
[paymentOrderId, req.user.enterpriseId, "paid"],
);
if (!order) return res.status(404).json({ error: "支付订单不存在或未支付" });
amountCents = order.amount_cents;
}
const enterpriseName = await getEnterpriseName(req.user.enterpriseId);
try {
const {
rows: [row],
} = await pool.query(
`
INSERT INTO invoices (enterprise_id, enterprise_name, payment_order_id, type, title, tax_no, amount_cents, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
RETURNING id
`,
[
req.user.enterpriseId,
enterpriseName,
paymentOrderId || null,
type,
title,
taxNo || null,
amountCents,
],
);
res.json({ id: row.id, success: true });
} catch (error) {
console.error("[enterprise/invoice-apply] failed", error);
res.status(500).json({ error: "申请发票失败" });
}
},
);
router.get("/enterprise/invoices", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { rows } = await pool.query(
"SELECT * FROM invoices WHERE enterprise_id = $1 ORDER BY id DESC",
[req.user.enterpriseId],
);
res.json(
rows.map((row) => ({
id: Number(row.id),
type: row.type,
title: row.title,
taxNo: row.tax_no,
amountCents: row.amount_cents,
status: row.status,
invoiceNo: row.invoice_no,
invoiceUrl: row.invoice_url,
issuedAt: row.issued_at,
createdAt: row.created_at,
})),
);
});
}
module.exports = {
registerEnterpriseRoutes,
};
+50
View File
@@ -0,0 +1,50 @@
const express = require('express')
const { registerAuthRoutes } = require('./auth')
const { registerPriceRoutes, registerPackageRoutes, registerHealthRoutes } = require('./public')
const { registerKeyRoutes } = require('./keys')
const { registerAdminRoutes, registerAdminInvoiceRoutes } = require('./admin')
const { registerEnterpriseRoutes } = require('./enterprise')
const { registerUserRoutes } = require('./user')
const { registerUsageReportRoutes, registerAdminUsageRoutes, registerUsageSummaryRoutes } = require('./usage')
const { registerConfigRoutes } = require('./config')
const { registerPaymentRoutes } = require('./payment')
const { registerProjectRoutes } = require('./projects')
const { registerOssRoutes } = require('./oss')
const { registerCommunityRoutes, registerAdminCommunityRoutes } = require('./community')
const { registerAiRoutes } = require('./ai')
const { registerEcommerceRoutes } = require("./ecommerce")
const { registerConversationRoutes } = require('./conversations')
const { registerReportRoutes } = require('./reports')
const { registerAssetRoutes } = require('./assets')
const { registerNotificationRoutes } = require('./notifications')
const { registerDraftRoutes } = require('./drafts')
const router = express.Router()
registerAuthRoutes(router)
registerPriceRoutes(router)
registerKeyRoutes(router)
registerAdminRoutes(router)
registerPackageRoutes(router)
registerEnterpriseRoutes(router)
registerAdminInvoiceRoutes(router)
registerUserRoutes(router)
registerUsageReportRoutes(router)
registerAdminUsageRoutes(router)
registerConfigRoutes(router)
registerUsageSummaryRoutes(router)
registerPaymentRoutes(router)
registerProjectRoutes(router)
registerOssRoutes(router)
registerCommunityRoutes(router)
registerAdminCommunityRoutes(router)
registerAiRoutes(router)
registerEcommerceRoutes(router)
registerConversationRoutes(router)
registerReportRoutes(router)
registerAssetRoutes(router)
registerNotificationRoutes(router)
registerDraftRoutes(router)
registerHealthRoutes(router)
module.exports = router
+99
View File
@@ -0,0 +1,99 @@
const { requireAuth, keyManager, preauthorizeCall } = require("./context");
function registerKeyRoutes(router) {
// ── Keys (with pre-authorization) ────────────────────────────────────
router.post("/keys/acquire", requireAuth, async (req, res) => {
const requestedWaitTimeoutMs = Number(req.body?.waitTimeoutMs);
const waitTimeoutMs = Number.isFinite(requestedWaitTimeoutMs)
? Math.max(0, Math.min(Math.trunc(requestedWaitTimeoutMs), 5 * 60 * 1000))
: 25_000;
const { provider } = req.body;
if (!provider) return res.status(400).json({ error: "缺少 provider" });
try {
// Pre-authorization check for all authenticated users
const preauth = await preauthorizeCall(req.user.id, provider);
if (!preauth.authorized) {
return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" });
}
const abortController = new AbortController();
const handleAbort = () => abortController.abort();
req.once("close", handleAbort);
req.once("aborted", handleAbort);
let result;
try {
result = await keyManager.acquireKey(provider, req.user, preauth, {
waitTimeoutMs,
signal: abortController.signal,
});
} finally {
req.off("close", handleAbort);
req.off("aborted", handleAbort);
}
if (!result) {
const status = await keyManager.getKeyStatus(provider);
if ((status?.totalCapacity || 0) <= 0) {
return res.status(404).json({ error: `${provider} 并发池未配置`, status });
}
return res.status(429).json({
error: `${provider} 所有 Key 已满 (${status.totalActive}/${status.totalCapacity}),请稍后重试`,
status,
});
}
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === "Cancelled") {
return;
}
console.error("[keys/acquire] failed", { provider, userId: req.user?.id, message });
res.status(500).json({ error: `分配并发槽失败: ${message}` });
}
});
router.post("/keys/release", requireAuth, async (req, res) => {
const { leaseToken } = req.body;
if (!leaseToken) return res.status(400).json({ error: "缺少 leaseToken" });
try {
const result = await keyManager.releaseKey(leaseToken, req.user);
if (result.notFound) {
return res.status(404).json({ error: "leaseToken 不存在", released: false });
}
if (!result.released && !result.alreadyReleased) {
return res.status(409).json({
error: "并发槽释放处理中,请稍后重试",
released: false,
provider: result.provider,
});
}
res.json({
success: true,
released: result.released,
alreadyReleased: result.alreadyReleased,
provider: result.provider,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[keys/release] failed", { leaseToken, userId: req.user?.id, message });
res.status(500).json({ error: `释放并发槽失败: ${message}` });
}
});
router.get("/keys/status", requireAuth, async (req, res) => {
const { provider } = req.query;
if (provider) {
return res.json(await keyManager.getKeyStatus(provider));
}
res.json(await keyManager.getAllStatus());
});
}
module.exports = {
registerKeyRoutes,
};
+134
View File
@@ -0,0 +1,134 @@
"use strict";
const { requireAuth, pool } = require("./context");
function cleanText(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function safeJsonString(value, fallback) {
if (value === undefined) return JSON.stringify(fallback);
try {
return JSON.stringify(value ?? fallback);
} catch {
return JSON.stringify(fallback);
}
}
function parseJson(value, fallback) {
if (!value || typeof value !== "string") return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function formatNotification(row) {
return {
id: Number(row.id),
type: row.type,
title: row.title,
description: row.description || "",
targetType: row.target_type || null,
targetId: row.target_id || null,
metadata: parseJson(row.metadata_json, {}),
readAt: row.read_at || null,
isRead: Boolean(row.read_at),
createdAt: row.created_at,
};
}
function registerNotificationRoutes(router) {
router.get("/notifications", requireAuth, async (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
const unreadOnly = req.query.unread === "1" || req.query.unread === "true";
const params = [req.user.id, limit];
const unreadClause = unreadOnly ? "AND read_at IS NULL" : "";
const { rows } = await pool.query(
`
SELECT *
FROM web_notifications
WHERE user_id = $1 ${unreadClause}
ORDER BY created_at DESC
LIMIT $2
`,
params,
);
res.json({ notifications: rows.map(formatNotification) });
} catch (err) {
console.error("[notifications] list failed:", err.message);
res.status(500).json({ error: "Failed to load notifications" });
}
});
router.post("/notifications", requireAuth, async (req, res) => {
const type = cleanText(req.body?.type, 64) || "info";
const title = cleanText(req.body?.title, 200);
const description = cleanText(req.body?.description, 2000) || null;
const targetType = cleanText(req.body?.targetType ?? req.body?.target_type, 64) || null;
const targetId = cleanText(req.body?.targetId ?? req.body?.target_id, 128) || null;
if (!title) return res.status(400).json({ error: "Missing notification title" });
try {
const { rows } = await pool.query(
`
INSERT INTO web_notifications (
user_id, type, title, description, target_type, target_id, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`,
[
req.user.id,
type,
title,
description,
targetType,
targetId,
safeJsonString(req.body?.metadata, {}),
],
);
res.status(201).json({ notification: formatNotification(rows[0]) });
} catch (err) {
console.error("[notifications] create failed:", err.message);
res.status(500).json({ error: "Failed to create notification" });
}
});
router.patch("/notifications/:id/read", requireAuth, async (req, res) => {
const markRead = req.body?.isRead !== false;
try {
const { rows } = await pool.query(
`
UPDATE web_notifications
SET read_at = ${markRead ? "COALESCE(read_at, NOW())" : "NULL"}
WHERE id = $1 AND user_id = $2
RETURNING *
`,
[req.params.id, req.user.id],
);
if (!rows[0]) return res.status(404).json({ error: "Notification not found" });
res.json({ notification: formatNotification(rows[0]) });
} catch (err) {
console.error("[notifications] mark read failed:", err.message);
res.status(500).json({ error: "Failed to update notification" });
}
});
router.post("/notifications/read-all", requireAuth, async (req, res) => {
try {
await pool.query(
"UPDATE web_notifications SET read_at = COALESCE(read_at, NOW()) WHERE user_id = $1",
[req.user.id],
);
res.json({ success: true });
} catch (err) {
console.error("[notifications] read-all failed:", err.message);
res.status(500).json({ error: "Failed to mark notifications read" });
}
});
}
module.exports = { registerNotificationRoutes };
+324
View File
@@ -0,0 +1,324 @@
const crypto = require("node:crypto");
const dns = require("node:dns");
const { requireAuth } = require("./context");
const { putObject, isOssConfigured, createSignedReadUrl } = require("../ossClient");
const DATA_URL_PATTERN = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/;
const DEFAULT_MAX_UPLOAD_BYTES = 200 * 1024 * 1024; // 200MB (must match nginx client_max_body_size)
const MAX_UPLOAD_BYTES = Math.max(
1,
Number(process.env.OSS_UPLOAD_MAX_BYTES || DEFAULT_MAX_UPLOAD_BYTES) || DEFAULT_MAX_UPLOAD_BYTES,
);
// --- SSRF protection: resolve hostname and check for private/internal IPs ---
const BLOCKED_HOSTNAMES = new Set([
'localhost',
'metadata.google.internal',
'metadata.internal',
'instance-data',
]);
function isPrivateIp(ip) {
if (!ip) return true;
// IPv4 private ranges
if (/^127\./.test(ip)) return true; // loopback
if (/^10\./.test(ip)) return true; // Class A private
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; // Class B private
if (/^192\.168\./.test(ip)) return true; // Class C private
if (/^169\.254\./.test(ip)) return true; // link-local (cloud metadata)
if (/^0\./.test(ip)) return true; // current network
if (ip === '0.0.0.0') return true;
// IPv6 private/special
if (ip === '::1') return true; // loopback
if (ip.startsWith('fe80:')) return true; // link-local
if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // unique local
if (ip === '::' || ip === '::ffff:0:0') return true; // unspecified
// IPv6-mapped IPv4: extract IPv4 part and recheck
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (mapped) return isPrivateIp(mapped[1]);
return false;
}
function resolveAndCheckHost(hostname) {
// Check hostname blocklist first
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return Promise.reject(new Error('禁止访问内网地址'));
}
return new Promise((resolve, reject) => {
dns.lookup(hostname, { all: true }, (err, addresses) => {
if (err) return reject(new Error(`DNS 解析失败: ${hostname}`));
if (!addresses || addresses.length === 0) return reject(new Error(`DNS 无记录: ${hostname}`));
for (const { address } of addresses) {
if (isPrivateIp(address)) {
return reject(new Error('禁止访问内网地址'));
}
}
resolve();
});
});
}
const MIME_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/mp4": "m4a",
"audio/x-m4a": "m4a",
"audio/aac": "aac",
"application/json": "json",
"application/octet-stream": "bin",
};
const PROFILE_UPLOAD_SCOPES = new Set(["profile-avatar", "profile-background"]);
const COMMUNITY_CASE_UPLOAD_SCOPES = new Set([
"community-case-asset",
"community-case-cover",
"community-case-image",
"community-case-media",
"community-case-video",
"community-case-workflow",
]);
function normalizeMimeType(value) {
const mimeType = String(value || "").trim().toLowerCase();
return MIME_EXTENSIONS[mimeType] ? mimeType : "application/octet-stream";
}
function getAssetDirectory(mimeType) {
if (mimeType.startsWith("image/")) return "images";
if (mimeType.startsWith("video/")) return "videos";
if (mimeType.startsWith("audio/")) return "audios";
return "files";
}
function getProfileObjectKey(scope, userId, ext, mimeType) {
if (!PROFILE_UPLOAD_SCOPES.has(scope)) return null;
if (!mimeType.startsWith("image/")) {
const error = new Error("Profile media must be an image");
error.status = 400;
throw error;
}
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
if (scope === "profile-avatar") {
return `users/${safeUserId}/profile/avatar/avatar.${ext}`;
}
return `users/${safeUserId}/profile/background/background.${ext}`;
}
function getCommunityObjectKey(scope, userId, ext, mimeType) {
if (!COMMUNITY_CASE_UPLOAD_SCOPES.has(scope)) return null;
const uniqueName = `${Date.now()}_${crypto.randomUUID()}.${ext}`;
if (scope === "community-case-cover" || scope === "community-case-image") {
if (!mimeType.startsWith("image/")) {
const error = new Error("Community case image uploads must be an image");
error.status = 400;
throw error;
}
return `community/images/${uniqueName}`;
}
if (scope === "community-case-video") {
if (!mimeType.startsWith("video/")) {
const error = new Error("Community case video uploads must be a video");
error.status = 400;
throw error;
}
return `community/videos/${uniqueName}`;
}
if (scope === "community-case-workflow") {
if (mimeType !== "application/json") {
const error = new Error("Community workflow uploads must be JSON");
error.status = 400;
throw error;
}
return `community/canvas/${uniqueName}`;
}
const assetDir = getAssetDirectory(mimeType);
const communityDir = mimeType === "application/json" ? "canvas" : assetDir;
return `community/${communityDir}/${uniqueName}`;
}
function buildOssPublicUrl(ossKey) {
const publicBaseUrl = String(process.env.OSS_PUBLIC_BASE_URL || "").trim().replace(/\/+$/, "");
if (publicBaseUrl) {
return `${publicBaseUrl}/${ossKey}`;
}
const bucket = String(process.env.OSS_BUCKET || "").trim();
const region = String(process.env.OSS_REGION || "").trim().replace(/^oss-/, "");
if (!bucket || !region) {
throw new Error("OSS bucket or region is not configured");
}
return `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`;
}
function parseUploadPayload(body) {
const rawData = String(body?.dataUrl || body?.data || "");
const dataUrlMatch = rawData.match(DATA_URL_PATTERN);
const mimeType = normalizeMimeType(body?.mimeType || dataUrlMatch?.[1]);
const base64 = (dataUrlMatch?.[2] || rawData).replace(/\s+/g, "");
if (!base64) {
const error = new Error("Missing upload data");
error.status = 400;
throw error;
}
const buffer = Buffer.from(base64, "base64");
if (!buffer.length) {
const error = new Error("Invalid upload data");
error.status = 400;
throw error;
}
if (buffer.length > MAX_UPLOAD_BYTES) {
const error = new Error("Upload file is too large");
error.status = 413;
throw error;
}
return { buffer, mimeType };
}
function registerOssRoutes(router) {
// ── OSS / STS ───────────────────────────────────────────────────────
const sts = require("../sts");
router.post("/oss/sts-token", requireAuth, async (req, res) => {
try {
if (!sts.isSTSConfigured()) {
return res.status(501).json({ error: "STS 未配置" });
}
const userId = req.user.id;
const credentials = await sts.assumeRole(userId);
res.set("Cache-Control", "no-store");
res.json(credentials);
} catch (err) {
console.error("[sts] AssumeRole failed:", err.message);
res.status(500).json({ error: "获取临时凭证失败" });
}
});
router.post("/oss/upload-by-url", requireAuth, async (req, res) => {
try {
if (!isOssConfigured()) {
return res.status(501).json({ error: "OSS 未配置" });
}
const sourceUrl = String(req.body?.sourceUrl || "").trim();
if (!sourceUrl || !/^https?:\/\//.test(sourceUrl)) {
return res.status(400).json({ error: "需要有效的 sourceUrl" });
}
const parsed = new URL(sourceUrl);
// SSRF protection: resolve DNS and check all resolved IPs are public
try {
await resolveAndCheckHost(parsed.hostname);
} catch (dnsErr) {
return res.status(400).json({ error: dnsErr.message });
}
console.info(`[oss/upload-by-url] fetching ${sourceUrl}`);
const fetchRes = await fetch(sourceUrl, {
signal: AbortSignal.timeout(60_000),
headers: { "User-Agent": "OmniAI-OSS-Proxy/1.0" },
});
if (!fetchRes.ok) {
return res.status(502).json({ error: `源文件下载失败: HTTP ${fetchRes.status}` });
}
const contentType = fetchRes.headers.get("content-type") || "";
const mimeType = normalizeMimeType(req.body?.mimeType || contentType);
const ext = MIME_EXTENSIONS[mimeType] || "bin";
const arrayBuffer = await fetchRes.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (!buffer.length) {
return res.status(400).json({ error: "源文件为空" });
}
if (buffer.length > MAX_UPLOAD_BYTES) {
return res.status(413).json({ error: "源文件过大" });
}
const safeUserId = String(req.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
const assetDir = getAssetDirectory(mimeType);
const scope = String(req.body?.scope || "").trim();
const objectKey =
getProfileObjectKey(scope, req.user.id, ext, mimeType) ||
getCommunityObjectKey(scope, req.user.id, ext, mimeType) ||
`tmp/${safeUserId}/generation-inputs/${assetDir}/${Date.now()}_${crypto.randomUUID()}.${ext}`;
await putObject(objectKey, buffer, mimeType, { "x-oss-object-acl": "public-read" });
const url = buildOssPublicUrl(objectKey);
const signedUrl = typeof createSignedReadUrl === "function" ? createSignedReadUrl(objectKey) : url;
console.info(`[oss/upload-by-url] done: ${objectKey} (${buffer.length} bytes)`);
res.status(201).json({ ossKey: objectKey, url, signedUrl });
} catch (err) {
const status = err.status || 500;
console.error("[oss/upload-by-url] failed:", err.message);
res.status(status).json({ error: err.message || "转存失败" });
}
});
router.post("/oss/upload", requireAuth, async (req, res) => {
try {
if (!isOssConfigured()) {
return res.status(501).json({ error: "OSS 未配置" });
}
const { buffer, mimeType } = parseUploadPayload(req.body);
const safeUserId = String(req.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
const ext = MIME_EXTENSIONS[mimeType] || "bin";
const assetDir = getAssetDirectory(mimeType);
const scope = String(req.body?.scope || "").trim();
const objectKey =
getProfileObjectKey(scope, req.user.id, ext, mimeType) ||
getCommunityObjectKey(scope, req.user.id, ext, mimeType) ||
`tmp/${safeUserId}/generation-inputs/${assetDir}/${Date.now()}_${crypto.randomUUID()}.${ext}`;
await putObject(objectKey, buffer, mimeType, { "x-oss-object-acl": "public-read" });
const url = buildOssPublicUrl(objectKey);
const signedUrl = typeof createSignedReadUrl === "function" ? createSignedReadUrl(objectKey) : url;
res.status(201).json({ ossKey: objectKey, url, signedUrl });
} catch (err) {
const status = err.status || 500;
console.error("[oss/upload] failed:", err.message);
res.status(status).json({ error: err.message || "上传素材失败" });
}
});
router.get("/oss/proxy", async (req, res) => {
const ossKey = String(req.query.key || "").trim();
if (!ossKey) return res.status(400).json({ error: "Missing key" });
const bucket = String(process.env.OSS_BUCKET || "").trim();
const region = String(process.env.OSS_REGION || "").trim();
if (!bucket || !region) return res.status(500).json({ error: "OSS not configured" });
const ossUrl = `https://${bucket}.${region}.aliyuncs.com/${ossKey}`;
try {
const upstream = await fetch(ossUrl);
if (!upstream.ok) return res.status(upstream.status).end();
const ct = upstream.headers.get("content-type");
const cl = upstream.headers.get("content-length");
if (ct) res.setHeader("Content-Type", ct);
if (cl) res.setHeader("Content-Length", cl);
res.setHeader("Cache-Control", "public, max-age=3600");
const { Readable } = require("node:stream");
Readable.fromWeb(upstream.body).pipe(res);
} catch (err) {
console.error("[oss/proxy] error:", err.message);
if (!res.headersSent) res.status(502).json({ error: "Proxy failed" });
}
});
}
module.exports = {
registerOssRoutes,
};
+145
View File
@@ -0,0 +1,145 @@
const { express, requireAuth, isSystemAdmin, wechatPay, alipay, pool } = require("./context");
function registerPaymentRoutes(router) {
// ── Payment ──────────────────────────────────────────────────────────
function getNotifyBaseUrl() {
return process.env.PAYMENT_NOTIFY_BASE_URL || "";
}
router.post("/payment/create", requireAuth, async (req, res) => {
const { orderNo, paymentMethod = "wechat" } = req.body;
if (!orderNo) return res.status(400).json({ error: "缺少订单号" });
const {
rows: [order],
} = await pool.query("SELECT * FROM payment_orders WHERE order_no = $1 AND status = $2", [
orderNo,
"pending",
]);
if (!order) return res.status(404).json({ error: "订单不存在或已处理" });
// Verify order belongs to user
const isOwnOrder =
(order.enterprise_id &&
req.user.enterpriseId &&
order.enterprise_id === req.user.enterpriseId) ||
(order.user_id && order.user_id === req.user.id);
if (!isOwnOrder && !isSystemAdmin(req.user)) {
return res.status(403).json({ error: "无权操作此订单" });
}
const notifyBase = getNotifyBaseUrl();
const description =
order.type === "package" ? `套餐购买 - ${order.order_no}` : `积分充值 - ${order.order_no}`;
try {
if (paymentMethod === "wechat") {
if (!wechatPay.isWechatPayEnabled())
return res.status(503).json({ error: "微信支付未配置" });
const notifyUrl = notifyBase ? `${notifyBase}/api/payment/notify/wechat` : "";
const result = await wechatPay.createNativeOrder(
orderNo,
order.amount_cents,
description,
notifyUrl,
);
return res.json({ paymentMethod: "wechat", codeUrl: result.codeUrl, orderNo });
}
if (paymentMethod === "alipay") {
if (!alipay.isAlipayEnabled()) return res.status(503).json({ error: "支付宝未配置" });
const notifyUrl = notifyBase ? `${notifyBase}/api/payment/notify/alipay` : "";
const result = await alipay.createPrecreateOrder(
orderNo,
order.amount_cents,
description,
notifyUrl,
);
return res.json({ paymentMethod: "alipay", codeUrl: result.qrCode, orderNo });
}
return res.status(400).json({ error: "不支持的支付方式" });
} catch (err) {
console.error("[payment/create] failed", err.message);
res.status(500).json({ error: `创建支付失败: ${err.message}` });
}
});
router.get("/payment/status", requireAuth, async (req, res) => {
const { orderNo } = req.query;
if (!orderNo) return res.status(400).json({ error: "缺少订单号" });
const {
rows: [order],
} = await pool.query(
"SELECT order_no, status, amount_cents, payment_method, paid_at, enterprise_id, user_id FROM payment_orders WHERE order_no = $1",
[orderNo],
);
if (!order) return res.status(404).json({ error: "订单不存在" });
const isOwn =
(order.enterprise_id &&
req.user.enterpriseId &&
order.enterprise_id === req.user.enterpriseId) ||
(order.user_id && order.user_id === req.user.id);
if (!isOwn && !isSystemAdmin(req.user)) {
return res.status(403).json({ error: "无权查看此订单" });
}
res.json({
orderNo: order.order_no,
status: order.status,
amountCents: order.amount_cents,
paymentMethod: order.payment_method,
paidAt: order.paid_at,
});
});
router.post("/payment/notify/wechat", express.raw({ type: "*/*" }), (req, res) => {
try {
let body = req.body;
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString("utf-8"));
const result = wechatPay.verifyAndDecryptNotification(req.headers, body);
if (!result) return res.status(400).json({ code: "FAIL", message: "签名验证失败" });
if (result.trade_state === "SUCCESS") {
wechatPay.handlePaymentSuccess(result.out_trade_no, result.transaction_id);
}
res.json({ code: "SUCCESS", message: "OK" });
} catch (err) {
console.error("[payment/notify/wechat] error:", err.message);
res.status(500).json({ code: "FAIL", message: "Internal error" });
}
});
router.post("/payment/notify/alipay", express.urlencoded({ extended: false }), (req, res) => {
try {
const body = req.body;
const verified = alipay.verifyCallback(req.headers, body);
if (!verified) return res.send("fail");
const orderNo = body.out_trade_no || body.trade_no;
const tradeNo = body.trade_no || body.trade_id;
if (body.trade_status === "TRADE_SUCCESS" || body.trade_status === "TRADE_FINISHED") {
alipay.handlePaymentSuccess(orderNo, tradeNo);
}
res.send("success");
} catch (err) {
console.error("[payment/notify/alipay] error:", err.message);
res.send("fail");
}
});
router.get("/payment/methods", (_req, res) => {
res.json({ wechat: wechatPay.isWechatPayEnabled(), alipay: alipay.isAlipayEnabled() });
});
}
module.exports = {
registerPaymentRoutes,
};
+904
View File
@@ -0,0 +1,904 @@
const {
requireAuth,
pool,
withTransaction,
computeNextRevision,
normalizeRevisionValue,
shouldRejectStaleRevision,
formatGenerationTaskRow,
normalizeGenerationTaskPayload,
normalizeProjectOssKey,
buildOssPublicUrl,
requireOwnedProject,
upsertGenerationTask,
} = require("./context");
const crypto = require("node:crypto");
const { getObject, putObject, isOssConfigured } = require("../ossClient");
function countArray(value) {
return Array.isArray(value) ? value.length : 0;
}
function stableProjectFingerprint(json) {
return crypto.createHash("sha256").update(json).digest("hex");
}
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function mediaUrlToProjectOssKey(mediaUrl, userId) {
if (typeof mediaUrl !== "string" || !mediaUrl.startsWith("media://")) return null;
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
try {
const parsed = new URL(mediaUrl);
const host = decodeURIComponent(parsed.hostname || "");
const parts = parsed.pathname
.split("/")
.map((part) => decodeURIComponent(part).trim())
.filter(Boolean);
if (!safeUserId || parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
return null;
}
if (host.startsWith("u--")) {
const mediaUserId = host.slice(3).replace(/[^a-zA-Z0-9_-]/g, "");
if (mediaUserId !== safeUserId) return null;
return `users/${safeUserId}/projects/${parts.join("/")}`;
}
if (host === "images" || host === "videos") {
return `users/${safeUserId}/projects/${host}/${parts.join("/")}`;
}
return `users/${safeUserId}/projects/${host}/${parts.join("/")}`;
} catch {
return null;
}
}
function resolveProjectMediaUrls(value, userId) {
if (typeof value === "string") {
const ossKey = mediaUrlToProjectOssKey(value, userId);
if (!ossKey) return value;
try {
return buildOssPublicUrl(ossKey);
} catch {
return value;
}
}
if (Array.isArray(value)) {
return value.map((item) => resolveProjectMediaUrls(item, userId));
}
if (isPlainObject(value)) {
const next = Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, resolveProjectMediaUrls(entry, userId)]),
);
const ossKey = String(next.ossKey || next.oss_key || "").trim().replace(/^\/+/, "");
if (
ossKey &&
/^(users|tmp)\/[^/]+\//.test(ossKey) &&
!next.url &&
!next.imageUrl &&
!next.image_url &&
!next.previewUrl &&
!next.preview_url &&
!next.coverUrl &&
!next.cover_url
) {
try {
next.publicUrl = buildOssPublicUrl(ossKey);
} catch {
// Keep the original metadata shape if OSS public URL config is incomplete.
}
}
return next;
}
return value;
}
const PROJECT_MEDIA_URL_KEYS = new Set([
"previewUrl",
"preview_url",
"imageUrl",
"image_url",
"videoUrl",
"video_url",
"coverUrl",
"cover_url",
"thumbnailUrl",
"thumbnail_url",
]);
const PROJECT_MEDIA_MATERIALIZE_MAX_BYTES = 180 * 1024 * 1024;
function isProjectMediaUrlKey(key) {
return PROJECT_MEDIA_URL_KEYS.has(String(key || ""));
}
function isSignedOrTemporaryMediaUrl(value, userId, projectId) {
if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return false;
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
const safeProjectId = String(projectId).replace(/[^a-zA-Z0-9_-]/g, "");
try {
const parsed = new URL(value);
const path = decodeURIComponent(parsed.pathname || "");
if (
(safeUserId && path.includes(`/users/${safeUserId}/generation-results/`)) ||
(safeUserId && safeProjectId && path.includes(`/users/${safeUserId}/projects/${safeProjectId}/`)) ||
(safeUserId && path.includes(`/users/${safeUserId}/assets/`))
) {
return false;
}
const queryKeys = Array.from(parsed.searchParams.keys()).join("&").toLowerCase();
const hasSignedQuery =
/(?:expires|signature|ossaccesskeyid|x-oss-signature|x-amz-signature|x-amz-expires|sig|se)=?/i.test(queryKeys) ||
/(?:expires|signature|ossaccesskeyid|x-oss-signature|x-amz-signature|x-amz-expires|sig|se)=/i.test(parsed.search);
const hostLooksTemporaryProvider =
/(?:dashscope|oss-accelerate|aliyuncs|volces|kling|grsai|dakka|rightcode)/i.test(parsed.hostname);
return hasSignedQuery || hostLooksTemporaryProvider;
} catch {
return false;
}
}
function mediaExtensionFromContentType(contentType, url, kindHint = "image") {
const mime = String(contentType || "").split(";")[0].trim().toLowerCase();
const mimeExtension = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/x-msvideo": "avi",
}[mime];
if (mimeExtension) return mimeExtension;
try {
const ext = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1];
if (ext) return ext.toLowerCase();
} catch {
// Use the kind fallback below.
}
return kindHint === "video" ? "mp4" : "png";
}
function isErrorDocumentContentType(contentType) {
return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || ""));
}
function getProjectMediaKindFromKey(key) {
return String(key || "").toLowerCase().includes("video") ? "video" : "image";
}
async function materializeProjectMediaUrl(url, userId, projectId, key) {
if (!isOssConfigured() || !isSignedOrTemporaryMediaUrl(url, userId, projectId)) return url;
try {
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
throw new Error(`media fetch returned ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
if (isErrorDocumentContentType(contentType)) {
const text = await response.text().catch(() => "");
throw new Error(`media fetch returned error document: ${text.slice(0, 120)}`);
}
const declaredLength = Number(response.headers.get("content-length") || 0);
if (declaredLength > PROJECT_MEDIA_MATERIALIZE_MAX_BYTES) {
throw new Error(`media is too large to persist (${declaredLength} bytes)`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) throw new Error("media fetch returned empty content");
if (buffer.length > PROJECT_MEDIA_MATERIALIZE_MAX_BYTES) {
throw new Error(`media is too large to persist (${buffer.length} bytes)`);
}
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
const safeProjectId = String(projectId).replace(/[^a-zA-Z0-9_-]/g, "");
const kind = getProjectMediaKindFromKey(key);
const extension = mediaExtensionFromContentType(contentType, url, kind);
const finalContentType = contentType || (kind === "video" ? "video/mp4" : `image/${extension === "jpg" ? "jpeg" : extension}`);
const objectKey = `users/${safeUserId}/projects/${safeProjectId}/media/${kind}s/${Date.now()}-${crypto.randomUUID()}.${extension}`;
const uploaded = await putObject(objectKey, buffer, finalContentType, { "x-oss-object-acl": "public-read" });
return uploaded.url;
} catch (error) {
console.warn("[projects] media materialization skipped:", error.message);
return url;
}
}
async function materializeProjectMediaUrls(value, userId, projectId, key = "") {
if (typeof value === "string") {
if (!isProjectMediaUrlKey(key)) return value;
return materializeProjectMediaUrl(value, userId, projectId, key);
}
if (Array.isArray(value)) {
const items = [];
for (const item of value) {
items.push(await materializeProjectMediaUrls(item, userId, projectId, key));
}
return items;
}
if (isPlainObject(value)) {
const next = {};
for (const [entryKey, entryValue] of Object.entries(value)) {
next[entryKey] = await materializeProjectMediaUrls(entryValue, userId, projectId, entryKey);
}
return next;
}
return value;
}
function formatProjectContentMeta(projectId, userId, content, meta = {}) {
const workflowNodes = Array.isArray(content?.workflowData?.nodes) ? content.workflowData.nodes : [];
const imageCount =
Number(meta.imageCount ?? meta.image_count) ||
workflowNodes.filter((node) => node?.type === "image" || node?.kind === "image").length;
const videoCount =
Number(meta.videoCount ?? meta.video_count) ||
countArray(content?.videos) ||
workflowNodes.filter((node) => node?.type === "video" || node?.kind === "video").length;
return {
id: projectId,
name: String(meta.name || content?.name || content?.projectName || "Untitled project").trim().slice(0, 200),
description:
meta.description === null || meta.description === undefined
? content?.description || content?.projectDescription || null
: String(meta.description).slice(0, 2000),
ossKey: `users/${String(userId).replace(/[^a-zA-Z0-9_-]/g, "")}/projects/${projectId}/current/project.json`,
thumbnailUrl:
meta.thumbnailUrl ||
meta.thumbnail_url ||
content?.thumbnailUrl ||
content?.thumbnail_url ||
null,
storyboardCount: Number(meta.storyboardCount ?? meta.storyboard_count) || countArray(content?.storyboards),
imageCount,
videoCount,
};
}
function registerProjectRoutes(router) {
// ── Projects (Cloud Sync) ──────────────────────────────────────────
router.get("/projects", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const { rows } = await pool.query(
`SELECT
id,
name,
description,
oss_key,
thumbnail_url,
storyboard_count,
image_count,
video_count,
file_size,
current_revision AS revision,
current_fingerprint AS fingerprint,
updated_by_device_id,
source_case_id,
origin_type,
created_at,
updated_at
FROM projects
WHERE user_id = $1
ORDER BY updated_at DESC`,
[userId],
);
res.json({ projects: rows });
} catch (err) {
console.error("[projects] list failed:", err.message);
res.status(500).json({ error: "获取项目列表失败" });
}
});
router.post("/projects/upsert", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const {
id,
name,
description,
ossKey,
thumbnailUrl,
storyboardCount,
imageCount,
videoCount,
fileSize,
fingerprint,
deviceId,
baseRevision,
forceOverwrite,
saveReason,
sourceCaseId,
originType,
} = req.body;
if (!id || !name || !ossKey) {
return res.status(400).json({ error: "缺少必要字段 (id, name, ossKey)" });
}
const normalizedOssKey = normalizeProjectOssKey(ossKey, userId, id);
if (normalizedOssKey.error) {
return res.status(400).json({ error: normalizedOssKey.error });
}
const saveReasonValue = String(saveReason || "save").slice(0, 32) || "save";
const originTypeValue = String(originType || (sourceCaseId ? "community_copy" : "manual"))
.trim()
.slice(0, 32);
const result = await withTransaction(async (client) => {
const { rows: existingRows } = await client.query(
"SELECT id, user_id, current_revision, current_fingerprint, oss_key, updated_at FROM projects WHERE id = $1 FOR UPDATE",
[id],
);
const existing = existingRows[0] || null;
if (existing && Number(existing.user_id) !== Number(userId)) {
const error = new Error("无权保存该项目");
error.status = 403;
throw error;
}
const revisionInfo = computeNextRevision(existing?.current_revision, baseRevision);
if (
existing &&
shouldRejectStaleRevision(
existing.current_revision,
baseRevision,
Boolean(forceOverwrite),
)
) {
const error = new Error("stale_revision");
error.status = 409;
error.code = "stale_revision";
error.currentRevision = revisionInfo.currentRevision;
error.normalizedBaseRevision = revisionInfo.normalizedBaseRevision;
error.currentFingerprint = existing.current_fingerprint || null;
error.currentOssKey = existing.oss_key || null;
error.currentUpdatedAt = existing.updated_at || null;
throw error;
}
const {
rows: [projectRow],
} = await client.query(
`
INSERT INTO projects (
id,
user_id,
name,
description,
oss_key,
thumbnail_url,
storyboard_count,
image_count,
video_count,
file_size,
current_revision,
current_fingerprint,
updated_by_device_id,
source_case_id,
origin_type,
created_at,
updated_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW()
)
ON CONFLICT (id) DO UPDATE SET
user_id = EXCLUDED.user_id,
name = EXCLUDED.name,
description = EXCLUDED.description,
oss_key = EXCLUDED.oss_key,
thumbnail_url = EXCLUDED.thumbnail_url,
storyboard_count = EXCLUDED.storyboard_count,
image_count = EXCLUDED.image_count,
video_count = EXCLUDED.video_count,
file_size = EXCLUDED.file_size,
current_revision = EXCLUDED.current_revision,
current_fingerprint = EXCLUDED.current_fingerprint,
updated_by_device_id = EXCLUDED.updated_by_device_id,
source_case_id = COALESCE(projects.source_case_id, EXCLUDED.source_case_id),
origin_type = CASE
WHEN projects.origin_type IS NULL OR projects.origin_type = 'manual'
THEN EXCLUDED.origin_type
ELSE projects.origin_type
END,
updated_at = NOW()
RETURNING
id,
name,
description,
oss_key,
thumbnail_url,
storyboard_count,
image_count,
video_count,
file_size,
current_revision AS revision,
current_fingerprint AS fingerprint,
updated_by_device_id,
source_case_id,
origin_type,
created_at,
updated_at
`,
[
id,
userId,
name,
description || null,
normalizedOssKey.value,
thumbnailUrl || null,
storyboardCount || 0,
imageCount || 0,
videoCount || 0,
fileSize || 0,
revisionInfo.nextRevision,
fingerprint || null,
deviceId || null,
sourceCaseId || null,
originTypeValue || "manual",
],
);
await client.query(
`
INSERT INTO project_revisions (
project_id,
revision_number,
oss_key,
content_fingerprint,
source_device_id,
save_reason
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, revision_number) DO UPDATE SET
oss_key = EXCLUDED.oss_key,
content_fingerprint = EXCLUDED.content_fingerprint,
source_device_id = EXCLUDED.source_device_id,
save_reason = EXCLUDED.save_reason
`,
[
id,
revisionInfo.nextRevision,
normalizedOssKey.value,
fingerprint || null,
deviceId || null,
saveReasonValue,
],
);
return {
project: projectRow,
appliedRevision: normalizeRevisionValue(projectRow?.revision),
baseWasStale: revisionInfo.baseWasStale,
};
});
res.json({
project: result.project,
appliedRevision: result.appliedRevision,
baseWasStale: result.baseWasStale,
});
} catch (err) {
const status = err && typeof err === "object" && err.status ? err.status : 500;
console.error("[projects] upsert failed:", err.message);
if (err.code === "stale_revision") {
return res.status(status).json({
error: err.message || "stale_revision",
code: err.code,
currentRevision: err.currentRevision,
normalizedBaseRevision: err.normalizedBaseRevision,
currentFingerprint: err.currentFingerprint,
currentOssKey: err.currentOssKey,
currentUpdatedAt: err.currentUpdatedAt,
});
}
res.status(status).json({ error: err.message || "保存项目元数据失败" });
}
});
router.put("/projects/:id/content", requireAuth, async (req, res) => {
const userId = req.user.id;
const projectId = req.params.id;
const { content, meta, baseRevision, fingerprint, deviceId, saveReason, forceOverwrite } = req.body || {};
if (!content || typeof content !== "object" || Array.isArray(content)) {
return res.status(400).json({ error: "content must be an object" });
}
if (!isOssConfigured()) {
return res.status(501).json({ error: "OSS is not configured" });
}
try {
const durableContent = await materializeProjectMediaUrls(content, userId, projectId);
const durableMeta = await materializeProjectMediaUrls(meta || {}, userId, projectId);
const contentJson = JSON.stringify(durableContent);
const resolvedMeta = formatProjectContentMeta(projectId, userId, durableContent, durableMeta);
const normalizedOssKey = normalizeProjectOssKey(resolvedMeta.ossKey, userId, projectId);
if (normalizedOssKey.error) {
return res.status(400).json({ error: normalizedOssKey.error });
}
const contentFingerprint = String(fingerprint || stableProjectFingerprint(contentJson)).slice(0, 128);
const saveReasonValue = String(saveReason || "web-autosave").slice(0, 32) || "web-autosave";
const result = await withTransaction(async (client) => {
const { rows: existingRows } = await client.query(
"SELECT id, user_id, current_revision, current_fingerprint, oss_key, updated_at FROM projects WHERE id = $1 FOR UPDATE",
[projectId],
);
const existing = existingRows[0] || null;
if (!existing || Number(existing.user_id) !== Number(userId)) {
const error = new Error("Project not found");
error.status = 404;
throw error;
}
const revisionInfo = computeNextRevision(existing.current_revision, baseRevision);
if (
shouldRejectStaleRevision(
existing.current_revision,
baseRevision,
Boolean(forceOverwrite),
)
) {
const error = new Error("stale_revision");
error.status = 409;
error.code = "stale_revision";
error.currentRevision = revisionInfo.currentRevision;
error.normalizedBaseRevision = revisionInfo.normalizedBaseRevision;
error.currentFingerprint = existing.current_fingerprint || null;
error.currentOssKey = existing.oss_key || null;
error.currentUpdatedAt = existing.updated_at || null;
throw error;
}
await putObject(normalizedOssKey.value, contentJson, "application/json");
const {
rows: [projectRow],
} = await client.query(
`
UPDATE projects SET
name = $3,
description = $4,
oss_key = $5,
thumbnail_url = $6,
storyboard_count = $7,
image_count = $8,
video_count = $9,
file_size = $10,
current_revision = $11,
current_fingerprint = $12,
updated_by_device_id = $13,
updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING
id,
name,
description,
oss_key,
thumbnail_url,
storyboard_count,
image_count,
video_count,
file_size,
current_revision AS revision,
current_fingerprint AS fingerprint,
updated_by_device_id,
source_case_id,
origin_type,
created_at,
updated_at
`,
[
projectId,
userId,
resolvedMeta.name,
resolvedMeta.description,
normalizedOssKey.value,
resolvedMeta.thumbnailUrl,
resolvedMeta.storyboardCount,
resolvedMeta.imageCount,
resolvedMeta.videoCount,
Buffer.byteLength(contentJson),
revisionInfo.nextRevision,
contentFingerprint,
deviceId || "web",
],
);
await client.query(
`
INSERT INTO project_revisions (
project_id,
revision_number,
oss_key,
content_fingerprint,
source_device_id,
save_reason
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, revision_number) DO UPDATE SET
oss_key = EXCLUDED.oss_key,
content_fingerprint = EXCLUDED.content_fingerprint,
source_device_id = EXCLUDED.source_device_id,
save_reason = EXCLUDED.save_reason
`,
[
projectId,
revisionInfo.nextRevision,
normalizedOssKey.value,
contentFingerprint,
deviceId || "web",
saveReasonValue,
],
);
return {
project: projectRow,
appliedRevision: normalizeRevisionValue(projectRow?.revision),
baseWasStale: revisionInfo.baseWasStale,
};
});
res.json(result);
} catch (err) {
const status = err && typeof err === "object" && err.status ? err.status : 500;
console.error("[projects] content save failed:", err.message);
if (err.code === "stale_revision") {
return res.status(status).json({
error: err.message || "stale_revision",
code: err.code,
currentRevision: err.currentRevision,
normalizedBaseRevision: err.normalizedBaseRevision,
currentFingerprint: err.currentFingerprint,
currentOssKey: err.currentOssKey,
currentUpdatedAt: err.currentUpdatedAt,
});
}
res.status(status).json({ error: err.message || "Failed to save project content" });
}
});
router.delete("/projects/:id", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const { rowCount } = await pool.query("DELETE FROM projects WHERE id = $1 AND user_id = $2", [
projectId,
userId,
]);
if (rowCount === 0) {
return res.status(404).json({ error: "项目不存在" });
}
res.json({ success: true });
} catch (err) {
console.error("[projects] delete failed:", err.message);
res.status(500).json({ error: "删除项目失败" });
}
});
router.get("/projects/:id", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const { rows } = await pool.query(
`SELECT
id,
name,
description,
oss_key,
thumbnail_url,
storyboard_count,
image_count,
video_count,
file_size,
current_revision AS revision,
current_fingerprint AS fingerprint,
updated_by_device_id,
source_case_id,
origin_type,
created_at,
updated_at
FROM projects
WHERE id = $1 AND user_id = $2`,
[projectId, userId],
);
if (rows.length === 0) {
return res.status(404).json({ error: "项目不存在" });
}
res.json({ project: rows[0] });
} catch (err) {
console.error("[projects] get failed:", err.message);
res.status(500).json({ error: "获取项目失败" });
}
});
router.get("/projects/:id/tasks", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const { rows } = await pool.query(
`SELECT gt.*
FROM generation_tasks gt
JOIN projects p ON p.id = gt.project_id
WHERE gt.project_id = $1 AND gt.user_id = $2 AND p.user_id = $2
ORDER BY gt.updated_at DESC
LIMIT 500`,
[projectId, userId],
);
res.json({ tasks: rows.map(formatGenerationTaskRow) });
} catch (err) {
console.error("[projects/tasks] list failed:", err.message);
res.status(500).json({ error: "获取项目任务失败" });
}
});
router.post("/projects/:id/tasks/upsert", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const normalized = normalizeGenerationTaskPayload(req.body || {});
if (normalized.error) {
return res.status(400).json({ error: normalized.error });
}
const task = await withTransaction(async (client) => {
const ownsProject = await requireOwnedProject(client, userId, projectId);
if (!ownsProject) {
const error = new Error("Project not found");
error.status = 404;
throw error;
}
return upsertGenerationTask(client, userId, projectId, normalized.value);
});
res.json({ task: formatGenerationTaskRow(task) });
} catch (err) {
const status = err && typeof err === "object" && err.status ? err.status : 500;
console.error("[projects/tasks] upsert failed:", err.message);
res.status(status).json({ error: err.message || "保存任务失败" });
}
});
router.post("/projects/:id/tasks/batch-upsert", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const taskBodies = Array.isArray(req.body?.tasks) ? req.body.tasks : [];
if (taskBodies.length === 0) {
return res.json({ tasks: [] });
}
const normalizedTasks = [];
for (const taskBody of taskBodies.slice(0, 500)) {
const normalized = normalizeGenerationTaskPayload(taskBody || {});
if (normalized.error) {
return res.status(400).json({ error: normalized.error });
}
normalizedTasks.push(normalized.value);
}
const tasks = await withTransaction(async (client) => {
const ownsProject = await requireOwnedProject(client, userId, projectId);
if (!ownsProject) {
const error = new Error("Project not found");
error.status = 404;
throw error;
}
const rows = [];
for (const task of normalizedTasks) {
rows.push(await upsertGenerationTask(client, userId, projectId, task));
}
return rows;
});
res.json({ tasks: tasks.map(formatGenerationTaskRow) });
} catch (err) {
const status = err && typeof err === "object" && err.status ? err.status : 500;
console.error("[projects/tasks] batch upsert failed:", err.message);
res.status(status).json({ error: err.message || "批量保存任务失败" });
}
});
router.delete("/projects/:id/tasks/:taskId", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const taskId = req.params.taskId;
const { rowCount } = await pool.query(
`DELETE FROM generation_tasks
WHERE project_id = $1 AND user_id = $2 AND client_queue_id = $3`,
[projectId, userId, taskId],
);
if (rowCount === 0) {
return res.status(404).json({ error: "任务不存在" });
}
res.json({ success: true });
} catch (err) {
console.error("[projects/tasks] delete failed:", err.message);
res.status(500).json({ error: "删除任务失败" });
}
});
router.get("/projects/:id/content", requireAuth, async (req, res) => {
try {
const userId = req.user.id;
const projectId = req.params.id;
const { rows } = await pool.query(
"SELECT oss_key FROM projects WHERE id = $1 AND user_id = $2",
[projectId, userId],
);
if (rows.length === 0) {
return res.status(404).json({ error: "项目不存在" });
}
const ossKey = rows[0].oss_key;
if (!ossKey) {
return res.status(404).json({ error: "项目内容未上传" });
}
if (!isOssConfigured()) {
return res.status(501).json({ error: "OSS 未配置" });
}
const content = await getObject(ossKey);
const parsed = JSON.parse(content);
const shouldResolveMedia =
req.query.resolveMedia === "1" ||
req.query.resolveMedia === "true" ||
req.query.resolve_media === "1";
res.json({ content: shouldResolveMedia ? resolveProjectMediaUrls(parsed, userId) : parsed });
} catch (err) {
const status = err && typeof err === "object" && err.status ? err.status : 500;
console.error("[projects] content download failed:", err.message);
if (status === 404 && err.code === "oss_no_such_key") {
return res.status(404).json({
error: "项目内容文件不存在,请重新保存或删除该旧项目。",
code: "project_content_missing",
});
}
res.status(status).json({ error: err.message || "获取项目内容失败" });
}
});
}
module.exports = {
registerProjectRoutes,
};
+49
View File
@@ -0,0 +1,49 @@
const { keyManager, listModelPrices, pool } = require("./context");
function registerPriceRoutes(router) {
// ── Public ───────────────────────────────────────────────────────────
router.get("/prices", async (_req, res) => {
const prices = await listModelPrices({ enabledOnly: true });
res.json(prices);
});
}
function registerPackageRoutes(router) {
// ── Public: Packages ─────────────────────────────────────────────────
router.get("/packages", async (_req, res) => {
const { rows } = await pool.query(
"SELECT * FROM packages WHERE enabled = 1 ORDER BY sort_order, id",
);
res.json(
rows.map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description,
priceCents: row.price_cents,
creditsCents: row.credits_cents,
imageQuota: row.image_quota,
videoQuota: row.video_quota,
textQuota: row.text_quota,
durationDays: row.duration_days,
sortOrder: row.sort_order,
})),
);
});
}
function registerHealthRoutes(router) {
// ── Health ───────────────────────────────────────────────────────────
router.get("/health", async (_req, res) => {
const status = await keyManager.getAllStatus();
res.json({ status: "ok", uptime: process.uptime(), providers: status });
});
}
module.exports = {
registerPriceRoutes,
registerPackageRoutes,
registerHealthRoutes,
};
+123
View File
@@ -0,0 +1,123 @@
"use strict";
const { getUserContextById, requireAuth, requireAdmin, verifyToken } = require("../auth");
const { pool } = require("../db");
function cleanText(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function getRequestIp(req) {
const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
return forwardedFor || req.socket?.remoteAddress || "";
}
async function optionalAuth(req, _res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
next();
return;
}
try {
const payload = verifyToken(authHeader.slice(7));
const user = await getUserContextById(payload.userId);
if (user?.enabled) req.user = user;
} catch {
// Public report submission should still work when the session is missing or expired.
}
next();
}
function registerReportRoutes(router) {
router.post("/reports", optionalAuth, async (req, res) => {
try {
const reportType = cleanText(req.body?.reportType, 64) || "other";
const targetType = cleanText(req.body?.targetType, 64) || null;
const targetId = cleanText(req.body?.targetId, 128) || null;
const contactName = cleanText(req.body?.contactName, 120) || null;
const contactEmail = cleanText(req.body?.contactEmail, 200) || null;
const contactPhone = cleanText(req.body?.contactPhone, 60) || null;
const title = cleanText(req.body?.title, 200);
const description = cleanText(req.body?.description, 5000);
const pageUrl = cleanText(req.body?.pageUrl, 1000) || null;
if (!title || !description) {
return res.status(400).json({ error: "请填写举报标题和详细说明" });
}
const { rows } = await pool.query(
`INSERT INTO user_reports (
user_id, report_type, target_type, target_id,
contact_name, contact_email, contact_phone,
title, description, page_url, ip_address, user_agent
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, status, created_at`,
[
req.user?.id || null,
reportType,
targetType,
targetId,
contactName,
contactEmail,
contactPhone,
title,
description,
pageUrl,
getRequestIp(req),
cleanText(req.headers["user-agent"], 1000) || null,
],
);
res.status(201).json({
report: {
id: rows[0].id,
status: rows[0].status,
createdAt: rows[0].created_at,
},
});
} catch (err) {
console.error("[reports] create failed:", err.message);
res.status(500).json({ error: "提交举报失败" });
}
});
router.get("/admin/reports", requireAuth, requireAdmin, async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT r.*, u.username
FROM user_reports r
LEFT JOIN users u ON u.id = r.user_id
ORDER BY r.created_at DESC
LIMIT 200`,
);
res.json({
reports: rows.map((row) => ({
id: row.id,
userId: row.user_id,
username: row.username,
reportType: row.report_type,
targetType: row.target_type,
targetId: row.target_id,
contactName: row.contact_name,
contactEmail: row.contact_email,
contactPhone: row.contact_phone,
title: row.title,
description: row.description,
pageUrl: row.page_url,
status: row.status,
ipAddress: row.ip_address,
userAgent: row.user_agent,
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
});
} catch (err) {
console.error("[reports] list failed:", err.message);
res.status(500).json({ error: "获取举报列表失败" });
}
});
}
module.exports = { registerReportRoutes };
+292
View File
@@ -0,0 +1,292 @@
const {
requireAuth,
requireManagementAccess,
calculateCostMills,
deductForApiCall,
pool,
withTransaction,
getManagementEnterpriseId,
appendEnterpriseScope,
getPeriodStart,
clampPositiveInteger,
clampNonNegativeInteger,
} = require("./context");
function registerUsageReportRoutes(router) {
// ── Usage Reporting (with settlement) ────────────────────────────────
router.post("/usage/report", requireAuth, async (req, res) => {
const records = Array.isArray(req.body) ? req.body : [req.body];
if (records.length === 0) return res.status(400).json({ error: "缺少上报数据" });
if (records.length > 50) return res.status(400).json({ error: "单次最多上报 50 条记录" });
try {
const billingFailures = [];
await withTransaction(async (client) => {
for (const row of records) {
if (!row.provider) continue;
const costMills = calculateCostMills(
row.model || null,
row.promptTokens,
row.completionTokens,
);
const costYuan = costMills != null ? costMills / 1000 : null;
const logStatus = row.status || "success";
const enterpriseName = req.user.enterpriseName || null;
const {
rows: [logRow],
} = await client.query(
`
INSERT INTO api_call_logs (user_id, enterprise_id, enterprise_name, provider, model, display_model, prompt_tokens, completion_tokens, duration_ms, status, cost_estimate, api_client)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id
`,
[
req.user.id,
req.user.enterpriseId || null,
enterpriseName,
row.provider,
row.model || null,
row.displayModel || row.model || null,
row.promptTokens || null,
row.completionTokens || null,
row.durationMs || null,
logStatus,
costYuan,
row.apiClient || null,
],
);
if (logStatus === "success" && costMills != null && costMills > 0) {
const deduction = await deductForApiCall(
req.user.id,
row.model || null,
row.promptTokens,
row.completionTokens,
);
if (!deduction.success) {
billingFailures.push({
logId: logRow?.id,
provider: row.provider,
model: row.model || null,
message: deduction.message,
});
continue;
}
if (row.leaseToken) {
await client.query(
"UPDATE key_leases SET settled = 1 WHERE lease_token = $1 AND settled = 0",
[row.leaseToken],
);
}
}
}
});
if (billingFailures.length > 0) {
console.warn("[usage/report] logged calls with unsettled billing", {
userId: req.user?.id,
count: billingFailures.length,
});
}
res.json({ success: true, count: records.length, billingFailures });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = err.status || 500;
console.error("[usage/report] failed", { userId: req.user?.id, message });
if (status === 402) {
res.status(402).json({ error: message, code: "INSUFFICIENT_BALANCE" });
} else {
res.status(500).json({ error: `使用量上报失败: ${message}` });
}
}
});
}
function registerAdminUsageRoutes(router) {
// ── Admin: Usage ─────────────────────────────────────────────────────
router.get("/admin/usage", requireAuth, requireManagementAccess, async (req, res) => {
const { limit = 100, user_id, provider } = req.query;
const safeLimit = clampPositiveInteger(limit, 100, 500);
let sql = `SELECT u.*, us.username FROM usage_logs u LEFT JOIN users us ON u.user_id = us.id WHERE 1 = 1`;
const params = [];
let idx = 1;
const enterpriseId = getManagementEnterpriseId(req.user);
if (enterpriseId != null) {
sql += ` AND COALESCE(u.enterprise_id, us.enterprise_id) = $${idx++}`;
params.push(enterpriseId);
}
if (user_id) {
sql += ` AND u.user_id = $${idx++}`;
params.push(user_id);
}
if (provider) {
sql += ` AND u.provider = $${idx++}`;
params.push(provider);
}
sql += ` ORDER BY u.id DESC LIMIT $${idx++}`;
params.push(safeLimit);
const { rows } = await pool.query(sql, params);
res.json(rows);
});
}
function registerUsageSummaryRoutes(router) {
// ── Usage Summary ────────────────────────────────────────────────────
router.get("/admin/usage/summary", requireAuth, requireManagementAccess, async (req, res) => {
const { period = "7d" } = req.query;
const periodStart = getPeriodStart(period);
const whereClauses = [];
const params = [];
let idx = 1;
if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`);
idx = appendEnterpriseScope(
whereClauses,
params,
req.user,
"COALESCE(l.enterprise_id, u.enterprise_id)",
idx,
);
let sql = `
SELECT u.id AS user_id, u.username, u.avatar_url, COUNT(*) AS total_calls,
COALESCE(SUM(l.prompt_tokens), 0) AS total_prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) AS total_completion_tokens,
COALESCE(SUM(l.duration_ms), 0) AS total_duration_ms, ROUND(COALESCE(SUM(l.cost_estimate), 0)::numeric, 4) AS total_cost,
SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) AS error_count, MAX(l.created_at) AS last_active
FROM api_call_logs l JOIN users u ON l.user_id = u.id
`;
if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`;
sql += " GROUP BY u.id, u.username, u.avatar_url ORDER BY total_cost DESC";
const { rows } = await pool.query(sql, params);
res.json(rows);
});
router.get("/admin/usage/by-model", requireAuth, requireManagementAccess, async (req, res) => {
const { period = "7d" } = req.query;
const periodStart = getPeriodStart(period);
const whereClauses = [];
const params = [];
let idx = 1;
if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`);
idx = appendEnterpriseScope(
whereClauses,
params,
req.user,
"COALESCE(l.enterprise_id, u.enterprise_id)",
idx,
);
let sql = `
SELECT l.model, l.provider, MAX(COALESCE(l.display_model, l.model, l.provider)) AS display_model, COUNT(*) AS total_calls,
COALESCE(SUM(l.prompt_tokens), 0) AS total_prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) AS total_completion_tokens,
ROUND(COALESCE(SUM(l.cost_estimate), 0)::numeric, 4) AS total_cost
FROM api_call_logs l LEFT JOIN users u ON l.user_id = u.id
`;
if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`;
sql += " GROUP BY l.model, l.provider ORDER BY total_cost DESC";
const { rows } = await pool.query(sql, params);
res.json(rows);
});
router.get("/admin/usage/daily", requireAuth, requireManagementAccess, async (req, res) => {
const { period = "7d" } = req.query;
const periodStart = getPeriodStart(period);
const whereClauses = [];
const params = [];
let idx = 1;
if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`);
idx = appendEnterpriseScope(
whereClauses,
params,
req.user,
"COALESCE(l.enterprise_id, u.enterprise_id)",
idx,
);
let sql = `
SELECT l.created_at::date AS date, COUNT(*) AS total_calls,
ROUND(COALESCE(SUM(l.cost_estimate), 0)::numeric, 4) AS total_cost,
SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) AS error_count
FROM api_call_logs l LEFT JOIN users u ON l.user_id = u.id
`;
if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`;
sql += " GROUP BY l.created_at::date ORDER BY date";
const { rows } = await pool.query(sql, params);
res.json(rows);
});
router.get("/admin/usage/details", requireAuth, requireManagementAccess, async (req, res) => {
const { period = "7d", user_id, model, limit = 50, offset = 0, date_from, date_to } = req.query;
const safeLimit = clampPositiveInteger(limit, 50, 500);
const safeOffset = clampNonNegativeInteger(offset, 0, 100000);
const periodStart = getPeriodStart(period);
const whereClauses = [];
const params = [];
let idx = 1;
if (periodStart) whereClauses.push(`l.created_at >= ${periodStart}`);
idx = appendEnterpriseScope(
whereClauses,
params,
req.user,
"COALESCE(l.enterprise_id, u.enterprise_id)",
idx,
);
if (user_id) {
whereClauses.push(`l.user_id = $${idx++}`);
params.push(user_id);
}
if (model) {
whereClauses.push(`l.model = $${idx++}`);
params.push(model);
}
if (date_from) {
whereClauses.push(`l.created_at >= $${idx++}`);
params.push(`${date_from}T00:00:00.000Z`);
}
if (date_to) {
whereClauses.push(`l.created_at <= $${idx++}`);
params.push(`${date_to}T23:59:59.999Z`);
}
let baseSql = `FROM api_call_logs l LEFT JOIN users u ON l.user_id = u.id`;
if (whereClauses.length > 0) baseSql += ` WHERE ${whereClauses.join(" AND ")}`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const {
rows: [countRow],
} = await pool.query(countSql, params);
const sql = `SELECT l.*, u.username, u.avatar_url ${baseSql} ORDER BY l.id DESC LIMIT $${idx++} OFFSET $${idx++}`;
const queryParams = [...params, safeLimit, safeOffset];
const { rows } = await pool.query(sql, queryParams);
res.json({
items: rows,
total: Number(countRow?.total || 0),
limit: safeLimit,
offset: safeOffset,
});
});
}
module.exports = {
registerUsageReportRoutes,
registerAdminUsageRoutes,
registerUsageSummaryRoutes,
};
+322
View File
@@ -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,
};