feat: add beta application review APIs
This commit is contained in:
@@ -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 };
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user