const http = require("http"); const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const root = path.resolve(__dirname, "..", "outputs"); const dataDir = path.resolve(__dirname, "..", "data"); const dataFile = path.join(dataDir, "crm_full_data.json"); const port = Number(process.env.PORT || 8090); const accountRoles = ["supervisor", "ops", "sales", "production", "warehouse", "logistics", "finance"]; const businessKinds = { salesOrders: { next: "nextSalesOrderId" }, salesBills: { next: "nextSalesBillId" }, productionTasks: { next: "nextProductionTaskId" }, stockItems: { next: "nextStockItemId" }, shipments: { next: "nextShipmentId" }, receivables: { next: "nextReceivableId" } }; const dealStatuses = ["已成交", "待发货/执行", "已交付", "售后维护中", "复购跟进中"]; const permissionCodes = new Set([ "customers:own", "customers:all", "customers:delete", "customers:assign", "customers:export", "sales:edit", "ops:edit", "orders:view", "orders:edit", "production:edit", "stock:edit", "logistics:edit", "finance:edit", "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" }, { username: "sales1", password: "123456", name: "销售一", role: "sales" }, { username: "sales2", password: "123456", name: "销售二", role: "sales" }, { username: "sales3", password: "123456", name: "销售三", role: "sales" } ]; function makeToken(user) { return Buffer.from(`${user.username}:${user.password}`).toString("base64"); } function defaultPermissions(role) { if (role === "supervisor") return ["customers:all", "customers:delete", "customers:assign", "customers:export", "orders:view", "orders:edit", "production:edit", "stock:edit", "logistics:edit", "finance:edit", "reports:view", "accounts:manage"]; if (role === "ops") return ["customers:all", "ops:edit", "reports:view"]; if (role === "production") return ["orders:view", "production:edit"]; if (role === "warehouse") return ["orders:view", "stock:edit"]; if (role === "logistics") return ["orders:view", "logistics:edit"]; if (role === "finance") return ["orders:view", "finance:edit", "reports:view"]; return ["customers:own", "sales:edit", "orders:view", "orders:edit"]; } function parsePermissions(value) { const raw = Array.isArray(value) ? value : String(value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); const selected = raw.filter((code) => permissionCodes.has(code)); return [...new Set(selected)]; } function getUsers(data) { if (!data.users) data.users = defaultUsers.map((user) => ({ ...user, phone: user.username, status: "active", permissions: defaultPermissions(user.role), createdAt: "2026-07-14 00:00", updatedAt: "" })); for (const user of defaultUsers) { if (!data.users.some((row) => row.username === user.username)) { data.users.push({ ...user, phone: user.username, status: "active", permissions: defaultPermissions(user.role), createdAt: nowText(), updatedAt: "" }); } } for (const user of data.users) { user.status = user.status || "active"; user.permissions = user.permissions || defaultPermissions(user.role); user.phone = user.phone || user.username; } return data.users; } function ensureBusinessData(data) { for (const [kind, meta] of Object.entries(businessKinds)) { if (!Array.isArray(data[kind])) data[kind] = []; if (!data[meta.next]) { const maxId = data[kind].reduce((max, row) => Math.max(max, Number(row.id) || 0), 0); data[meta.next] = maxId + 1; } } } function ensureCustomerData(data) { if (!Array.isArray(data.customers)) data.customers = []; for (const customer of data.customers) { if (!Object.prototype.hasOwnProperty.call(customer, "customerCategory")) { customer.customerCategory = customer.source || ""; } } } function publicUser(user) { return { username: user.username, name: user.name, phone: user.phone || user.username, role: user.role, status: user.status || "active", permissions: user.permissions || defaultPermissions(user.role), createdAt: user.createdAt || "", updatedAt: user.updatedAt || "" }; } 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()) { const auth = req.headers.authorization || ""; const token = body.token || url.searchParams.get("token") || (auth.startsWith("Bearer ") ? auth.slice(7) : ""); const account = accountFromToken(token, data); if (!account) { const err = new Error("请先登录账号"); err.status = 401; throw err; } return account; } function canSeeCustomer(account, customer) { return account.role === "supervisor" || account.role === "ops" || customer.owner === account.name; } function requireCustomerAccess(account, customer) { if (!canSeeCustomer(account, customer)) { const err = new Error("没有权限操作这个客户"); err.status = 403; throw err; } } function normalizeText(value) { return String(value || "").trim().toLowerCase().replace(/\s+/g, ""); } function duplicateCandidates(data, body) { const phone = normalizeText(body.phone); const wechat = normalizeText(body.wechat); const platform = normalizeText(body.platformAccount); const leadId = normalizeText(body.leadId); const company = normalizeText(body.company); const name = normalizeText(body.name); return data.customers.map((c) => { const reasons = []; let strong = false; if (phone && normalizeText(c.phone) === phone) { reasons.push("电话相同"); strong = true; } if (wechat && normalizeText(c.wechat) === wechat) { reasons.push("微信相同"); strong = true; } if (platform && normalizeText(c.platformAccount) === platform) { reasons.push("平台ID相同"); strong = true; } if (leadId && (normalizeText(c.leadId) === leadId || normalizeText(c.douyinCustomerId) === leadId)) { reasons.push("线索ID相同"); strong = true; } if (company && normalizeText(c.company) === company) reasons.push("公司名称相同"); if (name && normalizeText(c.name) === name) reasons.push("客户名称相同"); if (!reasons.length) return null; return { id: c.id, name: c.name, company: c.company, owner: c.owner, firstReceiver: c.firstReceiver, source: c.source, funnel: c.funnel, dealStatus: c.dealStatus, pool: c.pool, lastFollowupAt: c.followups?.[0]?.at || c.lastEffectiveFollowupAt || "", reasons, strong }; }).filter(Boolean).sort((a, b) => Number(b.strong) - Number(a.strong)).slice(0, 5); } function today() { return new Date().toISOString().slice(0, 10); } function addDays(dateText, days) { const d = new Date(`${dateText || today()}T00:00:00`); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); } function nowText() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function seedData() { return { nextCustomerId: 6, nextActionId: 1, customers: [ { id: 1, name: "王经理", company: "杭州佳源食品厂", phone: "138****9021", wechat: "wangjy", platformAccount: "baidu-wang", source: "百度", owner: "销售一", firstReceiver: "销售一", firstInputAt: "2026-06-18 10:20", firstConsultAt: "2026-06-18 10:10", region: "浙江杭州", scene: "食品车间空间消毒", demand: "臭氧消毒设备", params: "面积 800㎡,层高 4m", intention: "A", level: "A", funnel: "S5 已报价", stage: "报价", dealStatus: "未成交", dealAt: "", dealAmount: "", dealProduct: "", dealQuantity: "", dealUnitPrice: "", dealTotalPrice: "", revisitLevel: "铜", nextDealRevisit: "", protect: "即将到期", protectEnd: "2026-07-08", pool: "正常", poolReason: "", releasedAt: "", previousOwner: "", nextFollowupAt: "2026-07-07", lastEffectiveFollowupAt: "2026-06-27", remark: "客户关注设备稳定性和售后响应。", followups: [ { at: "2026-06-27", user: "销售一", channel: "微信", content: "客户确认车间面积和层高,要求重新核算型号。", next: "2026-07-07", effective: true }, { at: "2026-06-21", user: "销售一", channel: "电话", content: "已按 800㎡ 食品车间方案报价,等待客户内部审批。", next: "2026-06-27", effective: true } ], quotes: [ { at: "2026-06-21", user: "销售一", model: "空间消毒设备 A800", amount: "¥128,000", priceType: "标准价", approval: "无需审批" } ], dealRevisits: [], actions: [] }, { id: 2, name: "李工", company: "苏州清源水处理", phone: "136****1188", wechat: "liwater", platformAccount: "aicaigou-li", source: "爱采购", owner: "销售一", firstReceiver: "销售一", firstInputAt: "2026-06-24 15:35", firstConsultAt: "2026-06-24 15:20", region: "江苏苏州", scene: "水处理", demand: "杀菌设备", params: "水量 30T/h", intention: "B", level: "B", funnel: "S4 选型方案", stage: "选型", dealStatus: "未成交", dealAt: "", dealAmount: "", dealProduct: "", dealQuantity: "", dealUnitPrice: "", dealTotalPrice: "", revisitLevel: "铜", nextDealRevisit: "", protect: "保护中", protectEnd: "2026-07-18", pool: "正常", poolReason: "", releasedAt: "", previousOwner: "", nextFollowupAt: "2026-07-01", lastEffectiveFollowupAt: "2026-06-25", remark: "需要确认现场水质和安装空间。", followups: [], quotes: [], dealRevisits: [], actions: [] }, { id: 3, name: "周主任", company: "常州三院实验室", phone: "137****2190", wechat: "zhoulab", platformAccount: "site-zhou", source: "官网", owner: "销售一", firstReceiver: "销售一", firstInputAt: "2026-04-16 11:48", firstConsultAt: "2026-04-16 11:30", region: "江苏常州", scene: "实验室消毒", demand: "维护和耗材", params: "复购可能", intention: "B", level: "B", funnel: "S7 成交/执行", stage: "成交", dealStatus: "已交付", dealAt: "2026-05-20", dealAmount: "86000", dealProduct: "实验室消毒设备 X2", dealQuantity: "2", dealUnitPrice: "43000", dealTotalPrice: "86000", revisitLevel: "金", nextDealRevisit: "2026-07-20", protect: "保护中", protectEnd: "2026-09-20", pool: "预公海", poolReason: "成交客户需要回访", releasedAt: "", previousOwner: "", nextFollowupAt: "2026-07-09", lastEffectiveFollowupAt: "2026-06-20", remark: "已成交客户,需要跟进耗材复购和设备维护。", followups: [], quotes: [], dealRevisits: [ { at: "2026-06-20", user: "销售一", result: "设备正常,7 月中旬确认耗材需求。", next: "2026-07-20" } ], actions: [] }, { id: 4, name: "陈女士", company: "宁波海润养殖", phone: "135****7786", wechat: "chenhr", platformAccount: "douyin-chen", source: "抖音", owner: "销售三", firstReceiver: "销售三", firstInputAt: "2026-03-28 14:02", firstConsultAt: "2026-03-28 13:55", region: "浙江宁波", scene: "养殖场除味", demand: "除味杀菌", params: "2 个养殖棚", intention: "C", level: "C", funnel: "S8 暂缓/失败", stage: "暂缓", dealStatus: "未成交", dealAt: "", dealAmount: "", dealProduct: "", dealQuantity: "", dealUnitPrice: "", dealTotalPrice: "", revisitLevel: "铁", nextDealRevisit: "", protect: "已过期", protectEnd: "2026-06-10", pool: "公共客户池", poolReason: "保护期已过且无有效推进", releasedAt: "2026-07-01 09:00", previousOwner: "销售三", nextFollowupAt: "2026-06-08", lastEffectiveFollowupAt: "2026-04-01", remark: "客户预算低,后续如有补贴政策可再激活。", followups: [], quotes: [], dealRevisits: [], actions: [] } ], actions: [] }; } function ensureData() { fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(dataFile)) { fs.writeFileSync(dataFile, JSON.stringify(seedData(), null, 2), "utf8"); } } function load() { ensureData(); const data = JSON.parse(fs.readFileSync(dataFile, "utf8")); ensureBusinessData(data); ensureCustomerData(data); return data; } 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) { if (account.role !== "supervisor") { const err = new Error("只有主管可以管理账号和权限"); 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) { const row = { id: data.nextActionId++, customerId: customer.id, user, type, title, content, at: nowText() }; data.actions.unshift(row); customer.actions = customer.actions || []; customer.actions.unshift(row); } function json(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(body); } function parseBody(req) { return new Promise((resolve, reject) => { let raw = ""; req.on("data", (chunk) => { raw += chunk; }); req.on("end", () => { 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) { const visible = data.customers.filter((c) => canSeeCustomer(account, c)); const rows = []; data.manualReminders = data.manualReminders || []; for (const r of data.manualReminders.filter((r) => r.status !== "done")) { if (account.role === "supervisor" || r.user === account.name) { rows.push({ id: r.id, customerId: r.customerId, customer: r.customer || "手动提醒", type: "手动提醒", text: `${r.dueDate || ""} ${r.content || ""}`, manual: true }); } } for (const c of visible) { const isDeal = dealStatuses.includes(c.dealStatus); if (!isDeal && c.nextFollowupAt && c.nextFollowupAt <= today()) { const overdueDays = Math.max(0, Math.floor((new Date(today() + "T00:00:00") - new Date(c.nextFollowupAt + "T00:00:00")) / 86400000)); const type = overdueDays >= 7 ? "逾期7天主管升级" : overdueDays >= 3 ? "逾期3天提醒" : c.nextFollowupAt === today() ? "今日待跟进" : "超期未跟进"; rows.push({ customerId: c.id, customer: c.name, type, text: `${c.company},下次跟进时间 ${c.nextFollowupAt},已逾期${overdueDays}天` }); } if (c.pool === "预公海") rows.push({ customerId: c.id, customer: c.name, type: "预公海提醒", text: c.poolReason || "需要补充有效跟进或回访" }); if (isDeal) { if ((!c.dealRevisits || !c.dealRevisits.length) && !c.nextDealRevisit) { rows.push({ customerId: c.id, customer: c.name, type: "成交回访待登记", text: `${c.company} 已成交,但还没有成交回访记录,请补充首次回访。` }); } else if (c.nextDealRevisit && c.nextDealRevisit <= today()) { rows.push({ customerId: c.id, customer: c.name, type: "成交回访", text: `成交客户需要回访,计划时间 ${c.nextDealRevisit}` }); } } } return rows; } 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(); if (req.method === "POST" && url.pathname === "/api/login") { const body = await parseBody(req); const account = getUsers(data).find((user) => user.status !== "disabled" && user.username === body.username && user.password === body.password); if (!account) { json(res, 401, { error: "账号或密码不正确" }); return; } json(res, 200, { token: makeToken(account), user: publicUser(account), users: getUsers(data).map(publicUser) }); return; } if (req.method === "GET" && url.pathname === "/api/state") { const account = requireAccount(req, url, {}, data); const customers = data.customers.filter((c) => canSeeCustomer(account, c)); json(res, 200, { ...data, customers, users: undefined, allUsers: getUsers(data).map(publicUser), currentAccount: publicUser(account), reminders: remindersFor(data, account), today: today() }); return; } if (req.method === "GET" && url.pathname === "/api/export.csv") { const account = requireAccount(req, url, {}, data); const headers = ["序号", "客户", "公司", "电话", "微信", "平台账号", "来源", "客户类别", "免费/付费", "搜索词", "关键词", "时段", "经销商/终端", "客户地区", "内容/广告", "投放账号", "内容链接", "运营负责人", "线索ID", "负责人", "首次录入人", "首次录入时间", "阶段", "成交状态", "成交客户星级", "成交产品", "数量", "单价", "总价", "公海状态", "下次跟进", "备注"]; const lines = [headers]; data.customers.filter((c) => canSeeCustomer(account, c)).forEach((c, index) => { lines.push([index + 1, c.name, c.company, c.phone, c.wechat, c.platformAccount, c.source, c.customerCategory || c.source, c.trafficCostType, c.searchTerm, c.keyword, c.trafficTimeSlot, c.customerType, c.opRegion || c.region, c.contentName, c.sourceAccount, c.contentLink, c.opsOwner, c.leadId, c.owner, c.firstReceiver, c.firstInputAt, c.funnel, c.dealStatus, c.revisitLevel, c.dealProduct, c.dealQuantity, c.dealUnitPrice, c.dealTotalPrice || c.dealAmount, c.pool, c.nextFollowupAt, c.remark]); }); const csv = lines.map((row) => row.map((cell) => `"${String(cell || "").replace(/"/g, '""')}"`).join(",")).join("\r\n"); res.writeHead(200, { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": "attachment; filename=sales-crm-customers.csv" }); res.end("\ufeff" + csv); return; } if (req.method !== "POST") { json(res, 405, { error: "Method not allowed" }); return; } const body = await parseBody(req); const account = requireAccount(req, url, body, data); const user = account.name; if (url.pathname === "/api/duplicates") { json(res, 200, { duplicates: duplicateCandidates(data, body) }); return; } if (url.pathname === "/api/accounts/save") { requireSupervisor(account); const username = String(body.username || body.phone || "").trim(); const phone = String(body.phone || username).trim(); const name = String(body.name || "").trim(); const password = String(body.password || "").trim(); const roleValue = String(body.role || "").trim(); const status = body.status === "disabled" ? "disabled" : "active"; const permissions = Object.prototype.hasOwnProperty.call(body, "permissions") ? parsePermissions(body.permissions) : defaultPermissions(roleValue); if (!username || !name || !accountRoles.includes(roleValue)) { json(res, 400, { 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); 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; } if (url.pathname === "/api/accounts/status") { requireSupervisor(account); const username = String(body.username || "").trim(); const status = body.status === "disabled" ? "disabled" : "active"; if (!username || username === account.username) { json(res, 400, { 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(); return { account: publicUser(row), accounts: accountRows.map(publicUser) }; }); json(res, 200, result); return; } if (url.pathname === "/api/accounts/transfer") { requireSupervisor(account); const from = String(body.from || "").trim(); const to = String(body.to || "").trim(); if (!from || !to || from === to) { json(res, 400, { error: "请选择原负责人和新负责人" }); return; } const users = getUsers(data); const target = users.find((item) => item.name === to && item.role === "sales" && item.status !== "disabled"); if (!target) { json(res, 400, { error: "新负责人必须是启用中的销售账号" }); return; } let count = 0; for (const customer of data.customers) { if (customer.owner === from) { customer.previousOwner = customer.owner; customer.owner = to; customer.pool = customer.pool || "正常"; action(data, customer, user, "transfer_owner", "客户负责人转接", `${from} -> ${to}`); count++; } } save(data); json(res, 200, { ok: true, count }); return; } if (url.pathname === "/api/reminders") { data.manualReminders = data.manualReminders || []; const reminder = { id: Date.now(), customerId: Number(body.customerId) || null, customer: body.customer || "", user, dueDate: body.dueDate || today(), content: body.content || "", status: "open", createdAt: nowText() }; data.manualReminders.unshift(reminder); save(data); json(res, 200, { reminder }); return; } if (url.pathname === "/api/reminders/complete") { data.manualReminders = data.manualReminders || []; const reminder = data.manualReminders.find((r) => r.id === Number(body.id)); if (reminder) reminder.status = "done"; save(data); json(res, 200, { reminder }); return; } if (url.pathname === "/api/run-rules") { const now = new Date(today() + "T00:00:00"); for (const c of data.customers) { if (c.pool === "公共客户池") continue; if (dealStatuses.includes(c.dealStatus)) continue; const base = c.lastEffectiveFollowupAt || c.firstInputAt?.slice(0, 10); if (!base) continue; const days = Math.floor((now - new Date(base + "T00:00:00")) / 86400000); if (days >= 30) { c.previousOwner = c.owner; c.pool = "公共客户池"; c.poolReason = `自动公海:${days}天无有效跟进`; c.releasedAt = nowText(); c.protect = "已过期"; action(data, c, "系统", "auto_public_pool", "自动进入公海", c.poolReason); } } save(data); json(res, 200, { ok: true }); return; } if (url.pathname === "/api/customers/delete") { if (account.role !== "supervisor") { json(res, 403, { error: "只有主管可以删除客户" }); return; } const index = data.customers.findIndex((c) => c.id === Number(body.customerId)); if (index < 0) { json(res, 404, { error: "客户不存在" }); return; } const [removed] = data.customers.splice(index, 1); action(data, removed, user, "delete_customer", "删除客户", `${removed.name} / ${removed.company}`); save(data); json(res, 200, { ok: true }); return; } if (url.pathname === "/api/customers") { if (!body.name || !body.company) { json(res, 400, { error: "客户姓名和公司名称必填" }); return; } if (!body.phone && !body.wechat && !body.platformAccount && !body.leadId) { json(res, 400, { error: "手机号、微信、平台账号、线索ID至少填写一项" }); return; } const strongDup = duplicateCandidates(data, body).find((row) => row.strong || row.reasons.includes("公司名称相同")); if (strongDup) { json(res, 409, { error: `发现强重复客户:${strongDup.name} / ${strongDup.company},不能重复录入`, duplicate: strongDup }); return; } const customer = { id: data.nextCustomerId++, name: body.name || "", company: body.company || "", phone: body.phone || "", wechat: body.wechat || "", platformAccount: body.platformAccount || "", source: body.source || "", customerCategory: body.customerCategory || body.source || "", trafficCostType: body.trafficCostType || "免费", searchTerm: body.searchTerm || "", keyword: body.keyword || "", trafficTimeSlot: body.trafficTimeSlot || "", customerType: body.customerType || "", opRegion: body.opRegion || body.region || "", trafficType: body.trafficType || "", marketingType: body.marketingType || "", interactionScene: body.interactionScene || "", conversionStatus: body.conversionStatus || "", leadId: body.leadId || "", douyinCustomerId: body.douyinCustomerId || "", sourceAccount: body.sourceAccount || "", contentName: body.contentName || "", contentLink: body.contentLink || "", campaignId: body.campaignId || "", unitId: body.unitId || "", unitName: body.unitName || "", isPrivateLead: body.isPrivateLead || "", leadCreatedAt: body.leadCreatedAt || "", lastLeaveAt: body.lastLeaveAt || "", leaveCount: body.leaveCount || "", opsOwner: body.opsOwner || "", owner: account.role === "sales" ? user : (body.owner || "销售一"), firstReceiver: body.firstReceiver || user, firstInputAt: nowText(), firstConsultAt: body.firstConsultAt || nowText(), region: body.region || "", scene: body.scene || "", demand: body.demand || "", params: body.params || "", intention: body.intention || "C", level: body.intention || "C", funnel: body.funnel || "S1 新线索", stage: body.stage || "初询", dealStatus: body.dealStatus || "未成交", dealAt: "", dealAmount: "", dealProduct: "", dealQuantity: "", dealUnitPrice: "", dealTotalPrice: "", revisitLevel: body.revisitLevel || "铜", nextDealRevisit: "", protect: "保护中", protectEnd: body.protectEnd || "", pool: "正常", poolReason: "", releasedAt: "", previousOwner: "", nextFollowupAt: body.nextFollowupAt || "", lastEffectiveFollowupAt: "", remark: body.remark || "", followups: [], quotes: [], dealRevisits: [], actions: [] }; action(data, customer, user, "create_customer", "新增客户", "录入客户并生成首次录入记录"); data.customers.unshift(customer); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/business/create") { const kind = String(body.kind || ""); const meta = businessKinds[kind]; if (!meta) { json(res, 400, { error: "业务类型不正确" }); return; } const row = { ...(body.row || {}), id: data[meta.next]++, createdBy: user, createdAt: nowText(), updatedAt: nowText() }; if (!row.status) row.status = "待处理"; data[kind].unshift(row); save(data); json(res, 200, { row }); return; } if (url.pathname === "/api/business/update") { const kind = String(body.kind || ""); const meta = businessKinds[kind]; if (!meta) { json(res, 400, { error: "业务类型不正确" }); return; } const row = data[kind].find((item) => item.id === Number(body.id)); if (!row) { json(res, 404, { error: "记录不存在" }); return; } Object.assign(row, body.patch || {}, { updatedBy: user, updatedAt: nowText() }); save(data); json(res, 200, { row }); return; } if (url.pathname === "/api/business/delete") { const kind = String(body.kind || ""); if (!businessKinds[kind]) { json(res, 400, { error: "业务类型不正确" }); return; } const index = data[kind].findIndex((item) => item.id === Number(body.id)); if (index < 0) { json(res, 404, { error: "记录不存在" }); return; } const [row] = data[kind].splice(index, 1); save(data); json(res, 200, { row }); return; } const customer = data.customers.find((c) => c.id === Number(body.customerId)); if (!customer) { json(res, 404, { error: "客户不存在" }); return; } requireCustomerAccess(account, customer); if (url.pathname === "/api/customers/update") { const fields = ["name", "company", "phone", "wechat", "platformAccount", "source", "customerCategory", "trafficCostType", "searchTerm", "keyword", "trafficTimeSlot", "customerType", "opRegion", "trafficType", "marketingType", "interactionScene", "conversionStatus", "leadId", "douyinCustomerId", "sourceAccount", "contentName", "contentLink", "campaignId", "unitId", "unitName", "isPrivateLead", "leadCreatedAt", "lastLeaveAt", "leaveCount", "opsOwner", "owner", "firstReceiver", "firstInputAt", "region", "scene", "demand", "params", "intention", "funnel", "stage", "dealStatus", "pool", "poolReason", "nextFollowupAt", "remark"]; for (const field of fields) { if (field === "owner" && account.role !== "supervisor") continue; if (Object.prototype.hasOwnProperty.call(body, field)) customer[field] = body[field] || ""; } action(data, customer, user, "update_customer", "编辑客户信息", "修改客户基础资料"); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/followups") { const row = { at: body.at || today(), user, channel: body.channel || "电话", content: body.content || "", next: body.nextFollowupAt || "", effective: !!body.effective }; customer.followups.unshift(row); customer.nextFollowupAt = row.next || customer.nextFollowupAt; if (row.effective) customer.lastEffectiveFollowupAt = row.at; if (body.funnel) { const before = customer.funnel; customer.funnel = body.funnel; customer.stage = body.stage || body.funnel.replace(/^S\d+\s*/, ""); if (before !== customer.funnel) { action(data, customer, user, "change_funnel_stage", "调整客户阶段", `${before || "未设置"} -> ${customer.funnel}`); } } action(data, customer, user, "add_followup", "新增跟进记录", row.content); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/quotes") { customer.quotes = customer.quotes || []; const quantity = Number(body.quantity || 0); const unitPrice = Number(body.unitPrice || 0); const totalPrice = body.totalPrice || (quantity && unitPrice ? String(quantity * unitPrice) : ""); const row = { at: body.at || today(), user, model: body.model || "", quantity: body.quantity || "", unitPrice: body.unitPrice || "", amount: totalPrice, priceType: body.priceType || "标准价", approval: body.approval || "无需审批", quoteNo: body.quoteNo || "", remark: body.remark || "" }; customer.quotes.unshift(row); action(data, customer, user, "add_quote", "新增报价记录", `${row.model} ${row.amount}`); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/followups/update") { const index = Number(body.followupIndex); if (!customer.followups || !customer.followups[index]) { json(res, 404, { error: "跟进记录不存在" }); return; } customer.followups[index] = { ...customer.followups[index], channel: body.channel || customer.followups[index].channel, content: body.content || "", next: body.nextFollowupAt || "", effective: !!body.effective }; action(data, customer, user, "update_followup", "编辑跟进记录", customer.followups[index].content); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/followups/delete") { const index = Number(body.followupIndex); if (!customer.followups || !customer.followups[index]) { json(res, 404, { error: "跟进记录不存在" }); return; } const [removed] = customer.followups.splice(index, 1); action(data, customer, user, "delete_followup", "删除跟进记录", removed.content || ""); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/deal") { const nextStatus = body.dealStatus || "未成交"; customer.dealStatus = nextStatus; if (nextStatus === "未成交") { customer.stage = body.stage || "初询"; customer.funnel = body.funnel || "S2 已联系"; customer.dealAt = ""; customer.dealAmount = ""; customer.dealProduct = ""; customer.dealQuantity = ""; customer.dealUnitPrice = ""; customer.dealTotalPrice = ""; customer.nextDealRevisit = ""; action(data, customer, user, "update_deal_status", "修改成交状态", "已改为未成交,并清空成交信息"); } else { customer.stage = "成交"; customer.funnel = "S7 成交/执行"; customer.dealAt = body.dealAt || today(); customer.dealAmount = body.dealAmount || ""; customer.dealProduct = body.dealProduct || ""; customer.dealQuantity = body.dealQuantity || ""; customer.dealUnitPrice = body.dealUnitPrice || ""; customer.dealTotalPrice = body.dealTotalPrice || body.dealAmount || ""; customer.revisitLevel = body.revisitLevel || customer.revisitLevel || "铜"; customer.nextDealRevisit = body.nextDealRevisit || addDays(customer.dealAt, 7); action(data, customer, user, "mark_deal", "修改成交状态", `${nextStatus} ${customer.dealProduct} ${customer.dealAmount}`); } save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/deal-revisits") { const row = { at: body.at || today(), user, result: body.result || "", next: body.nextDealRevisit || "" }; customer.dealRevisits.unshift(row); customer.nextDealRevisit = row.next || customer.nextDealRevisit; action(data, customer, user, "add_deal_revisit", "新增成交回访", row.result); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/release") { if (account.role !== "supervisor") { json(res, 403, { error: "只有主管可以释放客户到公海" }); return; } customer.previousOwner = customer.owner; customer.pool = "公共客户池"; customer.poolReason = body.reason || "主管释放"; customer.releasedAt = nowText(); customer.protect = "已过期"; action(data, customer, user, "release_to_pool", "释放到公海", customer.poolReason); save(data); json(res, 200, { customer }); return; } if (url.pathname === "/api/assign") { if (account.role !== "supervisor") { json(res, 403, { error: "只有主管可以分配公海客户" }); return; } const to = body.owner || "销售一"; const target = getUsers(data).find((item) => item.name === to && item.role === "sales" && item.status !== "disabled"); if (!target) { json(res, 400, { error: "只能分配给启用中的销售账号" }); return; } customer.previousOwner = customer.owner; customer.owner = to; customer.pool = "正常"; customer.poolReason = ""; action(data, customer, user, "assign_from_pool", "公海重新分配", `分配给 ${to}`); save(data); json(res, 200, { customer }); return; } json(res, 404, { error: "API not found" }); } function serveFile(req, res) { const url = new URL(req.url, `http://${req.headers.host}`); let pathname = decodeURIComponent(url.pathname); if (pathname === "/") pathname = "/sales_crm_full_demo.html"; const file = path.resolve(root, `.${pathname}`); if (!file.startsWith(root)) { res.writeHead(403); res.end("Forbidden"); return; } fs.readFile(file, (err, data) => { if (err) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not found"); return; } const ext = path.extname(file).toLowerCase(); const type = ext === ".html" ? "text/html; charset=utf-8" : "application/octet-stream"; res.writeHead(200, { "Content-Type": type }); res.end(data); }); } 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); } }); } 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 };