"use strict"; const { getUserContextById, requireAuth, verifyToken } = require("../auth"); 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); } function cleanTextArray(value, maxItems = 20, maxLength = 200) { if (!Array.isArray(value)) return []; 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 { return JSON.parse(value); } catch { return fallback; } } function safeJsonString(value, fallback) { try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(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 || ""; } async function optionalAuth(req, _res, next) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { next(); return; } try { const payload = verifyToken(authHeader.slice(7)); const user = await getUserContextById(payload.userId); if (user?.enabled) req.user = user; } catch { // Public application submission should still work without a valid session. } next(); } function canReviewBetaApplications(user) { if (!user) return false; const role = String(user.role || "").trim().toLowerCase(); const username = String(user.username || "").trim().toLowerCase(); return role === "admin" || REVIEW_USERNAMES.has(username); } function requireBetaApplicationReviewer(req, res, next) { if (!canReviewBetaApplications(req.user)) { return res.status(403).json({ error: "无权审核内测申请" }); } next(); } async function ensureBetaApplicationSchema() { await pool.query(` CREATE TABLE IF NOT EXISTS beta_applications ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, name TEXT, email TEXT, phone TEXT, wechat TEXT, industry TEXT, company TEXT, city TEXT, ai_tools TEXT, ai_duration TEXT, ai_track TEXT, ai_direction_json TEXT NOT NULL DEFAULT '[]', weekly_usage TEXT, feedback_willing TEXT, 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, review_note TEXT, reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL, reviewed_at TIMESTAMPTZ, ip_address TEXT, user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_beta_applications_status_created 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), company: cleanText(body?.company, 200), city: cleanText(body?.city, 120), aiTools: cleanText(body?.aiTools ?? body?.ai_tools, 1000), aiDuration: cleanText(body?.aiDuration ?? body?.ai_duration, 120), aiTrack: cleanText(body?.aiTrack ?? body?.ai_track, 160), aiDirection: cleanTextArray(body?.aiDirection ?? body?.ai_direction), weeklyUsage: cleanText(body?.weeklyUsage ?? body?.weekly_usage, 120), feedbackWilling: cleanText(body?.feedbackWilling ?? body?.feedback_willing, 160), 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, }; } function formatApplication(row) { return { id: Number(row.id), 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 || "", company: row.company || "", city: row.city || "", aiTools: row.ai_tools || "", aiDuration: row.ai_duration || "", aiTrack: row.ai_track || "", aiDirection: parseJson(row.ai_direction_json, []), weeklyUsage: row.weekly_usage || "", feedbackWilling: row.feedback_willing || "", 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, reviewNote: row.review_note || null, reviewedBy: row.reviewed_by == null ? null : Number(row.reviewed_by), reviewerUsername: row.reviewer_username || null, reviewedAt: row.reviewed_at || null, ipAddress: row.ip_address || null, userAgent: row.user_agent || null, createdAt: row.created_at, updatedAt: row.updated_at, }; } async function selectApplicationById(client, id) { const { rows } = await client.query( ` SELECT a.*, u.username, reviewer.username AS reviewer_username FROM beta_applications a LEFT JOIN users u ON u.id = a.user_id LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by WHERE a.id = $1 LIMIT 1 `, [id], ); return rows[0] || null; } async function issueNextBetaInviteCode(client) { const codes = Array.from(loadBetaInviteCodes()).map(normalizeBetaInviteCode).filter(Boolean).sort(); for (const code of codes) { const { rows } = await client.query( ` SELECT 1 FROM beta_invite_code_uses WHERE code = $1 UNION ALL SELECT 1 FROM beta_applications WHERE invite_code = $1 AND status = 'approved' LIMIT 1 `, [code], ); if (rows.length === 0) return code; } return null; } async function createNotification(client, userId, input) { if (!userId) return; await client.query( ` INSERT INTO web_notifications ( user_id, type, title, description, target_type, target_id, metadata_json ) VALUES ($1, $2, $3, $4, $5, $6, $7) `, [ userId, input.type, input.title, input.description || null, input.targetType || "beta_application", input.targetId ? String(input.targetId) : null, safeJsonString(input.metadata, {}), ], ); } function buildReviewEmailContent(application, action, inviteCode, reviewNote) { const name = application.name || "内测申请人"; if (action === "approve") { const text = [ `${name},您好:`, "", "您的 OmniAI 内测申请已通过。", `内测码:${inviteCode}`, "", "请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。", "", "OmniAI 团队", ].join("\n"); const html = `

OmniAI 内测申请已通过

${name},您好:

您的 OmniAI 内测申请已通过。

内测码:${inviteCode}

请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。

OmniAI 团队

`; return { subject: "[OmniAI] 内测申请已通过", text, html }; } const reason = reviewNote || "很遗憾,您的内测申请暂未通过。"; const text = [ `${name},您好:`, "", "您未通过 OmniAI 内测申请。", `审核备注:${reason}`, "", "感谢您的关注。", "", "OmniAI 团队", ].join("\n"); const html = `

OmniAI 内测申请未通过

${name},您好:

您未通过 OmniAI 内测申请。

审核备注:${reason}

感谢您的关注。

OmniAI 团队

`; 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); 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, 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, 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, $20, $21) RETURNING id, status, created_at `, [ req.user?.id || null, app.name, app.email, app.phone, app.wechat, app.industry || null, app.company || null, app.city || null, app.aiTools || null, app.aiDuration || null, app.aiTrack || null, safeJsonString(app.aiDirection, []), app.weeklyUsage || null, app.feedbackWilling || null, safeJsonString(app.wantFeature, []), app.selfStatement, app.signature, app.applicationDate, app.agreeRules ? 1 : 0, getRequestIp(req), cleanText(req.headers["user-agent"], 1000) || null, ], ); res.status(201).json({ application: { id: rows[0].id, status: rows[0].status, createdAt: rows[0].created_at, }, }); } catch (err) { console.error("[beta-applications] create failed:", err.message); res.status(500).json({ error: "提交内测申请失败" }); } }); router.get("/admin/beta-applications", requireAuth, requireBetaApplicationReviewer, async (req, res) => { try { await ensureBetaApplicationSchema(); const status = cleanText(req.query.status, 32); const params = []; const where = []; if (status) { params.push(status); where.push(`a.status = $${params.length}`); } const { rows } = await pool.query( ` SELECT a.*, u.username, reviewer.username AS reviewer_username FROM beta_applications a LEFT JOIN users u ON u.id = a.user_id LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY CASE a.status WHEN 'pending' THEN 0 WHEN 'approved' THEN 1 ELSE 2 END, a.created_at DESC LIMIT 300 `, params, ); res.json({ applications: rows.map(formatApplication) }); } catch (err) { console.error("[admin/beta-applications] list failed:", err.message); res.status(500).json({ error: "读取内测申请失败" }); } }); router.patch("/admin/beta-applications/:id", requireAuth, requireBetaApplicationReviewer, async (req, res) => { const id = Number(req.params.id); const action = cleanText(req.body?.action, 32); const reviewNote = cleanText(req.body?.reviewNote ?? req.body?.review_note, 1000) || null; if (!Number.isFinite(id)) return res.status(400).json({ error: "申请 ID 不正确" }); if (action !== "approve" && action !== "reject") return res.status(400).json({ error: "审核动作不正确" }); try { await ensureBetaApplicationSchema(); const application = await withTransaction(async (client) => { const current = await selectApplicationById(client, id); if (!current) { const err = new Error("申请不存在"); err.status = 404; throw err; } if (current.status !== "pending") { const err = new Error("该申请已审核"); err.status = 409; throw err; } let inviteCode = null; if (action === "approve") { inviteCode = await issueNextBetaInviteCode(client); if (!inviteCode) { const err = new Error("暂无可用内测码,请先补充内测码"); err.status = 409; throw err; } } const { rows } = await client.query( ` UPDATE beta_applications SET status = $1, invite_code = $2, review_note = $3, reviewed_by = $4, reviewed_at = NOW(), updated_at = NOW() WHERE id = $5 RETURNING * `, [action === "approve" ? "approved" : "rejected", inviteCode, reviewNote, req.user.id, id], ); const updated = rows[0]; await sendBetaApplicationReviewEmail(updated, action, inviteCode, reviewNote); if (updated.user_id) { if (action === "approve") { await createNotification(client, updated.user_id, { type: "review_passed", title: "内测申请已通过", description: `您的内测申请已通过,内测码:${inviteCode}`, targetId: updated.id, metadata: { inviteCode }, }); } else { await createNotification(client, updated.user_id, { type: "review_rejected", title: "您未通过内测申请", description: reviewNote || "很遗憾,您的内测申请暂未通过。", targetId: updated.id, }); } } return selectApplicationById(client, id); }); res.json({ application: formatApplication(application) }); } catch (err) { const status = Number(err.status || 500); if (status >= 400 && status < 500) return res.status(status).json({ error: err.message }); console.error("[admin/beta-applications] review failed:", err.message); res.status(500).json({ error: "审核内测申请失败" }); } }); } module.exports = { registerBetaApplicationRoutes, canReviewBetaApplications };