869 lines
27 KiB
JavaScript
869 lines
27 KiB
JavaScript
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,
|
|
creditsToCreditUnits,
|
|
formatCreditsFromCents,
|
|
} = 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);
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
creditsToCreditUnits,
|
|
formatCreditsFromCents,
|
|
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,
|
|
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,
|
|
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,
|
|
};
|