---
name: "dict-biz-integration"
version: "1.0.0"
origin: "captured"
generation: 0
parent_skill_ids: []
status: "stable"
description: "BladeX 业务字典（blade_dict_biz）端到端集成模式。覆盖 SQL 种子数据、后端 DictBizCache Wrapper 翻译、前端 getDictionary 动态加载与回显、i18n 标签、测试 SQL 对齐。确保所有枚举/状态字段使用平台字典能力而非硬编码。"
trigger_phases: ["architecture", "implementation", "code-review"]
applicable_agents: ["Copilot Architect", "Copilot Code Gen Specialist", "Copilot Implementation", "Copilot Frontend Developer", "Copilot Code Review"]
priority: 10
---

# 业务字典端到端集成 (dict-biz-integration)

> **适用范围**: IMEX EOMS 所有含枚举/状态/类型字段的业务模块。
> **核心原则**: 所有枚举值通过 `blade_dict_biz` 管理，禁止在代码中硬编码 switch-case 或 if-else 翻译。
> **关联**: `fk-field-detection/SKILL.md`（FK 字段翻译）/ `element-plus-patterns/SKILL.md`（组件模式）/ `industrial-page-standard/SKILL.md`（页面骨架）。

---

## 1. 识别规则：哪些字段需要字典

### 1.1 必须使用字典的字段类型

| 特征 | 示例 | 字典 code 命名规则 |
|------|------|-------------------|
| 状态机字段（String 枚举） | `leadStatus`, `orderStatus` | `{module}_{entity}_{field}` 如 `crm_lead_status` |
| 类型分类字段 | `industryTag`, `companySize` | `{module}_{field}` 如 `crm_industry_tag` |
| 等级/优先级 | `gradeLevel`, `priority` | `{module}_{field}` 如 `crm_grade_level` |
| 性质/属性 | `enterpriseNature`, `contractType` | `{module}_{field}` 如 `crm_enterprise_nature` |

### 1.2 不使用字典的字段

- 布尔字段（`isDeleted`, `isSealed`）→ 前端直接用 `el-tag` 显示是/否
- FK 引用字段（`userId`, `deptId`）→ 使用 `fk-field-detection` Skill 的缓存翻译模式
- 自由文本（`remark`, `description`）→ 无需翻译

---

## 2. 后端：SQL 种子数据

### 2.1 blade_dict_biz 表结构

```
blade_dict_biz (
  id          BIGINT PRIMARY KEY,
  tenant_id   VARCHAR(12),    -- '000000' 为默认租户
  parent_id   BIGINT,         -- 0 = 父行，非0 = 引用父行 id
  code        VARCHAR(100),   -- 字典编码，同组父子行共享
  dict_key    VARCHAR(100),   -- '-1' = 父行标识；否则为枚举值
  dict_value  VARCHAR(100),   -- 父行: 字典中文名称；子行: 枚举显示值
  sort        INT,            -- 排序序号
  remark      VARCHAR(255),
  is_sealed   INT DEFAULT 0,
  status      INT DEFAULT 1,
  is_deleted  INT DEFAULT 0
)
```

### 2.2 种子 SQL 模板

```sql
-- {Module} {Entity} 业务字典种子数据
-- 文件位置: doc/sql/{module}/{module}-dict-biz-seed.sql

-- 幂等: 先删后插
DELETE FROM blade_dict_biz
WHERE code IN (
  '{module}_{field1}',
  '{module}_{field2}'
);

-- 父行: dict_key='-1', parent_id=0
-- 子行: dict_key=实际枚举值, parent_id=父行id
INSERT INTO blade_dict_biz
  (`id`, `tenant_id`, `parent_id`, `code`, `dict_key`, `dict_value`, `sort`, `remark`, `is_sealed`, `status`, `is_deleted`)
VALUES
  -- ========== {field1_label} ==========
  ({id_base}000, '000000', 0, '{module}_{field1}', '-1', '{field1_label}', 1, '{remark}', 0, 1, 0),
  ({id_base}001, '000000', {id_base}000, '{module}_{field1}', 'VALUE1', '显示值1', 1, NULL, 0, 1, 0),
  ({id_base}002, '000000', {id_base}000, '{module}_{field1}', 'VALUE2', '显示值2', 2, NULL, 0, 1, 0);
```

