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, };