21 KiB
渠道账号、会话与发送任务数据边界落地计划
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方法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 -qExpected: BUILD SUCCESS
-
Step 5: 写接口回归测试脚本(curl 或 devtools)
- 通过 API 创建 Conversation 并设置
contractId=100; - 调用
sync-conversations; - 查询 Conversation,断言
contractId仍为100。
- 通过 API 创建 Conversation 并设置
-
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: 读取
BindConversationAccountRequest与ConversationService.bindAccountcat 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 -qExpected: 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: 读取
resolveConversationTarget与resolvePersonTargetsed -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 buildExpected: 构建成功,无类型错误。
-
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 psExpected:
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末尾或新建文件记录验证结果。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:
- Subagent-Driven (recommended) - Dispatch a fresh coder subagent per task, review between tasks, fast iteration.
- 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.