const { bcrypt, requireAuth, login, getUserContextById, clearUserSession, generateUniqueEnterpriseCode, crypto, pool, withTransaction, SMS_PURPOSES, SMS_CODE_TTL_MINUTES, SMS_CODE_COOLDOWN_SECONDS, validateUsername, validatePassword, normalizePhone, validatePhone, normalizeEmail, validateEmail, hashSmsCode, generateSmsCode, sendSmsCode, createLoginResultForUserId, generateUniqueUsername, consumeSmsCode, getWechatLoginConfig, exchangeWechatCode, findOrCreateWechatUser, validateEnterpriseName, buildOssPublicUrl, normalizeAvatarOssKey, normalizeProfileMediaUrl, EMAIL_PURPOSES, EMAIL_CODE_TTL_MINUTES, EMAIL_CODE_COOLDOWN_SECONDS, EMAIL_CODE_MAX_ATTEMPTS, hashEmailCode, sendEmailCode, consumeEmailCode, } = require("./context"); const { checkBetaInviteCodeForRegistration, consumeBetaInviteCode, findEnterpriseBetaAccountByInviteCode, getBetaInviteCodeFromBody, } = require("../betaInviteCodes"); async function ensureRegistrationInvite(req, res, client = pool) { const result = await checkBetaInviteCodeForRegistration(getBetaInviteCodeFromBody(req.body), client); if (result.ok) return result; res.status(result.status || 403).json({ error: result.error || "内测码无效或缺失" }); return null; } async function ensureBetaInviteCode(req, res, client = pool) { const result = await ensureRegistrationInvite(req, res, client); return result ? result.code : null; } function createBetaInviteCodeError(result) { const error = new Error(result.error || "内测码无效或缺失"); error.status = result.status || 403; return error; } async function consumeBetaInviteCodeForUser(client, code, userId) { const result = await consumeBetaInviteCode(code, userId, client); if (!result.ok) throw createBetaInviteCodeError(result); } function hashRegistrationInviteCode(code) { const normalized = String(code || "") .trim() .replace(/[\s-]/g, "") .toUpperCase(); return crypto.createHash("sha256").update(normalized).digest("hex"); } async function resolveEnterpriseBetaRegistrationTarget(invite, client) { const account = invite?.account || findEnterpriseBetaAccountByInviteCode(invite?.code || invite); if (!account) return { enterpriseId: null, isEnterpriseBeta: false }; const { rows } = await client.query( "SELECT id, enabled FROM enterprises WHERE enterprise_code = $1 LIMIT 1", [account.enterpriseId], ); if (rows.length === 0 || !rows[0].enabled) { const error = new Error("企业内测账号尚未初始化,请先运行服务端数据库初始化"); error.status = 503; throw error; } return { enterpriseId: rows[0].id, isEnterpriseBeta: true, account, }; } async function consumeRegistrationInviteForUser(client, invite, userId, enterpriseTarget) { if (enterpriseTarget && enterpriseTarget.isEnterpriseBeta) { await client.query( ` INSERT INTO enterprise_members (enterprise_id, user_id, role) VALUES ($1, $2, 'employee') ON CONFLICT (enterprise_id, user_id) DO UPDATE SET role = EXCLUDED.role `, [enterpriseTarget.enterpriseId, userId], ); await client.query( ` UPDATE enterprise_invites SET used_at = COALESCE(used_at, NOW()) WHERE enterprise_id = $1 AND code_hash = $2 `, [enterpriseTarget.enterpriseId, hashRegistrationInviteCode(invite.code)], ); return; } await consumeBetaInviteCodeForUser(client, invite.code, userId); } function sendAuthRouteError(res, error, fallback) { const status = Number.isInteger(error?.status) ? error.status : 500; if (status >= 400 && status < 500) { res.status(status).json({ error: error.message || fallback }); return; } res.status(500).json({ error: fallback }); } function registerAuthRoutes(router) { // ── Auth ───────────────────────────────────────────────────────────── router.post("/auth/login", async (req, res) => { const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: "缺少用户名或密码" }); try { const result = await login(username, password, req.headers["user-agent"]); if (!result) return res.status(401).json({ error: "用户名或密码错误" }); res.json(result); } catch (err) { console.error("[auth/login] failed", err); res.status(500).json({ error: "登录失败" }); } }); router.post("/auth/login-email", async (req, res) => { const email = normalizeEmail(req.body?.email); const password = String(req.body?.password || ""); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!password) return res.status(400).json({ error: "缺少密码" }); try { const { rows } = await pool.query( "SELECT username FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email], ); if (rows.length === 0) return res.status(401).json({ error: "邮箱或密码错误" }); const result = await login(rows[0].username, password, req.headers["user-agent"]); if (!result) return res.status(401).json({ error: "邮箱或密码错误" }); res.json(result); } catch (err) { console.error("[auth/login-email] failed", err); res.status(500).json({ error: "邮箱登录失败" }); } }); router.post("/auth/register", async (req, res) => { const { username, password } = req.body; const usernameError = validateUsername(username); if (usernameError) return res.status(400).json({ error: usernameError }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const registrationInvite = await ensureRegistrationInvite(req, res); if (!registrationInvite) return; const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [ username, ]); if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" }); try { const hash = await bcrypt.hash(password, 10); await withTransaction(async (client) => { const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget( registrationInvite, client, ); const { rows } = await client.query( ` INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id `, [username, hash, "user", 30, enterpriseTarget.enterpriseId, 0, 0], ); await consumeRegistrationInviteForUser(client, registrationInvite, rows[0].id, enterpriseTarget); }); const loginResult = await login(username, password, req.headers["user-agent"]); res.json(loginResult); } catch (error) { console.error("[auth/register] failed", error); sendAuthRouteError(res, error, "Register failed"); } }); router.post("/auth/register-email", async (req, res) => { const email = normalizeEmail(req.body?.email); const usernameInput = String(req.body?.username || "").trim(); const password = String(req.body?.password || ""); const code = String(req.body?.code || "").trim(); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!code) return res.status(400).json({ error: "缺少验证码" }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const registrationInvite = await ensureRegistrationInvite(req, res); if (!registrationInvite) return; try { const verified = await consumeEmailCode(email, code, "register"); if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); const { rows: existingEmail } = await pool.query( "SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email], ); if (existingEmail.length > 0) return res.status(409).json({ error: "该邮箱已注册" }); let username = usernameInput; if (username) { const usernameError = validateUsername(username); if (usernameError) return res.status(400).json({ error: usernameError }); const { rows: existingUsername } = await pool.query("SELECT id FROM users WHERE username = $1", [ username, ]); if (existingUsername.length > 0) return res.status(409).json({ error: "用户名已被注册" }); } else { username = await generateUniqueUsername(email.split("@")[0], "email"); } const hash = await bcrypt.hash(password, 10); const { rows } = await withTransaction(async (client) => { const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget( registrationInvite, client, ); const insertResult = await client.query( ` INSERT INTO users (username, password_hash, email, email_verified, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, $3, 0, 'email', 'user', 30, $4, 0, 0) RETURNING id `, [username, hash, email, enterpriseTarget.enterpriseId], ); await consumeRegistrationInviteForUser( client, registrationInvite, insertResult.rows[0].id, enterpriseTarget, ); return insertResult; }); const loginResult = await createLoginResultForUserId(rows[0].id, req); res.json(loginResult); } catch (error) { console.error("[auth/register-email] failed", error); sendAuthRouteError(res, error, "Email register failed"); } }); router.post("/auth/sms/send", async (req, res) => { const phone = normalizePhone(req.body?.phone); const purpose = String(req.body?.purpose || "register"); const phoneError = validatePhone(phone); if (phoneError) return res.status(400).json({ error: phoneError }); if (!SMS_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" }); if (purpose === "register" && !(await ensureBetaInviteCode(req, res))) return; try { const { rows: existingUsers } = await pool.query( "SELECT id FROM users WHERE phone = $1 LIMIT 1", [phone], ); if (purpose === "register" && existingUsers.length > 0) { return res.status(409).json({ error: "该手机号已注册" }); } if (purpose === "login" && existingUsers.length === 0) { return res.status(404).json({ error: "该手机号尚未注册" }); } const { rows: recentCodes } = await pool.query( ` SELECT created_at FROM sms_verification_codes WHERE phone = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1 `, [phone, purpose, SMS_CODE_COOLDOWN_SECONDS], ); if (recentCodes.length > 0) { return res .status(429) .json({ error: `验证码发送太频繁,请 ${SMS_CODE_COOLDOWN_SECONDS} 秒后再试` }); } const code = generateSmsCode(); const codeHash = hashSmsCode(phone, code); await pool.query( ` INSERT INTO sms_verification_codes (phone, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval) `, [phone, purpose, codeHash, SMS_CODE_TTL_MINUTES], ); const sendResult = await sendSmsCode(phone, code, purpose); res.json({ success: true, provider: sendResult.provider, ttlSeconds: SMS_CODE_TTL_MINUTES * 60, cooldownSeconds: SMS_CODE_COOLDOWN_SECONDS, ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), }); } catch (error) { console.error("[auth/sms/send] failed", error); res.status(500).json({ error: "验证码发送失败" }); } }); router.post("/auth/register-phone", async (req, res) => { const phone = normalizePhone(req.body?.phone); const code = String(req.body?.code || "").trim(); const password = String(req.body?.password || ""); const phoneError = validatePhone(phone); if (phoneError) return res.status(400).json({ error: phoneError }); if (!code) return res.status(400).json({ error: "缺少验证码" }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const registrationInvite = await ensureRegistrationInvite(req, res); if (!registrationInvite) return; try { const { rows: existing } = await pool.query("SELECT id FROM users WHERE phone = $1 LIMIT 1", [ phone, ]); if (existing.length > 0) return res.status(409).json({ error: "该手机号已注册" }); const verified = await consumeSmsCode(phone, code, "register"); if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); const username = await generateUniqueUsername(`u${phone.slice(-4)}`, "phone"); const hash = await bcrypt.hash(password, 10); const { rows } = await withTransaction(async (client) => { const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget( registrationInvite, client, ); const insertResult = await client.query( ` INSERT INTO users (username, password_hash, phone, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, $3, 'phone', 'user', 30, $4, 0, 0) RETURNING id `, [username, hash, phone, enterpriseTarget.enterpriseId], ); await consumeRegistrationInviteForUser( client, registrationInvite, insertResult.rows[0].id, enterpriseTarget, ); return insertResult; }); const loginResult = await createLoginResultForUserId(rows[0].id, req); res.json(loginResult); } catch (error) { console.error("[auth/register-phone] failed", error); sendAuthRouteError(res, error, "Phone register failed"); } }); router.post("/auth/login-phone", async (req, res) => { const phone = normalizePhone(req.body?.phone); const code = String(req.body?.code || "").trim(); const phoneError = validatePhone(phone); if (phoneError) return res.status(400).json({ error: phoneError }); if (!code) return res.status(400).json({ error: "缺少验证码" }); try { const verified = await consumeSmsCode(phone, code, "login"); if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); const { rows } = await pool.query("SELECT id, enabled FROM users WHERE phone = $1 LIMIT 1", [ phone, ]); if (rows.length === 0) return res.status(404).json({ error: "该手机号尚未注册" }); if (!rows[0].enabled) return res.status(403).json({ error: "账号已禁用" }); const loginResult = await createLoginResultForUserId(rows[0].id, req); res.json(loginResult); } catch (error) { console.error("[auth/login-phone] failed", error); res.status(500).json({ error: "手机号登录失败" }); } }); router.get("/auth/wechat/login-url", async (req, res) => { const { appId, redirectUri } = getWechatLoginConfig(); if (!appId || !redirectUri) { return res.json({ configured: false, message: "微信开放平台 AppID 或回调地址未配置", }); } const state = String(req.query.state || crypto.randomBytes(8).toString("hex")); try { await pool.query( ` INSERT INTO wechat_login_sessions (state, status, expires_at) VALUES ($1, 'pending', NOW() + INTERVAL '10 minutes') ON CONFLICT (state) DO UPDATE SET status = 'pending', user_id = NULL, error = NULL, consumed_at = NULL, expires_at = NOW() + INTERVAL '10 minutes', updated_at = NOW() `, [state], ); } catch (error) { console.error("[auth/wechat/login-url] failed to create session", error); return res.status(500).json({ error: "微信登录会话创建失败" }); } const url = new URL("https://open.weixin.qq.com/connect/qrconnect"); url.searchParams.set("appid", appId); url.searchParams.set("redirect_uri", redirectUri); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", "snsapi_login"); url.searchParams.set("state", state); res.json({ configured: true, url: `${url.toString()}#wechat_redirect`, state, }); }); router.get("/auth/wechat/callback", async (req, res) => { const code = String(req.query?.code || "").trim(); const state = String(req.query?.state || "").trim(); if (!code || !state) { return res.status(400).send('
请回到 OmniAI 应用继续。
'); } catch (error) { console.error("[auth/wechat/callback] failed", error); await pool .query( ` UPDATE wechat_login_sessions SET status = 'failed', error = $2, updated_at = NOW() WHERE state = $1 `, [state, error instanceof Error ? error.message : "微信登录失败"], ) .catch(() => {}); res.status(500).send('请回到应用重试。
'); } }); router.get("/auth/wechat/session", async (req, res) => { const state = String(req.query?.state || "").trim(); if (!state) return res.status(400).json({ error: "缺少微信登录 state" }); try { const { rows } = await pool.query( ` SELECT state, status, user_id, error, consumed_at, expires_at FROM wechat_login_sessions WHERE state = $1 LIMIT 1 `, [state], ); const session = rows[0]; if (!session) return res.status(404).json({ status: "missing" }); if (session.consumed_at) return res.status(409).json({ status: "consumed" }); if (new Date(session.expires_at).getTime() < Date.now()) return res.status(410).json({ status: "expired" }); if (session.status === "failed") return res.status(400).json({ status: "failed", error: session.error || "微信登录失败" }); if (session.status !== "completed") return res.json({ status: "pending" }); const loginResult = await createLoginResultForUserId(session.user_id, req); if (!loginResult) return res.status(403).json({ status: "failed", error: "账号不可用" }); await pool.query( "UPDATE wechat_login_sessions SET consumed_at = NOW(), updated_at = NOW() WHERE state = $1", [state], ); res.json({ status: "completed", ...loginResult }); } catch (error) { console.error("[auth/wechat/session] failed", error); res.status(500).json({ error: "微信登录状态查询失败" }); } }); router.post("/auth/wechat/login", async (req, res) => { const code = String(req.body?.code || "").trim(); if (!code) return res.status(400).json({ error: "缺少微信授权 code" }); try { const wechatUser = await exchangeWechatCode(code); const userId = await findOrCreateWechatUser(wechatUser); const loginResult = await createLoginResultForUserId(userId, req); res.json(loginResult); } catch (error) { console.error("[auth/wechat/login] failed", error); const status = typeof error?.status === "number" ? error.status : 500; res.status(status).json({ error: "微信登录失败" }); } }); router.post("/auth/register-enterprise", async (req, res) => { const { companyName, contactName = "", contactPhone = "", taxId = "", legalPersonName = "", legalPersonPhone = "", username, password, } = req.body; const enterpriseError = validateEnterpriseName(companyName); if (enterpriseError) return res.status(400).json({ error: enterpriseError }); const usernameError = validateUsername(username); if (usernameError) return res.status(400).json({ error: usernameError }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const betaInviteCode = await ensureBetaInviteCode(req, res); if (!betaInviteCode) return; const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [ username, ]); if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" }); try { const enterpriseCode = await generateUniqueEnterpriseCode(); const hash = await bcrypt.hash(password, 10); await withTransaction(async (client) => { const eResult = await client.query( ` INSERT INTO enterprises (name, contact_name, contact_phone, tax_id, legal_person_name, legal_person_phone, enterprise_code) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id `, [ companyName.trim(), String(contactName || "").trim(), String(contactPhone || "").trim(), String(taxId || "").trim() || null, String(legalPersonName || "").trim() || null, String(legalPersonPhone || "").trim() || null, enterpriseCode, ], ); const enterpriseId = eResult.rows[0].id; const userResult = await client.query( ` INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, 'user', $3, $4, 1, 0) RETURNING id `, [username, hash, 30, enterpriseId], ); await consumeBetaInviteCodeForUser(client, betaInviteCode, userResult.rows[0].id); }); const loginResult = await login(username, password, req.headers["user-agent"]); res.json(loginResult); } catch (error) { console.error("[auth/register-enterprise] failed", error); sendAuthRouteError(res, error, "Enterprise register failed"); } }); router.post("/auth/register-employee", async (req, res) => { const { enterpriseCode, username, password } = req.body; if (!enterpriseCode) return res.status(400).json({ error: "缺少企业ID" }); const usernameError = validateUsername(username); if (usernameError) return res.status(400).json({ error: usernameError }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const betaInviteCode = await ensureBetaInviteCode(req, res); if (!betaInviteCode) return; const { rows: entRows } = await pool.query( "SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1", [enterpriseCode], ); if (entRows.length === 0) return res.status(404).json({ error: "企业ID不存在" }); if (!entRows[0].enabled) return res.status(403).json({ error: "该企业已被禁用" }); const enterpriseId = entRows[0].id; const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [ username, ]); if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" }); try { const hash = await bcrypt.hash(password, 10); await withTransaction(async (client) => { const { rows } = await client.query( ` INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents) VALUES ($1, $2, 'user', $3, $4, 0, 0) RETURNING id `, [username, hash, 30, enterpriseId], ); await consumeBetaInviteCodeForUser(client, betaInviteCode, rows[0].id); }); const loginResult = await login(username, password, req.headers["user-agent"]); res.json(loginResult); } catch (error) { console.error("[auth/register-employee] failed", error); sendAuthRouteError(res, error, "Employee register failed"); } }); router.get("/auth/enterprise-lookup", async (req, res) => { const { code } = req.query; if (!code) return res.status(400).json({ error: "缺少企业ID" }); const { rows } = await pool.query( "SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1", [code], ); if (rows.length === 0 || !rows[0].enabled) return res.json({ valid: false }); res.json({ valid: true, enterpriseName: rows[0].name }); }); router.get("/auth/me", requireAuth, (req, res) => { res.json({ user: req.user }); }); router.post("/auth/logout", requireAuth, async (req, res) => { try { await clearUserSession(req.user.id, req.auth?.sessionId); res.json({ success: true }); } catch (error) { console.error("[auth/logout] failed", error); res.status(500).json({ error: "退出登录失败" }); } }); router.put("/auth/profile", requireAuth, async (req, res) => { try { const normalizedAvatar = normalizeAvatarOssKey(req.body?.avatarOssKey, req.user.id); if (normalizedAvatar.error) { return res.status(400).json({ error: normalizedAvatar.error }); } const hasAvatarUrl = req.body && Object.prototype.hasOwnProperty.call(req.body, "avatarUrl"); const avatarUrlInput = normalizedAvatar.value !== undefined ? { value: normalizedAvatar.value ? `${buildOssPublicUrl(normalizedAvatar.value)}?v=${Date.now()}` : null } : hasAvatarUrl ? normalizeProfileMediaUrl(req.body?.avatarUrl) : { value: undefined }; if (avatarUrlInput.error) { return res.status(400).json({ error: avatarUrlInput.error }); } const backgroundUrlInput = normalizeProfileMediaUrl(req.body?.profileBackgroundUrl); if (backgroundUrlInput.error) { return res.status(400).json({ error: backgroundUrlInput.error }); } const fields = []; const values = []; if (avatarUrlInput.value !== undefined) { values.push(avatarUrlInput.value); fields.push(`avatar_url = $${values.length}`); } if (req.body && Object.prototype.hasOwnProperty.call(req.body, "bio")) { const bio = String(req.body.bio || "").trim().slice(0, 160) || null; values.push(bio); fields.push(`bio = $${values.length}`); } if (backgroundUrlInput.value !== undefined) { values.push(backgroundUrlInput.value); fields.push(`profile_background_url = $${values.length}`); } if (fields.length > 0) { values.push(req.user.id); await pool.query( `UPDATE users SET ${fields.join(", ")}, updated_at = NOW() WHERE id = $${values.length}`, values, ); } const user = await getUserContextById(req.user.id); res.json({ user }); } catch (err) { console.error("[auth/profile] update failed", err); res.status(500).json({ error: "更新个人资料失败" }); } }); // ============================================================ // Email verification routes // ============================================================ router.post("/auth/email/send-code", async (req, res) => { const email = normalizeEmail(req.body?.email); const purpose = String(req.body?.purpose || "register"); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" }); if (purpose === "register") { const inviteOk = await ensureBetaInviteCode(req, res); if (!inviteOk) return; } try { const { rows: recentCodes } = await pool.query( "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", [email, purpose, EMAIL_CODE_COOLDOWN_SECONDS] ); if (recentCodes.length > 0) { return res.status(429).json({ error: "验证码发送太频繁,请 " + EMAIL_CODE_COOLDOWN_SECONDS + " 秒后再试" }); } if (purpose === "register") { const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]); if (existing.length > 0) return res.status(409).json({ error: "该邮箱已注册" }); } if (purpose === "login" || purpose === "reset") { const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); if (existing.length === 0) return res.status(404).json({ error: "该邮箱尚未注册" }); } const code = generateSmsCode(); const codeHash = hashEmailCode(email, code); await pool.query( "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)", [email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES] ); const sendResult = await sendEmailCode(email, code, purpose); res.json({ success: true, provider: sendResult.provider, ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60, cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS, ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), }); } catch (error) { console.error("[auth/email/send-code] failed", error); res.status(500).json({ error: "验证码发送失败" }); } }); router.post("/auth/email/verify", async (req, res) => { const email = normalizeEmail(req.body?.email); const code = String(req.body?.code || "").trim(); const purpose = String(req.body?.purpose || "register"); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!code) return res.status(400).json({ error: "缺少验证码" }); if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" }); try { const verified = await consumeEmailCode(email, code, purpose); if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); if (purpose === "register" || purpose === "login") { await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]); } res.json({ success: true }); } catch (error) { console.error("[auth/email/verify] failed", error); res.status(500).json({ error: "验证失败" }); } }); router.post("/auth/forgot-password", async (req, res) => { const email = normalizeEmail(req.body?.email); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); try { const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); if (rows.length === 0) { return res.json({ success: true, message: "如果该邮箱已注册,重置链接已发送" }); } const { rows: recentCodes } = await pool.query( "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", [email, EMAIL_CODE_COOLDOWN_SECONDS] ); if (recentCodes.length > 0) { return res.status(429).json({ error: "发送太频繁,请 " + EMAIL_CODE_COOLDOWN_SECONDS + " 秒后再试" }); } const code = generateSmsCode(); const codeHash = hashEmailCode(email, code); await pool.query( "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)", [email, codeHash, EMAIL_CODE_TTL_MINUTES] ); await sendEmailCode(email, code, "reset"); res.json({ success: true, message: "重置验证码已发送到您的邮箱" }); } catch (error) { console.error("[auth/forgot-password] failed", error); res.status(500).json({ error: "发送失败" }); } }); router.post("/auth/reset-password", async (req, res) => { const email = normalizeEmail(req.body?.email); const code = String(req.body?.code || "").trim(); const newPassword = String(req.body?.newPassword || ""); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!code) return res.status(400).json({ error: "缺少验证码" }); const passwordError = validatePassword(newPassword); if (passwordError) return res.status(400).json({ error: passwordError }); try { const verified = await consumeEmailCode(email, code, "reset"); if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); const hash = await bcrypt.hash(newPassword, 10); await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]); res.json({ success: true, message: "密码重置成功,请重新登录" }); } catch (error) { console.error("[auth/reset-password] failed", error); res.status(500).json({ error: "密码重置失败" }); } }); } module.exports = { registerAuthRoutes, };