905 lines
34 KiB
JavaScript
905 lines
34 KiB
JavaScript
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,
|
|
EMAIL_PURPOSES,
|
|
EMAIL_CODE_TTL_MINUTES,
|
|
EMAIL_CODE_COOLDOWN_SECONDS,
|
|
EMAIL_CODE_MAX_ATTEMPTS,
|
|
hashEmailCode,
|
|
sendEmailCode,
|
|
consumeEmailCode,
|
|
} = 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 code = String(req.body?.code || "").trim();
|
|
|
|
const emailError = validateEmail(email);
|
|
if (emailError) return res.status(400).json({ error: emailError });
|
|
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 verified = await consumeEmailCode(email, code, "register");
|
|
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
|
|
|
|
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: "更新个人资料失败" });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// Email verification routes
|
|
// ============================================================
|
|
|
|
router.post("/auth/email/send-code", async (req, res) => {
|
|
const email = normalizeEmail(req.body?.email);
|
|
const purpose = String(req.body?.purpose || "register");
|
|
const emailError = validateEmail(email);
|
|
if (emailError) return res.status(400).json({ error: emailError });
|
|
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" });
|
|
|
|
if (purpose === "register") {
|
|
const inviteOk = await ensureBetaInviteCode(req, res);
|
|
if (!inviteOk) return;
|
|
}
|
|
|
|
try {
|
|
const { rows: recentCodes } = await pool.query(
|
|
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
|
|
[email, purpose, EMAIL_CODE_COOLDOWN_SECONDS]
|
|
);
|
|
if (recentCodes.length > 0) {
|
|
return res.status(429).json({ error: "验证码发送太频繁,请 " + EMAIL_CODE_COOLDOWN_SECONDS + " 秒后再试" });
|
|
}
|
|
|
|
if (purpose === "register") {
|
|
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]);
|
|
if (existing.length > 0) return res.status(409).json({ error: "该邮箱已注册" });
|
|
}
|
|
|
|
if (purpose === "login" || purpose === "reset") {
|
|
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
|
|
if (existing.length === 0) return res.status(404).json({ error: "该邮箱尚未注册" });
|
|
}
|
|
|
|
const code = generateSmsCode();
|
|
const codeHash = hashEmailCode(email, code);
|
|
await pool.query(
|
|
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)",
|
|
[email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES]
|
|
);
|
|
|
|
const sendResult = await sendEmailCode(email, code, purpose);
|
|
res.json({
|
|
success: true,
|
|
provider: sendResult.provider,
|
|
ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60,
|
|
cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS,
|
|
...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
|
|
});
|
|
} catch (error) {
|
|
console.error("[auth/email/send-code] failed", error);
|
|
res.status(500).json({ error: "验证码发送失败" });
|
|
}
|
|
});
|
|
|
|
router.post("/auth/email/verify", async (req, res) => {
|
|
const email = normalizeEmail(req.body?.email);
|
|
const code = String(req.body?.code || "").trim();
|
|
const purpose = String(req.body?.purpose || "register");
|
|
const emailError = validateEmail(email);
|
|
if (emailError) return res.status(400).json({ error: emailError });
|
|
if (!code) return res.status(400).json({ error: "缺少验证码" });
|
|
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" });
|
|
|
|
try {
|
|
const verified = await consumeEmailCode(email, code, purpose);
|
|
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
|
|
if (purpose === "register" || purpose === "login") {
|
|
await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]);
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("[auth/email/verify] failed", error);
|
|
res.status(500).json({ error: "验证失败" });
|
|
}
|
|
});
|
|
|
|
router.post("/auth/forgot-password", async (req, res) => {
|
|
const email = normalizeEmail(req.body?.email);
|
|
const emailError = validateEmail(email);
|
|
if (emailError) return res.status(400).json({ error: emailError });
|
|
|
|
try {
|
|
const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
|
|
if (rows.length === 0) {
|
|
return res.json({ success: true, message: "如果该邮箱已注册,重置链接已发送" });
|
|
}
|
|
|
|
const { rows: recentCodes } = await pool.query(
|
|
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
|
|
[email, EMAIL_CODE_COOLDOWN_SECONDS]
|
|
);
|
|
if (recentCodes.length > 0) {
|
|
return res.status(429).json({ error: "发送太频繁,请 " + EMAIL_CODE_COOLDOWN_SECONDS + " 秒后再试" });
|
|
}
|
|
|
|
const code = generateSmsCode();
|
|
const codeHash = hashEmailCode(email, code);
|
|
await pool.query(
|
|
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)",
|
|
[email, codeHash, EMAIL_CODE_TTL_MINUTES]
|
|
);
|
|
await sendEmailCode(email, code, "reset");
|
|
res.json({ success: true, message: "重置验证码已发送到您的邮箱" });
|
|
} catch (error) {
|
|
console.error("[auth/forgot-password] failed", error);
|
|
res.status(500).json({ error: "发送失败" });
|
|
}
|
|
});
|
|
|
|
router.post("/auth/reset-password", async (req, res) => {
|
|
const email = normalizeEmail(req.body?.email);
|
|
const code = String(req.body?.code || "").trim();
|
|
const newPassword = String(req.body?.newPassword || "");
|
|
const emailError = validateEmail(email);
|
|
if (emailError) return res.status(400).json({ error: emailError });
|
|
if (!code) return res.status(400).json({ error: "缺少验证码" });
|
|
const passwordError = validatePassword(newPassword);
|
|
if (passwordError) return res.status(400).json({ error: passwordError });
|
|
|
|
try {
|
|
const verified = await consumeEmailCode(email, code, "reset");
|
|
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
|
|
const hash = await bcrypt.hash(newPassword, 10);
|
|
await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]);
|
|
res.json({ success: true, message: "密码重置成功,请重新登录" });
|
|
} catch (error) {
|
|
console.error("[auth/reset-password] failed", error);
|
|
res.status(500).json({ error: "密码重置失败" });
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
module.exports = {
|
|
registerAuthRoutes,
|
|
};
|