### 2.3 ID 分配规则

- 每个模块分配一个 `id_base` 前缀（19 位 BIGINT），如 CRM = `19003000000000`
- 每个字典 code 占用一段连续 ID（父行 +000，子行 +001~+099）
- 不同 code 间留足间隔（每段 1000）

---

## 3. 后端：Wrapper DictBizCache 翻译

### 3.1 核心 API

```java
import org.springblade.system.cache.DictBizCache;

// 根据 code + key 获取字典值
String label = DictBizCache.getValue(String code, Object key);
// code = 字典编码（如 "crm_lead_status"）
// key  = 枚举值（如 "NEW", "1", "A"）
// 返回 = 字典值（如 "新建", "公海", "A级"）；未命中返回空字符串
```

### 3.2 Wrapper 实现模板

```java
package org.springblade.mom.{module}.wrapper;

import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictBizCache;
import java.util.Objects;

public class {Entity}Wrapper extends BaseEntityWrapper<{Entity}, {Entity}VO> {

    // 1. 声明字典编码常量
    private static final String DICT_CODE_{FIELD}_STATUS = "{module}_{field}";

    public static {Entity}Wrapper build() {
        return new {Entity}Wrapper();
    }

    @Override
    public {Entity}VO entityVO({Entity} entity) {
        {Entity}VO vo = Objects.requireNonNull(
            BeanUtil.copyProperties(entity, {Entity}VO.class));

        // 2. 字典字段翻译（带空值保护 + 回退）
        String fieldValue = entity.getFieldStatus();
        if (Func.isNotEmpty(fieldValue)) {
            String fieldLabel = DictBizCache.getValue(DICT_CODE_{FIELD}_STATUS, fieldValue);
            vo.setFieldStatusName(
                Func.isNotEmpty(fieldLabel) ? fieldLabel : fieldValue
            );
        }

        return vo;
    }
}
```

### 3.3 VO 层要求

每个字典字段需在 VO 中增加对应的 `Name` 翻译字段：

```java
// Entity 中
@Schema(description = "线索状态")
private String leadStatus;

// VO 中（额外字段）
@Schema(description = "线索状态名称")
private String leadStatusName;
```

---

## 4. 前端：动态加载字典

### 4.1 API 调用

```javascript
// 从 dictbiz.js 导入（注意不是 dict.js）
import { getDictionary } from '@/api/system/dictbiz';

// 调用方式
const res = await getDictionary({ code: 'crm_lead_status' });
// res.data.data = [{ id, dictKey, dictValue, code, parentId, sort, ... }]
```

### 4.2 数据规范化

```javascript
/**
 * 将字典 API 响应规范化为 { label, value } 选项数组
 */
function normalizeDictOptions(response) {
  const items = response?.data?.data;
  if (!Array.isArray(items)) return [];
  return items.map(item => ({
    label: item.dictValue,
    value: item.dictKey,
  }));
}
```

### 4.3 列表页完整加载模式

```javascript
import { ref, onMounted } from 'vue';
import { getDictionary } from '@/api/system/dictbiz';

// 1. 声明响应式字典选项
const leadStatusOptions = ref([]);
const gradeOptions = ref([]);

// 2. 统一加载函数
async function loadDictionaries() {
  const dictCodes = [
    { code: 'crm_lead_status', target: leadStatusOptions },
    { code: 'crm_grade_level', target: gradeOptions },
  ];

  await Promise.allSettled(
    dictCodes.map(async ({ code, target }) => {
      try {
        const res = await getDictionary({ code });
        const items = res?.data?.data;
        if (Array.isArray(items)) {
          target.value = items.map(item => ({
            label: item.dictValue,
            value: item.dictKey,
          }));
        }
      } catch (e) {
        console.warn(`[dict] Failed to load ${code}:`, e);
        // 回退: 保持空数组或使用 i18n 本地枚举
      }
    })
  );
}

// 3. 在 onMounted 中触发
onMounted(() => {
  loadDictionaries();
});
```

