# 渠道账号、会话与发送任务数据边界落地计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 在现有 `mci-server` / `admin-web` 内完成方案 A：明确中台与业务系统的数据边界，保护业务字段不被企微同步覆盖；完善账号-会话收发主备绑定；增强发送任务目标解析校验；补齐前端测试发送与绑定配置 UI；最终跑通“第三方维护资源 → 任务发送 → 查询记录”的完整链路。

**Architecture:** 沿用 `mci_send_record` 任务+日志合并表，通过 `target_type` + `target_value` 描述业务语义目标，由 `SendService` 解析为具体会话/人员并选择 `ChannelAccount` 实际发送。数据边界通过 Service 层校验与同步方法字段白名单实现。

**Tech Stack:** Spring Boot 4.1 / Java 21 / MyBatis-Plus / MySQL 8 / RabbitMQ / Vue 3 / Element Plus / Docker Compose

---

## 文件结构

| 文件 | 职责 | 操作 |
|------|------|------|
| `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java` | 渠道账号初始化、同步通讯录/群聊 | 修改：同步时不覆盖业务字段 |
| `backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java` | 会话 CRUD、账号绑定、标签绑定 | 修改：校验 binding_type、支持 4 种绑定 |
| `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java` | 发送任务解析、拆解、调度 | 修改：目标存在性/租户隔离/账号可用性校验 |
| `backend/mci-server/src/main/java/com/sino/mci/send/service/SendTarget.java` | 发送目标中间对象 | 修改：补充字段 |
| `backend/mci-server/src/main/java/com/sino/mci/send/dto/SendTestRequest.java` | 测试发送请求 DTO | 修改：扩展字段 |
| `admin-web/src/views/SendRecord.vue` | 发送记录与测试发送页面 | 修改：支持 tag/rule 测试 |
| `admin-web/src/api/sendRecord.ts` | 发送记录 API 封装 | 修改（如需要） |
| `admin-web/src/views/Conversation.vue` | 会话管理页面 | 修改：账号绑定抽屉 |
| `admin-web/src/api/channel.ts` | 会话/账号 API 封装 | 修改（如需要） |

---

## Task 1：保护业务字段不被企微同步覆盖

