wip: baseline changes before channel-conversation-task boundary implementation

This commit is contained in:
2026-07-08 16:23:09 +08:00
parent 89c9e6a066
commit 1298a558e8
139 changed files with 11781 additions and 666 deletions

View File

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

View File

@@ -0,0 +1,178 @@
# 渠道账号、会话与发送任务数据边界设计
> **版本**v1.0
> **日期**2026-07-08
> **状态**:已确认,待实施
> **关联文档**`docs/architecture/2026-07-06-message-customer-interaction-design.md`
---
## 1. 背景与目标
`2026-07-06-message-customer-interaction-design.md` 已完成整体架构与数据模型设计的基础上,进一步明确中台与业务系统之间的数据边界,以及“配置 + 选取人员 → 任务发送”的最小可用实现路径。
**本次目标**
- 业务系统通过中台 API 维护人员Person、渠道身份ChannelIdentity、会话Conversation、群聊关系
- 中台只保存渠道侧最小数据集,业务属性(机构、供应商、合同等)由业务系统写入;
- 企微官方接口只能拿到有限信息(`userid``external_userid``chat_id`、群名等),同步接口不得覆盖业务系统写入的字段;
- 所有出站发送通过 `/api/v1/send` 任务接口驱动,任务与发送日志合并为 `mci_send_record`
- 当前阶段不拆分 `openapi` 包,所有修改在现有 `mci-server``admin-web` 内完成;跑通后下一阶段再独立拆分第三方接入包。
---
## 2. 数据边界设计
### 2.1 中台保存 vs 业务系统维护
| 数据 | 中台保存 | 业务系统维护 | 说明 |
|------|---------|-------------|------|
| 人员基础信息 `mci_person` | 是 | 可同步 | 中台保存 `person_code``person_name``phone``person_type`;业务系统可写入或同步,中台不强制唯一来源。 |
| 业务属性(机构/供应商/合同) | 是字段,业务系统写入 | 是 | `institution_id``supplier_id``contract_id` 由业务系统赋值,企微同步不覆盖。 |
| 渠道身份 `mci_channel_identity` | 是 | 可同步 | 中台保存渠道用户 ID`userid``external_userid`、个微 `wxid` 等),属于渠道侧最小数据集。 |
| 会话 `mci_conversation` | 是 | 可同步/创建 | 中台保存 `channel_conversation_id`、群名、群主等;业务属性由业务系统写入。 |
| 标签 `mci_tag` | 是 | 可同步 | 标签表达角色、合同、机构、供应商等业务属性,支持层级;业务系统可通过 API 维护。 |
| 账号-会话绑定 `mci_channel_account_conversation` | 是 | 否 | 发送主备、接收主备属于渠道调度能力,由中台管理。 |
| 发送内容 | 否(接收最终内容) | 是 | 业务系统渲染最终消息后调用中台发送接口。 |
| 模板与变量 | 否 | 是 | 中台不保存模板。 |
### 2.2 企微同步的边界
`ChannelAccountService.syncContacts``syncConversations` 只能写入/更新以下字段:
- `mci_person``person_name`(企微返回的昵称/姓名)、`phone`(如有)
- `mci_channel_identity``channel_user_id``channel_user_name``subject_id``channel_type`
- `mci_conversation``conversation_type``channel_type``channel_conversation_id``conversation_name``owner_account_id``subject_id``create_source=1`
- `mci_conversation_participant`:群成员关系
**禁止行为**
- 不得覆盖 `institution_id``supplier_id``contract_id`
- 不得删除业务系统已绑定的标签;
- 不得修改 `channel_account_conversation` 中业务系统未授权的主备绑定。
---
## 3. 发送任务模型
沿用 `mci_send_record` 单表设计:一次 API 调用产生一个 `task_id`,每个最终目标(人/会话)一条记录。
### 3.1 支持的目标类型
| target_type | 说明 | target_value 示例 |
|-------------|------|-------------------|
| `conversation` | 单个会话 | `{"conversation_id": 1001}` |
| `person` | 单个联系人(单聊) | `{"person_id": 2001}` |
| `person_list` | 联系人列表 | `{"person_ids": [2001, 2002]}` |
| `tag` | 标签人群/群聊 | `{"tag_id": 3001, "scope": "all"}` |
| `rule` | 复合受众规则 | `{"logic": "and", "rules": [...]}` |
### 3.2 渠道策略
`channel_strategy` 描述渠道选择偏好:
```json
{
"channel_type": "wecom",
"strategy": "primary",
"fallback": true
}
```
中台根据目标解析结果选择实际账号:
- 群聊:从 `mci_channel_account_conversation` 取发送主号,主号不可用 fallback 到备用号;
- 单聊:取目标人对应的 `ChannelIdentity`,再选择默认 `ChannelAccount`
### 3.3 结果回调
发送请求可携带 `callback_url`,任务完成后按条或按任务聚合回调业务系统。
---
## 4. 本次实施范围(方案 A
### 4.1 后端
1. **校验与边界保护**
-`ConversationService``ChannelAccountService` 的同步方法中增加“业务字段保护”:企微同步不覆盖 `institution_id``supplier_id``contract_id`
-`SendService.resolveByType` 中增加目标存在性、租户隔离、账号可用性校验。
2. **账号-会话绑定补全**
- 确认 `ConversationController.bindAccount` 支持 `binding_type`1 发送主号、2 发送备用号、3 接收主号、4 接收备用号。
- 确认 `SendService.selectSendAccount` 按主备顺序选择。
3. **发送任务端到端**
- 验证 `POST /api/v1/send``POST /api/v1/send/test` 可正常创建任务、拆解记录、返回 `task_id`
- 验证 `SendMessageConsumer` 可调用 `ChannelAdapter` 实际发送并回写状态。
4. **渠道账号初始化**
- 验证 `POST /api/v1/channel-account/init` 支持企微内部账号、企微应用、个微、钉钉/飞书机器人;返回初始化结果或二维码信息。
5. **会话/群聊同步**
- 验证 `POST /api/v1/channel-account/{account_id}/sync-conversations` 能拉取账号所在群聊并创建 `Conversation``ConversationParticipant``ChannelAccountConversation` 默认绑定。
### 4.2 前端
1. **发送页面完善**
- `SendRecord.vue` 已支持测试发送和记录查询,补充“选择标签发送”和“选择规则发送”的测试入口。
- 在会话/人员列表中提供“发送消息”快捷入口,跳转或填充 `SendRecord.vue` 测试表单。
2. **账号-群绑定页面**
-`Conversation.vue` 或新增页面中,支持为会话绑定发送主备号、接收主备号。
3. **标签层级展示**
- `Tag.vue` 已支持层级,确认在人员/会话绑定时可以展示多级标签并直接选择。
4. **管理后台权限**
- 确认租户管理员、运营操作员、只读用户三类角色已落地,页面按钮按角色显隐。
### 4.3 测试验证
1. **单元/接口测试**
- 发送目标解析conversation、person、tag、rule
- 同步边界:企微同步不覆盖业务字段;
- 主备切换:发送主号不可用时启用备用号。
2. **端到端流程**
- 使用 mock 渠道适配器或企微测试企业号,完成:
1. 创建租户/用户;
2. 创建渠道主体;
3. 初始化渠道账号;
4. 同步/创建会话;
5. 绑定发送账号;
6. 调用发送任务;
7. 查询发送记录与统计。
---
## 5. 验收标准
- [ ] 第三方平台可通过 API 完成:创建 Person、绑定 ChannelIdentity、创建/更新 Conversation、绑定标签、绑定发送账号
- [ ] 企微 `sync-contacts` / `sync-conversations` 不覆盖 `institution_id``supplier_id``contract_id`
- [ ] `POST /api/v1/send` 支持 conversation / person / person_list / tag / rule 五种目标类型;
- [ ] 发送任务按目标拆解为 `mci_send_record` 记录,可查询任务状态、记录列表、统计;
- [ ] 前端可在“会话/人员/标签”维度完成测试发送,并查看发送记录;
- [ ] Docker 全栈服务健康,接口可通。
---
## 6. 未来工作
下一阶段(方案 A 验证通过后)将拆分独立的 `openapi` 包:
- 路径前缀 `/api/v1/open/**`
- 面向业务系统的 DTO 更严格,隐藏内部状态字段;
- 独立的鉴权方式(`X-Tenant-Code` + `X-App-Key`
- 管理后台 API 与第三方 API 代码分离,便于文档生成和权限控制。
---
## 7. 关键文件清单
| 模块 | 文件 |
|------|------|
| 发送核心 | `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java` |
| 发送控制器 | `backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java` |
| 会话管理 | `backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java` |
| 渠道账号 | `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java` |
| 人员管理 | `backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java` |
| 发送页面 | `admin-web/src/views/SendRecord.vue` |
| 会话页面 | `admin-web/src/views/Conversation.vue` |
| 标签页面 | `admin-web/src/views/Tag.vue` |

View File

@@ -0,0 +1,326 @@
# sino-mci 测试补齐计划
> **日期**2026-07-07
> **范围**backendmci-server单元/集成测试、API 冒烟测试、前端手工测试、Chrome DevTools 测试场景、Docker 部署验证
> **执行标准命令**:见第 7 节
---
## 1. 单元测试(已存在)
| 测试类 | 路径 | 覆盖内容 |
|--------|------|---------|
| `PolicyEngineTest` | `send/service/PolicyEngineTest.java` | 频率限制rate_limit、时间窗time_window、黑名单blacklist及组合策略的允许/拒绝判定 |
| `SendServiceFailoverTest` | `send/service/SendServiceFailoverTest.java` | 发送记录主备账号切换、全部账号不可用、无健康账号三种场景下的 `SendService.dispatchRecord` |
| `AccountSelectorTest` | `channel/service/AccountSelectorTest.java` | 会话发送账号选择:主号健康、主号不健康切备用、`backup` 策略优先、无健康账号返回 null |
| `FlowEngineTest` | `inbound/engine/FlowEngineTest.java` | 入站流程引擎:条件分支命中 webhook、未命中执行 reply、无发布流程时标记已处理 |
| `IntentRecognitionServiceTest` | `inbound/intent/IntentRecognitionServiceTest.java` | 规则 + 槽位抽取的意图识别,无规则/无 LLM 时返回 `unknown` |
| `InboundContextServiceTest` | `inbound/service/InboundContextServiceTest.java` | 根据会话/联系人解析业务上下文,合并时优先保留会话维度字段 |
| `WebhookPayloadBuilderTest` | `inbound/webhook/WebhookPayloadBuilderTest.java` | 入站 webhook payload 结构、方向映射、`null` 意图/空白 JSON 处理 |
| `WebhookSignatureServiceTest` | `inbound/webhook/WebhookSignatureServiceTest.java` | HMAC-SHA256/512 签名、默认算法、不同 secret/算法签名差异 |
| `AccountControllerTest` | `admin/controller/AccountControllerTest.java` | 租户创建接口 |
| `ChannelControllerTest` | `channel/controller/ChannelControllerTest.java` | 渠道主体与渠道账号创建、企微回调 echo 校验 |
| `ChannelAccountHeartbeatTest` | `channel/ChannelAccountHeartbeatTest.java` | 心跳更新账号在线/风险状态、超时离线检测 |
| `WeComSessionArchiveTest` | `channel/controller/WeComSessionArchiveTest.java` | 企微 `msgaudit_notify` 回调、会话存档拉取并保存消息事件/媒体记录 |
| `WeComSyncTest` | `channel/controller/WeComSyncTest.java` | 企微通讯录同步(人员+渠道身份)、群聊同步(会话+参与者+账号绑定) |
| `CrmControllerTest` | `crm/controller/CrmControllerTest.java` | 联系人、标签、会话的创建与列表查询 |
| `IvrFlowControllerTest` | `flow/controller/IvrFlowControllerTest.java` | IVR 流程的增删改查、发布、下线生命周期 |
| `InboundFlowControllerTest` | `inbound/controller/InboundFlowControllerTest.java` | 入站流程创建/发布、接收消息事件 |
| `SendControllerTest` | `send/controller/SendControllerTest.java` | 发送接口:单聊发送、规则目标 `and/or` 逻辑、发送记录查询 |
| `SendPreviewControllerTest` | `send/controller/SendPreviewControllerTest.java` | 发送预览:会话/规则目标、渠道拆分、账号用量、黑名单命中但不做限流计数 |
| `SendMessageConsumerTest` | `send/mq/SendMessageConsumerTest.java` | MQ 异步消费:成功回调、失败重试、超过最大重试后的失败回调 |
| `TenantWebhookControllerTest` | `admin/controller/TenantWebhookControllerTest.java` | 租户 webhook 配置查询/更新、测试推送成功/失败 |
---
## 2. 集成测试(补齐)
### 2.1 现有集成测试缺口
- 现有 controller 级测试大多聚焦单接口或局部链路,缺少**从租户创建到发送记录生成**的完整出站链路。
- 缺少**从消息事件接收到 webhook 推送**的完整入站链路验证。
### 2.2 新增集成测试
#### `SendEndToEndTest.java`
- **路径**`backend/mci-server/src/test/java/com/sino/mci/send/service/SendEndToEndTest.java`
- **技术**`@SpringBootTest` + `@AutoConfigureMockMvc` + `@Transactional`直接复用真实数据库Docker 中 MySQL
- **覆盖步骤**
1. 创建租户并登录,获取 `authToken`
2. 创建渠道主体wecom
3. 创建渠道账号并关联主体;
4. 创建会话并关联主体;
5. 绑定账号为会话发送主号;
6. 调用 `/api/v1/send/test` 模拟发送;
7. 查询 `/api/v1/send/{taskId}/records`,校验记录数、状态、渠道类型、会话/账号 ID、内容。
#### `InboundEndToEndTest.java`
- **路径**`backend/mci-server/src/test/java/com/sino/mci/inbound/engine/InboundEndToEndTest.java`
- **技术**`@SpringBootTest` + `@AutoConfigureMockMvc` + `@Transactional`,通过 `@TestConfiguration``RestTemplate` bean 替换为 mock。
- **覆盖步骤**
1. 创建租户并登录;
2. 配置租户入站 webhook URL 与签名密钥;
3. 创建并发布入站流程:`receive -> intent -> webhook`
4. 调用 `/api/v1/inbound/receive` 发送消息事件;
5. 校验返回事件 `isProcessed=1`
6. 验证 `RestTemplate.exchange` 被调用,且目标 URL 为租户配置的 webhook URL。
---
## 3. API 冒烟测试
> 基础地址:`http://localhost:8080/msg-platform`
> 所有业务接口需携带 `X-Tenant-Code` 与 `Authorization: Bearer <token>`。
### 3.1 健康检查
```bash
curl -fsS http://localhost:8080/msg-platform/api/v1/health
```
### 3.2 创建租户
```bash
curl -X POST http://localhost:8080/msg-platform/api/v1/account/tenant \
-H 'Content-Type: application/json' \
-d '{"tenantCode":"rescue","tenantName":"救援业务"}'
```
### 3.3 登录(获取 token
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/msg-platform/api/v1/account/login \
-H 'X-Tenant-Code: rescue' \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | jq -r '.data.token')
echo "Bearer $TOKEN"
```
### 3.4 渠道主体 CRUD
```bash
curl -X POST http://localhost:8080/msg-platform/api/v1/channel-subject \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"channelType":"wecom","subjectCode":"sino","subjectName":"Sino"}'
curl -s http://localhost:8080/msg-platform/api/v1/channel-subject/list \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN"
```
### 3.5 渠道账号创建
```bash
curl -X POST http://localhost:8080/msg-platform/api/v1/channel-account \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"accountType":1,"channelType":"wecom","accountId":"ZhangSan","accountName":"张三"}'
```
### 3.6 会话创建与账号绑定
```bash
CONV_ID=$(curl -s -X POST http://localhost:8080/msg-platform/api/v1/conversation \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"conversationType":2,"channelType":"wecom","channelConversationId":"room-001","conversationName":"测试群"}' | jq -r '.data.id')
curl -X POST "http://localhost:8080/msg-platform/api/v1/conversation/$CONV_ID/bind-account" \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"accountId":1,"bindingType":1,"sortOrder":1}'
```
### 3.7 发送消息(模拟发送)
```bash
TASK_ID=$(curl -s -X POST http://localhost:8080/msg-platform/api/v1/send/test \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":$CONV_ID},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}" | jq -r '.data.taskId')
curl -s "http://localhost:8080/msg-platform/api/v1/send/$TASK_ID/records" \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN"
```
### 3.8 入站消息接收
```bash
curl -X POST http://localhost:8080/msg-platform/api/v1/inbound/receive \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"eventId":"evt-001","channelType":"wecom","eventType":"message","messageSource":"realtime","conversationId":1,"personId":1,"content":{"type":"text","body":"我要救援"}}'
```
### 3.9 入站流程管理
```bash
curl -X POST http://localhost:8080/msg-platform/api/v1/inbound/flow \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"flowCode":"inbound_default","flowName":"默认入站流程","flowType":"inbound","definitionJson":{"nodes":[{"id":"n1","type":"receive"},{"id":"n2","type":"intent"},{"id":"n3","type":"webhook"}],"edges":[{"source":"n1","target":"n2"},{"source":"n2","target":"n3"}]}}'
curl -X POST http://localhost:8080/msg-platform/api/v1/inbound/flow/inbound_default/publish \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN"
```
### 3.10 租户 webhook 配置
```bash
curl -X POST http://localhost:8080/msg-platform/api/v1/account/tenant/webhook \
-H 'X-Tenant-Code: rescue' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"inbound_webhook_url":"https://example.com/webhook","auth_config":{"sign_algorithm":"hmac-sha256","webhook_secret":"secret-123"}}'
```
---
## 4. 前端手工测试
前端地址:`http://localhost`
| 页面 | 路由 | 测试步骤 | 预期结果 |
|------|------|---------|---------|
| 登录 | `/login` | 输入租户编码、用户名 `admin`、密码 `admin123` 登录 | 跳转 `/dashboard`,本地存储 token |
| 渠道主体 | `/channel-subject` | 新增 wecom 主体 → 保存 → 刷新列表 → 编辑主体名称 | 列表展示新增/更新后的主体,无报错 |
| 渠道账号 | `/channel-account` | 选择主体 → 新增企微内部账号 → 保存 → 触发同步通讯录/群聊 | 账号列表更新,同步返回成功提示 |
| 会话/群聊 | `/conversation` | 新增群聊 → 绑定发送/接收账号 → 绑定标签 | 会话详情正确显示绑定关系 |
| 发送策略 | `/send-policy` | 新建策略组 → 添加限流/时间窗/黑名单规则 → 保存 | 策略列表展示,规则 JSON 可预览 |
| 入站 Webhook | `/inbound-webhook` | 配置 URL 与签名密钥 → 点击测试推送 | 页面提示推送结果200/失败原因) |
| 入站流程编排 | `/inbound-flow` | 拖拽 receive / intent / condition / webhook 节点 → 连线 → 保存并发布 | 流程定义持久化,发布状态变为已发布 |
| 发送记录 | `/send-record` | 发送消息后进入页面 → 按任务 ID/时间/状态筛选 | 列表展示发送记录及状态 |
| IVR 流程 | `/ivr-flow` | 新建 IVR 流程 → 配置 start / menu 节点 → 保存/发布/下线 | 生命周期状态正常切换 |
| 消息测试 | `/message-test` | 选择会话 → 填写内容 → 点击发送 | 返回任务 ID发送记录可查询 |
---
## 5. Chrome DevTools 测试场景
### 5.1 通用检查项
1. 打开 `DevTools → Console`,切换页面/提交表单,确认无未处理异常。
2. 打开 `Network` 面板,过滤 `XHR/Fetch`,确认所有 API 请求:
- 携带 `X-Tenant-Code`
- 携带 `Authorization: Bearer <token>`
- 返回 `code: 0`(业务成功)。
3. 检查请求/响应 Payload 与架构文档字段是否一致snake_case / camelCase
4. 模拟弱网:在 `Network` 面板选择 `Slow 3G`,观察加载状态与超时提示。
5. 模拟后端 500`Network` 面板对 `/api/v1/send/test` 请求右键 `Block request URL`,观察前端错误处理。
### 5.2 页面级场景
| 页面 | DevTools 操作 | 验证点 |
|------|---------------|--------|
| `/login` | 登录后查看 `Application → Local Storage` | `auth-store` 中保存 token、tenantCode、userInfo |
| `/channel-subject` | 新增主体后查看 Network | POST `/api/v1/channel-subject` 返回 `code: 0`,列表 GET 包含新主体 |
| `/channel-account` | 点击同步通讯录 | POST `/api/v1/channel-account/{id}/sync-contacts` 返回 `syncedCount` |
| `/conversation` | 绑定账号 | POST `/api/v1/conversation/{id}/bind-account` 请求体字段正确 |
| `/send-policy` | 保存策略 | POST `/api/v1/policy-group` 请求体 `policies` JSON 与表单一致 |
| `/inbound-webhook` | 点击测试推送 | POST `/api/v1/account/tenant/webhook/test` 返回 `success``message` |
| `/inbound-flow` | 保存/发布流程 | POST `/api/v1/inbound/flow``/publish` 顺序正确payload 含 nodes/edges |
| `/message-test` | 发送消息 | POST `/api/v1/send/test` 返回 `taskId`,随后 GET `/api/v1/send/{taskId}/records` 返回状态 2 |
| `/send-record` | 筛选/分页 | GET `/api/v1/send/records` 含 page/size 参数,响应 `records` 非空 |
| `/ivr-flow` | 发布/下线 | POST `/api/v1/flow/{code}/publish``/offline` 后状态码正确 |
---
## 6. Docker 部署测试
### 6.1 启动基础设施
```bash
cd /Users/marsal/Projects/sino-project/sino-mci
cp .env.example .env
# 按需编辑 .env
docker compose --profile infra up -d
```
等待所有 `infra` 服务 healthy
```bash
docker compose --profile infra ps
```
### 6.2 启动应用服务
```bash
docker compose --profile app up -d
```
### 6.3 验证健康检查
```bash
# MySQL
docker exec mci-mysql mysqladmin ping -h localhost -u root -proot
# Redis
docker exec mci-redis redis-cli ping
# RabbitMQ
docker exec mci-rabbitmq rabbitmq-diagnostics ping
# mci-server
curl -fsS http://localhost:8080/msg-platform/api/v1/health
# channel-service
curl -fsS http://localhost:8000/health
# admin-web通过首页判断
curl -fsS -o /dev/null -w '%{http_code}' http://localhost/
```
### 6.4 查看日志
```bash
docker compose --profile app logs -f mci-server
docker compose --profile app logs -f channel-service
```
---
## 7. 标准测试命令与结果
### 7.1 命令
```bash
cd /Users/marsal/Projects/sino-project/sino-mci/backend
docker run --rm --network sino-mci_mci-network --dns 8.8.8.8 \
-v "$HOME/.m2:/root/.m2" -v "$(pwd):/app" -w /app \
-e SPRING_DATASOURCE_URL='jdbc:mysql://mci-mysql:3306/sino_mci?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true' \
-e SPRING_DATASOURCE_USERNAME=root -e SPRING_DATASOURCE_PASSWORD=root \
-e SPRING_DATA_REDIS_HOST=mci-redis -e SPRING_RABBITMQ_HOST=mci-rabbitmq \
maven:3.9-eclipse-temurin-21-alpine \
mvn -B -pl mci-server -am test
```
### 7.2 运行结果
- **总测试数**59
- **失败**0
- **错误**0
- **跳过**0
- **构建状态**`BUILD SUCCESS`
新增测试类 `SendEndToEndTest``InboundEndToEndTest` 均通过。
---
## 8. 后续可补充项
- 引入 Testcontainers 或 H2 以支持无 Docker 环境的本地单元测试运行。
-`channel-service` 增加 Python 侧端到端测试。
- 补充前端自动化测试Playwright / Cypress覆盖第 4、5 节手工场景。
- 增加性能/稳定性测试高并发发送、MQ 堆积恢复、会话存档大批量拉取。