### 4.4 回退策略

当 `blade_dict_biz` 种子数据未初始化时，前端应有本地 i18n 回退：

```javascript
// i18n fallback（仅在字典加载失败时使用）
const leadStatusFallback = [
  { label: t('crm.lead.status.NEW'), value: 'NEW' },
  { label: t('crm.lead.status.CONTACTING'), value: 'CONTACTING' },
  // ...
];

// 合并策略: 字典优先，空时回退
const effectiveOptions = computed(() =>
  leadStatusOptions.value.length > 0
    ? leadStatusOptions.value
    : leadStatusFallback
);
```

### 4.5 显示标签辅助函数

```javascript
/**
 * 根据枚举值从选项数组中查找显示标签
 */
function getDictLabel(options, value) {
  if (!value) return '';
  const opt = options.find(o => String(o.value) === String(value));
  return opt ? opt.label : value;
}

// 用于详情页显示
// {{ getDictLabel(leadStatusOptions, detailData.leadStatus) }}
```

---

## 5. 前端：Option 配置传递

### 5.1 createTableOption 工厂接受字典参数

```javascript
// src/option/{module}/{entity}.js
export function createTableOption(t, dictOptions = {}) {
  return {
    columns: [
      {
        prop: 'leadStatus',
        label: t('crm.lead.leadStatus'),
        filterType: 'select',
        // 动态字典选项传入
        options: dictOptions.leadStatusOptions || [],
        render: (row) => {
          // 使用 tag 组件显示字典标签
          const opt = (dictOptions.leadStatusOptions || [])
            .find(o => String(o.value) === String(row.leadStatus));
          return opt ? opt.label : row.leadStatus;
        },
      },
    ],
  };
}
```

### 5.2 列表页传递字典选项

```javascript
// list.vue
import { createTableOption } from '@/option/{module}/{entity}';

const tableOption = computed(() =>
  createTableOption(t, {
    leadStatusOptions: leadStatusOptions.value,
    gradeOptions: gradeOptions.value,
  })
);
```

---

## 6. 前端：表单中的字典下拉

### 6.1 新建/编辑表单

```vue
<el-form-item :label="t('crm.lead.leadStatus')" prop="leadStatus">
  <el-select v-model="formData.leadStatus" :placeholder="t('common.pleaseSelect')">
    <el-option
      v-for="opt in leadStatusOptions"
      :key="opt.value"
      :label="opt.label"
      :value="opt.value"
    />
  </el-select>
</el-form-item>
```

### 6.2 详情页只读显示

```vue
<el-descriptions-item :label="t('crm.lead.leadStatus')">
  {{ getDictLabel(leadStatusOptions, detailData.leadStatus) }}
</el-descriptions-item>
```

---

## 7. 测试 SQL 对齐

测试 SQL 中的枚举值必须与字典种子的 `dict_key` 一致：

```sql
-- doc/sql/{module}/{entity}-test-seed.sql
INSERT INTO crm_lead (
  id,
  lead_code,
  lead_status,
  pool_status,
  grade_level,
  tenant_id,
  create_user,
  create_time,
  is_deleted
) VALUES (
  1900000000000001001,
  'LEAD-DEMO-0001',
  'NEW',
  1,
  'A',
  '000000',
  112233,
  NOW(),
  0
);
```

---

## 8. 交付检查清单

