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

744 lines
25 KiB
JavaScript

const {
bcrypt,
requireAuth,
requireAdmin,
requireEnterpriseAdmin,
listModelPrices,
loadPriceCache,
creditUserBalance,
creditsToCreditUnits,
formatCreditsFromCents,
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 { amountCredits, amountCents } = req.body;
const creditUnits =
amountCredits !== undefined && amountCredits !== null && amountCredits !== ""
? creditsToCreditUnits(amountCredits)
: Number(amountCents);
if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "积分必须大于 0" });
try {
const newBalance = await creditUserBalance(
targetUserId,
creditUnits,
`管理员 ${req.user.username} 发放 ${formatCreditsFromCents(creditUnits)} 积分`,
);
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,
credits,
amountCredits,
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),
credits !== undefined || amountCredits !== undefined
? creditsToCreditUnits(credits ?? amountCredits)
: 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,
credits,
amountCredits,
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 (credits !== undefined || amountCredits !== undefined) {
updates.push(`credits_cents = $${idx++}`);
params.push(creditsToCreditUnits(credits ?? amountCredits));
} else 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,
};