Initial commit: OmniAI backend server
This commit is contained in:
+322
@@ -0,0 +1,322 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const crypto = require("node:crypto");
|
||||
const { pool } = require("./db");
|
||||
const { getJwtSecret } = require("./securityConfig");
|
||||
|
||||
const JWT_SECRET = getJwtSecret();
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
|
||||
const MAX_CONCURRENT_SESSIONS = 2;
|
||||
|
||||
const USER_CONTEXT_SELECT = `
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.avatar_url,
|
||||
u.bio,
|
||||
u.profile_background_url,
|
||||
u.email,
|
||||
u.email_verified,
|
||||
u.phone,
|
||||
u.auth_provider,
|
||||
u.current_session_id,
|
||||
u.current_session_started_at,
|
||||
u.role,
|
||||
u.max_concurrency,
|
||||
u.enabled,
|
||||
u.enterprise_id,
|
||||
u.is_enterprise_admin,
|
||||
u.balance_cents AS user_balance_cents,
|
||||
u.billing_mode,
|
||||
u.beta_expires_at,
|
||||
e.name AS enterprise_name,
|
||||
e.enabled AS enterprise_enabled,
|
||||
e.balance_cents AS enterprise_balance_cents,
|
||||
e.enterprise_code,
|
||||
e.admin_user_id AS enterprise_admin_user_id,
|
||||
COALESCE(
|
||||
em.role,
|
||||
CASE
|
||||
WHEN u.is_enterprise_admin = 1 THEN 'admin'
|
||||
WHEN u.enterprise_id IS NOT NULL THEN 'employee'
|
||||
ELSE NULL
|
||||
END
|
||||
) AS enterprise_role
|
||||
FROM users u
|
||||
LEFT JOIN enterprises e ON e.id = u.enterprise_id
|
||||
LEFT JOIN enterprise_members em ON em.enterprise_id = u.enterprise_id AND em.user_id = u.id
|
||||
`;
|
||||
|
||||
function generateEnterpriseCode() {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "ENT-";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
async function generateUniqueEnterpriseCode() {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const code = generateEnterpriseCode();
|
||||
const { rows } = await pool.query("SELECT 1 FROM enterprises WHERE enterprise_code = $1", [
|
||||
code,
|
||||
]);
|
||||
if (rows.length === 0) return code;
|
||||
}
|
||||
throw new Error("Failed to generate unique enterprise code");
|
||||
}
|
||||
|
||||
async function toUserResponse(user) {
|
||||
if (!user) return null;
|
||||
|
||||
const enterpriseId = user.enterprise_id == null ? null : Number(user.enterprise_id);
|
||||
const isEnterpriseAdmin = !!user.is_enterprise_admin;
|
||||
const enterpriseRole = enterpriseId
|
||||
? (user.enterprise_role || (isEnterpriseAdmin ? "admin" : "employee"))
|
||||
: undefined;
|
||||
|
||||
const result = {
|
||||
id: Number(user.id),
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url || null,
|
||||
bio: user.bio || null,
|
||||
profileBackgroundUrl: user.profile_background_url || null,
|
||||
email: user.email || null,
|
||||
emailVerified: !!user.email_verified,
|
||||
phone: user.phone || null,
|
||||
authProvider: user.auth_provider || "password",
|
||||
sessionId: user.current_session_id || null,
|
||||
sessionStartedAt: user.current_session_started_at || null,
|
||||
role: user.role,
|
||||
maxConcurrency: Number(user.max_concurrency || 0),
|
||||
enabled: !!user.enabled,
|
||||
enterpriseId,
|
||||
enterpriseName: user.enterprise_name || null,
|
||||
isEnterpriseAdmin,
|
||||
enterpriseRole,
|
||||
enterpriseAdminUserId:
|
||||
user.enterprise_admin_user_id == null ? null : Number(user.enterprise_admin_user_id),
|
||||
accountType: enterpriseId ? "enterprise" : "personal",
|
||||
balanceCents: user.user_balance_cents != null ? Number(user.user_balance_cents) : 0,
|
||||
billingMode: user.billing_mode || "credits",
|
||||
betaExpiresAt: user.beta_expires_at || null,
|
||||
enterpriseCode: user.enterprise_code || null,
|
||||
};
|
||||
|
||||
if (enterpriseId) {
|
||||
result.enterpriseBalanceCents =
|
||||
user.enterprise_balance_cents != null ? Number(user.enterprise_balance_cents) : 0;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
const { rows: activePackages } = await pool.query(
|
||||
`
|
||||
SELECT ep.remaining_image, ep.remaining_video, ep.remaining_text, ep.expires_at, p.name AS package_name
|
||||
FROM enterprise_packages ep
|
||||
JOIN packages p ON ep.package_id = p.id
|
||||
WHERE ep.enterprise_id = $1 AND ep.expires_at > $2
|
||||
ORDER BY ep.expires_at ASC
|
||||
`,
|
||||
[enterpriseId, now],
|
||||
);
|
||||
|
||||
result.activePackages = activePackages.map((pkg) => ({
|
||||
name: pkg.package_name,
|
||||
expiresAt: pkg.expires_at,
|
||||
remainingImage: pkg.remaining_image,
|
||||
remainingVideo: pkg.remaining_video,
|
||||
remainingText: pkg.remaining_text,
|
||||
}));
|
||||
} catch {
|
||||
result.activePackages = [];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getUserContextById(userId) {
|
||||
const { rows } = await pool.query(`${USER_CONTEXT_SELECT} WHERE u.id = $1 LIMIT 1`, [userId]);
|
||||
return toUserResponse(rows[0]);
|
||||
}
|
||||
|
||||
function isSystemAdmin(user) {
|
||||
return user?.role === "admin";
|
||||
}
|
||||
|
||||
function isEnterpriseAdmin(user) {
|
||||
return Boolean(user?.enterpriseId && user?.isEnterpriseAdmin);
|
||||
}
|
||||
|
||||
function generateToken(user, sessionId = user.sessionId) {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
sessionId,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
enterpriseId: user.enterpriseId,
|
||||
isEnterpriseAdmin: user.isEnterpriseAdmin,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN },
|
||||
);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
}
|
||||
|
||||
async function startUserSession(userId, userAgent) {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await pool.query(
|
||||
"INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())",
|
||||
[sessionId, userId, userAgent || null],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
AND id NOT IN (
|
||||
SELECT id FROM user_sessions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
)`,
|
||||
[userId, MAX_CONCURRENT_SESSIONS],
|
||||
);
|
||||
await pool.query(
|
||||
"UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2",
|
||||
[sessionId, userId],
|
||||
);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function clearUserSession(userId, sessionId) {
|
||||
if (!userId || !sessionId) return;
|
||||
await pool.query(
|
||||
"DELETE FROM user_sessions WHERE user_id = $1 AND id = $2",
|
||||
[userId, sessionId],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET current_session_id = NULL,
|
||||
current_session_started_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND current_session_id = $2`,
|
||||
[userId, sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
async function isSessionValid(userId, sessionId) {
|
||||
if (!userId || !sessionId) return false;
|
||||
const { rows } = await pool.query(
|
||||
"SELECT 1 FROM user_sessions WHERE user_id = $1 AND id = $2 LIMIT 1",
|
||||
[userId, sessionId],
|
||||
);
|
||||
if (rows.length > 0) return true;
|
||||
const { rows: legacy } = await pool.query(
|
||||
"SELECT 1 FROM users WHERE id = $1 AND current_session_id = $2 LIMIT 1",
|
||||
[userId, sessionId],
|
||||
);
|
||||
if (legacy.length > 0) {
|
||||
await pool.query(
|
||||
"INSERT INTO user_sessions (id, user_id, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (id) DO NOTHING",
|
||||
[sessionId, userId],
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "未登录" });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.slice(7);
|
||||
const payload = verifyToken(token);
|
||||
const user = await getUserContextById(payload.userId);
|
||||
if (!user?.enabled) {
|
||||
return res.status(403).json({ error: "账号已禁用" });
|
||||
}
|
||||
const sessionOk = await isSessionValid(payload.userId, payload.sessionId);
|
||||
if (!sessionOk) {
|
||||
return res.status(401).json({ error: "您已在别处登录", code: "SESSION_REPLACED" });
|
||||
}
|
||||
if (user.enterpriseId && user.enterpriseName == null) {
|
||||
return res.status(403).json({ error: "企业信息不存在" });
|
||||
}
|
||||
req.user = user;
|
||||
req.auth = { token, sessionId: payload.sessionId };
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: "登录已过期" });
|
||||
}
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!isSystemAdmin(req.user)) {
|
||||
return res.status(403).json({ error: "需要系统管理员权限" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requireEnterpriseAdmin(req, res, next) {
|
||||
if (!isEnterpriseAdmin(req.user)) {
|
||||
return res.status(403).json({ error: "需要企业管理员权限" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requireManagementAccess(req, res, next) {
|
||||
if (!isSystemAdmin(req.user) && !isEnterpriseAdmin(req.user)) {
|
||||
return res.status(403).json({ error: "需要管理权限" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
async function login(username, password, userAgent) {
|
||||
const { rows } = await pool.query(
|
||||
`${USER_CONTEXT_SELECT} WHERE u.username = $1 AND u.enabled = 1 LIMIT 1`,
|
||||
[username],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) return null;
|
||||
|
||||
const { rows: pwRows } = await pool.query("SELECT password_hash FROM users WHERE id = $1", [
|
||||
user.id,
|
||||
]);
|
||||
if (!pwRows[0] || !(await bcrypt.compare(password, pwRows[0].password_hash))) return null;
|
||||
|
||||
const safeUser = await toUserResponse(user);
|
||||
const sessionId = await startUserSession(safeUser.id, userAgent);
|
||||
const userWithSession = {
|
||||
...safeUser,
|
||||
sessionId,
|
||||
sessionStartedAt: new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
token: generateToken(userWithSession, sessionId),
|
||||
user: userWithSession,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
verifyToken,
|
||||
startUserSession,
|
||||
clearUserSession,
|
||||
isSessionValid,
|
||||
login,
|
||||
getUserContextById,
|
||||
isSystemAdmin,
|
||||
isEnterpriseAdmin,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
requireEnterpriseAdmin,
|
||||
requireManagementAccess,
|
||||
generateUniqueEnterpriseCode,
|
||||
};
|
||||
Reference in New Issue
Block a user