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