Files
sino-mci/docs/superpowers/plans/2026-07-08-channel-conversation-task-boundary.md

21 KiB
Raw Blame History

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

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_idmci_conversation.institution_id/supplier_id/contract_id/channel_code

  • Step 1: 读取现有 syncContactssyncConversations 方法

    sed -n '1,100p' backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
    
  • Step 2: 在更新 Person 前读取旧记录并保留业务字段

    修改点:找到 personMapper.updateById(person) 或类似逻辑,改为:

    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) 或类似逻辑,改为:

    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: 编译验证

    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

    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: 读取 BindConversationAccountRequestConversationService.bindAccount

    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,确保包含:

    @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 处理

    逻辑示例:

    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 的账号。若未实现,补充:

    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: 编译并跑现有测试

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

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

  • Step 6: Commit

    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: 读取 resolveConversationTargetresolvePersonTarget

    sed -n '320,370p' backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java
    
  • Step 2: 增加账号可用性校验

    resolveConversationTarget 返回前校验选中的 accountId

    ChannelAccount account = accountMapper.selectById(accountId);
    if (account == null || !tenantCode.equals(account.getTenantCode()) || account.getStatus() != 1) {
        throw new BusinessException("会话没有可用的发送账号: " + conversationId);
    }
    
  • Step 3: 增加单聊 channel_type 一致性校验

    resolvePersonTarget 中:

    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 或字段相同。确认它包含:

    @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: 编译并测试

    cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
    ./mvnw test -q
    
  • Step 6: Commit

    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: 读取现有测试表单

    sed -n '40,80p' admin-web/src/views/SendRecord.vue
    
  • Step 2: 在目标类型选择器中增加 tag / rule

    <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 中补充:

    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 {}
      }
    }
    

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

    <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: 加载标签列表

    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: 本地构建验证

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

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

  • Step 6: Commit

    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

    sed -n '1,80p' admin-web/src/views/Conversation.vue
    
  • Step 2: 在表格操作列增加“绑定账号”按钮

    <el-button link type="primary" @click="openBindAccountDialog(row)">绑定账号</el-button>
    
  • Step 3: 新增绑定抽屉/弹窗

    <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: 实现数据与接口调用

    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,补充:

    export function bindConversationAccount(conversationId: number, data: any) {
      return request.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
    }
    
  • Step 6: 构建验证

    cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
    npm run build
    
  • Step 7: Commit

    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: 重启服务确保最新代码生效

    cd /Users/marsal/Projects/sino-project/sino-mci
    docker compose down
    docker compose up -d --build
    
  • Step 2: 等待服务 healthy

    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/ConversationSendRecord.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 末尾或新建文件记录验证结果。

    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.