| | |
| | | const http = require("http"); |
| | | const crypto = require("crypto"); |
| | | const fs = require("fs"); |
| | | const path = require("path"); |
| | | |
| | |
| | | "reports:view", |
| | | "accounts:manage" |
| | | ]); |
| | | const accountWriteLocks = new Map(); |
| | | const passwordChangePath = "/api/v1/me/password/change"; |
| | | const passwordChangeBodyLimit = 16 * 1024; |
| | | const defaultUsers = [ |
| | | { username: "admin", password: "123456", name: "主管", role: "supervisor" }, |
| | | { username: "ops1", password: "123456", name: "运营一", role: "ops" }, |
| | |
| | | function accountFromToken(token, data) { |
| | | if (!token) return null; |
| | | return getUsers(data).find((user) => user.status !== "disabled" && makeToken(user) === token) || null; |
| | | } |
| | | |
| | | function accountFromTokenIncludingDisabled(token, data) { |
| | | if (!token) return null; |
| | | return getUsers(data).find((user) => makeToken(user) === token) || null; |
| | | } |
| | | |
| | | function bearerTokenFromHeader(req) { |
| | | const auth = String(req.headers.authorization || ""); |
| | | return auth.startsWith("Bearer ") && auth.length > 7 ? auth.slice(7) : ""; |
| | | } |
| | | |
| | | function passwordChangeError(status, code, message) { |
| | | const err = new Error(message); |
| | | err.status = status; |
| | | err.passwordChangeCode = code; |
| | | return err; |
| | | } |
| | | |
| | | function dataVersionConflictError() { |
| | | const err = new Error("数据已被其他请求更新"); |
| | | err.status = 409; |
| | | err.code = "DATA_VERSION_CONFLICT"; |
| | | return err; |
| | | } |
| | | |
| | | function requireAccount(req, url, body = {}, data = load()) { |
| | |
| | | return data; |
| | | } |
| | | |
| | | function save(data) { |
| | | data._version = (Number(data._version) || 0) + 1; |
| | | const tmpFile = `${dataFile}.${process.pid}.tmp`; |
| | | fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf8"); |
| | | function dataVersion(data) { |
| | | const value = Number(data && data._version); |
| | | return Number.isSafeInteger(value) && value >= 0 ? value : 0; |
| | | } |
| | | |
| | | function save(data, expectedVersion = dataVersion(data)) { |
| | | ensureData(); |
| | | const stored = JSON.parse(fs.readFileSync(dataFile, "utf8")); |
| | | if (dataVersion(stored) !== expectedVersion) throw dataVersionConflictError(); |
| | | |
| | | const nextData = { ...data, _version: expectedVersion + 1 }; |
| | | const tmpFile = `${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`; |
| | | try { |
| | | fs.writeFileSync(tmpFile, JSON.stringify(nextData, null, 2), "utf8"); |
| | | fs.renameSync(tmpFile, dataFile); |
| | | data._version = nextData._version; |
| | | } finally { |
| | | if (fs.existsSync(tmpFile)) { |
| | | try { fs.unlinkSync(tmpFile); } catch (_) { /* best-effort cleanup */ } |
| | | } |
| | | } |
| | | } |
| | | |
| | | async function withAccountWriteLock(accountKey, task) { |
| | | const key = String(accountKey); |
| | | const previous = accountWriteLocks.get(key) || Promise.resolve(); |
| | | let release; |
| | | const current = new Promise((resolve) => { release = resolve; }); |
| | | accountWriteLocks.set(key, current); |
| | | await previous; |
| | | try { |
| | | return await task(); |
| | | } finally { |
| | | release(); |
| | | if (accountWriteLocks.get(key) === current) accountWriteLocks.delete(key); |
| | | } |
| | | } |
| | | |
| | | function mutateAccountWithCas(accountKey, expectedVersion, mutator, store = { load, save }) { |
| | | return withAccountWriteLock(accountKey, () => { |
| | | const data = store.load(); |
| | | if (dataVersion(data) !== expectedVersion) throw dataVersionConflictError(); |
| | | const result = mutator(data); |
| | | if (result && typeof result.then === "function") { |
| | | throw new Error("账号写入回调必须同步完成"); |
| | | } |
| | | store.save(data, expectedVersion); |
| | | return result; |
| | | }); |
| | | } |
| | | |
| | | function requireSupervisor(account) { |
| | |
| | | err.status = 403; |
| | | throw err; |
| | | } |
| | | } |
| | | |
| | | function httpError(status, message) { |
| | | const err = new Error(message); |
| | | err.status = status; |
| | | return err; |
| | | } |
| | | |
| | | function action(data, customer, user, type, title, content) { |
| | |
| | | try { resolve(raw ? JSON.parse(raw) : {}); } catch (err) { reject(err); } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | function parsePasswordChangeBody(req) { |
| | | return new Promise((resolve, reject) => { |
| | | let raw = ""; |
| | | let size = 0; |
| | | let tooLarge = false; |
| | | req.on("data", (chunk) => { |
| | | size += chunk.length; |
| | | if (size > passwordChangeBodyLimit) { |
| | | tooLarge = true; |
| | | raw = ""; |
| | | } else if (!tooLarge) { |
| | | raw += chunk; |
| | | } |
| | | }); |
| | | req.on("end", () => { |
| | | if (tooLarge || !raw) { |
| | | reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效")); |
| | | return; |
| | | } |
| | | try { |
| | | resolve(JSON.parse(raw)); |
| | | } catch (_) { |
| | | reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效")); |
| | | } |
| | | }); |
| | | req.on("aborted", () => reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效"))); |
| | | req.on("error", () => reject(passwordChangeError(400, "INVALID_REQUEST", "请求无效"))); |
| | | }); |
| | | } |
| | | |
| | | function validatePasswordChangeEnvelope(req, url) { |
| | | const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase(); |
| | | if (contentType !== "application/json" || !url.searchParams.keys().next().done) { |
| | | throw passwordChangeError(400, "INVALID_REQUEST", "请求无效"); |
| | | } |
| | | } |
| | | |
| | | function validatePasswordChangePayload(body) { |
| | | if (!body || Array.isArray(body) || typeof body !== "object") { |
| | | throw passwordChangeError(400, "INVALID_REQUEST", "请求无效"); |
| | | } |
| | | const keys = Object.keys(body); |
| | | const allowed = new Set(["currentPassword", "newPassword"]); |
| | | if (keys.length !== 2 || keys.some((key) => !allowed.has(key)) |
| | | || typeof body.currentPassword !== "string" || typeof body.newPassword !== "string") { |
| | | throw passwordChangeError(400, "INVALID_REQUEST", "请求无效"); |
| | | } |
| | | } |
| | | |
| | | function passwordChangeRequestId() { |
| | | return `pwd-${crypto.randomUUID()}`; |
| | | } |
| | | |
| | | function writePasswordChangeError(res, err, requestId) { |
| | | const body = JSON.stringify({ |
| | | error: { |
| | | code: err.passwordChangeCode, |
| | | message: err.message, |
| | | requestId |
| | | } |
| | | }); |
| | | res.writeHead(err.status, { |
| | | "Content-Type": "application/json; charset=utf-8", |
| | | "Cache-Control": "no-store" |
| | | }); |
| | | res.end(body); |
| | | } |
| | | |
| | | function normalizedPasswordChangeError(err) { |
| | | if (err && err.passwordChangeCode) return err; |
| | | if (err && err.code === "DATA_VERSION_CONFLICT") { |
| | | return passwordChangeError(409, "PASSWORD_UPDATE_CONFLICT", "密码状态已变化,请重新登录"); |
| | | } |
| | | if (err instanceof SyntaxError || (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code))) { |
| | | return passwordChangeError(503, "PASSWORD_CHANGE_UNAVAILABLE", "密码修改暂不可用"); |
| | | } |
| | | return passwordChangeError(500, "INTERNAL_ERROR", "服务暂时无法处理请求"); |
| | | } |
| | | |
| | | async function handlePasswordChange(req, res, url, store = { load, save }) { |
| | | const requestId = passwordChangeRequestId(); |
| | | try { |
| | | validatePasswordChangeEnvelope(req, url); |
| | | const body = await parsePasswordChangeBody(req); |
| | | validatePasswordChangePayload(body); |
| | | |
| | | let initialData; |
| | | try { |
| | | initialData = store.load(); |
| | | } catch (err) { |
| | | throw normalizedPasswordChangeError(err); |
| | | } |
| | | const token = bearerTokenFromHeader(req); |
| | | const authenticated = accountFromTokenIncludingDisabled(token, initialData); |
| | | if (!authenticated) { |
| | | throw passwordChangeError(401, "AUTHENTICATION_REQUIRED", "请重新登录"); |
| | | } |
| | | const expectedVersion = dataVersion(initialData); |
| | | |
| | | await mutateAccountWithCas(authenticated.username, expectedVersion, (latestData) => { |
| | | const latest = getUsers(latestData).find((row) => row.username === authenticated.username); |
| | | if (!latest) { |
| | | throw passwordChangeError(409, "PASSWORD_UPDATE_CONFLICT", "密码状态已变化,请重新登录"); |
| | | } |
| | | if (latest.status === "disabled") { |
| | | throw passwordChangeError(403, "ACCOUNT_DISABLED", "账号不可用"); |
| | | } |
| | | if (makeToken(latest) !== token) { |
| | | throw passwordChangeError(409, "PASSWORD_UPDATE_CONFLICT", "密码状态已变化,请重新登录"); |
| | | } |
| | | if (latest.password !== body.currentPassword) { |
| | | throw passwordChangeError(422, "CURRENT_PASSWORD_INCORRECT", "当前密码不正确"); |
| | | } |
| | | const codePointLength = Array.from(body.newPassword).length; |
| | | if (codePointLength < 8 || codePointLength > 64) { |
| | | throw passwordChangeError(422, "PASSWORD_LENGTH_INVALID", "新密码长度必须为8至64个字符"); |
| | | } |
| | | if (body.newPassword === body.currentPassword) { |
| | | throw passwordChangeError(422, "NEW_PASSWORD_SAME_AS_CURRENT", "新密码不能与当前密码相同"); |
| | | } |
| | | latest.password = body.newPassword; |
| | | latest.updatedAt = nowText(); |
| | | }, store); |
| | | |
| | | res.writeHead(204, { "Cache-Control": "no-store" }); |
| | | res.end(); |
| | | } catch (err) { |
| | | writePasswordChangeError(res, normalizedPasswordChangeError(err), requestId); |
| | | } |
| | | } |
| | | |
| | | function remindersFor(data, account) { |
| | |
| | | return rows; |
| | | } |
| | | |
| | | async function handleApi(req, res) { |
| | | async function handleApi(req, res, options = {}) { |
| | | const url = new URL(req.url, "http://127.0.0.1"); |
| | | if (req.method === "POST" && url.pathname === passwordChangePath) { |
| | | await handlePasswordChange(req, res, url, options.passwordStore || { load, save }); |
| | | return; |
| | | } |
| | | const data = load(); |
| | | const url = new URL(req.url, `http://${req.headers.host}`); |
| | | |
| | | if (req.method === "POST" && url.pathname === "/api/login") { |
| | | const body = await parseBody(req); |
| | |
| | | json(res, 400, { error: "请填写账号、姓名和岗位" }); |
| | | return; |
| | | } |
| | | const accountRows = getUsers(data); |
| | | const result = await mutateAccountWithCas(username, dataVersion(data), (latestData) => { |
| | | const latestActor = getUsers(latestData).find((item) => item.username === account.username && item.status !== "disabled"); |
| | | if (!latestActor) throw httpError(401, "请先登录账号"); |
| | | requireSupervisor(latestActor); |
| | | |
| | | const accountRows = getUsers(latestData); |
| | | let row = accountRows.find((item) => item.username === username); |
| | | if (!row && accountRows.some((item) => item.phone === phone)) { |
| | | json(res, 409, { error: "这个手机号已经存在账号" }); |
| | | return; |
| | | throw httpError(409, "这个手机号已经存在账号"); |
| | | } |
| | | if (!row && !password) { |
| | | json(res, 400, { error: "新增账号必须设置初始密码" }); |
| | | return; |
| | | } |
| | | if (row && row.username === account.username && status === "disabled") { |
| | | json(res, 400, { error: "不能停用当前登录的主管账号" }); |
| | | return; |
| | | if (!row && !password) throw httpError(400, "新增账号必须设置初始密码"); |
| | | if (row && row.username === latestActor.username && status === "disabled") { |
| | | throw httpError(400, "不能停用当前登录的主管账号"); |
| | | } |
| | | if (row) { |
| | | row.name = name; |
| | |
| | | }; |
| | | accountRows.push(row); |
| | | } |
| | | save(data); |
| | | json(res, 200, { account: publicUser(row), accounts: accountRows.map(publicUser) }); |
| | | return { account: publicUser(row), accounts: accountRows.map(publicUser) }; |
| | | }); |
| | | json(res, 200, result); |
| | | return; |
| | | } |
| | | |
| | |
| | | json(res, 400, { error: "不能修改当前登录账号状态" }); |
| | | return; |
| | | } |
| | | const row = getUsers(data).find((item) => item.username === username); |
| | | if (!row) { |
| | | json(res, 404, { error: "账号不存在" }); |
| | | return; |
| | | } |
| | | const result = await mutateAccountWithCas(username, dataVersion(data), (latestData) => { |
| | | const latestActor = getUsers(latestData).find((item) => item.username === account.username && item.status !== "disabled"); |
| | | if (!latestActor) throw httpError(401, "请先登录账号"); |
| | | requireSupervisor(latestActor); |
| | | |
| | | const accountRows = getUsers(latestData); |
| | | const row = accountRows.find((item) => item.username === username); |
| | | if (!row) throw httpError(404, "账号不存在"); |
| | | row.status = status; |
| | | row.updatedAt = nowText(); |
| | | save(data); |
| | | json(res, 200, { account: publicUser(row), accounts: getUsers(data).map(publicUser) }); |
| | | return { account: publicUser(row), accounts: accountRows.map(publicUser) }; |
| | | }); |
| | | json(res, 200, result); |
| | | return; |
| | | } |
| | | |
| | |
| | | }); |
| | | } |
| | | |
| | | const server = http.createServer((req, res) => { |
| | | function createServer(options = {}) { |
| | | return http.createServer((req, res) => { |
| | | if (req.url.startsWith("/api/")) { |
| | | handleApi(req, res).catch((err) => json(res, err.status || 500, { error: err.message })); |
| | | handleApi(req, res, options).catch((err) => json(res, err.status || 500, { error: err.message })); |
| | | } else { |
| | | serveFile(req, res); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | if (require.main === module) { |
| | | ensureData(); |
| | | const server = createServer(); |
| | | server.listen(port, "0.0.0.0", () => { |
| | | console.log(`Full CRM demo is running at http://127.0.0.1:${port}/`); |
| | | }); |
| | | } |
| | | |
| | | module.exports = { |
| | | createServer, |
| | | dataVersion, |
| | | makeToken, |
| | | mutateAccountWithCas, |
| | | validatePasswordChangePayload |
| | | }; |