From 679e8bd227bed42ceb86e9268ad4a585a0e12b2c Mon Sep 17 00:00:00 2001
From: huangning <huangning@boborobots.com>
Date: Sat, 18 Jul 2026 01:32:27 +0800
Subject: [PATCH] 完善研发治理与员工改密最小闭环
---
work/crm_full_server.js | 361 ++++++++++++++++++++++++++++++++++++++++++---------
1 files changed, 296 insertions(+), 65 deletions(-)
diff --git a/work/crm_full_server.js b/work/crm_full_server.js
index 9d5f451..df82345 100644
--- a/work/crm_full_server.js
+++ b/work/crm_full_server.js
@@ -1,4 +1,5 @@
const http = require("http");
+const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
@@ -33,6 +34,9 @@
"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" },
@@ -130,6 +134,30 @@
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()) {
@@ -414,11 +442,55 @@
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");
- fs.renameSync(tmpFile, dataFile);
+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) {
@@ -427,6 +499,12 @@
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) {
@@ -450,6 +528,137 @@
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) {
@@ -480,9 +689,13 @@
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);
@@ -559,44 +772,45 @@
json(res, 400, { error: "请填写账号、姓名和岗位" });
return;
}
- const accountRows = getUsers(data);
- let row = accountRows.find((item) => item.username === username);
- if (!row && accountRows.some((item) => item.phone === phone)) {
- json(res, 409, { error: "这个手机号已经存在账号" });
- return;
- }
- if (!row && !password) {
- json(res, 400, { error: "新增账号必须设置初始密码" });
- return;
- }
- if (row && row.username === account.username && status === "disabled") {
- json(res, 400, { error: "不能停用当前登录的主管账号" });
- return;
- }
- if (row) {
- row.name = name;
- row.phone = phone;
- row.role = roleValue;
- row.status = status;
- row.permissions = permissions;
- if (password) row.password = password;
- row.updatedAt = nowText();
- } else {
- row = {
- username,
- phone,
- password,
- name,
- role: roleValue,
- status,
- permissions,
- createdAt: nowText(),
- updatedAt: ""
- };
- accountRows.push(row);
- }
- save(data);
- json(res, 200, { account: publicUser(row), accounts: accountRows.map(publicUser) });
+ 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)) {
+ throw httpError(409, "这个手机号已经存在账号");
+ }
+ if (!row && !password) throw httpError(400, "新增账号必须设置初始密码");
+ if (row && row.username === latestActor.username && status === "disabled") {
+ throw httpError(400, "不能停用当前登录的主管账号");
+ }
+ if (row) {
+ row.name = name;
+ row.phone = phone;
+ row.role = roleValue;
+ row.status = status;
+ row.permissions = permissions;
+ if (password) row.password = password;
+ row.updatedAt = nowText();
+ } else {
+ row = {
+ username,
+ phone,
+ password,
+ name,
+ role: roleValue,
+ status,
+ permissions,
+ createdAt: nowText(),
+ updatedAt: ""
+ };
+ accountRows.push(row);
+ }
+ return { account: publicUser(row), accounts: accountRows.map(publicUser) };
+ });
+ json(res, 200, result);
return;
}
@@ -608,15 +822,19 @@
json(res, 400, { error: "不能修改当前登录账号状态" });
return;
}
- const row = getUsers(data).find((item) => item.username === username);
- if (!row) {
- json(res, 404, { error: "账号不存在" });
- return;
- }
- row.status = status;
- row.updatedAt = nowText();
- save(data);
- json(res, 200, { account: publicUser(row), accounts: getUsers(data).map(publicUser) });
+ 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();
+ return { account: publicUser(row), accounts: accountRows.map(publicUser) };
+ });
+ json(res, 200, result);
return;
}
@@ -1049,15 +1267,28 @@
});
}
-const server = http.createServer((req, res) => {
- if (req.url.startsWith("/api/")) {
- handleApi(req, res).catch((err) => json(res, err.status || 500, { error: err.message }));
- } else {
- serveFile(req, res);
- }
-});
+function createServer(options = {}) {
+ return http.createServer((req, res) => {
+ if (req.url.startsWith("/api/")) {
+ handleApi(req, res, options).catch((err) => json(res, err.status || 500, { error: err.message }));
+ } else {
+ serveFile(req, res);
+ }
+ });
+}
-ensureData();
-server.listen(port, "0.0.0.0", () => {
- console.log(`Full CRM demo is running at http://127.0.0.1:${port}/`);
-});
+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
+};
--
Gitblit v1.9.3