**Files:**
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java`
- Test: 通过接口测试或单元测试验证同步后业务字段不变

**背景：** 企微 `syncContacts` / `syncConversations` 只能写入渠道侧字段，不能覆盖 `mci_person.institution_id/supplier_id`、`mci_conversation.institution_id/supplier_id/contract_id/channel_code`。

- [ ] **Step 1: 读取现有 `syncContacts` 与 `syncConversations` 方法**

  ```bash
  sed -n '1,100p' backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
  ```

- [ ] **Step 2: 在更新 Person 前读取旧记录并保留业务字段**

  修改点：找到 `personMapper.updateById(person)` 或类似逻辑，改为：

  ```java
  Person existing = personMapper.selectById(person.getId());
  if (existing != null) {
      person.setInstitutionId(existing.getInstitutionId());
      person.setSupplierId(existing.getSupplierId());
      // person_type 如需保护也一并保留
  }
  ```

- [ ] **Step 3: 在更新 Conversation 前读取旧记录并保留业务字段**

  修改点：找到 `conversationMapper.updateById(conversation)` 或类似逻辑，改为：

  ```java
  Conversation existing = conversationMapper.selectById(conversation.getId());
  if (existing != null) {
      conversation.setInstitutionId(existing.getInstitutionId());
      conversation.setSupplierId(existing.getSupplierId());
      conversation.setContractId(existing.getContractId());
      conversation.setChannelCode(existing.getChannelCode());
  }
  ```

- [ ] **Step 4: 编译验证**

  ```bash
  cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
  ./mvnw compile -q
  ```

  Expected: BUILD SUCCESS

- [ ] **Step 5: 写接口回归测试脚本（curl 或 devtools）**

  1. 通过 API 创建 Conversation 并设置 `contractId=100`；
  2. 调用 `sync-conversations`；
  3. 查询 Conversation，断言 `contractId` 仍为 `100`。

- [ ] **Step 6: Commit**

  ```bash
  git add backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
  git commit -m "fix: protect business fields during wecom sync"
  ```

---

## Task 2：账号-会话绑定支持收发主备 4 种类型

**Files:**
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java`
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java`
- Modify: `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java`
- Test: 接口测试

**背景：** `mci_channel_account_conversation.binding_type` 已定义 1发送主号/2发送备用号/3接收主号/4接收备用号，需要确保 Service 层正确识别并使用。

- [ ] **Step 1: 读取 `BindConversationAccountRequest` 与 `ConversationService.bindAccount`**

  ```bash
  cat backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java
  sed -n '1,80p' backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java
  ```

- [ ] **Step 2: 扩展 DTO 支持全部 binding_type**

  若 DTO 只有 `accountId`，确保包含：

  ```java
  @Data
  public class BindConversationAccountRequest {
      @NotNull
      private Long accountId;
      @NotNull
      @Range(min = 1, max = 4)
      private Integer bindingType;
      private Integer sortOrder;
  }
  ```

- [ ] **Step 3: 在 `ConversationService.bindAccount` 中按 binding_type 处理**

  逻辑示例：

  ```java
  ChannelAccountConversation binding = new ChannelAccountConversation();
  binding.setAccountId(request.getAccountId());
  binding.setConversationId(conversationId);
  binding.setBindingType(request.getBindingType());
  binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
  binding.setStatus(1);
  // 先删除同类型同账号旧绑定，再插入
  channelAccountConversationMapper.delete(
      new LambdaQueryWrapper<ChannelAccountConversation>()
          .eq(ChannelAccountConversation::getAccountId, request.getAccountId())
          .eq(ChannelAccountConversation::getConversationId, conversationId)
          .eq(ChannelAccountConversation::getBindingType, request.getBindingType()));
  channelAccountConversationMapper.insert(binding);
  ```

- [ ] **Step 4: 修改 `SendService.selectSendAccount` 按发送主备选择**

  当前实现若已支持主备，检查逻辑是否优先 `binding_type=1`，再 `binding_type=2`，且只选择 `status=1` 的账号。若未实现，补充：

  ```java
  private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType,
                                 Map<String, Object> channelStrategy) {
      List<Integer> bindingTypes = Arrays.asList(1, 2); // 发送主号、发送备用号
      for (Integer bindingType : bindingTypes) {
          List<ChannelAccountConversation> bindings = channelAccountConversationMapper.selectList(
              new LambdaQueryWrapper<ChannelAccountConversation>()
                  .eq(ChannelAccountConversation::getConversationId, conversationId)
                  .eq(ChannelAccountConversation::getBindingType, bindingType)
                  .eq(ChannelAccountConversation::getStatus, 1)
                  .orderByAsc(ChannelAccountConversation::getSortOrder));
          for (ChannelAccountConversation binding : bindings) {
              ChannelAccount account = accountMapper.selectById(binding.getAccountId());
              if (account != null && tenantCode.equals(account.getTenantCode())
                      && channelType.name().equalsIgnoreCase(account.getChannelType())
                      && account.getStatus() == 1) {
                  return account.getId();
              }
          }
      }
      return null;
  }
  ```

- [ ] **Step 5: 编译并跑现有测试**

  ```bash
  cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
  ./mvnw test -q
  ```

  Expected: BUILD SUCCESS（若测试失败先修复）

- [ ] **Step 6: Commit**

  ```bash
  git add backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java
  git add backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java
  git add backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java
  git commit -m "feat: support send/receive primary/backup account binding"
  ```

---

## Task 3：发送目标解析增加校验与错误信息

**Files:**
- Modify: `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java`
- Modify: `backend/mci-server/src/main/java/com/sino/mci/send/dto/SendTestRequest.java`
- Test: 接口测试

**背景：** 当前 `resolveConversationTarget` / `resolvePersonTarget` 已做基本校验，但缺少对账号可用状态、租户隔离、单聊 channel_type 一致性的校验。

- [ ] **Step 1: 读取 `resolveConversationTarget` 与 `resolvePersonTarget`**

  ```bash
  sed -n '320,370p' backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java
  ```

- [ ] **Step 2: 增加账号可用性校验**

  在 `resolveConversationTarget` 返回前校验选中的 `accountId`：

  ```java
  ChannelAccount account = accountMapper.selectById(accountId);
  if (account == null || !tenantCode.equals(account.getTenantCode()) || account.getStatus() != 1) {
      throw new BusinessException("会话没有可用的发送账号: " + conversationId);
  }
  ```

- [ ] **Step 3: 增加单聊 channel_type 一致性校验**

  在 `resolvePersonTarget` 中：

  ```java
  ChannelAccount account = accountMapper.selectById(accountId);
  if (account == null || !tenantCode.equals(account.getTenantCode()) || account.getStatus() != 1) {
      throw new BusinessException("租户没有可用的发送账号: channel=" + channelType);
  }
  if (!channelType.name().equalsIgnoreCase(account.getChannelType())) {
      throw new BusinessException("单聊发送账号渠道类型与目标身份不一致");
  }
  ```

- [ ] **Step 4: 扩展 `SendTestRequest` 以支持 tag/rule 测试**

  当前 `SendTestRequest` 可能只继承 `SendRequest` 或字段相同。确认它包含：

  ```java
  @Data
  public class SendTestRequest {
      @NotBlank
      private String targetType;
      @NotNull
      private Map<String, Object> targetValue;
      @NotBlank
      private String contentType;
      @NotNull
      private Map<String, Object> content;
      private Map<String, Object> channelStrategy;
      private String callbackUrl;
  }
  ```

- [ ] **Step 5: 编译并测试**

  ```bash
  cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
  ./mvnw test -q
  ```

- [ ] **Step 6: Commit**

  ```bash
  git add backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java
  git add backend/mci-server/src/main/java/com/sino/mci/send/dto/SendTestRequest.java
  git commit -m "feat: add send target validation and extend test request"
  ```

---

## Task 4：前端测试发送支持 tag / rule 目标

**Files:**
- Modify: `admin-web/src/views/SendRecord.vue`
- Modify（如需要）: `admin-web/src/api/sendRecord.ts`

**背景：** 当前 `SendRecord.vue` 测试发送只支持 conversation / person，需要增加 tag / rule，方便运营人员验证按标签/规则发送。

- [ ] **Step 1: 读取现有测试表单**

  ```bash
  sed -n '40,80p' admin-web/src/views/SendRecord.vue
  ```

- [ ] **Step 2: 在目标类型选择器中增加 tag / rule**

  ```vue
  <el-select v-model="testForm.targetType" style="width: 140px">
    <el-option label="会话" value="conversation" />
    <el-option label="人员" value="person" />
    <el-option label="标签" value="tag" />
    <el-option label="规则" value="rule" />
  </el-select>
  ```

- [ ] **Step 3: 增加 tag / rule 的目标输入表单**

  在 `buildTargetValue` 中补充：

  ```typescript
  if (testForm.targetType === 'tag' && selectedTagId.value) {
    return { tag_id: Number(selectedTagId.value), scope: tagScope.value }
  }
  if (testForm.targetType === 'rule' && ruleJson.value) {
    try {
      return JSON.parse(ruleJson.value)
    } catch {
      ElMessage.error('规则 JSON 格式错误')
      return {}
    }
  }
  ```

  模板中增加对应输入控件：

  ```vue
  <el-form-item v-if="testForm.targetType === 'tag'" label="标签">
    <el-select v-model="selectedTagId" placeholder="选择标签" clearable>
      <el-option v-for="t in tags" :key="t.id" :label="t.tagName" :value="t.id" />
    </el-select>
    <el-select v-model="tagScope" style="width: 120px; margin-left: 8px;">
      <el-option label="全部" value="all" />
      <el-option label="会话" value="conversation" />
      <el-option label="人员" value="person" />
    </el-select>
  </el-form-item>

  <el-form-item v-if="testForm.targetType === 'rule'" label="规则 JSON">
    <el-input v-model="ruleJson" type="textarea" :rows="4" placeholder='{"logic":"and","rules":[{"field":"tag","operator":"eq","value":1}]}' />
  </el-form-item>
  ```

- [ ] **Step 4: 加载标签列表**

  ```typescript
  import { listTags } from '@/api/tag'

  const tags = ref<any[]>([])
  const selectedTagId = ref<number | null>(null)
  const tagScope = ref('all')
  const ruleJson = ref('')

  async function loadTags() {
    const res: any = await listTags()
    tags.value = res.data || []
  }

  onMounted(() => {
    loadConversations()
    loadTags()
    loadRecords()
    loadStatistics()
  })
  ```

- [ ] **Step 5: 本地构建验证**

  ```bash
  cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
  npm run build
  ```

  Expected: 构建成功，无类型错误。

- [ ] **Step 6: Commit**

  ```bash
  git add admin-web/src/views/SendRecord.vue
  git add admin-web/src/api/sendRecord.ts
  git commit -m "feat(ui): support tag and rule target in test send"
  ```

---

## Task 5：会话管理页面增加账号绑定抽屉

**Files:**
- Modify: `admin-web/src/views/Conversation.vue`
- Modify（如需要）: `admin-web/src/api/channel.ts`

**背景：** 当前 `ConversationController` 已提供 `bind-account` 接口，前端需要 UI 配置发送主备、接收主备。

- [ ] **Step 1: 读取现有 Conversation.vue**

  ```bash
  sed -n '1,80p' admin-web/src/views/Conversation.vue
  ```

- [ ] **Step 2: 在表格操作列增加“绑定账号”按钮**

  ```vue
  <el-button link type="primary" @click="openBindAccountDialog(row)">绑定账号</el-button>
  ```

- [ ] **Step 3: 新增绑定抽屉/弹窗**

  ```vue
  <el-dialog v-model="bindDialogVisible" title="绑定账号" width="500px">
    <el-form :model="bindForm" label-width="100px">
      <el-form-item label="账号">
        <el-select v-model="bindForm.accountId" placeholder="选择渠道账号">
          <el-option v-for="a in accounts" :key="a.id" :label="a.accountName" :value="a.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="绑定类型">
        <el-select v-model="bindForm.bindingType">
          <el-option label="发送主号" :value="1" />
          <el-option label="发送备用号" :value="2" />
          <el-option label="接收主号" :value="3" />
          <el-option label="接收备用号" :value="4" />
        </el-select>
      </el-form-item>
      <el-form-item label="优先级">
        <el-input-number v-model="bindForm.sortOrder" :min="0" />
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="bindDialogVisible = false">取消</el-button>
      <el-button type="primary" @click="handleBindAccount">保存</el-button>
    </template>
  </el-dialog>
  ```

- [ ] **Step 4: 实现数据与接口调用**

  ```typescript
  import { bindConversationAccount, listChannelAccounts } from '@/api/channel'

  const bindDialogVisible = ref(false)
  const currentConversation = ref<any>(null)
  const accounts = ref<any[]>([])
  const bindForm = reactive({ accountId: undefined, bindingType: 1, sortOrder: 0 })

  async function openBindAccountDialog(row: any) {
    currentConversation.value = row
    bindForm.accountId = undefined
    bindForm.bindingType = 1
    bindForm.sortOrder = 0
    const res: any = await listChannelAccounts()
    accounts.value = (res.data || []).filter((a: any) => a.channelType === row.channelType)
    bindDialogVisible.value = true
  }

  async function handleBindAccount() {
    if (!bindForm.accountId || !currentConversation.value) return
    await bindConversationAccount(currentConversation.value.id, bindForm)
    ElMessage.success('绑定成功')
    bindDialogVisible.value = false
    await loadConversations()
  }
  ```

- [ ] **Step 5: 确认 API 封装存在**

  若 `admin-web/src/api/channel.ts` 没有 `bindConversationAccount`，补充：

  ```typescript
  export function bindConversationAccount(conversationId: number, data: any) {
    return request.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
  }
  ```

- [ ] **Step 6: 构建验证**

  ```bash
  cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
  npm run build
  ```

- [ ] **Step 7: Commit**

  ```bash
  git add admin-web/src/views/Conversation.vue
  git add admin-web/src/api/channel.ts
  git commit -m "feat(ui): conversation account binding for send/receive primary/backup"
  ```

---

## Task 6：端到端集成验证

**Files:**
- 全栈
- Test: 手工或脚本

- [ ] **Step 1: 重启服务确保最新代码生效**

  ```bash
  cd /Users/marsal/Projects/sino-project/sino-mci
  docker compose down
  docker compose up -d --build
  ```

- [ ] **Step 2: 等待服务 healthy**

  ```bash
  docker compose ps
  ```

  Expected: `mci-server`, `admin-web`, `mysql`, `rabbitmq`, `redis`, `seaweedfs` 均为 healthy。

- [ ] **Step 3: 登录并创建测试租户/用户**

  通过 admin-web 登录全局管理员，创建租户 `test`，创建租户管理员。

- [ ] **Step 4: 创建渠道主体与账号**

  在 admin-web 的“渠道主体”页面创建企微主体；
  在“渠道账号”页面调用 init 初始化一个测试账号（可用 mock 或真实企微测试号）。

- [ ] **Step 5: 创建 Person 并绑定 ChannelIdentity**

  通过 API 或页面创建 Person，并绑定 `channel_type=wecom` 的 identity。

- [ ] **Step 6: 创建 Conversation 并绑定发送账号**

  通过 API 或页面创建 Conversation，绑定发送主号。

- [ ] **Step 7: 测试发送**

  在 `SendRecord.vue` 页面选择 conversation 目标，输入文本，点击“测试发送”。

- [ ] **Step 8: 查询记录**

  发送后刷新 `SendRecord.vue` 列表，确认出现一条 `send_status` 记录；
  若使用 mock 适配器，状态应为 `success`；
  若使用真实企微，状态按实际返回。

- [ ] **Step 9: 标签发送测试**

  创建标签，绑定到 Person/Conversation；
  在 `SendRecord.vue` 选择标签目标发送；
  确认产生多条 `mci_send_record`。

- [ ] **Step 10: 验证业务字段保护**

  调用 API 设置 Conversation 的 `contractId`；
  调用 `sync-conversations`；
  查询 Conversation，确认 `contractId` 未被覆盖。

- [ ] **Step 11: 写验证报告并 Commit**

  在 `docs/superpowers/plans/2026-07-08-channel-conversation-task-boundary.md` 末尾或新建文件记录验证结果。

  ```bash
  git add docs/superpowers/plans/2026-07-08-channel-conversation-task-boundary.md
  git commit -m "docs: add verification report for channel-conversation-task boundary"
  ```

---

## 自测清单

| 检查项 | 验证方式 |
|--------|---------|
| 企微同步不覆盖 `contractId` / `institutionId` / `supplierId` | Task 1 回归测试 |
| 会话可绑定发送主号/备用号/接收主号/接收备用号 | Task 2 接口测试 + Task 5 UI |
| 发送任务按主备顺序选号 | Task 2 单元/接口测试 |
| 发送目标解析对不可用账号给出明确错误 | Task 3 接口测试 |
| 前端支持 conversation/person/tag/rule 四种测试发送 | Task 4 UI 点击 |
| 前端会话页面可绑定账号 | Task 5 UI 点击 |
| Docker 全栈 healthy | Task 6 Step 2 |
| 端到端发送成功并可在记录列表查看 | Task 6 Step 7-8 |

---

## 执行方式

**Plan complete and saved to `docs/superpowers/plans/2026-07-08-channel-conversation-task-boundary.md`.**

Two execution options:

1. **Subagent-Driven (recommended)** - Dispatch a fresh coder subagent per task, review between tasks, fast iteration.
2. **Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints.

Recommended: **Subagent-Driven** for parallelizable UI/backend work and clear boundaries.
