diff --git a/src/routes/betaApplications.js b/src/routes/betaApplications.js new file mode 100644 index 0000000..0268674 --- /dev/null +++ b/src/routes/betaApplications.js @@ -0,0 +1,388 @@ +"use strict"; + +const { getUserContextById, verifyToken } = require("../auth"); +const { pool, withTransaction } = require("../db"); +const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes"); + +const REVIEW_USERNAMES = new Set(["xqy1912"]); + +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 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 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, + 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, + 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); + `); +} + +function normalizeApplicationBody(body) { + return { + name: cleanText(body?.name, 120), + 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), + 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 || "", + 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 || "", + 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 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 { rows } = await pool.query( + ` + INSERT INTO beta_applications ( + user_id, name, 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 + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + RETURNING id, status, created_at + `, + [ + req.user?.id || null, + app.name, + 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.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", 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", 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]; + 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 }; diff --git a/src/routes/index.js b/src/routes/index.js index 7889472..ca12bd9 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -17,6 +17,7 @@ const { registerConversationRoutes } = require('./conversations') const { registerReportRoutes } = require('./reports') const { registerAssetRoutes } = require('./assets') const { registerNotificationRoutes } = require('./notifications') +const { registerBetaApplicationRoutes } = require('./betaApplications') const { registerDraftRoutes } = require('./drafts'); const { registerFileExtractRoutes } = require('./fileExtract'); const mountClientErrorRoutes = require('./clientErrors') @@ -48,6 +49,7 @@ registerConversationRoutes(router) registerReportRoutes(router) registerAssetRoutes(router) registerNotificationRoutes(router) +registerBetaApplicationRoutes(router) registerDraftRoutes(router) registerFileExtractRoutes(router) registerHealthRoutes(router)