Files
omniai-server/src/routes/auth.js
T
2026-06-04 18:58:45 +08:00

905 lines
34 KiB
JavaScript

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('<meta charset="utf-8"><h3>微信授权参数缺失</h3>');
}
try {
const { rows: sessions } = await pool.query(
`
SELECT state
FROM wechat_login_sessions
WHERE state = $1 AND expires_at > NOW() AND consumed_at IS NULL
LIMIT 1
`,
[state],
);
if (sessions.length === 0) {
return res
.status(400)
.send('<meta charset="utf-8"><h3>微信登录会话已过期,请回到应用重新扫码</h3>');
}
const wechatUser = await exchangeWechatCode(code);
const userId = await findOrCreateWechatUser(wechatUser);
await pool.query(
`
UPDATE wechat_login_sessions
SET status = 'completed', user_id = $2, error = NULL, updated_at = NOW()
WHERE state = $1
`,
[state, userId],
);
res.send('<meta charset="utf-8"><h3>微信登录成功</h3><p>请回到 OmniAI 应用继续。</p>');
} 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('<meta charset="utf-8"><h3>微信登录失败</h3><p>请回到应用重试。</p>');
}
});
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,
};