Add beta email review and task safeguards
This commit is contained in:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user