From 0f94010104e1cc8026e3313fd9223b482a0ea09c Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 15:55:43 +0800 Subject: [PATCH 1/5] Use latest login session as active device --- src/auth.js | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/auth.js b/src/auth.js index 0829e60..7ea3c3a 100644 --- a/src/auth.js +++ b/src/auth.js @@ -6,7 +6,6 @@ const { getJwtSecret } = require("./securityConfig"); const JWT_SECRET = getJwtSecret(); const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d"; -const MAX_CONCURRENT_SESSIONS = 2; const USER_CONTEXT_SELECT = ` SELECT @@ -170,25 +169,26 @@ function verifyToken(token) { async function startUserSession(userId, userAgent) { const sessionId = crypto.randomUUID(); - await pool.query( - "INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())", - [sessionId, userId, userAgent || null], - ); - await pool.query( - `DELETE FROM user_sessions - WHERE user_id = $1 - AND id NOT IN ( - SELECT id FROM user_sessions - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT $2 - )`, - [userId, MAX_CONCURRENT_SESSIONS], - ); - await pool.query( - "UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2", - [sessionId, userId], - ); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]); + await client.query("DELETE FROM user_sessions WHERE user_id = $1", [userId]); + await client.query( + "INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())", + [sessionId, userId, userAgent || null], + ); + await client.query( + "UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2", + [sessionId, userId], + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } return sessionId; } From 071a98bd9693de2df5ce7f4fc310f740ac30d249 Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 16:03:49 +0800 Subject: [PATCH 2/5] Scale generation billing charges to 1-to-100 credits --- src/billing.js | 5 ++++- src/enterpriseVideoBilling.js | 7 +++++-- src/routes/user.js | 12 ++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/billing.js b/src/billing.js index 7e6f0c9..f14b94c 100644 --- a/src/billing.js +++ b/src/billing.js @@ -18,9 +18,12 @@ const { pool, withTransaction } = require("./db"); const { calculateCostMills, getModelPrice } = require("./pricing"); const CREDIT_UNITS_PER_CREDIT = 100; +const CREDITS_PER_CNY = 100; const CREDIT_UNITS_PER_CNY_CENT = 100; const CREDIT_UNITS_PER_CNY_MILL = 10; -const IMAGE_GENERATION_FLAT_COST_CENTS = 20 * CREDIT_UNITS_PER_CREDIT; +const IMAGE_GENERATION_FLAT_COST_CNY = 20; +const IMAGE_GENERATION_FLAT_COST_CENTS = + IMAGE_GENERATION_FLAT_COST_CNY * CREDITS_PER_CNY * CREDIT_UNITS_PER_CREDIT; function creditsToCreditUnits(credits) { return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT)); diff --git a/src/enterpriseVideoBilling.js b/src/enterpriseVideoBilling.js index d44da31..23dd027 100644 --- a/src/enterpriseVideoBilling.js +++ b/src/enterpriseVideoBilling.js @@ -19,6 +19,9 @@ const ENTERPRISE_VIDEO_ALLOWED_MODELS = new Set([ "pixverse-c1-i2v", ]); +const CREDITS_PER_CNY = 100; +const CREDIT_UNITS_PER_CREDIT = 100; + function normalizeModel(value) { return String(value || "").trim().toLowerCase(); } @@ -102,7 +105,7 @@ function getEnterpriseVideoCreditRate(input) { function calculateEnterpriseVideoCredits(input) { const duration = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration); - return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2)); + return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2)); } function calculateEnterpriseVideoCost(input) { @@ -113,7 +116,7 @@ function calculateEnterpriseVideoCost(input) { resolution, durationSeconds, }); - const rateCentsPerSecond = Math.round(rateCreditsPerSecond * 100); + const rateCentsPerSecond = Math.round(rateCreditsPerSecond * CREDITS_PER_CNY * CREDIT_UNITS_PER_CREDIT); return { resolution, durationSeconds, diff --git a/src/routes/user.js b/src/routes/user.js index 5e0c24e..6fd39fe 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -136,8 +136,8 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents - WHEN status = 'completed' AND type = 'image' THEN 2000 - WHEN status = 'completed' AND type = 'video' THEN 500 + WHEN status = 'completed' AND type = 'image' THEN 200000 + WHEN status = 'completed' AND type = 'video' THEN 50000 ELSE 0 END ), 0) AS used_cents @@ -162,7 +162,7 @@ function registerUserRoutes(router) { resolution = params.resolution || params.quality || params.ratio || null; if (row.status === "completed") { if (row.type === "image") { - estimatedCents = 2000; + estimatedCents = 200000; } else if (row.type === "video") { const dur = params.duration || 5; const res = String(params.resolution || params.quality || "").toUpperCase(); @@ -172,7 +172,7 @@ function registerUserRoutes(router) { else if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) rate = res === "720P" ? 0.6 : 1; else if (model.includes("animate-mix") || model.includes("s2v")) rate = res === "720P" ? 0.6 : 1; else if (model.includes("kling")) rate = res === "720P" ? 0.6 : 0.8; - estimatedCents = Math.ceil(rate * dur * 100); + estimatedCents = Math.ceil(rate * dur * 10000); } } } catch { @@ -209,8 +209,8 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents - WHEN status = 'completed' AND type = 'image' THEN 2000 - WHEN status = 'completed' AND type = 'video' THEN 500 + WHEN status = 'completed' AND type = 'image' THEN 200000 + WHEN status = 'completed' AND type = 'video' THEN 50000 ELSE 0 END ), 0) AS used_cents From 9d604b5dd98cadd547c542e894ebb024e600ea58 Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 16:07:01 +0800 Subject: [PATCH 3/5] Restore image generation charge to 20 credits --- src/billing.js | 5 ++--- src/routes/user.js | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/billing.js b/src/billing.js index f14b94c..43422fd 100644 --- a/src/billing.js +++ b/src/billing.js @@ -21,9 +21,8 @@ const CREDIT_UNITS_PER_CREDIT = 100; const CREDITS_PER_CNY = 100; const CREDIT_UNITS_PER_CNY_CENT = 100; const CREDIT_UNITS_PER_CNY_MILL = 10; -const IMAGE_GENERATION_FLAT_COST_CNY = 20; -const IMAGE_GENERATION_FLAT_COST_CENTS = - IMAGE_GENERATION_FLAT_COST_CNY * CREDITS_PER_CNY * CREDIT_UNITS_PER_CREDIT; +const IMAGE_GENERATION_FLAT_COST_CREDITS = 20; +const IMAGE_GENERATION_FLAT_COST_CENTS = IMAGE_GENERATION_FLAT_COST_CREDITS * CREDIT_UNITS_PER_CREDIT; function creditsToCreditUnits(credits) { return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT)); diff --git a/src/routes/user.js b/src/routes/user.js index 6fd39fe..26498af 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -136,7 +136,7 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents - WHEN status = 'completed' AND type = 'image' THEN 200000 + WHEN status = 'completed' AND type = 'image' THEN 2000 WHEN status = 'completed' AND type = 'video' THEN 50000 ELSE 0 END @@ -162,7 +162,7 @@ function registerUserRoutes(router) { resolution = params.resolution || params.quality || params.ratio || null; if (row.status === "completed") { if (row.type === "image") { - estimatedCents = 200000; + estimatedCents = 2000; } else if (row.type === "video") { const dur = params.duration || 5; const res = String(params.resolution || params.quality || "").toUpperCase(); @@ -209,7 +209,7 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents - WHEN status = 'completed' AND type = 'image' THEN 200000 + WHEN status = 'completed' AND type = 'image' THEN 2000 WHEN status = 'completed' AND type = 'video' THEN 50000 ELSE 0 END From 6f8658bf857f7e599554c020c522f6860389b0f2 Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 16:20:45 +0800 Subject: [PATCH 4/5] Require auth for beta application review routes --- src/routes/betaApplications.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/betaApplications.js b/src/routes/betaApplications.js index 0268674..1d57ae3 100644 --- a/src/routes/betaApplications.js +++ b/src/routes/betaApplications.js @@ -1,6 +1,6 @@ "use strict"; -const { getUserContextById, verifyToken } = require("../auth"); +const { getUserContextById, requireAuth, verifyToken } = require("../auth"); const { pool, withTransaction } = require("../db"); const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes"); @@ -274,7 +274,7 @@ function registerBetaApplicationRoutes(router) { } }); - router.get("/admin/beta-applications", requireBetaApplicationReviewer, async (req, res) => { + router.get("/admin/beta-applications", requireAuth, requireBetaApplicationReviewer, async (req, res) => { try { await ensureBetaApplicationSchema(); const status = cleanText(req.query.status, 32); @@ -305,7 +305,7 @@ function registerBetaApplicationRoutes(router) { } }); - router.patch("/admin/beta-applications/:id", requireBetaApplicationReviewer, async (req, res) => { + 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; From 47b7bff2acdf7456ff08080b867b6404af7de182 Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 18:30:20 +0800 Subject: [PATCH 5/5] Add beta email review and task safeguards --- src/routes/ai.js | 42 +++++++--- src/routes/betaApplications.js | 141 +++++++++++++++++++++++++++++++-- src/routes/bugFeedback.js | 96 ++++++++++++++++++++++ src/routes/context.js | 123 ++++++++++++++++++++++++---- src/routes/index.js | 2 + 5 files changed, 376 insertions(+), 28 deletions(-) create mode 100644 src/routes/bugFeedback.js diff --git a/src/routes/ai.js b/src/routes/ai.js index b8f57c1..5a6cf78 100644 --- a/src/routes/ai.js +++ b/src/routes/ai.js @@ -97,6 +97,18 @@ function clampImageQualityForModel(model, quality) { return normalized; } +function isDashscopeWan27Limited2KScene(params) { + const model = String(params?.model || "").toLowerCase(); + if (model !== "wan2.7-image-pro") return false; + const hasReferenceImages = Array.isArray(params.referenceUrls) && params.referenceUrls.some(Boolean); + return hasReferenceImages || getGridCount(params.gridMode) > 1; +} + +function resolveDashscopeImageQuality(params) { + const quality = clampImageQualityForModel(params.model, params.quality); + return isDashscopeWan27Limited2KScene(params) && quality === "4K" ? "2K" : quality; +} + function clampGrsaiImageQualityForModel(model, quality) { const normalized = normalizeQuality(quality, "1K"); const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); @@ -334,18 +346,25 @@ async function assertUserGenerationConcurrencyLimit(userId, client = pool) { [userId], ); + const { rows: limitRows } = await client.query( + "SELECT max_concurrency FROM users WHERE id = $1", + [userId], + ); + const rawLimit = Number(limitRows[0]?.max_concurrency); + const concurrencyLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : MAX_USER_ACTIVE_GENERATION_TASKS; + const { rows } = await client.query( "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", [userId], ); const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); - if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; + if (activeCount < concurrencyLimit) return; - const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); + const error = new Error(`最多只能同时进行${concurrencyLimit}个任务`); error.status = 429; error.code = "GENERATION_CONCURRENCY_LIMIT"; error.activeCount = activeCount; - error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; + error.maxActiveTasks = concurrencyLimit; throw error; } @@ -469,17 +488,22 @@ function buildDashscopeImageBody(params) { if (url) content.push({ image: url }); } content.push({ text: params.prompt }); - const quality = clampImageQualityForModel(params.model, params.quality); + const quality = resolveDashscopeImageQuality(params); + const gridCount = getGridCount(params.gridMode); + const parameters = { + size: mapAspectRatioToDashscopeSize(params.ratio, quality), + n: gridCount, + watermark: false, + }; + if (gridCount > 1) { + parameters.enable_sequential = true; + } return { model: params.model, input: { messages: [{ role: "user", content }], }, - parameters: { - size: mapAspectRatioToDashscopeSize(params.ratio, quality), - n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1, - watermark: false, - }, + parameters, }; } diff --git a/src/routes/betaApplications.js b/src/routes/betaApplications.js index 1d57ae3..9d0bf57 100644 --- a/src/routes/betaApplications.js +++ b/src/routes/betaApplications.js @@ -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 = ` +
+

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); - 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, { diff --git a/src/routes/bugFeedback.js b/src/routes/bugFeedback.js new file mode 100644 index 0000000..452dda7 --- /dev/null +++ b/src/routes/bugFeedback.js @@ -0,0 +1,96 @@ +const express = require("express"); +const { requireAuth, requireAdmin } = require("../auth"); +const { pool } = require("../db"); +const { creditUserBalance } = require("../billing"); + +const router = express.Router(); + +router.post("/bug-feedback", requireAuth, async (req, res) => { + const userId = req.user.id; + const { title, description, screenshotUrl } = req.body; + if (!title || String(title).trim().length === 0) return res.status(400).json({ error: "标题不能为空" }); + if (!description || String(description).trim().length === 0) return res.status(400).json({ error: "描述不能为空" }); + if (String(title).length > 200) return res.status(400).json({ error: "标题不能超过200字" }); + if (String(description).length > 5000) return res.status(400).json({ error: "描述不能超过5000字" }); + + try { + const result = await pool.query( + `INSERT INTO bug_feedback (user_id, title, description, screenshot_url) VALUES ($1, $2, $3, $4) RETURNING id, status, created_at`, + [userId, String(title).trim(), String(description).trim(), screenshotUrl || null] + ); + res.json({ feedback: { id: result.rows[0].id, status: result.rows[0].status, createdAt: result.rows[0].created_at } }); + } catch (err) { + console.error("[bug-feedback] submit failed:", err.message); + res.status(500).json({ error: "提交失败,请稍后重试" }); + } +}); + +router.get("/bug-feedback/mine", requireAuth, async (req, res) => { + const userId = req.user.id; + try { + const result = await pool.query( + `SELECT id, title, description, screenshot_url, status, admin_note, created_at FROM bug_feedback WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50`, + [userId] + ); + res.json({ feedbacks: result.rows.map(r => ({ id: r.id, title: r.title, description: r.description, screenshotUrl: r.screenshot_url, status: r.status, adminNote: r.admin_note, createdAt: r.created_at })) }); + } catch (err) { + console.error("[bug-feedback] list mine failed:", err.message); + res.status(500).json({ error: "获取反馈列表失败" }); + } +}); + +router.get("/admin/bug-feedback", requireAuth, requireAdmin, async (req, res) => { + const status = req.query.status || null; + const limit = Math.min(Number(req.query.limit) || 20, 100); + const offset = Number(req.query.offset) || 0; + try { + const where = status ? "WHERE bf.status = $1" : ""; + const params = status ? [status, limit, offset] : [limit, offset]; + const countWhere = status ? "WHERE status = $1" : ""; + const countParams = status ? [status] : []; + const [dataRes, countRes] = await Promise.all([ + pool.query(`SELECT bf.id, bf.title, bf.description, bf.screenshot_url, bf.status, bf.admin_note, bf.reward_credited, bf.created_at, u.username FROM bug_feedback bf JOIN users u ON u.id = bf.user_id ${where} ORDER BY bf.created_at DESC LIMIT $${status ? 2 : 1} OFFSET $${status ? 3 : 2}`, params), + pool.query(`SELECT COUNT(*)::int AS total FROM bug_feedback ${countWhere}`, countParams), + ]); + res.json({ + feedbacks: dataRes.rows.map(r => ({ id: r.id, title: r.title, description: r.description, screenshotUrl: r.screenshot_url, status: r.status, adminNote: r.admin_note, rewardCredited: r.reward_credited, username: r.username, createdAt: r.created_at })), + total: countRes.rows[0].total, + }); + } catch (err) { + console.error("[admin/bug-feedback] list failed:", err.message); + res.status(500).json({ error: "获取反馈列表失败" }); + } +}); + +router.patch("/admin/bug-feedback/:id", requireAuth, requireAdmin, async (req, res) => { + const feedbackId = Number(req.params.id); + const { status, adminNote } = req.body; + if (!["approved", "rejected"].includes(status)) return res.status(400).json({ error: "状态只能是 approved 或 rejected" }); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const existing = await client.query("SELECT id, user_id, status, reward_credited FROM bug_feedback WHERE id = $1 FOR UPDATE", [feedbackId]); + if (existing.rows.length === 0) { await client.query("ROLLBACK"); return res.status(404).json({ error: "反馈不存在" }); } + const row = existing.rows[0]; + + await client.query("UPDATE bug_feedback SET status = $1, admin_note = $2, updated_at = NOW() WHERE id = $3", [status, adminNote || null, feedbackId]); + + let rewardCredited = row.reward_credited; + if (status === "approved" && !row.reward_credited) { + await creditUserBalance(row.user_id, 100, "Bug反馈奖励 1 积分"); + await client.query("UPDATE bug_feedback SET reward_credited = TRUE WHERE id = $1", [feedbackId]); + rewardCredited = true; + } + await client.query("COMMIT"); + res.json({ success: true, rewardCredited }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("[admin/bug-feedback] patch failed:", err.message); + res.status(500).json({ error: "操作失败" }); + } finally { + client.release(); + } +}); + +module.exports = router; diff --git a/src/routes/context.js b/src/routes/context.js index a15138c..887d657 100644 --- a/src/routes/context.js +++ b/src/routes/context.js @@ -212,28 +212,123 @@ function hashEmailCode(email, code) { return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex"); } +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 escapeEmailHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function buildEmailCodeContent(code, purpose) { + const purposeText = purpose === "register" ? "注册" : purpose === "login" ? "登录" : "重置密码"; + const ttlText = String(EMAIL_CODE_TTL_MINUTES); + const safeCode = escapeEmailHtml(code); + const safePurposeText = escapeEmailHtml(purposeText); + const preheader = `您的 OmniAI ${purposeText}验证码是 ${code},${ttlText} 分钟内有效。`; + + return { + subject: "[OmniAI] 邮箱验证码", + text: + `您的验证码是:${code}\n` + + `用途:${purposeText}\n` + + `有效期:${ttlText} 分钟\n` + + "请勿将验证码转发给他人。如非本人操作,请忽略此邮件。", + html: ` + + + + + OmniAI 邮箱验证码 + + +
${escapeEmailHtml(preheader)}
+ + + + +
+ + + + + + + + + + +
+
OmniAI
+

万物可爱邮箱验证

+

请使用下方验证码完成${safePurposeText}操作。

+
+
+
验证码
+
${safeCode}
+
${ttlText} 分钟内有效
+
+ + + + + + + + + +
用途${safePurposeText}
有效期${ttlText} 分钟
+
+ 请勿将验证码转发给他人。万物可爱工作人员不会向您索要邮箱验证码。 +
+

如果不是您本人操作,可以直接忽略此邮件。

+
+ 此邮件由系统自动发送,请勿直接回复。
OmniAI · 万物可爱 +
+
+ +`, + }; +} + 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 smtpOptions = buildSmtpTransportOptions("SYSTEM"); + const transporter = nodemailer.createTransport(smtpOptions); + const fromAddress = process.env.SYSTEM_SMTP_FROM || process.env.SMTP_FROM || smtpOptions.auth.user; + const fromName = process.env.SYSTEM_SMTP_FROM_NAME || process.env.SMTP_FROM_NAME || "万物可爱"; - const purposeText = purpose === "register" ? "注册" : purpose === "login" ? "登录" : "重置密码"; + const content = buildEmailCodeContent(code, purpose); await transporter.sendMail({ - from: process.env.SMTP_FROM || process.env.SMTP_USER, + from: formatEmailAddress(fromAddress, fromName), 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: "

OmniAI \u90ae\u7bb1\u9a8c\u8bc1

\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a

" + code + "

\u7528\u9014\uff1a" + purposeText + "

\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f


\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002

", + subject: content.subject, + text: content.text, + html: content.html, }); return { provider: "smtp" }; } diff --git a/src/routes/index.js b/src/routes/index.js index ca12bd9..530f762 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -21,6 +21,7 @@ const { registerBetaApplicationRoutes } = require('./betaApplications') const { registerDraftRoutes } = require('./drafts'); const { registerFileExtractRoutes } = require('./fileExtract'); const mountClientErrorRoutes = require('./clientErrors') +const bugFeedbackRouter = require("./bugFeedback") const router = express.Router() @@ -52,6 +53,7 @@ registerNotificationRoutes(router) registerBetaApplicationRoutes(router) registerDraftRoutes(router) registerFileExtractRoutes(router) +router.use(bugFeedbackRouter) registerHealthRoutes(router) module.exports = router