Initial commit: OmniAI backend server
This commit is contained in:
@@ -0,0 +1,758 @@
|
||||
const {
|
||||
bcrypt,
|
||||
requireAuth,
|
||||
login,
|
||||
getUserContextById,
|
||||
clearUserSession,
|
||||
generateUniqueEnterpriseCode,
|
||||
crypto,
|
||||
pool,
|
||||
withTransaction,
|
||||
SMS_PURPOSES,
|
||||
SMS_CODE_TTL_MINUTES,
|
||||
SMS_CODE_COOLDOWN_SECONDS,
|
||||
validateUsername,
|
||||
validatePassword,
|
||||
normalizePhone,
|
||||
validatePhone,
|
||||
normalizeEmail,
|
||||
validateEmail,
|
||||
hashSmsCode,
|
||||
generateSmsCode,
|
||||
sendSmsCode,
|
||||
createLoginResultForUserId,
|
||||
generateUniqueUsername,
|
||||
consumeSmsCode,
|
||||
getWechatLoginConfig,
|
||||
exchangeWechatCode,
|
||||
findOrCreateWechatUser,
|
||||
validateEnterpriseName,
|
||||
buildOssPublicUrl,
|
||||
normalizeAvatarOssKey,
|
||||
normalizeProfileMediaUrl,
|
||||
} = require("./context");
|
||||
const {
|
||||
checkBetaInviteCodeForRegistration,
|
||||
consumeBetaInviteCode,
|
||||
findEnterpriseBetaAccountByInviteCode,
|
||||
getBetaInviteCodeFromBody,
|
||||
} = require("../betaInviteCodes");
|
||||
|
||||
async function ensureRegistrationInvite(req, res, client = pool) {
|
||||
const result = await checkBetaInviteCodeForRegistration(getBetaInviteCodeFromBody(req.body), client);
|
||||
if (result.ok) return result;
|
||||
res.status(result.status || 403).json({ error: result.error || "内测码无效或缺失" });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureBetaInviteCode(req, res, client = pool) {
|
||||
const result = await ensureRegistrationInvite(req, res, client);
|
||||
return result ? result.code : null;
|
||||
}
|
||||
|
||||
function createBetaInviteCodeError(result) {
|
||||
const error = new Error(result.error || "内测码无效或缺失");
|
||||
error.status = result.status || 403;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function consumeBetaInviteCodeForUser(client, code, userId) {
|
||||
const result = await consumeBetaInviteCode(code, userId, client);
|
||||
if (!result.ok) throw createBetaInviteCodeError(result);
|
||||
}
|
||||
|
||||
function hashRegistrationInviteCode(code) {
|
||||
const normalized = String(code || "")
|
||||
.trim()
|
||||
.replace(/[\s-]/g, "")
|
||||
.toUpperCase();
|
||||
return crypto.createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
async function resolveEnterpriseBetaRegistrationTarget(invite, client) {
|
||||
const account =
|
||||
invite?.account || findEnterpriseBetaAccountByInviteCode(invite?.code || invite);
|
||||
if (!account) return { enterpriseId: null, isEnterpriseBeta: false };
|
||||
|
||||
const { rows } = await client.query(
|
||||
"SELECT id, enabled FROM enterprises WHERE enterprise_code = $1 LIMIT 1",
|
||||
[account.enterpriseId],
|
||||
);
|
||||
if (rows.length === 0 || !rows[0].enabled) {
|
||||
const error = new Error("企业内测账号尚未初始化,请先运行服务端数据库初始化");
|
||||
error.status = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
enterpriseId: rows[0].id,
|
||||
isEnterpriseBeta: true,
|
||||
account,
|
||||
};
|
||||
}
|
||||
|
||||
async function consumeRegistrationInviteForUser(client, invite, userId, enterpriseTarget) {
|
||||
if (enterpriseTarget && enterpriseTarget.isEnterpriseBeta) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO enterprise_members (enterprise_id, user_id, role)
|
||||
VALUES ($1, $2, 'employee')
|
||||
ON CONFLICT (enterprise_id, user_id) DO UPDATE SET role = EXCLUDED.role
|
||||
`,
|
||||
[enterpriseTarget.enterpriseId, userId],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
UPDATE enterprise_invites
|
||||
SET used_at = COALESCE(used_at, NOW())
|
||||
WHERE enterprise_id = $1 AND code_hash = $2
|
||||
`,
|
||||
[enterpriseTarget.enterpriseId, hashRegistrationInviteCode(invite.code)],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await consumeBetaInviteCodeForUser(client, invite.code, userId);
|
||||
}
|
||||
|
||||
function sendAuthRouteError(res, error, fallback) {
|
||||
const status = Number.isInteger(error?.status) ? error.status : 500;
|
||||
if (status >= 400 && status < 500) {
|
||||
res.status(status).json({ error: error.message || fallback });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: fallback });
|
||||
}
|
||||
|
||||
function registerAuthRoutes(router) {
|
||||
// ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.post("/auth/login", async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: "缺少用户名或密码" });
|
||||
|
||||
try {
|
||||
const result = await login(username, password, req.headers["user-agent"]);
|
||||
if (!result) return res.status(401).json({ error: "用户名或密码错误" });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[auth/login] failed", err);
|
||||
res.status(500).json({ error: "登录失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/login-email", async (req, res) => {
|
||||
const email = normalizeEmail(req.body?.email);
|
||||
const password = String(req.body?.password || "");
|
||||
const emailError = validateEmail(email);
|
||||
if (emailError) return res.status(400).json({ error: emailError });
|
||||
if (!password) return res.status(400).json({ error: "缺少密码" });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT username FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1",
|
||||
[email],
|
||||
);
|
||||
if (rows.length === 0) return res.status(401).json({ error: "邮箱或密码错误" });
|
||||
|
||||
const result = await login(rows[0].username, password, req.headers["user-agent"]);
|
||||
if (!result) return res.status(401).json({ error: "邮箱或密码错误" });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error("[auth/login-email] failed", err);
|
||||
res.status(500).json({ error: "邮箱登录失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/register", async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) return res.status(400).json({ error: usernameError });
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||
const registrationInvite = await ensureRegistrationInvite(req, res);
|
||||
if (!registrationInvite) return;
|
||||
|
||||
const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [
|
||||
username,
|
||||
]);
|
||||
if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" });
|
||||
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await withTransaction(async (client) => {
|
||||
const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget(
|
||||
registrationInvite,
|
||||
client,
|
||||
);
|
||||
const { rows } = await client.query(
|
||||
`
|
||||
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
[username, hash, "user", 30, enterpriseTarget.enterpriseId, 0, 0],
|
||||
);
|
||||
await consumeRegistrationInviteForUser(client, registrationInvite, rows[0].id, enterpriseTarget);
|
||||
});
|
||||
|
||||
const loginResult = await login(username, password, req.headers["user-agent"]);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/register] failed", error);
|
||||
sendAuthRouteError(res, error, "Register failed");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/register-email", async (req, res) => {
|
||||
const email = normalizeEmail(req.body?.email);
|
||||
const usernameInput = String(req.body?.username || "").trim();
|
||||
const password = String(req.body?.password || "");
|
||||
|
||||
const emailError = validateEmail(email);
|
||||
if (emailError) return res.status(400).json({ error: emailError });
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||
const registrationInvite = await ensureRegistrationInvite(req, res);
|
||||
if (!registrationInvite) return;
|
||||
|
||||
try {
|
||||
const { rows: existingEmail } = await pool.query(
|
||||
"SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1",
|
||||
[email],
|
||||
);
|
||||
if (existingEmail.length > 0) return res.status(409).json({ error: "该邮箱已注册" });
|
||||
|
||||
let username = usernameInput;
|
||||
if (username) {
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) return res.status(400).json({ error: usernameError });
|
||||
const { rows: existingUsername } = await pool.query("SELECT id FROM users WHERE username = $1", [
|
||||
username,
|
||||
]);
|
||||
if (existingUsername.length > 0) return res.status(409).json({ error: "用户名已被注册" });
|
||||
} else {
|
||||
username = await generateUniqueUsername(email.split("@")[0], "email");
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const { rows } = await withTransaction(async (client) => {
|
||||
const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget(
|
||||
registrationInvite,
|
||||
client,
|
||||
);
|
||||
const insertResult = await client.query(
|
||||
`
|
||||
INSERT INTO users (username, password_hash, email, email_verified, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
|
||||
VALUES ($1, $2, $3, 0, 'email', 'user', 30, $4, 0, 0)
|
||||
RETURNING id
|
||||
`,
|
||||
[username, hash, email, enterpriseTarget.enterpriseId],
|
||||
);
|
||||
await consumeRegistrationInviteForUser(
|
||||
client,
|
||||
registrationInvite,
|
||||
insertResult.rows[0].id,
|
||||
enterpriseTarget,
|
||||
);
|
||||
return insertResult;
|
||||
});
|
||||
|
||||
const loginResult = await createLoginResultForUserId(rows[0].id, req);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/register-email] failed", error);
|
||||
sendAuthRouteError(res, error, "Email register failed");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/sms/send", async (req, res) => {
|
||||
const phone = normalizePhone(req.body?.phone);
|
||||
const purpose = String(req.body?.purpose || "register");
|
||||
const phoneError = validatePhone(phone);
|
||||
if (phoneError) return res.status(400).json({ error: phoneError });
|
||||
if (!SMS_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" });
|
||||
if (purpose === "register" && !(await ensureBetaInviteCode(req, res))) return;
|
||||
|
||||
try {
|
||||
const { rows: existingUsers } = await pool.query(
|
||||
"SELECT id FROM users WHERE phone = $1 LIMIT 1",
|
||||
[phone],
|
||||
);
|
||||
if (purpose === "register" && existingUsers.length > 0) {
|
||||
return res.status(409).json({ error: "该手机号已注册" });
|
||||
}
|
||||
if (purpose === "login" && existingUsers.length === 0) {
|
||||
return res.status(404).json({ error: "该手机号尚未注册" });
|
||||
}
|
||||
|
||||
const { rows: recentCodes } = await pool.query(
|
||||
`
|
||||
SELECT created_at
|
||||
FROM sms_verification_codes
|
||||
WHERE phone = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[phone, purpose, SMS_CODE_COOLDOWN_SECONDS],
|
||||
);
|
||||
if (recentCodes.length > 0) {
|
||||
return res
|
||||
.status(429)
|
||||
.json({ error: `验证码发送太频繁,请 ${SMS_CODE_COOLDOWN_SECONDS} 秒后再试` });
|
||||
}
|
||||
|
||||
const code = generateSmsCode();
|
||||
const codeHash = hashSmsCode(phone, code);
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO sms_verification_codes (phone, purpose, code_hash, expires_at)
|
||||
VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)
|
||||
`,
|
||||
[phone, purpose, codeHash, SMS_CODE_TTL_MINUTES],
|
||||
);
|
||||
|
||||
const sendResult = await sendSmsCode(phone, code, purpose);
|
||||
res.json({
|
||||
success: true,
|
||||
provider: sendResult.provider,
|
||||
ttlSeconds: SMS_CODE_TTL_MINUTES * 60,
|
||||
cooldownSeconds: SMS_CODE_COOLDOWN_SECONDS,
|
||||
...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[auth/sms/send] failed", error);
|
||||
res.status(500).json({ error: "验证码发送失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/register-phone", async (req, res) => {
|
||||
const phone = normalizePhone(req.body?.phone);
|
||||
const code = String(req.body?.code || "").trim();
|
||||
const password = String(req.body?.password || "");
|
||||
|
||||
const phoneError = validatePhone(phone);
|
||||
if (phoneError) return res.status(400).json({ error: phoneError });
|
||||
if (!code) return res.status(400).json({ error: "缺少验证码" });
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||
const registrationInvite = await ensureRegistrationInvite(req, res);
|
||||
if (!registrationInvite) return;
|
||||
|
||||
try {
|
||||
const { rows: existing } = await pool.query("SELECT id FROM users WHERE phone = $1 LIMIT 1", [
|
||||
phone,
|
||||
]);
|
||||
if (existing.length > 0) return res.status(409).json({ error: "该手机号已注册" });
|
||||
|
||||
const verified = await consumeSmsCode(phone, code, "register");
|
||||
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
|
||||
|
||||
const username = await generateUniqueUsername(`u${phone.slice(-4)}`, "phone");
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const { rows } = await withTransaction(async (client) => {
|
||||
const enterpriseTarget = await resolveEnterpriseBetaRegistrationTarget(
|
||||
registrationInvite,
|
||||
client,
|
||||
);
|
||||
const insertResult = await client.query(
|
||||
`
|
||||
INSERT INTO users (username, password_hash, phone, auth_provider, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
|
||||
VALUES ($1, $2, $3, 'phone', 'user', 30, $4, 0, 0)
|
||||
RETURNING id
|
||||
`,
|
||||
[username, hash, phone, enterpriseTarget.enterpriseId],
|
||||
);
|
||||
await consumeRegistrationInviteForUser(
|
||||
client,
|
||||
registrationInvite,
|
||||
insertResult.rows[0].id,
|
||||
enterpriseTarget,
|
||||
);
|
||||
return insertResult;
|
||||
});
|
||||
|
||||
const loginResult = await createLoginResultForUserId(rows[0].id, req);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/register-phone] failed", error);
|
||||
sendAuthRouteError(res, error, "Phone register failed");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/login-phone", async (req, res) => {
|
||||
const phone = normalizePhone(req.body?.phone);
|
||||
const code = String(req.body?.code || "").trim();
|
||||
|
||||
const phoneError = validatePhone(phone);
|
||||
if (phoneError) return res.status(400).json({ error: phoneError });
|
||||
if (!code) return res.status(400).json({ error: "缺少验证码" });
|
||||
|
||||
try {
|
||||
const verified = await consumeSmsCode(phone, code, "login");
|
||||
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
|
||||
|
||||
const { rows } = await pool.query("SELECT id, enabled FROM users WHERE phone = $1 LIMIT 1", [
|
||||
phone,
|
||||
]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: "该手机号尚未注册" });
|
||||
if (!rows[0].enabled) return res.status(403).json({ error: "账号已禁用" });
|
||||
|
||||
const loginResult = await createLoginResultForUserId(rows[0].id, req);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/login-phone] failed", error);
|
||||
res.status(500).json({ error: "手机号登录失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/auth/wechat/login-url", async (req, res) => {
|
||||
const { appId, redirectUri } = getWechatLoginConfig();
|
||||
if (!appId || !redirectUri) {
|
||||
return res.json({
|
||||
configured: false,
|
||||
message: "微信开放平台 AppID 或回调地址未配置",
|
||||
});
|
||||
}
|
||||
|
||||
const state = String(req.query.state || crypto.randomBytes(8).toString("hex"));
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO wechat_login_sessions (state, status, expires_at)
|
||||
VALUES ($1, 'pending', NOW() + INTERVAL '10 minutes')
|
||||
ON CONFLICT (state) DO UPDATE
|
||||
SET status = 'pending',
|
||||
user_id = NULL,
|
||||
error = NULL,
|
||||
consumed_at = NULL,
|
||||
expires_at = NOW() + INTERVAL '10 minutes',
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[state],
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[auth/wechat/login-url] failed to create session", error);
|
||||
return res.status(500).json({ error: "微信登录会话创建失败" });
|
||||
}
|
||||
|
||||
const url = new URL("https://open.weixin.qq.com/connect/qrconnect");
|
||||
url.searchParams.set("appid", appId);
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", "snsapi_login");
|
||||
url.searchParams.set("state", state);
|
||||
|
||||
res.json({
|
||||
configured: true,
|
||||
url: `${url.toString()}#wechat_redirect`,
|
||||
state,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/auth/wechat/callback", async (req, res) => {
|
||||
const code = String(req.query?.code || "").trim();
|
||||
const state = String(req.query?.state || "").trim();
|
||||
if (!code || !state) {
|
||||
return res.status(400).send('<meta charset="utf-8"><h3>微信授权参数缺失</h3>');
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows: sessions } = await pool.query(
|
||||
`
|
||||
SELECT state
|
||||
FROM wechat_login_sessions
|
||||
WHERE state = $1 AND expires_at > NOW() AND consumed_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[state],
|
||||
);
|
||||
if (sessions.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.send('<meta charset="utf-8"><h3>微信登录会话已过期,请回到应用重新扫码</h3>');
|
||||
}
|
||||
|
||||
const wechatUser = await exchangeWechatCode(code);
|
||||
const userId = await findOrCreateWechatUser(wechatUser);
|
||||
await pool.query(
|
||||
`
|
||||
UPDATE wechat_login_sessions
|
||||
SET status = 'completed', user_id = $2, error = NULL, updated_at = NOW()
|
||||
WHERE state = $1
|
||||
`,
|
||||
[state, userId],
|
||||
);
|
||||
|
||||
res.send('<meta charset="utf-8"><h3>微信登录成功</h3><p>请回到 OmniAI 应用继续。</p>');
|
||||
} catch (error) {
|
||||
console.error("[auth/wechat/callback] failed", error);
|
||||
await pool
|
||||
.query(
|
||||
`
|
||||
UPDATE wechat_login_sessions
|
||||
SET status = 'failed', error = $2, updated_at = NOW()
|
||||
WHERE state = $1
|
||||
`,
|
||||
[state, error instanceof Error ? error.message : "微信登录失败"],
|
||||
)
|
||||
.catch(() => {});
|
||||
res.status(500).send('<meta charset="utf-8"><h3>微信登录失败</h3><p>请回到应用重试。</p>');
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/auth/wechat/session", async (req, res) => {
|
||||
const state = String(req.query?.state || "").trim();
|
||||
if (!state) return res.status(400).json({ error: "缺少微信登录 state" });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT state, status, user_id, error, consumed_at, expires_at
|
||||
FROM wechat_login_sessions
|
||||
WHERE state = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[state],
|
||||
);
|
||||
const session = rows[0];
|
||||
if (!session) return res.status(404).json({ status: "missing" });
|
||||
if (session.consumed_at) return res.status(409).json({ status: "consumed" });
|
||||
if (new Date(session.expires_at).getTime() < Date.now())
|
||||
return res.status(410).json({ status: "expired" });
|
||||
if (session.status === "failed")
|
||||
return res.status(400).json({ status: "failed", error: session.error || "微信登录失败" });
|
||||
if (session.status !== "completed") return res.json({ status: "pending" });
|
||||
|
||||
const loginResult = await createLoginResultForUserId(session.user_id, req);
|
||||
if (!loginResult) return res.status(403).json({ status: "failed", error: "账号不可用" });
|
||||
|
||||
await pool.query(
|
||||
"UPDATE wechat_login_sessions SET consumed_at = NOW(), updated_at = NOW() WHERE state = $1",
|
||||
[state],
|
||||
);
|
||||
res.json({ status: "completed", ...loginResult });
|
||||
} catch (error) {
|
||||
console.error("[auth/wechat/session] failed", error);
|
||||
res.status(500).json({ error: "微信登录状态查询失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/wechat/login", async (req, res) => {
|
||||
const code = String(req.body?.code || "").trim();
|
||||
if (!code) return res.status(400).json({ error: "缺少微信授权 code" });
|
||||
|
||||
try {
|
||||
const wechatUser = await exchangeWechatCode(code);
|
||||
const userId = await findOrCreateWechatUser(wechatUser);
|
||||
const loginResult = await createLoginResultForUserId(userId, req);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/wechat/login] failed", error);
|
||||
const status = typeof error?.status === "number" ? error.status : 500;
|
||||
res.status(status).json({ error: "微信登录失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/register-enterprise", async (req, res) => {
|
||||
const {
|
||||
companyName,
|
||||
contactName = "",
|
||||
contactPhone = "",
|
||||
taxId = "",
|
||||
legalPersonName = "",
|
||||
legalPersonPhone = "",
|
||||
username,
|
||||
password,
|
||||
} = req.body;
|
||||
|
||||
const enterpriseError = validateEnterpriseName(companyName);
|
||||
if (enterpriseError) return res.status(400).json({ error: enterpriseError });
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) return res.status(400).json({ error: usernameError });
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||
const betaInviteCode = await ensureBetaInviteCode(req, res);
|
||||
if (!betaInviteCode) return;
|
||||
|
||||
const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [
|
||||
username,
|
||||
]);
|
||||
if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" });
|
||||
|
||||
try {
|
||||
const enterpriseCode = await generateUniqueEnterpriseCode();
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
const eResult = await client.query(
|
||||
`
|
||||
INSERT INTO enterprises (name, contact_name, contact_phone, tax_id, legal_person_name, legal_person_phone, enterprise_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
companyName.trim(),
|
||||
String(contactName || "").trim(),
|
||||
String(contactPhone || "").trim(),
|
||||
String(taxId || "").trim() || null,
|
||||
String(legalPersonName || "").trim() || null,
|
||||
String(legalPersonPhone || "").trim() || null,
|
||||
enterpriseCode,
|
||||
],
|
||||
);
|
||||
|
||||
const enterpriseId = eResult.rows[0].id;
|
||||
|
||||
const userResult = await client.query(
|
||||
`
|
||||
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
|
||||
VALUES ($1, $2, 'user', $3, $4, 1, 0)
|
||||
RETURNING id
|
||||
`,
|
||||
[username, hash, 30, enterpriseId],
|
||||
);
|
||||
await consumeBetaInviteCodeForUser(client, betaInviteCode, userResult.rows[0].id);
|
||||
});
|
||||
|
||||
const loginResult = await login(username, password, req.headers["user-agent"]);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/register-enterprise] failed", error);
|
||||
sendAuthRouteError(res, error, "Enterprise register failed");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/auth/register-employee", async (req, res) => {
|
||||
const { enterpriseCode, username, password } = req.body;
|
||||
|
||||
if (!enterpriseCode) return res.status(400).json({ error: "缺少企业ID" });
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) return res.status(400).json({ error: usernameError });
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||
const betaInviteCode = await ensureBetaInviteCode(req, res);
|
||||
if (!betaInviteCode) return;
|
||||
|
||||
const { rows: entRows } = await pool.query(
|
||||
"SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1",
|
||||
[enterpriseCode],
|
||||
);
|
||||
if (entRows.length === 0) return res.status(404).json({ error: "企业ID不存在" });
|
||||
if (!entRows[0].enabled) return res.status(403).json({ error: "该企业已被禁用" });
|
||||
|
||||
const enterpriseId = entRows[0].id;
|
||||
|
||||
const { rows: existing } = await pool.query("SELECT id FROM users WHERE username = $1", [
|
||||
username,
|
||||
]);
|
||||
if (existing.length > 0) return res.status(409).json({ error: "用户名已被注册" });
|
||||
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await withTransaction(async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`
|
||||
INSERT INTO users (username, password_hash, role, max_concurrency, enterprise_id, is_enterprise_admin, balance_cents)
|
||||
VALUES ($1, $2, 'user', $3, $4, 0, 0)
|
||||
RETURNING id
|
||||
`,
|
||||
[username, hash, 30, enterpriseId],
|
||||
);
|
||||
await consumeBetaInviteCodeForUser(client, betaInviteCode, rows[0].id);
|
||||
});
|
||||
|
||||
const loginResult = await login(username, password, req.headers["user-agent"]);
|
||||
res.json(loginResult);
|
||||
} catch (error) {
|
||||
console.error("[auth/register-employee] failed", error);
|
||||
sendAuthRouteError(res, error, "Employee register failed");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/auth/enterprise-lookup", async (req, res) => {
|
||||
const { code } = req.query;
|
||||
if (!code) return res.status(400).json({ error: "缺少企业ID" });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
"SELECT id, name, enabled FROM enterprises WHERE enterprise_code = $1",
|
||||
[code],
|
||||
);
|
||||
if (rows.length === 0 || !rows[0].enabled) return res.json({ valid: false });
|
||||
|
||||
res.json({ valid: true, enterpriseName: rows[0].name });
|
||||
});
|
||||
|
||||
router.get("/auth/me", requireAuth, (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
router.post("/auth/logout", requireAuth, async (req, res) => {
|
||||
try {
|
||||
await clearUserSession(req.user.id, req.auth?.sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[auth/logout] failed", error);
|
||||
res.status(500).json({ error: "退出登录失败" });
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/auth/profile", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const normalizedAvatar = normalizeAvatarOssKey(req.body?.avatarOssKey, req.user.id);
|
||||
if (normalizedAvatar.error) {
|
||||
return res.status(400).json({ error: normalizedAvatar.error });
|
||||
}
|
||||
|
||||
const hasAvatarUrl = req.body && Object.prototype.hasOwnProperty.call(req.body, "avatarUrl");
|
||||
const avatarUrlInput =
|
||||
normalizedAvatar.value !== undefined
|
||||
? { value: normalizedAvatar.value ? `${buildOssPublicUrl(normalizedAvatar.value)}?v=${Date.now()}` : null }
|
||||
: hasAvatarUrl
|
||||
? normalizeProfileMediaUrl(req.body?.avatarUrl)
|
||||
: { value: undefined };
|
||||
if (avatarUrlInput.error) {
|
||||
return res.status(400).json({ error: avatarUrlInput.error });
|
||||
}
|
||||
|
||||
const backgroundUrlInput = normalizeProfileMediaUrl(req.body?.profileBackgroundUrl);
|
||||
if (backgroundUrlInput.error) {
|
||||
return res.status(400).json({ error: backgroundUrlInput.error });
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (avatarUrlInput.value !== undefined) {
|
||||
values.push(avatarUrlInput.value);
|
||||
fields.push(`avatar_url = $${values.length}`);
|
||||
}
|
||||
if (req.body && Object.prototype.hasOwnProperty.call(req.body, "bio")) {
|
||||
const bio = String(req.body.bio || "").trim().slice(0, 160) || null;
|
||||
values.push(bio);
|
||||
fields.push(`bio = $${values.length}`);
|
||||
}
|
||||
if (backgroundUrlInput.value !== undefined) {
|
||||
values.push(backgroundUrlInput.value);
|
||||
fields.push(`profile_background_url = $${values.length}`);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
values.push(req.user.id);
|
||||
await pool.query(
|
||||
`UPDATE users SET ${fields.join(", ")}, updated_at = NOW() WHERE id = $${values.length}`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getUserContextById(req.user.id);
|
||||
res.json({ user });
|
||||
} catch (err) {
|
||||
console.error("[auth/profile] update failed", err);
|
||||
res.status(500).json({ error: "更新个人资料失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerAuthRoutes,
|
||||
};
|
||||
Reference in New Issue
Block a user