Merge pull request 'Codex/beta application review' (#5) from codex/beta-application-review into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
+20
-20
@@ -6,7 +6,6 @@ const { getJwtSecret } = require("./securityConfig");
|
|||||||
|
|
||||||
const JWT_SECRET = getJwtSecret();
|
const JWT_SECRET = getJwtSecret();
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
|
||||||
const MAX_CONCURRENT_SESSIONS = 2;
|
|
||||||
|
|
||||||
const USER_CONTEXT_SELECT = `
|
const USER_CONTEXT_SELECT = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -170,25 +169,26 @@ function verifyToken(token) {
|
|||||||
|
|
||||||
async function startUserSession(userId, userAgent) {
|
async function startUserSession(userId, userAgent) {
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
await pool.query(
|
const client = await pool.connect();
|
||||||
"INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())",
|
try {
|
||||||
[sessionId, userId, userAgent || null],
|
await client.query("BEGIN");
|
||||||
);
|
await client.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]);
|
||||||
await pool.query(
|
await client.query("DELETE FROM user_sessions WHERE user_id = $1", [userId]);
|
||||||
`DELETE FROM user_sessions
|
await client.query(
|
||||||
WHERE user_id = $1
|
"INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())",
|
||||||
AND id NOT IN (
|
[sessionId, userId, userAgent || null],
|
||||||
SELECT id FROM user_sessions
|
);
|
||||||
WHERE user_id = $1
|
await client.query(
|
||||||
ORDER BY created_at DESC
|
"UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2",
|
||||||
LIMIT $2
|
[sessionId, userId],
|
||||||
)`,
|
);
|
||||||
[userId, MAX_CONCURRENT_SESSIONS],
|
await client.query("COMMIT");
|
||||||
);
|
} catch (error) {
|
||||||
await pool.query(
|
await client.query("ROLLBACK");
|
||||||
"UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2",
|
throw error;
|
||||||
[sessionId, userId],
|
} finally {
|
||||||
);
|
client.release();
|
||||||
|
}
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -18,9 +18,11 @@ const { pool, withTransaction } = require("./db");
|
|||||||
const { calculateCostMills, getModelPrice } = require("./pricing");
|
const { calculateCostMills, getModelPrice } = require("./pricing");
|
||||||
|
|
||||||
const CREDIT_UNITS_PER_CREDIT = 100;
|
const CREDIT_UNITS_PER_CREDIT = 100;
|
||||||
|
const CREDITS_PER_CNY = 100;
|
||||||
const CREDIT_UNITS_PER_CNY_CENT = 100;
|
const CREDIT_UNITS_PER_CNY_CENT = 100;
|
||||||
const CREDIT_UNITS_PER_CNY_MILL = 10;
|
const CREDIT_UNITS_PER_CNY_MILL = 10;
|
||||||
const IMAGE_GENERATION_FLAT_COST_CENTS = 20 * 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) {
|
function creditsToCreditUnits(credits) {
|
||||||
return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT));
|
return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT));
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const ENTERPRISE_VIDEO_ALLOWED_MODELS = new Set([
|
|||||||
"pixverse-c1-i2v",
|
"pixverse-c1-i2v",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const CREDITS_PER_CNY = 100;
|
||||||
|
const CREDIT_UNITS_PER_CREDIT = 100;
|
||||||
|
|
||||||
function normalizeModel(value) {
|
function normalizeModel(value) {
|
||||||
return String(value || "").trim().toLowerCase();
|
return String(value || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -102,7 +105,7 @@ function getEnterpriseVideoCreditRate(input) {
|
|||||||
|
|
||||||
function calculateEnterpriseVideoCredits(input) {
|
function calculateEnterpriseVideoCredits(input) {
|
||||||
const duration = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration);
|
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) {
|
function calculateEnterpriseVideoCost(input) {
|
||||||
@@ -113,7 +116,7 @@ function calculateEnterpriseVideoCost(input) {
|
|||||||
resolution,
|
resolution,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
});
|
});
|
||||||
const rateCentsPerSecond = Math.round(rateCreditsPerSecond * 100);
|
const rateCentsPerSecond = Math.round(rateCreditsPerSecond * CREDITS_PER_CNY * CREDIT_UNITS_PER_CREDIT);
|
||||||
return {
|
return {
|
||||||
resolution,
|
resolution,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
|
|||||||
+33
-9
@@ -97,6 +97,18 @@ function clampImageQualityForModel(model, quality) {
|
|||||||
return normalized;
|
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) {
|
function clampGrsaiImageQualityForModel(model, quality) {
|
||||||
const normalized = normalizeQuality(quality, "1K");
|
const normalized = normalizeQuality(quality, "1K");
|
||||||
const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase());
|
const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase());
|
||||||
@@ -334,18 +346,25 @@ async function assertUserGenerationConcurrencyLimit(userId, client = pool) {
|
|||||||
[userId],
|
[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(
|
const { rows } = await client.query(
|
||||||
"SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')",
|
"SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')",
|
||||||
[userId],
|
[userId],
|
||||||
);
|
);
|
||||||
const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0);
|
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.status = 429;
|
||||||
error.code = "GENERATION_CONCURRENCY_LIMIT";
|
error.code = "GENERATION_CONCURRENCY_LIMIT";
|
||||||
error.activeCount = activeCount;
|
error.activeCount = activeCount;
|
||||||
error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS;
|
error.maxActiveTasks = concurrencyLimit;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,17 +488,22 @@ function buildDashscopeImageBody(params) {
|
|||||||
if (url) content.push({ image: url });
|
if (url) content.push({ image: url });
|
||||||
}
|
}
|
||||||
content.push({ text: params.prompt });
|
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 {
|
return {
|
||||||
model: params.model,
|
model: params.model,
|
||||||
input: {
|
input: {
|
||||||
messages: [{ role: "user", content }],
|
messages: [{ role: "user", content }],
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters,
|
||||||
size: mapAspectRatioToDashscopeSize(params.ratio, quality),
|
|
||||||
n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1,
|
|
||||||
watermark: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { getUserContextById, verifyToken } = require("../auth");
|
const { getUserContextById, requireAuth, verifyToken } = require("../auth");
|
||||||
const { pool, withTransaction } = require("../db");
|
const { pool, withTransaction } = require("../db");
|
||||||
const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes");
|
const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes");
|
||||||
|
|
||||||
const REVIEW_USERNAMES = new Set(["xqy1912"]);
|
const REVIEW_USERNAMES = new Set(["xqy1912"]);
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
function cleanText(value, maxLength) {
|
function cleanText(value, maxLength) {
|
||||||
return String(value || "").trim().slice(0, 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);
|
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) {
|
function parseJson(value, fallback) {
|
||||||
if (!value || typeof value !== "string") return fallback;
|
if (!value || typeof value !== "string") return fallback;
|
||||||
try {
|
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) {
|
function getRequestIp(req) {
|
||||||
const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
||||||
return forwardedFor || req.socket?.remoteAddress || "";
|
return forwardedFor || req.socket?.remoteAddress || "";
|
||||||
@@ -74,6 +107,7 @@ async function ensureBetaApplicationSchema() {
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
|
email TEXT,
|
||||||
phone TEXT,
|
phone TEXT,
|
||||||
wechat TEXT,
|
wechat TEXT,
|
||||||
industry TEXT,
|
industry TEXT,
|
||||||
@@ -88,6 +122,7 @@ async function ensureBetaApplicationSchema() {
|
|||||||
want_feature_json TEXT NOT NULL DEFAULT '[]',
|
want_feature_json TEXT NOT NULL DEFAULT '[]',
|
||||||
self_statement TEXT,
|
self_statement TEXT,
|
||||||
signature TEXT,
|
signature TEXT,
|
||||||
|
application_date TEXT,
|
||||||
agree_rules INTEGER NOT NULL DEFAULT 0,
|
agree_rules INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
invite_code TEXT,
|
invite_code TEXT,
|
||||||
@@ -103,12 +138,19 @@ async function ensureBetaApplicationSchema() {
|
|||||||
ON beta_applications(status, created_at DESC);
|
ON beta_applications(status, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_beta_applications_user_created
|
CREATE INDEX IF NOT EXISTS idx_beta_applications_user_created
|
||||||
ON beta_applications(user_id, created_at DESC);
|
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) {
|
function normalizeApplicationBody(body) {
|
||||||
return {
|
return {
|
||||||
name: cleanText(body?.name, 120),
|
name: cleanText(body?.name, 120),
|
||||||
|
email: normalizeEmail(body?.email),
|
||||||
phone: cleanText(body?.phone, 60),
|
phone: cleanText(body?.phone, 60),
|
||||||
wechat: cleanText(body?.wechat, 120),
|
wechat: cleanText(body?.wechat, 120),
|
||||||
industry: cleanText(body?.industry, 160),
|
industry: cleanText(body?.industry, 160),
|
||||||
@@ -123,6 +165,7 @@ function normalizeApplicationBody(body) {
|
|||||||
wantFeature: cleanTextArray(body?.wantFeature ?? body?.want_feature),
|
wantFeature: cleanTextArray(body?.wantFeature ?? body?.want_feature),
|
||||||
selfStatement: cleanText(body?.selfStatement ?? body?.self_statement, 5000),
|
selfStatement: cleanText(body?.selfStatement ?? body?.self_statement, 5000),
|
||||||
signature: cleanText(body?.signature, 120),
|
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,
|
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),
|
userId: row.user_id == null ? null : Number(row.user_id),
|
||||||
username: row.username || null,
|
username: row.username || null,
|
||||||
name: row.name || "",
|
name: row.name || "",
|
||||||
|
email: row.email || "",
|
||||||
phone: row.phone || "",
|
phone: row.phone || "",
|
||||||
wechat: row.wechat || "",
|
wechat: row.wechat || "",
|
||||||
industry: row.industry || "",
|
industry: row.industry || "",
|
||||||
@@ -147,6 +191,7 @@ function formatApplication(row) {
|
|||||||
wantFeature: parseJson(row.want_feature_json, []),
|
wantFeature: parseJson(row.want_feature_json, []),
|
||||||
selfStatement: row.self_statement || "",
|
selfStatement: row.self_statement || "",
|
||||||
signature: row.signature || "",
|
signature: row.signature || "",
|
||||||
|
applicationDate: row.application_date || "",
|
||||||
agreeRules: Boolean(row.agree_rules),
|
agreeRules: Boolean(row.agree_rules),
|
||||||
status: row.status || "pending",
|
status: row.status || "pending",
|
||||||
inviteCode: row.invite_code || null,
|
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) {
|
function registerBetaApplicationRoutes(router) {
|
||||||
router.post("/beta-applications", optionalAuth, async (req, res) => {
|
router.post("/beta-applications", optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureBetaApplicationSchema();
|
await ensureBetaApplicationSchema();
|
||||||
const app = normalizeApplicationBody(req.body);
|
const app = normalizeApplicationBody(req.body);
|
||||||
if (!app.name || !app.phone || !app.wechat || !app.selfStatement || !app.signature || !app.agreeRules) {
|
const emailError = validateEmail(app.email);
|
||||||
return res.status(400).json({ error: "请填写姓名、手机号、微信、申请自述、签名并同意内测规则" });
|
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(
|
const { rows } = await pool.query(
|
||||||
`
|
`
|
||||||
INSERT INTO beta_applications (
|
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,
|
ai_tools, ai_duration, ai_track, ai_direction_json,
|
||||||
weekly_usage, feedback_willing, want_feature_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
|
RETURNING id, status, created_at
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
req.user?.id || null,
|
req.user?.id || null,
|
||||||
app.name,
|
app.name,
|
||||||
|
app.email,
|
||||||
app.phone,
|
app.phone,
|
||||||
app.wechat,
|
app.wechat,
|
||||||
app.industry || null,
|
app.industry || null,
|
||||||
@@ -255,6 +383,7 @@ function registerBetaApplicationRoutes(router) {
|
|||||||
safeJsonString(app.wantFeature, []),
|
safeJsonString(app.wantFeature, []),
|
||||||
app.selfStatement,
|
app.selfStatement,
|
||||||
app.signature,
|
app.signature,
|
||||||
|
app.applicationDate,
|
||||||
app.agreeRules ? 1 : 0,
|
app.agreeRules ? 1 : 0,
|
||||||
getRequestIp(req),
|
getRequestIp(req),
|
||||||
cleanText(req.headers["user-agent"], 1000) || null,
|
cleanText(req.headers["user-agent"], 1000) || null,
|
||||||
@@ -274,7 +403,7 @@ function registerBetaApplicationRoutes(router) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/admin/beta-applications", requireBetaApplicationReviewer, async (req, res) => {
|
router.get("/admin/beta-applications", requireAuth, requireBetaApplicationReviewer, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureBetaApplicationSchema();
|
await ensureBetaApplicationSchema();
|
||||||
const status = cleanText(req.query.status, 32);
|
const status = cleanText(req.query.status, 32);
|
||||||
@@ -305,7 +434,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 id = Number(req.params.id);
|
||||||
const action = cleanText(req.body?.action, 32);
|
const action = cleanText(req.body?.action, 32);
|
||||||
const reviewNote = cleanText(req.body?.reviewNote ?? req.body?.review_note, 1000) || null;
|
const reviewNote = cleanText(req.body?.reviewNote ?? req.body?.review_note, 1000) || null;
|
||||||
@@ -353,6 +482,8 @@ function registerBetaApplicationRoutes(router) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updated = rows[0];
|
const updated = rows[0];
|
||||||
|
await sendBetaApplicationReviewEmail(updated, action, inviteCode, reviewNote);
|
||||||
|
|
||||||
if (updated.user_id) {
|
if (updated.user_id) {
|
||||||
if (action === "approve") {
|
if (action === "approve") {
|
||||||
await createNotification(client, updated.user_id, {
|
await createNotification(client, updated.user_id, {
|
||||||
|
|||||||
@@ -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;
|
||||||
+109
-14
@@ -212,28 +212,123 @@ function hashEmailCode(email, code) {
|
|||||||
return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
|
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, ">")
|
||||||
|
.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: `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OmniAI 邮箱验证码</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f7fb;color:#1f2937;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',Arial,sans-serif;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeEmailHtml(preheader)}</div>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;background:#f4f7fb;margin:0;padding:28px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;max-width:560px;background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e5ebf3;box-shadow:0 18px 45px rgba(31,41,55,0.08);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 28px 20px;background:#101827;color:#ffffff;">
|
||||||
|
<div style="font-size:13px;letter-spacing:2px;text-transform:uppercase;color:#a7f3d0;font-weight:700;">OmniAI</div>
|
||||||
|
<h1 style="margin:10px 0 0;font-size:24px;line-height:1.35;font-weight:800;color:#ffffff;">万物可爱邮箱验证</h1>
|
||||||
|
<p style="margin:10px 0 0;font-size:14px;line-height:1.8;color:#cbd5e1;">请使用下方验证码完成${safePurposeText}操作。</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
<div style="border:1px solid #dbe6f4;background:#f8fbff;border-radius:14px;padding:22px 18px;text-align:center;">
|
||||||
|
<div style="font-size:13px;color:#64748b;margin-bottom:10px;">验证码</div>
|
||||||
|
<div style="font-size:38px;line-height:1.2;letter-spacing:8px;font-weight:800;color:#0f766e;font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">${safeCode}</div>
|
||||||
|
<div style="font-size:13px;color:#64748b;margin-top:14px;">${ttlText} 分钟内有效</div>
|
||||||
|
</div>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin-top:22px;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#64748b;font-size:14px;">用途</td>
|
||||||
|
<td align="right" style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#111827;font-size:14px;font-weight:700;">${safePurposeText}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#64748b;font-size:14px;">有效期</td>
|
||||||
|
<td align="right" style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#111827;font-size:14px;font-weight:700;">${ttlText} 分钟</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:22px;padding:14px 16px;border-radius:12px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;font-size:13px;line-height:1.8;">
|
||||||
|
请勿将验证码转发给他人。万物可爱工作人员不会向您索要邮箱验证码。
|
||||||
|
</div>
|
||||||
|
<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.8;">如果不是您本人操作,可以直接忽略此邮件。</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 28px;background:#f8fafc;border-top:1px solid #edf2f7;color:#94a3b8;font-size:12px;line-height:1.7;text-align:center;">
|
||||||
|
此邮件由系统自动发送,请勿直接回复。<br>OmniAI · 万物可爱
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function sendEmailCode(email, code, purpose) {
|
async function sendEmailCode(email, code, purpose) {
|
||||||
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
|
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
|
||||||
|
|
||||||
if (provider === "smtp") {
|
if (provider === "smtp") {
|
||||||
const nodemailer = require("nodemailer");
|
const nodemailer = require("nodemailer");
|
||||||
const transporter = nodemailer.createTransport({
|
const smtpOptions = buildSmtpTransportOptions("SYSTEM");
|
||||||
host: process.env.SMTP_HOST,
|
const transporter = nodemailer.createTransport(smtpOptions);
|
||||||
port: Number(process.env.SMTP_PORT) || 587,
|
const fromAddress = process.env.SYSTEM_SMTP_FROM || process.env.SMTP_FROM || smtpOptions.auth.user;
|
||||||
secure: process.env.SMTP_SECURE === "1",
|
const fromName = process.env.SYSTEM_SMTP_FROM_NAME || process.env.SMTP_FROM_NAME || "万物可爱";
|
||||||
auth: {
|
|
||||||
user: process.env.SMTP_USER,
|
|
||||||
pass: process.env.SMTP_PASS,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const purposeText = purpose === "register" ? "注册" : purpose === "login" ? "登录" : "重置密码";
|
const content = buildEmailCodeContent(code, purpose);
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
from: formatEmailAddress(fromAddress, fromName),
|
||||||
to: email,
|
to: email,
|
||||||
subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
|
subject: content.subject,
|
||||||
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",
|
text: content.text,
|
||||||
html: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><h2 style=\"color:#333\">OmniAI \u90ae\u7bb1\u9a8c\u8bc1</h2><p style=\"font-size:16px;color:#555\">\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a</p><p style=\"font-size:32px;font-weight:bold;letter-spacing:6px;color:#1677ff;margin:16px 0\">" + code + "</p><p style=\"color:#888\">\u7528\u9014\uff1a" + purposeText + "</p><p style=\"color:#888\">\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f</p><hr style=\"border:none;border-top:1px solid #eee;margin:24px 0\"><p style=\"color:#aaa;font-size:13px\">\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002</p></div>",
|
html: content.html,
|
||||||
});
|
});
|
||||||
return { provider: "smtp" };
|
return { provider: "smtp" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { registerBetaApplicationRoutes } = require('./betaApplications')
|
|||||||
const { registerDraftRoutes } = require('./drafts');
|
const { registerDraftRoutes } = require('./drafts');
|
||||||
const { registerFileExtractRoutes } = require('./fileExtract');
|
const { registerFileExtractRoutes } = require('./fileExtract');
|
||||||
const mountClientErrorRoutes = require('./clientErrors')
|
const mountClientErrorRoutes = require('./clientErrors')
|
||||||
|
const bugFeedbackRouter = require("./bugFeedback")
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ registerNotificationRoutes(router)
|
|||||||
registerBetaApplicationRoutes(router)
|
registerBetaApplicationRoutes(router)
|
||||||
registerDraftRoutes(router)
|
registerDraftRoutes(router)
|
||||||
registerFileExtractRoutes(router)
|
registerFileExtractRoutes(router)
|
||||||
|
router.use(bugFeedbackRouter)
|
||||||
registerHealthRoutes(router)
|
registerHealthRoutes(router)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
+3
-3
@@ -137,7 +137,7 @@ function registerUserRoutes(router) {
|
|||||||
WHEN billing_refunded = 1 THEN 0
|
WHEN billing_refunded = 1 THEN 0
|
||||||
WHEN cost_cents > 0 THEN cost_cents
|
WHEN cost_cents > 0 THEN cost_cents
|
||||||
WHEN status = 'completed' AND type = 'image' THEN 2000
|
WHEN status = 'completed' AND type = 'image' THEN 2000
|
||||||
WHEN status = 'completed' AND type = 'video' THEN 500
|
WHEN status = 'completed' AND type = 'video' THEN 50000
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
), 0) AS used_cents
|
), 0) AS used_cents
|
||||||
@@ -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("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("animate-mix") || model.includes("s2v")) rate = res === "720P" ? 0.6 : 1;
|
||||||
else if (model.includes("kling")) rate = res === "720P" ? 0.6 : 0.8;
|
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 {
|
} catch {
|
||||||
@@ -210,7 +210,7 @@ function registerUserRoutes(router) {
|
|||||||
WHEN billing_refunded = 1 THEN 0
|
WHEN billing_refunded = 1 THEN 0
|
||||||
WHEN cost_cents > 0 THEN cost_cents
|
WHEN cost_cents > 0 THEN cost_cents
|
||||||
WHEN status = 'completed' AND type = 'image' THEN 2000
|
WHEN status = 'completed' AND type = 'image' THEN 2000
|
||||||
WHEN status = 'completed' AND type = 'video' THEN 500
|
WHEN status = 'completed' AND type = 'video' THEN 50000
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
), 0) AS used_cents
|
), 0) AS used_cents
|
||||||
|
|||||||
Reference in New Issue
Block a user