| # | 检查项 | 层级 | 验证方式 |
|---|--------|------|----------|
| D-01 | 每个枚举字段有对应 `blade_dict_biz` 种子 SQL | DB | 检查 `doc/sql/{module}/` |
| D-02 | 种子 SQL 幂等（DELETE + INSERT） | DB | SQL 可重复执行 |
| D-03 | Wrapper 中使用 `DictBizCache.getValue()` 翻译 | Backend | Code Review |
| D-04 | VO 中有 `{field}Name` 翻译字段 | Backend | Entity/VO 对比 |
| D-05 | 前端从 `getDictionary` API 动态加载 | Frontend | 检查 `onMounted` |
| D-06 | 列表列通过字典选项渲染标签而非原始值 | Frontend | 页面截图 |
| D-07 | 表单下拉使用字典选项数组 | Frontend | 表单交互 |
| D-08 | 详情页通过 `getDictLabel` 显示翻译值 | Frontend | 详情页截图 |
| D-09 | 测试 SQL 中的 `dict_key` 值与种子一致 | DB | 测试 SQL 检查 |
| D-10 | i18n 有回退标签 | Frontend | 字典加载失败场景 |

---

## 9. 反模式（禁止）

| 反模式 | 正确做法 |
|--------|----------|
| Wrapper 中写 `switch-case` 翻译枚举 | 使用 `DictBizCache.getValue()` |
| 前端 `computed` 硬编码选项数组 | 使用 `getDictionary` 动态加载 |
| 列表直接显示 `row.leadStatus` 原始值 | 通过字典选项翻译为标签 |
| 表单用 `<el-input>` 让用户手输枚举值 | 用 `<el-select>` + 字典选项 |
| 不同模块重复定义相同枚举 | 共享同一 `blade_dict_biz` code |
| 种子 SQL 不幂等（只有 INSERT） | 先 DELETE 再 INSERT |
| 增量新增字典项使用 `DELETE + INSERT` 裸操作 | 使用 `INSERT ... ON DUPLICATE KEY UPDATE` 幂等 upsert |
| 字典 key 改名/删除时不清洗业务表引用 | 先扫描业务表引用，再同步 UPDATE 业务表与字典表 |
| 切片独立 DELETE 其他切片已发布的 code | 每个切片仅负责自身所属 code，不得跨切片清理 |

---

## 10. 字典演进与数据引用完整性

> **背景**: IMEX 项目仍处开发初期，字典种子频繁迭代。但一旦业务表中已写入 `dict_key` 值，字典项的 rename / remove / merge 就会造成"孤儿数据"（业务表引用了不存在的字典 key）。本章定义演进场景的安全操作模板。

### 10.1 字典生命周期五种演进场景

| 场景 | 触发条件 | 风险 | 处理模板 |
|---|---|---|---|
| **INIT** — 首次建立 | 新模块上线 | 无 | §10.2 首次建立模板 |
| **ADD_KEY** — 增加子项 | 已上线字典追加新枚举值 | 低 | §10.3 幂等 Upsert 模板 |
| **RENAME_KEY** — 改名 | 改 `dict_key` 值（如 `CONTACTING` → `FOLLOWING_UP`） | **高** — 业务数据引用失效 | §10.4 改名 + 业务回写模板 |
| **DEPRECATE_KEY** — 下线 | 某枚举不再使用，但历史数据要保留 | 中 — 页面需能展示已下线标签 | §10.5 下线 + 封存模板 |
| **REMOVE_KEY** — 硬删除 | 枚举从未被业务表引用过，需要物理清理 | 中 — 误删风险 | §10.6 前置校验 + 硬删模板 |

### 10.2 INIT（首次建立）模板

```sql
-- 仅首次初始化使用；已上线后不得再用此模板，否则会清空增量
DELETE FROM `blade_dict_biz` WHERE `code` = '{module}_{field}';

INSERT INTO `blade_dict_biz`
  (`id`, `tenant_id`, `parent_id`, `code`, `dict_key`, `dict_value`, `sort`, `remark`, `is_sealed`, `is_deleted`)
VALUES
  ({id_base}001, '000000', 0, '{module}_{field}', '-1', '{字典中文名}', 1, NULL, 0, 0),
  ({id_base}002, '000000', {id_base}001, '{module}_{field}', 'VALUE1', '显示1', 1, NULL, 0, 0),
  ({id_base}003, '000000', {id_base}001, '{module}_{field}', 'VALUE2', '显示2', 2, NULL, 0, 0);
```

