You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

389 lines
22 KiB

<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<title>Architecture review — crm-backend-matt</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: "neutral", securityLevel: "loose" });
</script>
<style>
.seam { stroke-dasharray: 4 4; }
.leak { stroke: #dc2626; }
.deep { background: linear-gradient(135deg, #0f172a, #1e293b); color: #e2e8f0; }
.badge-strong { background: #059669; color: #fff; }
.badge-explore { background: #d97706; color: #fff; }
.badge-spec { background: #64748b; color: #fff; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.label-tag { font-size: 0.625rem; text-transform: uppercase; letter-spacing: 0.05em; }
.mod-box { border: 1.5px solid #94a3b8; border-radius: 6px; padding: 6px 10px; text-align: center; font-size: 0.7rem; }
.mod-shallow { border-color: #f59e0b; border-width: 1px; }
.mod-deep { border-color: #0f172a; border-width: 3px; background: #f8fafc; }
.mod-dead { border-color: #dc2626; border-style: dashed; opacity: 0.6; }
.arrow { font-size: 0.65rem; color: #64748b; }
.legend-box { display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; }
</style>
</head>
<body class="bg-stone-50 text-slate-900 font-sans">
<main class="max-w-5xl mx-auto px-6 py-12 space-y-12">
<header class="space-y-3">
<div class="flex items-baseline justify-between">
<h1 class="text-2xl font-bold tracking-tight">架构深化审查 — CRM Backend</h1>
<span class="text-sm text-slate-500">2026-08-13</span>
</div>
<div class="flex gap-5 flex-wrap">
<span class="legend-box"><span class="mod-box" style="display:inline-block;width:22px;height:14px;padding:0"></span> module</span>
<span class="legend-box"><span class="inline-block w-6 border-t-2 border-dashed border-slate-400"></span> seam</span>
<span class="legend-box"><span class="inline-block w-6 border-t-2 border-red-600"></span> leakage</span>
<span class="legend-box"><span class="mod-box mod-dead" style="display:inline-block;width:22px;height:14px;padding:0"></span> dead code</span>
<span class="legend-box"><span class="mod-box mod-deep" style="display:inline-block;width:22px;height:14px;padding:0"></span> deep module</span>
</div>
<p class="text-sm text-slate-600">热点来源:最近 10 次提交集中在 <span class="mono">crm-lead</span> 模块(线索全生命周期)。审查范围:crm-lead / crm-base / crm-file。</p>
</header>
<section id="candidates" class="space-y-10">
<article class="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div class="p-6 space-y-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-lg font-semibold">1. 将线索状态机坍缩为深模块</h2>
<div class="flex gap-2">
<span class="badge-strong label-tag px-2 py-0.5 rounded">Strong</span>
<span class="bg-slate-200 label-tag px-2 py-0.5 rounded text-slate-700">in-process</span>
</div>
</div>
<div class="text-sm text-slate-600">
<span class="label-tag text-slate-400">Files</span><br>
<span class="mono text-xs">crm-lead/.../service/impl/LeadServiceImpl.java (738 行)</span><br>
<span class="mono text-xs">crm-lead/.../job/LeadRecycleJob.java</span><br>
<span class="mono text-xs">crm-lead/.../job/LeadExpireJob.java</span><br>
<span class="mono text-xs">crm-lead/.../event/PoolChangedEventListener.java</span>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<p class="label-tag text-amber-600 font-semibold">Before — 状态机散落 9 处</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-3" style="min-height:320px">
<div class="mermaid">
flowchart TD
subgraph SVC["LeadServiceImpl (738行)"]
CL["claimLead()"] --> CAS1["casUpdate()"]
AS["assignToPool()"] --> CAS1
AU["assignToUser()"] --> CAS2["inline CAS"]
FB["submitFeedback()"] --> CAS2
CV["convertToOpportunity()"] --> CAS1
RL["releaseLead()"] --> CAS1
AC["activateLead()"] --> CAS2
end
subgraph JOB["定时任务 (绕过 service)"]
RJ["LeadRecycleJob"] --> MAGIC1["magic: status IN 3,4"]
EJ["LeadExpireJob"] --> MAGIC2["magic: NOT IN 5,6"]
end
CAS1 --> DB[(lead table)]
CAS2 --> DB
MAGIC1 --> DB
MAGIC2 --> DB
style CAS2 fill:#fef3c7
style MAGIC1 fill:#fecaca
style MAGIC2 fill:#fecaca
style SVC fill:#f8fafc
style JOB fill:#fff7ed
</div>
</div>
<ul class="text-xs text-slate-500 space-y-0.5">
<li>• 7 方法各自 if-else 校验状态、构建 wrapper</li>
<li>• 2 Job 绕过 service,用魔法数字硬编码状态</li>
<li>• CAS 调用不一致:4 处用 casUpdate(),3 处内联</li>
</ul>
</div>
<div class="space-y-2">
<p class="label-tag text-emerald-600 font-semibold">After — 一个深模块收拢全部迁移</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-3" style="min-height:320px">
<div class="mermaid">
flowchart TD
subgraph SVC["LeadServiceImpl (瘦身)"]
CL["claimLead()"]
FB["submitFeedback()"]
RL["releaseLead()"]
OTHER["...其他方法"]
end
subgraph SM["LeadTransition (深模块)"]
RULES["迁移规则表\n(from, action) to (to, sideEffects)"]
EXEC["execute(leadId, action, ctx)\nCAS + side-effects + history"]
end
subgraph JOB["定时任务"]
RJ["LeadRecycleJob"]
EJ["LeadExpireJob"]
end
CL --> SM
FB --> SM
RL --> SM
OTHER --> SM
RJ --> SM
EJ --> SM
EXEC --> DB[(lead table)]
style SM fill:#0f172a,color:#e2e8f0
style RULES fill:#1e293b,color:#e2e8f0
style EXEC fill:#1e293b,color:#e2e8f0
</div>
</div>
<ul class="text-xs text-slate-500 space-y-0.5">
<li>• 9 个调用点 to 1 个 interface</li>
<li>• 状态规则集中声明,魔法数字消除</li>
<li>• 纯逻辑可零 mock 测试</li>
</ul>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="label-tag text-slate-400">Problem</p>
<p class="mt-1">7 态状态机的迁移规则隐式散落在 7 个 service 方法 + 2 个定时任务中。理解"从已领取能走到哪"需要读 5 个方法。Job 用魔法数字 <span class="mono">3,4</span> 替代 <span class="mono">STATUS_CLAIMED</span>。CAS 调用方式不统一——4 处走 <span class="mono">casUpdate()</span> helper,3 处内联 <span class="mono">baseMapper.update()</span></p>
</div>
<div>
<p class="label-tag text-slate-400">Solution</p>
<p class="mt-1">提取 <span class="mono">LeadTransition</span> 深模块:声明全部 (from, action) to (to, sideEffects) 规则,暴露 <span class="mono">execute(leadId, action)</span> 一个方法做 CAS + 副作用 + history。Service 和 Job 都调它。</p>
</div>
</div>
<div>
<p class="label-tag text-slate-400">Wins</p>
<div class="mt-1 flex flex-wrap gap-2 text-xs">
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">locality: 迁移 bug 集中到一个模块</span>
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">leverage: 9 调用点 to 1 interface</span>
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">interface 缩窄;implementation 吸收 wrapper</span>
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">零 mock 测试迁移规则</span>
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">消除魔法数字</span>
</div>
</div>
</div>
</article>
<article class="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div class="p-6 space-y-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-lg font-semibold">2. 删除遗留的 DataScopeHelper 影子上下文</h2>
<div class="flex gap-2">
<span class="badge-strong label-tag px-2 py-0.5 rounded">Strong</span>
<span class="bg-slate-200 label-tag px-2 py-0.5 rounded text-slate-700">dead code</span>
</div>
</div>
<div class="text-sm text-slate-600">
<span class="label-tag text-slate-400">Files</span><br>
<span class="mono text-xs">crm-base/.../security/DataScopeHelper.java (141 行)</span><br>
<span class="mono text-xs">crm-lead/.../service/impl/LeadServiceImpl.java (line 14 死 import)</span>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<p class="label-tag text-amber-600 font-semibold">Before — 两套并行上下文</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-4 flex justify-center gap-8 items-center" style="min-height:200px">
<div class="space-y-2 text-center">
<div class="mod-box mod-dead" style="width:120px">
DataScopeHelper<br>
<span class="text-red-500 text-xs">141行 死代码</span>
</div>
<p class="text-xs text-slate-400">initContext() 无人调<br>shouldSkip() 无人调<br>registerTable() 无人调</p>
</div>
<div class="space-y-2 text-center">
<div class="mod-box mod-deep" style="width:120px">
DataVisibilityContext<br>
<span class="text-emerald-600 text-xs"></span>
</div>
<p class="text-xs text-slate-400">load() / clear()<br>currentScope(module)<br>ADR-0018</p>
</div>
</div>
<p class="text-center text-xs text-red-500">读者困惑:该用哪个?</p>
</div>
<div class="space-y-2">
<p class="label-tag text-emerald-600 font-semibold">After — 一套上下文</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-4 flex justify-center items-center" style="min-height:200px">
<div class="text-center">
<div class="mod-box mod-deep" style="width:140px">
DataVisibilityContext<br>
<span class="text-emerald-600 text-xs">唯一真相源</span>
</div>
<p class="text-xs text-slate-400 mt-2">DataVisibility (值对象)<br>VisibilityScope (过滤结果)<br>DataScopeTables (表声明)</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="label-tag text-slate-400">Problem</p>
<p class="mt-1">ADR-0018 将数据权限从单档位改为按模块配置时,引入了 <span class="mono">DataVisibilityContext</span> + <span class="mono">DataVisibility</span>,但旧的 <span class="mono">DataScopeHelper</span>(141 行,6 个 ThreadLocal)未被清除。代码中零调用——唯一引用是 LeadServiceImpl 的一个死 import。</p>
</div>
<div>
<p class="label-tag text-slate-400">Solution</p>
<p class="mt-1">删除 <span class="mono">DataScopeHelper</span>,移除 LeadServiceImpl 的死 import。deletion test:删除它不移动任何复杂度——它已经是死的。</p>
</div>
</div>
<div>
<p class="label-tag text-slate-400">Wins</p>
<div class="mt-1 flex flex-wrap gap-2 text-xs">
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">locality: 一套上下文系统</span>
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">删除 141 行死代码 + 1 死 import</span>
<span class="bg-emerald-50 text-emerald-700 px-2 py-1 rounded">新读者不再面临虚假选择</span>
</div>
</div>
</div>
</article>
<article class="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div class="p-6 space-y-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-lg font-semibold">3. 缩略图读取逻辑从 FileApiImpl 分离</h2>
<div class="flex gap-2">
<span class="badge-explore label-tag px-2 py-0.5 rounded">Worth exploring</span>
<span class="bg-slate-200 label-tag px-2 py-0.5 rounded text-slate-700">in-process</span>
</div>
</div>
<div class="text-sm text-slate-600">
<span class="label-tag text-slate-400">Files</span><br>
<span class="mono text-xs">crm-file/.../service/impl/FileApiImpl.java (551 行)</span>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<p class="label-tag text-amber-600 font-semibold">Before — 两个职责挤在一个模块</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-3 flex flex-col items-center gap-2 py-4" style="min-height:200px">
<div class="mod-box mod-shallow" style="width:200px">FileApiImpl (551行)</div>
<div class="flex gap-3">
<div class="mod-box" style="width:80px;background:#dbeafe">
文件CRUD<br>
<span class="text-xs text-slate-400">upload/download<br>preview/delete<br>分片上传</span>
</div>
<div class="mod-box" style="width:80px;background:#fef3c7">
缩略图读取<br>
<span class="text-xs text-slate-400">PENDING轮询<br>READY读取<br>FAILED占位</span>
</div>
</div>
<p class="text-xs text-amber-600 mt-1">~100行轮询逻辑与文件CRUD混在一起</p>
</div>
</div>
<div class="space-y-2">
<p class="label-tag text-emerald-600 font-semibold">After — 缩略图读取独立深模块</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-3 flex flex-col items-center gap-2 py-4" style="min-height:200px">
<div class="flex gap-3">
<div class="mod-box mod-deep" style="width:110px">
FileApiImpl<br>
<span class="text-xs text-slate-400">~400行<br>文件CRUD</span>
</div>
<div class="mod-box mod-deep" style="width:110px">
ThumbnailReader<br>
<span class="text-xs text-slate-400">getThumbnail(fileId)<br>轮询/占位/缓存</span>
</div>
</div>
<p class="text-xs text-slate-400 mt-1">interface: 1 方法<br>implementation: 吸收轮询逻辑</p>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="label-tag text-slate-400">Problem</p>
<p class="mt-1">FileApiImpl 同时承担文件 CRUD 和缩略图读取两个职责。缩略图读取有独立的状态机(PENDING to READY/FAILED/UNSUPPORTED)和同步兜底轮询逻辑(~100行),与文件上传/下载无业务关联。</p>
</div>
<div>
<p class="label-tag text-slate-400">Solution</p>
<p class="mt-1">提取 <span class="mono">ThumbnailReader</span> 模块,暴露 <span class="mono">getThumbnail(fileId)</span> 一个方法。FileApiImpl 委托给它。ADR-0013 的架构决策不被推翻——只是把读取侧从文件模块中拆出。</p>
</div>
</div>
<div>
<p class="label-tag text-slate-400">Wins</p>
<div class="mt-1 flex flex-wrap gap-2 text-xs">
<span class="bg-amber-50 text-amber-700 px-2 py-1 rounded">locality: 缩略图状态逻辑集中</span>
<span class="bg-amber-50 text-amber-700 px-2 py-1 rounded">FileApiImpl 从 551 行瘦身至 ~400</span>
<span class="bg-amber-50 text-amber-700 px-2 py-1 rounded">缩略图读取可独立测试</span>
</div>
</div>
</div>
</article>
<article class="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div class="p-6 space-y-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-lg font-semibold">4. 线索操作日志写入收拢为统一模块</h2>
<div class="flex gap-2">
<span class="badge-explore label-tag px-2 py-0.5 rounded">Worth exploring</span>
<span class="bg-slate-200 label-tag px-2 py-0.5 rounded text-slate-700">in-process</span>
</div>
</div>
<div class="text-sm text-slate-600">
<span class="label-tag text-slate-400">Files</span><br>
<span class="mono text-xs">crm-lead/.../service/impl/LeadServiceImpl.java (writeHistory + buildDetail)</span><br>
<span class="mono text-xs">crm-lead/.../job/LeadRecycleJob.java (inline history insert)</span><br>
<span class="mono text-xs">crm-lead/.../job/LeadExpireJob.java (inline history insert)</span>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<p class="label-tag text-amber-600 font-semibold">Before — 3 处重复写入</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-3 flex flex-col items-center gap-3 py-4" style="min-height:200px">
<div class="flex gap-3">
<div class="mod-box mod-shallow" style="width:90px">
LeadServiceImpl<br>
<span class="text-xs text-slate-400">writeHistory()<br>+ buildDetail()</span>
</div>
<div class="mod-box mod-shallow" style="width:90px">
RecycleJob<br>
<span class="text-xs text-red-400">手拼JSON<br>inline insert</span>
</div>
<div class="mod-box mod-shallow" style="width:90px">
ExpireJob<br>
<span class="text-xs text-red-400">手拼JSON<br>inline insert</span>
</div>
</div>
<p class="text-xs text-amber-600">同一模式 x 3;Job 手拼 JSON 绕过 ObjectMapper</p>
</div>
</div>
<div class="space-y-2">
<p class="label-tag text-emerald-600 font-semibold">After — 统一 LeadHistoryWriter</p>
<div class="rounded-lg border border-slate-200 bg-stone-50 p-3 flex flex-col items-center gap-3 py-4" style="min-height:200px">
<div class="flex gap-3">
<div class="mod-box" style="width:70px">LeadServiceImpl</div>
<div class="mod-box" style="width:70px">RecycleJob</div>
<div class="mod-box" style="width:70px">ExpireJob</div>
</div>
<p class="arrow">all delegate down</p>
<div class="mod-box mod-deep" style="width:160px">
LeadHistoryWriter<br>
<span class="text-xs text-slate-400">write(leadId, type, userId, detail)<br>统一 JSON 序列化</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="label-tag text-slate-400">Problem</p>
<p class="mt-1">操作日志写入模式在 3 处重复。两个 Job 手拼 JSON 字符串(<span class="mono">"{\"ownerUserIdBefore\":" + lead.getOwnerUserId()</span>)绕过 ObjectMapper,与 Service 的 <span class="mono">buildDetail()</span> 不一致。若 Candidate 1 先做,此问题部分消解。</p>
</div>
<div>
<p class="label-tag text-slate-400">Solution</p>
<p class="mt-1">提取 <span class="mono">LeadHistoryWriter</span> 模块:<span class="mono">write(leadId, type, userId, detail)</span> 一个方法,内部统一用 ObjectMapper 序列化 detail。3 个调用点委托给它。</p>
</div>
</div>
<div>
<p class="label-tag text-slate-400">Wins</p>
<div class="mt-1 flex flex-wrap gap-2 text-xs">
<span class="bg-amber-50 text-amber-700 px-2 py-1 rounded">locality: 日志格式集中</span>
<span class="bg-amber-50 text-amber-700 px-2 py-1 rounded">消除手拼 JSON</span>
<span class="bg-amber-50 text-amber-700 px-2 py-1 rounded">3 调用点 to 1 interface</span>
</div>
</div>
<div class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-800">
<strong>注意:</strong>若先做 Candidate 1(状态机坍缩),Job 的 history 写入会被状态机模块收拢,本候选项的大部分价值会被吸收。建议在 Candidate 1 之后评估是否仍需独立提取。
</div>
</div>
</article>
</section>
<section id="top-recommendation" class="bg-slate-900 text-slate-100 rounded-xl p-8 space-y-3">
<p class="label-tag text-slate-400">Top recommendation</p>
<h2 class="text-xl font-bold">先做 Candidate 2(删除死代码),再做 Candidate 1(状态机坍缩)</h2>
<div class="space-y-2 text-sm text-slate-300">
<p><strong class="text-emerald-400">Candidate 2</strong> 是零风险的即时收益——删 141 行死代码 + 1 个死 import,不改变任何运行时行为,立即可做。</p>
<p><strong class="text-emerald-400">Candidate 1</strong> 是本审查的核心深化机会。LeadServiceImpl 是最近 3 次提交的热点文件,738 行且仍在增长。状态机规则散落在 9 个调用点(含 2 个绕过 service 的定时任务),是测试难度和 bug 风险的主要来源。坍缩为深模块后,985 行的测试文件可以大幅瘦身——迁移规则从"10 个 mock 的集成测试"变为"纯函数单元测试"。</p>
</div>
<a href="#candidates" class="inline-block text-xs text-slate-400 hover:text-slate-200 mt-2">回到候选项列表</a>
</section>
</main>
</body>
</html>