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

578 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 渠道账号、会话与发送任务数据边界落地计划
> **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.