Files
omniai-server/src/routes/context.js
T

865 lines
27 KiB
JavaScript
Raw Normal View History

2026-06-02 13:14:10 +08:00
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);
const EMAIL_PURPOSES = new Set(["register", "login", "reset"]);
const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10);
const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60);
const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);
2026-06-02 13:14:10 +08:00
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 hashEmailCode(email, code) {
const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret";
return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
}
async function sendEmailCode(email, code, purpose) {
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
if (provider === "smtp") {
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === "1",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const purposeText = purpose === "register" ? "注册" : purpose === "login" ? "登录" : "重置密码";
await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: email,
subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002",
html: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><h2 style=\"color:#333\">OmniAI \u90ae\u7bb1\u9a8c\u8bc1</h2><p style=\"font-size:16px;color:#555\">\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a</p><p style=\"font-size:32px;font-weight:bold;letter-spacing:6px;color:#1677ff;margin:16px 0\">" + code + "</p><p style=\"color:#888\">\u7528\u9014\uff1a" + purposeText + "</p><p style=\"color:#888\">\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f</p><hr style=\"border:none;border-top:1px solid #eee;margin:24px 0\"><p style=\"color:#aaa;font-size:13px\">\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002</p></div>",
});
return { provider: "smtp" };
}
console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)");
return {
provider: "mock",
devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined,
};
}
async function consumeEmailCode(email, code, purpose) {
const { rows } = await pool.query(
"SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
[email, purpose]
);
const row = rows[0];
if (!row) return false;
if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) {
return false;
}
const expectedHash = hashEmailCode(email, String(code || "").trim());
if (row.code_hash !== expectedHash) {
await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]);
return false;
}
await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
return true;
}
2026-06-02 13:14:10 +08:00
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,
EMAIL_PURPOSES,
EMAIL_CODE_TTL_MINUTES,
EMAIL_CODE_COOLDOWN_SECONDS,
EMAIL_CODE_MAX_ATTEMPTS,
2026-06-02 13:14:10 +08:00
SMS_PURPOSES,
SMS_CODE_TTL_MINUTES,
SMS_CODE_COOLDOWN_SECONDS,
SMS_CODE_MAX_ATTEMPTS,
validateUsername,
validatePassword,
normalizePhone,
validatePhone,
normalizeEmail,
validateEmail,
hashSmsCode,
generateSmsCode,
sendSmsCode,
hashEmailCode,
sendEmailCode,
consumeEmailCode,
2026-06-02 13:14:10 +08:00
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,
};