Add beta email review and task safeguards

This commit is contained in:
stringadmin
2026-06-08 18:30:20 +08:00
parent 6f8658bf85
commit 47b7bff2ac
5 changed files with 376 additions and 28 deletions
+136 -5
View File
@@ -5,6 +5,7 @@ const { pool, withTransaction } = require("../db");
const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes");
const REVIEW_USERNAMES = new Set(["xqy1912"]);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function cleanText(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
@@ -15,6 +16,17 @@ function cleanTextArray(value, maxItems = 20, maxLength = 200) {
return value.map((item) => cleanText(item, maxLength)).filter(Boolean).slice(0, maxItems);
}
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function validateEmail(email) {
const normalized = normalizeEmail(email);
if (!normalized) return "请填写用于接收内测码的邮箱";
if (!EMAIL_PATTERN.test(normalized)) return "邮箱格式不正确";
return null;
}
function parseJson(value, fallback) {
if (!value || typeof value !== "string") return fallback;
try {
@@ -32,6 +44,27 @@ function safeJsonString(value, fallback) {
}
}
function buildSmtpTransportOptions(scope) {
const prefix = scope ? `${scope}_` : "";
return {
host: process.env[`${prefix}SMTP_HOST`] || process.env.SMTP_HOST,
port: Number(process.env[`${prefix}SMTP_PORT`] || process.env.SMTP_PORT) || 587,
secure: String(process.env[`${prefix}SMTP_SECURE`] || process.env.SMTP_SECURE || "") === "1",
auth: {
user: process.env[`${prefix}SMTP_USER`] || process.env.SMTP_USER,
pass: process.env[`${prefix}SMTP_PASS`] || process.env.SMTP_PASS,
},
};
}
function formatEmailAddress(address, displayName) {
const email = String(address || "").trim();
const name = String(displayName || "").trim();
if (!name) return email;
const escapedName = name.replace(/"/g, '\\"');
return `"${escapedName}" <${email}>`;
}
function getRequestIp(req) {
const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
return forwardedFor || req.socket?.remoteAddress || "";
@@ -74,6 +107,7 @@ async function ensureBetaApplicationSchema() {
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT,
email TEXT,
phone TEXT,
wechat TEXT,
industry TEXT,
@@ -88,6 +122,7 @@ async function ensureBetaApplicationSchema() {
want_feature_json TEXT NOT NULL DEFAULT '[]',
self_statement TEXT,
signature TEXT,
application_date TEXT,
agree_rules INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
invite_code TEXT,
@@ -103,12 +138,19 @@ async function ensureBetaApplicationSchema() {
ON beta_applications(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_beta_applications_user_created
ON beta_applications(user_id, created_at DESC);
ALTER TABLE beta_applications
ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE beta_applications
ADD COLUMN IF NOT EXISTS application_date TEXT;
CREATE INDEX IF NOT EXISTS idx_beta_applications_email
ON beta_applications(LOWER(email));
`);
}
function normalizeApplicationBody(body) {
return {
name: cleanText(body?.name, 120),
email: normalizeEmail(body?.email),
phone: cleanText(body?.phone, 60),
wechat: cleanText(body?.wechat, 120),
industry: cleanText(body?.industry, 160),
@@ -123,6 +165,7 @@ function normalizeApplicationBody(body) {
wantFeature: cleanTextArray(body?.wantFeature ?? body?.want_feature),
selfStatement: cleanText(body?.selfStatement ?? body?.self_statement, 5000),
signature: cleanText(body?.signature, 120),
applicationDate: cleanText(body?.applicationDate ?? body?.application_date, 120),
agreeRules: body?.agreeRules === true || body?.agree_rules === true || body?.agreeRules === 1 || body?.agree_rules === 1,
};
}
@@ -133,6 +176,7 @@ function formatApplication(row) {
userId: row.user_id == null ? null : Number(row.user_id),
username: row.username || null,
name: row.name || "",
email: row.email || "",
phone: row.phone || "",
wechat: row.wechat || "",
industry: row.industry || "",
@@ -147,6 +191,7 @@ function formatApplication(row) {
wantFeature: parseJson(row.want_feature_json, []),
selfStatement: row.self_statement || "",
signature: row.signature || "",
applicationDate: row.application_date || "",
agreeRules: Boolean(row.agree_rules),
status: row.status || "pending",
inviteCode: row.invite_code || null,
@@ -218,29 +263,112 @@ async function createNotification(client, userId, input) {
);
}
function buildReviewEmailContent(application, action, inviteCode, reviewNote) {
const name = application.name || "内测申请人";
if (action === "approve") {
const text = [
`${name},您好:`,
"",
"您的 OmniAI 内测申请已通过。",
`内测码:${inviteCode}`,
"",
"请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。",
"",
"OmniAI 团队",
].join("\n");
const html = `
<div style="font-family:Arial,'Microsoft YaHei',sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222">
<h2 style="margin:0 0 16px;color:#166534">OmniAI 内测申请已通过</h2>
<p>${name},您好:</p>
<p>您的 OmniAI 内测申请已通过。</p>
<p style="padding:14px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;font-size:20px;font-weight:700;letter-spacing:1px;color:#166534">内测码:${inviteCode}</p>
<p>请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。</p>
<p style="margin-top:24px;color:#666">OmniAI 团队</p>
</div>
`;
return { subject: "[OmniAI] 内测申请已通过", text, html };
}
const reason = reviewNote || "很遗憾,您的内测申请暂未通过。";
const text = [
`${name},您好:`,
"",
"您未通过 OmniAI 内测申请。",
`审核备注:${reason}`,
"",
"感谢您的关注。",
"",
"OmniAI 团队",
].join("\n");
const html = `
<div style="font-family:Arial,'Microsoft YaHei',sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222">
<h2 style="margin:0 0 16px;color:#991b1b">OmniAI 内测申请未通过</h2>
<p>${name},您好:</p>
<p>您未通过 OmniAI 内测申请。</p>
<p style="padding:12px 14px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;color:#7f1d1d">审核备注:${reason}</p>
<p>感谢您的关注。</p>
<p style="margin-top:24px;color:#666">OmniAI 团队</p>
</div>
`;
return { subject: "[OmniAI] 内测申请未通过", text, html };
}
async function sendBetaApplicationReviewEmail(application, action, inviteCode, reviewNote) {
const email = normalizeEmail(application.email);
const emailError = validateEmail(email);
if (emailError) {
const err = new Error(`申请邮箱无效,无法发送审核结果:${emailError}`);
err.status = 409;
throw err;
}
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
const content = buildReviewEmailContent(application, action, inviteCode, reviewNote);
if (provider === "smtp") {
const nodemailer = require("nodemailer");
const smtpOptions = buildSmtpTransportOptions("BETA");
const transporter = nodemailer.createTransport(smtpOptions);
const fromAddress = process.env.BETA_SMTP_FROM || process.env.SMTP_FROM || smtpOptions.auth.user;
const fromName = process.env.BETA_SMTP_FROM_NAME || process.env.SMTP_FROM_NAME || "万物可爱";
await transporter.sendMail({
from: formatEmailAddress(fromAddress, fromName),
to: email,
subject: content.subject,
text: content.text,
html: content.html,
});
return { provider: "smtp" };
}
console.log(`[beta-application-email:${action}] ${email} ${content.subject}`);
return { provider: "mock" };
}
function registerBetaApplicationRoutes(router) {
router.post("/beta-applications", optionalAuth, async (req, res) => {
try {
await ensureBetaApplicationSchema();
const app = normalizeApplicationBody(req.body);
if (!app.name || !app.phone || !app.wechat || !app.selfStatement || !app.signature || !app.agreeRules) {
return res.status(400).json({ error: "请填写姓名、手机号、微信、申请自述、签名并同意内测规则" });
const emailError = validateEmail(app.email);
if (!app.name || emailError || !app.phone || !app.wechat || !app.selfStatement || !app.signature || !app.applicationDate || !app.agreeRules) {
return res.status(400).json({ error: emailError || "请填写姓名、手机号、微信、申请自述、签名、申请日期并同意内测规则" });
}
const { rows } = await pool.query(
`
INSERT INTO beta_applications (
user_id, name, phone, wechat, industry, company, city,
user_id, name, email, phone, wechat, industry, company, city,
ai_tools, ai_duration, ai_track, ai_direction_json,
weekly_usage, feedback_willing, want_feature_json,
self_statement, signature, agree_rules, ip_address, user_agent
self_statement, signature, application_date, agree_rules, ip_address, user_agent
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING id, status, created_at
`,
[
req.user?.id || null,
app.name,
app.email,
app.phone,
app.wechat,
app.industry || null,
@@ -255,6 +383,7 @@ function registerBetaApplicationRoutes(router) {
safeJsonString(app.wantFeature, []),
app.selfStatement,
app.signature,
app.applicationDate,
app.agreeRules ? 1 : 0,
getRequestIp(req),
cleanText(req.headers["user-agent"], 1000) || null,
@@ -353,6 +482,8 @@ function registerBetaApplicationRoutes(router) {
);
const updated = rows[0];
await sendBetaApplicationReviewEmail(updated, action, inviteCode, reviewNote);
if (updated.user_id) {
if (action === "approve") {
await createNotification(client, updated.user_id, {