feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】 - 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword) - register-email 现在需要验证码 - 服务端新增 email_verification_codes 表 + patch-email-verification.js - App.tsx 登录后 emailVerified 检查提醒 - keyServerClient token 显式传递修复 401 错误 【电商模块】 - 自动推进: 策划完成后自动生成分镜图/视频 - 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词 - 任务持久化指纹修复 (图片数量替代 blob URL) - 新增「视频换装」入口 (happyhorse-1.0-video-edit) 【剧本评分】 - 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取) - 历史记录支持点击查看/恢复评测结果 【画布】 - ReactFlow 节点禁止内置拖拽避免冲突 - 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标) 【页面修复】 - 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题 - 资产库新增悬停删除按钮 - scriptEvalClient 改用服务端 /api/ai/chat 端点 - TokenUsagePage 未登录跳过 API 调用
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// ── Patch 1: context.js ──────────────────────────────────────
|
||||
const ctxPath = "/opt/omniai-server/src/routes/context.js";
|
||||
let ctx = fs.readFileSync(ctxPath, "utf8");
|
||||
|
||||
const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);";
|
||||
const emailConsts = `
|
||||
const EMAIL_PURPOSES = new Set(["register", "login", "reset"]);
|
||||
const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10);
|
||||
const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60);
|
||||
const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`;
|
||||
|
||||
if (!ctx.includes("EMAIL_PURPOSES")) {
|
||||
ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts);
|
||||
console.log("[ctx] added EMAIL_PURPOSES");
|
||||
}
|
||||
|
||||
const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}';
|
||||
const emailFuncs = `
|
||||
function hashEmailCode(email, code) {
|
||||
const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret";
|
||||
return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
|
||||
}
|
||||
|
||||
async function sendEmailCode(email, code, purpose) {
|
||||
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
|
||||
|
||||
if (provider === "smtp") {
|
||||
const nodemailer = require("nodemailer");
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === "1",
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
});
|
||||
|
||||
const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801";
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to: email,
|
||||
subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
|
||||
text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002",
|
||||
html: '<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px"><h2 style="color:#333">OmniAI \u90ae\u7bb1\u9a8c\u8bc1</h2><p style="font-size:16px;color:#555">\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a</p><p style="font-size:32px;font-weight:bold;letter-spacing:6px;color:#1677ff;margin:16px 0">' + code + '</p><p style="color:#888">\u7528\u9014\uff1a' + purposeText + '</p><p style="color:#888">\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f</p><hr style="border:none;border-top:1px solid #eee;margin:24px 0"><p style="color:#aaa;font-size:13px">\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002</p></div>',
|
||||
});
|
||||
return { provider: "smtp" };
|
||||
}
|
||||
|
||||
console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)");
|
||||
return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined };
|
||||
}
|
||||
|
||||
async function consumeEmailCode(email, code, purpose) {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
|
||||
[email, purpose]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return false;
|
||||
if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false;
|
||||
|
||||
const expectedHash = hashEmailCode(email, String(code || "").trim());
|
||||
if (row.code_hash !== expectedHash) {
|
||||
await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]);
|
||||
return false;
|
||||
}
|
||||
await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
|
||||
return true;
|
||||
}`;
|
||||
|
||||
if (!ctx.includes("hashEmailCode")) {
|
||||
ctx = ctx.replace(afterConsume, afterConsume + emailFuncs);
|
||||
console.log("[ctx] added email functions");
|
||||
}
|
||||
|
||||
// Update exports
|
||||
if (!ctx.includes("EMAIL_PURPOSES,")) {
|
||||
ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,");
|
||||
}
|
||||
if (!ctx.includes("hashEmailCode,")) {
|
||||
ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,");
|
||||
}
|
||||
|
||||
fs.writeFileSync(ctxPath, ctx, "utf8");
|
||||
console.log("[ctx] written");
|
||||
|
||||
// ── Patch 2: auth.js ─────────────────────────────────────────
|
||||
const authPath = "/opt/omniai-server/src/routes/auth.js";
|
||||
let auth = fs.readFileSync(authPath, "utf8");
|
||||
|
||||
// 2a. Add imports inside context.js destructuring
|
||||
if (!auth.includes("hashEmailCode,")) {
|
||||
auth = auth.replace(
|
||||
'} = require("./context");',
|
||||
' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");'
|
||||
);
|
||||
console.log("[auth] added imports");
|
||||
}
|
||||
|
||||
// 2b. Insert new routes before module.exports
|
||||
const newRoutes = `
|
||||
// ============================================================
|
||||
// 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: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
|
||||
|
||||
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: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
|
||||
}
|
||||
|
||||
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: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" });
|
||||
}
|
||||
|
||||
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: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" });
|
||||
}
|
||||
|
||||
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: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" });
|
||||
}
|
||||
});
|
||||
|
||||
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: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
||||
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
|
||||
|
||||
try {
|
||||
const verified = await consumeEmailCode(email, code, purpose);
|
||||
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
||||
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: "\u9a8c\u8bc1\u5931\u8d25" });
|
||||
}
|
||||
});
|
||||
|
||||
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: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" });
|
||||
}
|
||||
|
||||
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: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
|
||||
}
|
||||
|
||||
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: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" });
|
||||
} catch (error) {
|
||||
console.error("[auth/forgot-password] failed", error);
|
||||
res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" });
|
||||
}
|
||||
});
|
||||
|
||||
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: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
||||
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: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
||||
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: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" });
|
||||
} catch (error) {
|
||||
console.error("[auth/reset-password] failed", error);
|
||||
res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" });
|
||||
}
|
||||
});
|
||||
|
||||
`;
|
||||
|
||||
if (!auth.includes("/auth/email/send-code")) {
|
||||
const endMarker = "\n}\n\nmodule.exports = {";
|
||||
auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {");
|
||||
console.log("[auth] added new routes");
|
||||
}
|
||||
|
||||
// 2c. Update register-email to require verification code
|
||||
// Replace: router.post("/auth/register-email" ... without code check
|
||||
// With: router.post("/auth/register-email" ... with code verification
|
||||
|
||||
const oldRegisterEmail = ` 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 emailError = validateEmail(email);
|
||||
if (emailError) return res.status(400).json({ error: emailError });
|
||||
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: existingEmail }`;
|
||||
|
||||
const newRegisterEmail = ` 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: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
||||
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: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
||||
|
||||
const { rows: existingEmail }`;
|
||||
|
||||
if (auth.includes(oldRegisterEmail)) {
|
||||
auth = auth.replace(oldRegisterEmail, newRegisterEmail);
|
||||
console.log("[auth] updated register-email with verification");
|
||||
} else {
|
||||
console.log("[auth] WARNING: register-email pattern not found, skipping");
|
||||
}
|
||||
|
||||
fs.writeFileSync(authPath, auth, "utf8");
|
||||
console.log("[auth] written");
|
||||
console.log("\nDone.");
|
||||
Reference in New Issue
Block a user