### 10.3 ADD_KEY（幂等增量）模板

> **关键要求**: 字典 `(tenant_id, code, dict_key)` 应视为自然唯一键。若 DB 已建唯一索引，直接使用 `ON DUPLICATE KEY UPDATE`；若未建，需要显式检查。

```sql
-- 增加单个字典项（允许反复执行）
-- 推荐方案 A：已有唯一索引时
INSERT INTO `blade_dict_biz`
  (`id`, `tenant_id`, `parent_id`, `code`, `dict_key`, `dict_value`, `sort`, `remark`, `is_sealed`, `is_deleted`)
VALUES
  (1710010099, '000000',
   (SELECT p.id FROM (SELECT id FROM `blade_dict_biz` WHERE `code`='crm_lead_status' AND `dict_key`='-1' AND `is_deleted`=0 LIMIT 1) p),
   'crm_lead_status', 'POSTPONED', '已推迟', 9, NULL, 0, 0)
ON DUPLICATE KEY UPDATE
  `dict_value` = VALUES(`dict_value`),
  `sort`       = VALUES(`sort`),
  `remark`     = VALUES(`remark`),
  `is_sealed`  = VALUES(`is_sealed`),
  `is_deleted` = 0;

-- 推荐方案 B：未建唯一索引时（用 NOT EXISTS 包一层）
INSERT INTO `blade_dict_biz`
  (`id`, `tenant_id`, `parent_id`, `code`, `dict_key`, `dict_value`, `sort`, `remark`, `is_sealed`, `is_deleted`)
SELECT 1710010099, '000000', p.id, 'crm_lead_status', 'POSTPONED', '已推迟', 9, NULL, 0, 0
FROM (SELECT id FROM `blade_dict_biz` WHERE `code`='crm_lead_status' AND `dict_key`='-1' AND `is_deleted`=0 LIMIT 1) p
WHERE NOT EXISTS (
  SELECT 1 FROM `blade_dict_biz`
  WHERE `code`='crm_lead_status' AND `dict_key`='POSTPONED' AND `tenant_id`='000000' AND `is_deleted`=0
);

-- 更新已存在项的展示值/排序（不涉及 key 改名）
UPDATE `blade_dict_biz`
SET `dict_value` = '已推迟', `sort` = 9
WHERE `code` = 'crm_lead_status' AND `dict_key` = 'POSTPONED' AND `tenant_id` = '000000';
```

### 10.4 RENAME_KEY（改名 + 业务回写）模板

> **关键要求**: 必须"先改业务表，再改字典表"，顺序错会造成瞬时引用失效。

```sql
-- Step 1: 前置审计 —— 业务表中有多少行引用旧 key
SELECT COUNT(*) AS affected_rows FROM `crm_lead`
WHERE `lead_status` = 'CONTACTING' AND `tenant_id` = '000000' AND `is_deleted` = 0;

-- Step 2: 业务表回写（一个 key 可能被多张表引用，全部列出）
UPDATE `crm_lead`
SET `lead_status` = 'FOLLOWING_UP', `update_time` = NOW()
WHERE `lead_status` = 'CONTACTING' AND `tenant_id` = '000000' AND `is_deleted` = 0;

-- Step 3: 字典表 dict_key 改名
UPDATE `blade_dict_biz`
SET `dict_key` = 'FOLLOWING_UP', `dict_value` = '跟进中（新）'
WHERE `code` = 'crm_lead_status' AND `dict_key` = 'CONTACTING' AND `tenant_id` = '000000';

-- Step 4: 验证 —— 不应有引用旧 key 的业务行
SELECT COUNT(*) AS orphan_rows FROM `crm_lead`
WHERE `lead_status` = 'CONTACTING' AND `tenant_id` = '000000' AND `is_deleted` = 0;
-- 期望: 0
```

