323 lines
9.3 KiB
JavaScript
323 lines
9.3 KiB
JavaScript
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 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();
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]);
|
|
await client.query("DELETE FROM user_sessions WHERE user_id = $1", [userId]);
|
|
await client.query(
|
|
"INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())",
|
|
[sessionId, userId, userAgent || null],
|
|
);
|
|
await client.query(
|
|
"UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2",
|
|
[sessionId, userId],
|
|
);
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
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,
|
|
};
|