| name | refactor-safety |
| description | 当用户要求重构代码(提取组件、合并重复逻辑、重命名变量、优化结构)时触发。提供重构安全检查清单,防止丢失原始上下文。 |
重构安全规范
触发场景
- 用户说"重构"、"提取"、"合并"、"优化结构"、"简化代码"
- 涉及表格列、数据结构、配置项的修改
- 合并条件分支(if/else/switch)
重构流程
1. 读取原始代码(必须)
不要凭记忆或推测重构,必须完整读取原始代码:
Read: file_path="src/views/Order.vue"
Grep: pattern="if.*健康云" -A 50 -B 5
Grep: pattern="else" -A 50 -B 5
常见错误:
- ❌ 只读一个分支,推测其他分支
- ❌ 凭记忆重构表格列
- ❌ 假设两个条件分支结构相似
2. 制作对比清单
表格重构示例
原始代码(健康云租户):
列1: 订单编号
列2: 订单日期
列3: 材料名称
列4: 材料数量
列5: 单价
列6: 金额
原始代码(非健康云租户):
列1: 订单编号
列2: 创建时间
列3: 材料名称
列4: 单价
列5: 金额
对比清单:
| 健康云 | 非健康云 | 状态 |
|---|
| 订单编号 | 订单编号 | ✅ 一致 |
| 订单日期 | 创建时间 | ⚠️ 名称不同 |
| 材料名称 | 材料名称 | ✅ 一致 |
| 材料数量 | - | ❌ 非健康云无此列 |
| 单价 | 单价 | ✅ 一致 |
| 金额 | 金额 | ✅ 一致 |
数据结构重构示例
原始接口:
interface Order {
id: string
date: string
items: Item[]
total: number
}
重构后接口:
interface Order {
id: string
createdAt: string
items: Item[]
total: number
}
对比清单:
- ✅ 字段数量一致(4 个)
- ⚠️ 字段重命名:date → createdAt
- ✅ 字段顺序一致
- ✅ 字段类型一致
3. 验证检查点
重构完成后,必须逐项检查:
表格重构检查
条件分支检查
数据结构检查
4. 输出格式
重构完成后,输出对比清单:
## 重构对比
### 原始结构(健康云租户)
- 列1: 订单编号
- 列2: 订单日期
- 列3: 材料名称
- 列4: 材料数量
- 列5: 单价
- 列6: 金额
### 原始结构(非健康云租户)
- 列1: 订单编号
- 列2: 创建时间
- 列3: 材料名称
- 列4: 单价
- 列5: 金额
### 重构后结构
- 列1: 订单编号
- 列2: 日期(健康云显示"订单日期",非健康云显示"创建时间")
- 列3: 材料名称
- 列4: 材料数量(仅健康云显示)
- 列5: 单价
- 列6: 金额
### 变更说明
- 合并:订单日期 + 创建时间 → 日期(条件显示)
- 条件显示:材料数量仅在健康云租户显示
- 删除:无
- 新增:无
常见陷阱
1. 假设驱动重构
错误示例:
if (isHealthCloud) {
columns = ['订单编号', '订单日期', '材料名称', '材料数量', '单价', '金额']
}
else {
columns = ['订单编号', '创建时间', '材料名称', '材料数量', '单价', '金额']
}
正确做法:
if (isHealthCloud) {
columns = ['订单编号', '订单日期', '材料名称', '材料数量', '单价', '金额']
} else {
columns = ['订单编号', '创建时间', '材料名称', '单价', '金额']
}
2. 记忆重构
错误示例:
const columns = ['订单编号', '材料名称', '单价', '金额', '订单日期']
正确做法:
Read: file_path="src/views/Order.vue"
const columns = ['订单编号', '订单日期', '材料名称', '材料数量', '单价', '金额']
3. 跳过验证
错误示例:
正确做法:
## 验证清单
- [x] 列数一致:原始 6 列 → 重构后 6 列
- [x] 列顺序一致:订单编号、订单日期、材料名称、材料数量、单价、金额
- [x] 列名一致:完全一致
- [x] 没有遗漏列
- [x] 没有多余列
5. 提取子模块导致循环依赖
场景:从大服务/组件拆分子模块时,子模块需要回调父模块的方法
⚠️ 预检流程(拆分前必须执行)
先提取共享方法,再拆分子服务:
拆分前:
1. 扫描父服务,识别被多个子服务调用的工具方法
2. 将这些方法提取到独立 Helper/Utils 类
3. 然后再拆分子服务(此时子服务依赖 Helper,不依赖父服务)
❌ 错误顺序:拆分子服务 → 发现循环依赖 → 修复
✅ 正确顺序:提取共享方法 → 拆分子服务 → 无循环依赖
⚠️ 重复模式警告
如果第一个子服务提取时已出现循环依赖,后续所有子服务提取必须先检查相同问题:
真实案例:
- ReportService → 提取 ReportInvoiceService → 循环依赖(resolveTenantIds)
- ReportService → 提取 ReportPaymentService → 同样的循环依赖!
根因:resolveTenantIds() 留在 ReportService,所有子服务都需要它
修复:应在第一次发现时就提取 TenantHelper,避免后续重复犯错
错误示例:
@Service
@RequiredArgsConstructor
public class ReportInvoiceService {
private final ReportService reportService;
}
正确做法(按优先级):
@Component
public class TenantHelper {
public List<Long> resolveTenantIds(Long tenantId) { ... }
}
@Service
public class ReportInvoiceService {
@Autowired @Lazy
private ReportService reportService;
}
public void process(Function<Long, List<Long>> tenantResolver) { ... }
检查清单:
不仅限于 Spring:
- Go:包级循环引用(编译错误)→ 提取公共包
- Vue:组件循环引用 → 异步组件或提取公共逻辑到 composables
- TypeScript:模块循环引用 → 提取公共模块
6. 提取状态管理时的初始化冲突
场景:将组件内的 state 提取到自定义 Hook/composable/Service 时,封装的 open 方法添加了初始化逻辑,覆盖了调用方预设的状态
真实案例:
setMarkPaidText(selectedOrderNumbers)
setMarkPaidModalVisible(true)
const openMarkPaidModal = () => {
setMarkPaidText('')
setMarkPaidModalVisible(true)
}
setMarkPaidText(selectedOrderNumbers)
openMarkPaidModal()
正确做法:
const openMarkPaidModal = (initialText?: string) => {
if (initialText !== undefined) setMarkPaidText(initialText)
setMarkPaidModalVisible(true)
}
const closeMarkPaidModal = () => {
setMarkPaidText('')
setMarkPaidModalVisible(false)
}
检查清单:
适用范围:
- React:useState → useXxxModal hook
- Vue:ref → useXxxDialog composable
- Java:Service 方法添加默认值逻辑
7. UI 组件重构时的布局结构丢失
场景:重构 Modal/Dialog/Drawer 等容器组件时,将复杂的 title/header/footer 简化,导致按钮布局和功能丢失
真实案例:
<Modal
title={
<div className="flex justify-between">
<span>发票详情</span>
{!editMode && <Button icon={<EditOutlined />}>编辑发票</Button>}
{editMode && (
<Space>
<Button icon={<CloseOutlined />}>取消</Button>
<Button type="primary" icon={<SaveOutlined />}>保存</Button>
</Space>
)}
</div>
}
footer={[
<Button key="download" type="primary" icon={<DownloadOutlined />}>
下载原始PDF
</Button>,
<Button key="close">关闭</Button>,
]}
/>
<Modal
title={`发票详情 - ${invoice?.invoiceNumber}`} // 丢失编辑/取消/保存按钮
footer={[
<button key="edit">编辑</button>, // 降级为原生 button + 丢失"下载PDF"
<button key="close">关闭</button>,
]}
/>
检查清单:
适用范围:
- React:Modal、Dialog、Drawer 的 title/footer
- Vue:el-dialog、el-drawer 的 header/footer 插槽
- 任何包含条件渲染按钮的 UI 容器组件
8. 复杂渲染逻辑被简化替换
场景:重构表格列或组件时,将复杂的 render 函数(JSON 解析、条件格式化、diff 对比)替换为简单文本显示,导致功能降级
真实案例:
{
title: '变更详情',
key: 'changeDetails',
render: (_, record) => {
const oldValues = JSON.parse(record.beforeValue)
const newValues = JSON.parse(record.afterValue)
return <div>{changedKeys.map(key => (
<div>{translateFieldName(key)}: {oldValue} → {newValue}</div>
))}</div>
}
}
{
title: '变更摘要',
dataIndex: 'changeSummary',
render: (val) => val || '-'
}
检查清单:
适用范围:
- 表格列的 render 函数重构
- 列表项的自定义渲染重构
- 任何包含 JSON 解析、条件格式化的渲染逻辑
4. 字段名推测重构(极其隐蔽)
场景:类型定义中有多个相似字段,重构时选错了
错误示例:
interface InvoiceHistory {
changedAt?: string
createdAt: string
changeType?: string
operationType: string
}
dataIndex: 'changedAt'
dataIndex: 'changeType'
typeMap: {
CREATE: { text: '创建', color: 'green' },
UPDATE: { text: '更新', color: 'blue' },
DELETE: { text: '删除', color: 'red' },
}
正确做法:
git show HEAD~1:src/components/InvoiceHistoryTab.tsx
dataIndex: 'createdAt'
dataIndex: 'operationType'
typeMap: {
UPLOAD: { text: '上传发票', color: 'blue' },
OCR_START: { text: '开始OCR', color: 'cyan' },
OCR_SUCCESS: { text: 'OCR成功', color: 'green' },
OCR_FAILED: { text: 'OCR失败', color: 'red' },
OCR_RETRY: { text: '重试OCR', color: 'orange' },
MANUAL_EDIT: { text: '手动编辑', color: 'orange' },
LINK_ORDER: { text: '关联订单', color: 'purple' },
UNLINK_ORDER: { text: '取消关联', color: 'magenta' },
FIELD_CONFIRM: { text: '确认字段', color: 'green' },
DELETE: { text: '删除发票', color: 'red' },
}
render: (val: string) => {
if (!val) return '-'
try {
return new Date(val).toLocaleString('zh-CN')
} catch {
return val
}
}
检查清单:
为什么 TypeScript 无法检测:
dataIndex: 'changedAt'
render: (val: string) => new Date(val).toLocaleString()
陷阱 #9: 散弹式修复(Shotgun Fix)
场景: 同一个问题在多个文件中重复出现,修复时逐个文件加相同的补丁代码,而不是抽取共享逻辑
问题根因
当一个横切关注点(如图片 URL 补全、金额格式化、权限校验)散落在多个 Service 中时,修复者容易"哪里报错修哪里",最终在 5+ 个文件里各写一份几乎相同的方法。
错误示例
private String resolveImageUrl(String url) {
if (url == null || url.startsWith("http")) return url;
return minioService.getPresignedUrl(url, 60 * 24 * 7);
}
private String resolveImageUrl(String url) { }
private String resolveImageUrl(String url) { }
正确做法
@Component
public class ImageUrlResolver {
private final MinioService minioService;
public String resolve(String imageUrl) {
if (imageUrl == null || imageUrl.isBlank()) return imageUrl;
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
return imageUrl;
}
return minioService.getPresignedUrl(imageUrl, 60 * 24 * 7);
}
}
识别信号
当你发现以下情况时,应该停下来考虑是否在做散弹式修复:
| 信号 | 说明 |
|---|
| 同一个 private 方法在 3+ 个类里出现 | 应抽成共享工具 |
| 修复一个接口后,用户又报另一个接口同样的问题 | 应先全局扫描再一次性修 |
| 修复 diff 里有 5+ 个文件的相同改动模式 | 应抽取公共逻辑 |
检查清单
陷阱 #10: 位置对应集合的去重/过滤错位
场景: 多条 list 在最终输出中按索引位置一一对应(如 [公司, 单号] / [姓名, 年龄] / [字段名, 字段值] / [问题, 答案]),重构时只对其中一条 distinct() / filter() / sorted(),其他不动 → 长度变了,索引错位,输出数据彻底乱套。
问题根因
代码里两条 List 看似独立,但语义上是 zip 关系。开发者以为"去重让结果更干净",但实际上破坏了"第 i 个公司对应第 i 个单号"的隐式契约。这种错误特别隐蔽:
- 单元测试经常没覆盖(多包裹同公司的情况)
- 编译/类型检查不会报错(两条 list 类型一致)
- 测试数据巧合地都不重复时,根本看不出来
- 只有真实业务数据出现重复时才爆雷(
顺丰:1, 圆通:2, 顺丰:3 → 公司 顺丰;圆通、单号 1;2;3)
错误示例(来自真实 review)
List<String> companies = packages.stream()
.map(ShippingPackage::getCompany)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
List<String> numbers = packages.stream()
.map(ShippingPackage::getTrackingNo)
.collect(Collectors.toList());
writeRow(sheet, "快递公司", String.join(";", companies));
writeRow(sheet, "快递单号", String.join(";", numbers));
正确做法
方案 A:对齐操作(最小改动)
要么两条 list 一起 distinct/filter,要么都不动:
List<String> companies = packages.stream()
.map(ShippingPackage::getCompany)
.collect(Collectors.toList());
List<String> numbers = packages.stream()
.map(ShippingPackage::getTrackingNo)
.collect(Collectors.toList());
if (companies.stream().distinct().count() == 1) {
writeRow(sheet, "快递公司", companies.get(0));
} else {
writeRow(sheet, "快递公司", String.join(";", companies));
}
writeRow(sheet, "快递单号", String.join(";", numbers));
方案 B:改用配对结构(推荐,从根上消除问题)
把"两条并行 list"改成"一条 list of Pair/Record",索引对应关系由语言保证:
public record ShippingPackage(String company, String trackingNo) {}
List<ShippingPackage> packages = order.getPackages();
String companyValue = packages.size() == 1
? packages.get(0).company()
: packages.stream().map(ShippingPackage::company).collect(joining(";"));
String numberValue = packages.stream()
.map(ShippingPackage::trackingNo)
.collect(joining(";"));
任何后续操作(filter、sorted、partition)都作用在整个 record 上,不会出现单字段被改而其他字段不动的情况。
何时用哪个
| 场景 | 推荐方案 |
|---|
| 现有代码只是临时拼接,改动成本高 | 方案 A:对齐操作 |
| 这两个字段在多处一起出现,或后续可能加更多字段 | 方案 B:配对结构 |
| 字段超过 3 个还按位置对应 | 必须用方案 B(再多就读不懂了) |
检查清单
多语言示例
完整的 Java(Stream + Record)/ Go(slice + struct)/ TypeScript(Array + interface)实现示例见 references/multi-lang-examples.md。
规则溯源
> 📋 本回复遵循:`refactor-safety` - [章节]