### 10.5 DEPRECATE_KEY（下线但保留历史）模板

> **使用场景**: 某枚举新业务不再使用，但旧数据仍在引用。前端仍需能翻译标签。

```sql
-- 前置审计
SELECT COUNT(*) AS historical_refs FROM `crm_lead`
WHERE `lead_status` = 'OLD_STATUS' AND `tenant_id` = '000000' AND `is_deleted` = 0;

-- 封存（标 is_sealed=1，前端显示时仍能翻译，但下拉列表过滤掉）
UPDATE `blade_dict_biz`
SET `is_sealed` = 1, `remark` = CONCAT(IFNULL(`remark`,''), ' [DEPRECATED 2026-04-24]')
WHERE `code` = 'crm_lead_status' AND `dict_key` = 'OLD_STATUS' AND `tenant_id` = '000000';
```

**前端配合**: `getDictionary()` API 返回的 `is_sealed=1` 项，列表/详情应继续翻译，但表单 `<el-select>` 下拉选项应过滤 `is_sealed=0` 的项。

```javascript
// 过滤下拉选项，仅展示未封存项
const activeOptions = computed(() =>
  leadStatusOptions.value.filter(opt => opt.isSealed === 0 || opt.isSealed === false)
);
// 列表翻译仍使用全量
const allOptions = leadStatusOptions.value;
```

### 10.6 REMOVE_KEY（硬删）模板

> **仅当业务表零引用时允许使用**。任何非零引用都必须先走 RENAME 或 DEPRECATE。

```sql
-- Step 1: 前置审计（必须全部返回 0）
SELECT 'crm_lead.lead_status' AS ref_location, COUNT(*) AS ref_count FROM `crm_lead`
WHERE `lead_status` = 'TO_REMOVE' AND `is_deleted` = 0
UNION ALL
SELECT 'crm_lead_pool_log.action_type', COUNT(*) FROM `crm_lead_pool_log`
WHERE `action_type` = 'TO_REMOVE' AND `is_deleted` = 0;
-- 若任何一行 ref_count > 0，禁止执行 Step 2

-- Step 2: 硬删（软删 + 改 is_deleted=1，保留审计痕迹）
UPDATE `blade_dict_biz`
SET `is_deleted` = 1, `remark` = CONCAT(IFNULL(`remark`,''), ' [REMOVED 2026-04-24]')
WHERE `code` = 'crm_lead_status' AND `dict_key` = 'TO_REMOVE' AND `tenant_id` = '000000';
```

---

## 11. 字典种子 SQL 分层结构

> **背景**: 早期一个模块一份 `<module>-dict-biz-seed.sql` 混装 INIT + ADD_KEY + DEPRECATE，导致可重复执行性差、演进审计困难。本章定义分层目录结构。

### 11.1 目标结构

```
doc/sql/<module>/dict-biz/
  V001__init-<dict-code>.sql            # 首次建立（§10.2 模板）
  V002__add-<dict-code>-<key>.sql       # 新增子项（§10.3 模板）
  V003__rename-<dict-code>-<key>.sql    # 改名（§10.4 模板）
  V004__deprecate-<dict-code>-<key>.sql # 下线封存（§10.5 模板）
  V005__remove-<dict-code>-<key>.sql    # 硬删（§10.6 模板）
  <module>-dict-biz-all.sql             # 合并视图（仅从上述 V* 按时序合并生成，不手写）
```

### 11.2 命名约束

- `V{NNN}__` 前缀按执行顺序递增（参考 Flyway 命名，便于未来引入增量迁移工具）
- `<dict-code>` 必须是完整字典编码（如 `crm_lead_status`），不可省略为 `lead_status`
- `<key>` 在 ADD/RENAME/DEPRECATE/REMOVE 场景下必填，便于检索历史
- 同一版本号 `V{NNN}__` 在整个模块内必须全局唯一

### 11.3 合并视图 `<module>-dict-biz-all.sql`

用于开发环境首次建库一次性初始化。**只能从 V* 文件合并生成，严禁手写**。合并顺序：
1. 收集 V001~Vnnn 所有 INIT 段（每个 code 只保留最后一次 INIT）
2. 按 V 序号叠加 ADD_KEY / RENAME_KEY / DEPRECATE_KEY / REMOVE_KEY 段
3. 每个 code 的最终状态是所有版本叠加后的结果

### 11.4 过渡策略（现有 `<module>-dict-biz-seed.sql` 如何迁移）

当前 `doc/sql/crm/crm-dict-biz-seed.sql` 等已是大文件。过渡期允许：
- 保留现有单文件作为 `<module>-dict-biz-all.sql` 的初始版本
- 新增演进（ADD/RENAME/DEPRECATE/REMOVE）必须通过 V* 文件增量维护
- 每个切片的 `Schema Guardian` 检查时，同步更新合并视图

---

## 12. 数据引用完整性检查（跨表审计）

> **背景**: 字典 key 改名/下线后，业务表可能留下孤儿值，但现有流程无机制发现。本章定义后端/SQL 层的审计脚本。

### 12.1 字典引用全库扫描脚本

为每个业务字典 code 生成一份"所有引用此字典的业务字段清单"，放在模块根：

```
doc/sql/<module>/dict-biz/references.md
```

内容示例（CRM Lead 模块）：

```markdown
# CRM Lead 字典引用矩阵

| 字典 code | 引用表.字段 | Entity 字段 | VO 翻译字段 |
|---|---|---|---|
| crm_lead_status | crm_lead.lead_status | Lead.leadStatus | LeadVO.leadStatusName |
| crm_lead_grade_level | crm_lead.grade_level | Lead.gradeLevel | LeadVO.gradeLevelName |
| crm_lead_industry_tag | crm_lead.industry_tag | Lead.industryTag | LeadVO.industryTagName |
| crm_lead_pool_action_type | crm_lead_pool_log.action_type | LeadPoolLog.actionType | LeadPoolLogVO.actionTypeName |
```

### 12.2 孤儿数据审计 SQL

每个字典 code 配套一份审计查询（放在 `doc/sql/<module>/dict-biz/audit/<dict-code>-orphan-check.sql`）：

```sql
-- 审计: 业务表中是否有引用了不存在（或已软删）的字典 key
-- Expected: 0 行返回
SELECT DISTINCT 'crm_lead' AS table_name, l.lead_status AS orphan_key
FROM `crm_lead` l
LEFT JOIN `blade_dict_biz` d
  ON d.`code` = 'crm_lead_status'
  AND d.`dict_key` = l.lead_status
  AND d.`tenant_id` = l.tenant_id
  AND d.`is_deleted` = 0
WHERE l.lead_status IS NOT NULL
  AND l.lead_status != ''
  AND l.is_deleted = 0
  AND d.id IS NULL;
```

### 12.3 Schema Guardian 集成点

`copilot-schema-guardian` 在 `post-implementation` 和 `pr-gate` 模式下，需要扫描：
- 新增字典 seed 文件 → 验证是否有对应的 `references.md` 更新
- 修改字典 key → 验证是否有对应的 RENAME_KEY 脚本（业务表回写）
- 业务表新增 `*_status` / `*_type` 等字段 → 验证是否有对应字典 code 与 references 记录

详见 `copilot-schema-guardian.agent.md` 的 `Dictionary reference integrity` 检查维度。

---

## 13. 参考实现

- **后端 Wrapper**: `blade-service/imex-crm/src/main/java/org/springblade/mom/crm/wrapper/CrmLeadWrapper.java`
- **种子 SQL**: `doc/sql/crm/crm-dict-biz-seed.sql`
- **前端字典 API**: `src/api/system/dictbiz.js` → `getDictionary()`
- **列表页加载**: `src/views/crm/lead/list.vue` → `loadLeadDictionaries()`
- **Option 传递**: `src/option/crm/crmLead.js` → `createTableOption(t, dictOptions)`
