diff --git a/.stitch/DESIGN.md b/.stitch/DESIGN.md
new file mode 100644
index 0000000..61057eb
--- /dev/null
+++ b/.stitch/DESIGN.md
@@ -0,0 +1,116 @@
+# Design System: Sino-MCI 消息中台
+
+## 1. Visual Theme & Atmosphere
+
+A modern, high-density administration dashboard with a calm, professional atmosphere. The interface feels like a precision cockpit: information-dense but breathable, predictable but polished. Visual hierarchy is established through restrained color, generous whitespace in headers, and tight data grids in tables. The personality is trustworthy and operational — no marketing fluff, no decorative gradients, no visual noise.
+
+- **Density:** 7/10 — Cockpit-dense tables and forms, airy page headers.
+- **Variance:** 3/10 — Predictable, symmetric layouts for operational efficiency.
+- **Motion:** 4/10 — Fluid, purposeful micro-interactions; no cinematic delays.
+
+## 2. Color Palette & Roles
+
+- **Canvas White** (#F8FAFC) — Primary background surface behind all content.
+- **Pure Surface** (#FFFFFF) — Cards, modals, drawers, table rows, input backgrounds.
+- **Charcoal Ink** (#0F172A) — Primary text, headings, key labels.
+- **Muted Steel** (#64748B) — Secondary text, placeholders, metadata, disabled states.
+- **Whisper Border** (#E2E8F0) — 1px structural borders, table dividers, section separators.
+- **Soft Tint** (#F1F5F9) — Hover backgrounds, zebra striping, subtle section fills.
+- **Signal Blue** (#2563EB) — Single accent for primary buttons, active nav items, focus rings, links, status "online".
+- **Signal Red** (#DC2626) — Destructive actions, errors, failed status.
+- **Signal Green** (#16A34A) — Success states, sent/delivered status.
+- **Signal Amber** (#D97706) — Warnings, pending status, cautions.
+
+No neon glows, no purple accents, no gradient text, no pure black.
+
+## 3. Typography Rules
+
+- **Display / Headlines:** `Geist, "Noto Sans SC", system-ui, sans-serif` — tight tracking, weight-driven hierarchy. H1 24px/700, H2 20px/600, H3 16px/600.
+- **Body:** `Geist, "Noto Sans SC", system-ui, sans-serif` — 14px/1.6 line-height for Chinese readability, max 65ch in prose.
+- **Mono / Data:** `"Geist Mono", "JetBrains Mono", "SF Mono", monospace` — timestamps, IDs, counts, JSON preview. 13px.
+- **Banned:** Inter as the primary font, generic serifs, decorative display fonts.
+
+Numbers in tables and metrics use monospace for alignment.
+
+## 4. Component Stylings
+
+### Buttons
+- **Primary:** Signal Blue fill (#2563EB), white text, 6px border-radius, 32px height, 12px horizontal padding. Active: -1px translateY, darker fill (#1D4ED8). No outer glow.
+- **Secondary:** White fill, Whisper Border, Charcoal Ink text. Hover: Soft Tint background.
+- **Danger:** Signal Red fill or ghost style depending on context.
+- **Text/Link buttons:** Signal Blue text, no underline, hover underline.
+
+### Cards / Sections
+- White surface, 1px Whisper Border, 8px border-radius, subtle shadow `0 1px 3px rgba(15, 23, 42, 0.06)`.
+- Used to group related controls (filter bar, stats row, form sections).
+- In high-density tables, replace nested cards with border-top dividers.
+
+### Inputs / Forms
+- Label above input, 12px/500 Muted Steel.
+- Input height 32px, 6px border-radius, Whisper Border, focus ring 2px Signal Blue at 20% opacity.
+- Error text below input in Signal Red, 12px.
+- No floating labels, no placeholder-only inputs.
+
+### Tables
+- Header: Soft Tint background, 13px/600 Muted Steel uppercase tracking.
+- Rows: White, 44px min-height, hover Soft Tint.
+- Cell padding: 12px 16px.
+- Status tags: small rounded pills with tinted backgrounds.
+- Row actions: icon buttons or link buttons grouped at right.
+
+### Navigation
+- Left sidebar: 220px width, White surface, Whisper Border right.
+- Menu items: 40px height, 12px/14px text, Signal Blue left-border active indicator, Soft Tint active background.
+- Submenu: indented 16px, lighter Muted Steel.
+- Top header: 56px height, White surface, bottom Whisper Border, page title left, global search + user avatar right.
+
+### Drawers / Modals
+- Drawer: 560px width from right, White surface, Whisper Border left, 16px padding.
+- Modal: 520px max-width, 8px border-radius, centered, backdrop rgba(15,23,42,0.4).
+- Close icon top-right.
+
+### Tags / Pills
+- Small rounded pills (12px height, 4px radius) with tinted background.
+- Examples: blue tint for active, gray tint for default, green tint for success, red tint for error.
+
+### Empty States
+- Centered composed illustration + headline + single CTA.
+- No generic "No data" alone; explain what to do next.
+
+## 5. Layout Principles
+
+- **Sidebar + Main:** Fixed left sidebar, main content scrolls. Main has 24px padding.
+- **Page Header:** H1 page title + short description + top-right primary action button.
+- **Filter Bar:** Collapsible card directly below header; inline form items with 12px gaps.
+- **Data Table:** Full-width card below filter bar.
+- **Max Width:** None for tables (fluid); prose max-width 720px.
+- **Grid:** Use CSS Grid for dashboard cards; tables are full-width.
+- **Responsive:** Below 768px, sidebar collapses to hamburger, tables horizontal-scroll with sticky first column, drawers become full-screen.
+- **Spacing Scale:** 4px base. Common: 8, 12, 16, 24, 32, 48.
+
+## 6. Motion & Interaction
+
+- **Spring default:** `cubic-bezier(0.25, 0.46, 0.45, 0.94)` for UI transitions; 150ms for hovers, 200ms for drawer/modal open.
+- **Micro-interactions:**
+ - Buttons: -1px lift on hover, press-down on active.
+ - Table rows: background-color transition on hover.
+ - Status dots: subtle pulse when in "sending" / "online" state.
+ - Drawers: slide from right with backdrop fade.
+- **Loading:** Skeleton shimmer matching exact row dimensions in tables; no generic spinners.
+- **Performance:** Animate only `transform` and `opacity`.
+
+## 7. Anti-Patterns (Banned)
+
+- No emojis in UI copy or icons.
+- No Inter as the primary typeface.
+- No pure black (#000000).
+- No neon/outer glow shadows.
+- No gradient text or hero gradients.
+- No 3-column equal feature cards.
+- No centered hero sections.
+- No AI copywriting clichés: "Seamless", "Elevate", "Unleash", "Next-Gen".
+- No filler text like "Scroll to explore" or bouncing arrows.
+- No fabricated metrics or fake statistics.
+- No overlapping elements — every component has clear spatial separation.
+- No custom mouse cursors.
+- No broken Unsplash links — use abstract SVG patterns or neutral placeholders.
diff --git a/.stitch/designs/sino-mci-prototype.html b/.stitch/designs/sino-mci-prototype.html
new file mode 100644
index 0000000..20af8d8
--- /dev/null
+++ b/.stitch/designs/sino-mci-prototype.html
@@ -0,0 +1,656 @@
+
+
+
+
+
+ Sino-MCI 消息中台 - 完整交互原型
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
最近消息
+
| 时间 | 渠道 | 发送方 | 内容摘要 | 状态 |
+
+ | 10:23 | 企微 | 客户 A | 我要查询订单状态,订单号是 2025... | 待处理 |
+ | 10:18 | 个微 | 客户 B | [图片] 车牌号 京 A12345 | 已归档 |
+ | 10:05 | 企微 | 客户 C | 帮我发一份报价单到邮箱 | 处理中 |
+
+
+
+
+
+
+
+
+
+
+
+
+ | 主体名称 | 渠道类型 | CorpID / 标识 | 应用数 | 账号数 | 状态 |
+
+
+ | Sinocare 企业微信 |
+ 企微 |
+ ww1234567890abcdef |
+ 2 |
+ 18 |
+ 已连接 |
+
+
+ | Sinocare 个人微信 |
+ 个微 |
+ pywechat-001 |
+ - |
+ 3 |
+ 已连接 |
+
+
+
+
+
+
共 2 条
+
+
+
+
+
+
+
+
+
+
+ 企微应用列表
+
+
+
+
AgentID: 1000002
Secret: ••••••••
会话存档: 已开启
+
+
+
+
+
AgentID: 1000005
Secret: ••••••••
主动推送: 已开启
+
+
+
+
+
+
+
+
+
+
+
+
+
| 账号昵称 | UserID | 所属主体 | 部门路径 | 会话存档 | 状态 |
+
+ | 张三 | zhangsan | Sinocare 企业微信 | /销售部/华东区 | 已开启 | 在线 |
+ | 李四 | lisi | Sinocare 企业微信 | /客服部 | 未开启 | 离线 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
| 群名称 | 群ID | 所属主体 | 人数 | 标签 |
+
+ | 华东销售群 | wrABC123 | Sinocare 企业微信 | 128 | 销售群 |
+
+
+
+
+
+
+
+
+
+
+
| 任务ID | 业务系统 | 目标类型 | 目标数 | 发送时间 | 回调地址 | 状态 | 日志 |
+
+ | SND-20250709-001 | CRM | 单聊 | 1,200 | 2025-07-09 09:30 | https://crm.example.com/callback | 成功 | |
+ | SND-20250709-002 | ERP | 群聊 | 45 | 2025-07-09 09:45 | - | 失败 | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
入站消息触发
+
+
LLM 意图识别
+
+
+
注:画布仅示意,真实实现使用 @vue-flow
+
+
+
+
+
+
+
+
+
| 租户编码 | 租户名称 | 负责人 | 业务系统数 | 状态 |
+
+ | TENANT_DEFAULT | 默认租户 | system | 3 | 启用 |
+ | TENANT_001 | Sinocare | 张三 | 2 | 启用 |
+
+
+
+
+
+
+
+
+
+
+
| 用户名 | 角色 | 所属租户 | 手机号 | 状态 |
+
+ | admin | 平台管理员 | 全部 | 138****0000 | 启用 |
+ | ops_01 | 租户管理员 | Sinocare | 139****1111 | 启用 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..4f1f38c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,94 @@
+# Sino-MCI 项目约定
+
+> 本文件用于固定开发/部署时的关键操作,避免反复踩坑。
+
+## 1. Java 与 Maven
+
+- **必须使用 JDK 21**:`/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home`
+- 项目已配置 Maven Toolchain,**必须在 `backend/` 目录下执行完整 reactor 构建**:
+ ```bash
+ cd /Users/marsal/Projects/sino-project/sino-mci/backend
+ mvn clean install -DskipTests
+ ```
+- **不要**用 `-pl` 单模块编译,会跳过 `.mvn/maven.config` 中的 toolchains 配置,导致编译报 JDK 17 错误。
+- 当前环境 `java -version` 可能显示 JDK 17,但 Maven 会通过 toolchain 自动切换到 JDK 21,无需手动修改 `JAVA_HOME`。
+
+## 2. Docker Compose 项目名(重要)
+
+Docker Compose 的 `-p` 参数决定容器前缀和命名卷前缀。反复换 `-p` 会产生多个 MySQL 数据卷,导致数据“丢失”的假象。
+
+**统一使用项目名 `sino-mci`**:
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci
+
+# 启动全部基础设施 + 应用
+# 使用 --profile 同时启用 infra 和 app,否则带 profile 的服务不会启动
+docker compose -p sino-mci --profile infra --profile app up -d
+
+# 只启动基础设施(MySQL/Redis/RabbitMQ/SeaweedFS)
+docker compose -p sino-mci --profile infra up -d
+
+# 只启动应用(mci-server / admin-web / channel-service)
+docker compose -p sino-mci --profile app up -d
+
+# 停止整个项目(不删除数据卷)
+docker compose -p sino-mci --profile infra --profile app down
+```
+
+- 不带 `-p` 时,项目名默认就是目录名 `sino-mci`,与上面等价。
+- **不要**再使用 `-p infrastructure` 或其他名字启动,避免产生新的数据卷。
+
+## 3. 数据卷
+
+当前项目使用的命名卷:
+
+- `sino-mci_mci-mysql-data`
+- `sino-mci_mci-seaweedfs-data`
+- `sino-mci_channel-service-data`
+
+### 检查当前 MySQL 容器挂载的是哪个卷
+
+```bash
+docker inspect -f '{{ range .Mounts }}{{ if eq .Destination "/var/lib/mysql" }}{{ .Name }}{{ end }}{{ end }}' mci-mysql
+```
+
+### 查看所有 sino-mci 相关卷
+
+```bash
+docker volume ls | grep sino-mci
+```
+
+### 历史 `infrastructure_*` 卷
+
+早期用 `-p infrastructure` 启动过,留下以下卷,**目前未被使用**:
+
+- `infrastructure_mci-mysql-data`
+- `infrastructure_mci-seaweedfs-data`
+- `infrastructure_channel-service-data`
+
+如需迁移/核对其中的数据,请先停止当前 `mci-mysql`,再临时挂载该卷启动容器查询,查完立即恢复。
+
+## 4. 常用命令
+
+```bash
+# 查看服务状态
+docker compose -p sino-mci ps
+
+# 查看 mci-server 日志
+docker logs -f mci-server --tail 100
+
+# 进入 MySQL
+docker exec -it mci-mysql mysql -uroot -proot sino_mci
+
+# 进入 Redis
+docker exec -it mci-redis redis-cli
+
+# 重新构建 mci-server 镜像(先执行 mvn clean install -DskipTests)
+docker compose -p sino-mci --profile app build mci-server
+```
+
+## 5. 环境变量
+
+- 复制 `.env.example` 为 `.env` 后再修改本地配置。
+- 默认 root 密码为 `root`,仅本地开发使用。
diff --git a/admin-web/REFACTOR_GUIDE.md b/admin-web/REFACTOR_GUIDE.md
new file mode 100644
index 0000000..bc0a1d8
--- /dev/null
+++ b/admin-web/REFACTOR_GUIDE.md
@@ -0,0 +1,47 @@
+# admin-web 视图重构指南
+
+## 目标
+把现有 Element Plus 页面统一为新的 Sino-MCI 设计语言:现代、统一、操作便捷、信息密度高。
+
+## 设计参考
+参考原型:`.stitch/designs/sino-mci-prototype.html`
+
+## 通用规范
+
+### 页面结构
+1. 顶部工具栏:左侧放搜索/筛选,右侧放唯一主操作按钮(新增/创建/测试发送等)。
+2. 内容区:白色卡片(`el-card` 或自定义 `.bg-white rounded-xl border`)包裹表格。
+3. 表格:
+ - 删除无意义复选框;
+ - 删除每行多个操作按钮;
+ - 行可点击,点击后从右侧滑出抽屉(`el-drawer`)查看/编辑详情;
+ - 保留状态开关、标签等必要展示字段;
+ - 需要时显示分页(`el-pagination`)。
+4. 新增用弹窗(`el-dialog`),编辑用抽屉,保持两者形式统一:标题栏 + 表单 + 底部确认/取消。
+
+### 视觉
+- 主色:`#2563EB`
+- 背景:`#F8FAFC`
+- 卡片/表面:`#FFFFFF`,圆角 12px,边框 `#E2E8F0`
+- 文字主色:`#0F172A`,次要:`#64748B`
+- 字号:正文 14px,标题 16-18px,辅助 12-13px
+- 间距:页面内边距 24px,卡片内部 16-20px
+
+### 交互
+- 行 hover 变灰(`hover:bg-slate-50` 等效样式)。
+- 按钮层级:主操作 `type="primary"`,次级 `link` 或默认。
+- 删除操作放在抽屉底部或二次确认弹窗中,不要直接显示在列表行。
+- 状态变更尽量使用行内 `el-switch`,切换后调用 API 并刷新列表。
+
+### 代码约束
+- 保留原有 API 调用和业务逻辑,只改模板和样式。
+- 使用 `
diff --git a/admin-web/src/components/PageCard.vue b/admin-web/src/components/PageCard.vue
new file mode 100644
index 0000000..f2d2e61
--- /dev/null
+++ b/admin-web/src/components/PageCard.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts
index 84472df..528791c 100644
--- a/admin-web/src/router/index.ts
+++ b/admin-web/src/router/index.ts
@@ -2,19 +2,16 @@ import { createRouter, createWebHistory } from 'vue-router'
import Layout from '../components/Layout.vue'
import Dashboard from '../views/Dashboard.vue'
import ChannelSubject from '../views/ChannelSubject.vue'
+import ChannelApp from '../views/ChannelApp.vue'
import ChannelAccount from '../views/ChannelAccount.vue'
-import Conversation from '../views/Conversation.vue'
import Person from '../views/Person.vue'
+import Conversation from '../views/Conversation.vue'
import Tag from '../views/Tag.vue'
import SendRecord from '../views/SendRecord.vue'
+import Messages from '../views/Messages.vue'
import InboundFlow from '../views/InboundFlow.vue'
-import IvrFlow from '../views/IvrFlow.vue'
-import FlowTrace from '../views/FlowTrace.vue'
-import MessageTest from '../views/MessageTest.vue'
import TenantManagement from '../views/TenantManagement.vue'
import UserManagement from '../views/UserManagement.vue'
-import InboundWebhook from '../views/InboundWebhook.vue'
-import SendPolicy from '../views/SendPolicy.vue'
import Login from '../views/Login.vue'
import { useAuthStore } from '../stores/auth'
@@ -30,20 +27,17 @@ const routes = [
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: Dashboard, meta: { title: '概览' } },
- { path: 'channel-subject', component: ChannelSubject, meta: { title: '渠道主体' } },
- { path: 'channel-account', component: ChannelAccount, meta: { title: '渠道账号' } },
- { path: 'conversation', component: Conversation, meta: { title: '会话/群聊' } },
- { path: 'person', component: Person, meta: { title: '联系人' } },
- { path: 'tag', component: Tag, meta: { title: '标签' } },
- { path: 'send-record', component: SendRecord, meta: { title: '发送记录' } },
- { path: 'inbound-flow', component: InboundFlow, meta: { title: '入站流程' } },
- { path: 'ivr-flow', component: IvrFlow, meta: { title: 'IVR 流程' } },
- { path: 'flow-trace', component: FlowTrace, meta: { title: '流程追踪' } },
- { path: 'message-test', component: MessageTest, meta: { title: '消息测试' } },
- { path: 'inbound-webhook', component: InboundWebhook, meta: { title: '入站 Webhook' } },
- { path: 'send-policy', component: SendPolicy, meta: { title: '发送策略' } },
- { path: 'tenant-management', component: TenantManagement, meta: { title: '租户管理' } },
- { path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
+ { path: 'channel/subject', component: ChannelSubject, meta: { title: '渠道主体' } },
+ { path: 'channel/app', component: ChannelApp, meta: { title: '渠道应用' } },
+ { path: 'channel/account', component: ChannelAccount, meta: { title: '渠道账号' } },
+ { path: 'customer/person', component: Person, meta: { title: '联系人' } },
+ { path: 'customer/group', component: Conversation, meta: { title: '群聊' } },
+ { path: 'customer/tag', component: Tag, meta: { title: '标签体系' } },
+ { path: 'message/send-record', component: SendRecord, meta: { title: '发送记录' } },
+ { path: 'message/query', component: Messages, meta: { title: '消息查询' } },
+ { path: 'flow/inbound', component: InboundFlow, meta: { title: '入站流程' } },
+ { path: 'system/tenant', component: TenantManagement, meta: { title: '租户管理' } },
+ { path: 'system/user', component: UserManagement, meta: { title: '用户管理' } },
],
},
]
diff --git a/admin-web/src/stores/auth.ts b/admin-web/src/stores/auth.ts
index 5f74f6c..6b0bdee 100644
--- a/admin-web/src/stores/auth.ts
+++ b/admin-web/src/stores/auth.ts
@@ -30,6 +30,11 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('mci-role-codes', JSON.stringify(roles))
}
+ function setTenant(code: string) {
+ tenantCode.value = code
+ localStorage.setItem('mci-tenant-code', code)
+ }
+
function clearAuth() {
token.value = ''
tenantCode.value = ''
@@ -41,5 +46,5 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.removeItem('mci-role-codes')
}
- return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
+ return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, setTenant, clearAuth }
})
diff --git a/admin-web/src/style.css b/admin-web/src/style.css
index 76a7198..aa68bd0 100644
--- a/admin-web/src/style.css
+++ b/admin-web/src/style.css
@@ -4,7 +4,7 @@
body {
margin: 0;
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-family: 'Inter', 'Noto Sans SC', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#app {
@@ -12,6 +12,65 @@ body {
min-height: 100vh;
}
+/* Sino-MCI shared page styles */
+.sino-page {
+ min-height: 100%;
+}
+
+.sino-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.sino-toolbar-left,
+.sino-toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.sino-card {
+ background: #ffffff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.sino-pagination {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ padding: 12px 20px;
+ background: #ffffff;
+ border-top: 1px solid #f1f5f9;
+}
+
+/* Table header unify */
+.sino-table .el-table__header-wrapper th.el-table__cell {
+ background: #f8fafc !important;
+ color: #64748b !important;
+ font-weight: 500 !important;
+}
+
+.sino-table .el-table__row {
+ cursor: pointer;
+}
+
+/* Status cell */
+.status-cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.status-text {
+ font-size: 13px;
+ color: #475569;
+}
+
.mt-4 {
margin-top: 20px;
}
diff --git a/admin-web/src/views/ChannelAccount.vue b/admin-web/src/views/ChannelAccount.vue
index 0c30cbb..cfb9534 100644
--- a/admin-web/src/views/ChannelAccount.vue
+++ b/admin-web/src/views/ChannelAccount.vue
@@ -1,10 +1,22 @@
-
-
- 创建渠道账号
-
+
+
+
+
+
+
+
+
+
+ 新增账号
+
-
+
@@ -16,48 +28,50 @@
{{ channelLabel(row.channelType) }}
-
-
+
+
-
+
{{ row.loginStatus === 1 ? '已登录' : '未登录' }}
-
- {{ row.onlineStatus === 1 ? '在线' : '离线' }}
+
+
+ {{ row.onlineStatus === 1 ? '在线' : '离线' }}
+
{{ row.lastHeartbeat }}
-
- {{ row.status === 1 ? '启用' : '禁用' }}
-
-
-
-
-
- 编辑
- 删除
handleStatusChange(row, val)"
/>
- 初始化
- 同步会话
- 刷新状态
-
+
+
+
+
+
@@ -101,6 +115,63 @@
+
+
+
+ 初始化
+ 同步联系人
+ 同步会话
+ 刷新状态
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/admin-web/src/views/ChannelSubject.vue b/admin-web/src/views/ChannelSubject.vue
index ceacfda..7af762c 100644
--- a/admin-web/src/views/ChannelSubject.vue
+++ b/admin-web/src/views/ChannelSubject.vue
@@ -1,46 +1,64 @@
-
-
- 创建渠道主体
-
+
+
+
+ 查询
+
+
+ 新增主体
+
-
-
-
+
+
+
- {{ channelLabel(row.channelType) }}
-
-
-
-
-
-
-
-
- {{ row.status === 1 ? '启用' : '禁用' }}
+
+ {{ channelLabel(row.channelType) }}
-
+
+
+
- 编辑
- 删除
- handleStatusChange(row, val)"
- />
- 详情
+
+ handleStatusChange(row, val)"
+ />
+ {{ row.status === 1 ? '启用' : '禁用' }}
+
-
+
+
+
+
+
+
-
+
@@ -55,9 +73,6 @@
-
-
-
@@ -77,26 +92,61 @@
-
-
- {{ currentSubject.id }}
- {{ channelLabel(currentSubject.channelType) }}
- {{ currentSubject.subjectCode }}
- {{ currentSubject.subjectName }}
- {{ mask(currentSubject.corpId) }}
- {{ mask(currentSubject.corpSecret) }}
- {{ mask(currentSubject.agentId) }}
- {{ mask(currentSubject.agentSecret) }}
- {{ currentSubject.sessionArchiveEnabled === 1 ? '开启' : '关闭' }}
- {{ currentSubject.status === 1 ? '启用' : '禁用' }}
- {{ currentSubject.createTime }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/admin-web/src/views/Conversation.vue b/admin-web/src/views/Conversation.vue
index 5db3984..b577b3a 100644
--- a/admin-web/src/views/Conversation.vue
+++ b/admin-web/src/views/Conversation.vue
@@ -1,10 +1,26 @@
-
-
- 创建会话
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增会话
+
-
+
@@ -19,35 +35,75 @@
{{ formatAccountName(row.ownerAccountId) }}
-
+
-
- {{ row.status === 1 ? '启用' : '禁用' }}
-
-
-
-
-
-
-
编辑
-
删除
+
handleStatusChange(row, val)"
/>
-
-
- 绑定账号
- 绑定标签
+ {{ row.status === 1 ? '启用' : '禁用' }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
@@ -84,11 +140,23 @@
/>
+
+
+
+
+
关联信息
+
+ 绑定账号
+ 绑定标签
+
+
+
- 取消
+ 取消
+ 删除
保存
-
+
@@ -138,11 +206,12 @@
确认
-
+
diff --git a/admin-web/src/views/Dashboard.vue b/admin-web/src/views/Dashboard.vue
index fba6dc9..777a75f 100644
--- a/admin-web/src/views/Dashboard.vue
+++ b/admin-web/src/views/Dashboard.vue
@@ -1,41 +1,50 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 使用说明
- 1. 先在「渠道主体」录入企微主体信息。
- 2. 在「渠道账号」添加发送/接收账号。
- 3. 在「会话/群聊」维护业务群并绑定发送账号。
- 4. 在「发送记录」查看业务系统调用发送 API 的结果。
-
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
1. 先在「渠道主体」录入企微主体信息。
+
2. 在「渠道账号」添加发送/接收账号。
+
3. 在「会话/群聊」维护业务群并绑定发送账号。
+
4. 在「发送记录」查看业务系统调用发送 API 的结果。
+
+
diff --git a/admin-web/src/views/InboundFlow.vue b/admin-web/src/views/InboundFlow.vue
index 834af80..3791f19 100644
--- a/admin-web/src/views/InboundFlow.vue
+++ b/admin-web/src/views/InboundFlow.vue
@@ -1,209 +1,281 @@
-
-
-
-
-
-
-
-
-
-
-
-
- {{ statusText(scope.row.status) }}
-
-
-
-
-
- v{{ scope.row.version || 1 }}
-
-
-
-
+
+
+
+
+ 新建流程
+
+
-
-
-
-
+
+
+
+
+
+
- {{ item.label }}
+
+
+
+
+
+ {{ statusText(scope.row.status) }}
+
+
+
+
+
+ v{{ scope.row.version || 1 }}
+
+
+
+
+
+
+
-
-
-
-
-
-
- 保存
- 发布
- 下线
- 删除
- 清空画布
-
-
-
-
-
-
-
-
-
-
{{ nodeTypeIcon(nodeProps.data.nodeType) }}
-
{{ nodeProps.label }}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认
+
+
+
+
+
+
+
+ 流程编码
+ {{ selectedFlow.flowCode }}
+
+
+ 流程名称
+ {{ selectedFlow.flowName }}
+
+
+ 状态
+
+ {{ statusText(selectedFlow.status) }}
+
+
+
+ 版本
+ v{{ selectedFlow.version || 1 }}
+
+
+
+
+
+
diff --git a/admin-web/src/views/Messages.vue b/admin-web/src/views/Messages.vue
new file mode 100644
index 0000000..43cebe5
--- /dev/null
+++ b/admin-web/src/views/Messages.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
{{ conv.name }}
+
{{ conv.preview }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-web/src/views/Person.vue b/admin-web/src/views/Person.vue
index 5b8e285..f7fda42 100644
--- a/admin-web/src/views/Person.vue
+++ b/admin-web/src/views/Person.vue
@@ -1,48 +1,63 @@
-
-
- 创建联系人
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增联系人
+
-
+
-
+
{{ formatPersonType(row.personType) }}
-
+
-
- {{ row.status === 1 ? '启用' : '禁用' }}
-
-
-
-
-
-
-
编辑
-
删除
+
handleStatusChange(row, val)"
/>
-
-
- 渠道身份
- 绑定标签
+ {{ row.status === 1 ? '启用' : '禁用' }}
-
+
+
+
+
+
@@ -61,7 +76,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
关联信息
+
+ 管理渠道身份
+ 绑定标签
+
+
+
+
+ 取消
+ 删除
+ 保存
+
+
+
-
+
@@ -128,11 +190,12 @@
确认
-
+
diff --git a/admin-web/src/views/SendRecord.vue b/admin-web/src/views/SendRecord.vue
index ee93354..432a9ad 100644
--- a/admin-web/src/views/SendRecord.vue
+++ b/admin-web/src/views/SendRecord.vue
@@ -1,14 +1,14 @@
-
+
-
+
-
+
-
+
成功
@@ -17,7 +17,7 @@
-
+
失败
@@ -26,7 +26,7 @@
-
+
待发送
@@ -36,11 +36,84 @@
-
-
-
- 测试发送
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+ 测试发送
+
+
+
+
+
+
+
+
+ 待发送
+ 发送中
+ 成功
+ 失败
+ 未知
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -97,94 +170,12 @@
-
- 测试发送
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 查询
- 重置
-
-
-
-
-
-
-
-
-
-
-
-
- 待发送
- 发送中
- 成功
- 失败
- 未知
-
-
-
-
-
-
- 详情
-
- 取消
-
-
-
-
-
-
-
+
+ 取消
+ 确认发送
+
+
@@ -226,6 +217,12 @@
{{ currentRecord.finishTime || '-' }}
+
+
+
@@ -242,6 +239,7 @@ import {
} from '@/api/sendRecord'
import { listConversations } from '@/api/channel'
import { listTags } from '@/api/crm'
+import PageCard from '@/components/PageCard.vue'
interface RecordItem {
id: number
@@ -282,6 +280,7 @@ const records = ref
([])
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref(null)
+const testDialogVisible = ref(false)
const testForm = reactive({
targetType: 'conversation',
@@ -297,6 +296,10 @@ const textContent = ref('')
const conversations = ref([])
const tags = ref([])
+function openTestDialog() {
+ testDialogVisible.value = true
+}
+
function buildTargetValue() {
if (testForm.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
@@ -392,6 +395,7 @@ async function handleTestSend() {
})
ElMessage.success('测试发送已提交')
textContent.value = ''
+ testDialogVisible.value = false
await loadRecords()
await loadStatistics()
} catch (e: any) {
@@ -404,6 +408,7 @@ async function handleCancel(taskId: string) {
await ElMessageBox.confirm('确定取消该任务的待发送记录?', '提示', { type: 'warning' })
await cancelTask(taskId)
ElMessage.success('取消成功')
+ detailVisible.value = false
await loadRecords()
await loadStatistics()
} catch (e: any) {
@@ -459,13 +464,23 @@ onMounted(() => {
diff --git a/admin-web/src/views/Tag.vue b/admin-web/src/views/Tag.vue
index 33d6a4d..b159fa0 100644
--- a/admin-web/src/views/Tag.vue
+++ b/admin-web/src/views/Tag.vue
@@ -1,10 +1,22 @@
-
-
- 创建标签
-
+
+
+
+
+
+
+
+
+
+ 新增标签
+
-
+
@@ -13,31 +25,32 @@
{{ formatLevel(row.level) }}
-
+
-
- {{ row.status === 1 ? '启用' : '禁用' }}
-
-
-
-
-
-
-
编辑
-
删除
+
handleStatusChange(row, val)"
/>
+ {{ row.status === 1 ? '启用' : '禁用' }}
-
+
+
+
+
+
@@ -68,13 +81,48 @@
保存
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 删除
+ 保存
+
+
+
-
-
diff --git a/admin-web/src/views/TenantManagement.vue b/admin-web/src/views/TenantManagement.vue
index d748e46..44b07a3 100644
--- a/admin-web/src/views/TenantManagement.vue
+++ b/admin-web/src/views/TenantManagement.vue
@@ -1,40 +1,57 @@
-
-
- 创建租户
-
+
+
+
+
+
+ 新增租户
+
-
+
-
-
-
-
+
+
+
+
-
- {{ row.status === 1 ? '启用' : '禁用' }}
-
-
-
-
-
- 编辑
- 删除
- handleStatusChange(row, val)"
- />
+
+ handleStatusChange(row, val)"
+ />
+
-
+
+
+
+
+
+
-
+
@@ -51,30 +68,62 @@
inactive-text="禁用"
/>
-
-
- 同时创建租户管理员
-
-
-
-
-
-
-
-
+
+ 同时创建租户管理员
+
+
+
+
+
+
+
取消
保存
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-web/src/views/UserManagement.vue b/admin-web/src/views/UserManagement.vue
index 083b93a..7bf651c 100644
--- a/admin-web/src/views/UserManagement.vue
+++ b/admin-web/src/views/UserManagement.vue
@@ -1,11 +1,11 @@
-
-
+
+
+
+ 新增用户
+
-
+
-
-
-
-
-
+
+
+
+
+
{{ formatRoles(row.roleCodes) }}
-
+
-
- {{ row.status === 1 ? '启用' : '禁用' }}
-
-
-
-
-
- 编辑
- 删除
- handleStatusChange(row, val)"
- />
+
+ handleStatusChange(row, val)"
+ />
+
-
+
+
+
+
+
+
-
+
-
+
-
+
@@ -101,7 +118,60 @@
保存
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-web/tsconfig.app.tsbuildinfo b/admin-web/tsconfig.app.tsbuildinfo
index f2200c8..44af9c2 100644
--- a/admin-web/tsconfig.app.tsbuildinfo
+++ b/admin-web/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/views/channelaccount.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}
\ No newline at end of file
+{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/api/synctask.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/components/pagecard.vue","./src/views/channelaccount.vue","./src/views/channelapp.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/messages.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}
\ No newline at end of file
diff --git a/backend/.mvn/maven.config b/backend/.mvn/maven.config
new file mode 100644
index 0000000..1ee9461
--- /dev/null
+++ b/backend/.mvn/maven.config
@@ -0,0 +1,2 @@
+--toolchains
+/Users/marsal/Projects/sino-project/sino-mci/backend/.mvn/toolchains.xml
diff --git a/backend/.mvn/toolchains.xml b/backend/.mvn/toolchains.xml
new file mode 100644
index 0000000..dc4fc64
--- /dev/null
+++ b/backend/.mvn/toolchains.xml
@@ -0,0 +1,13 @@
+
+
+
+ jdk
+
+ 21
+ zulu
+
+
+ /Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home
+
+
+
diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java
index bdf194e..669b3c9 100644
--- a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java
+++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/MockWeComSyncClient.java
@@ -1,5 +1,6 @@
package com.sino.mci.channel.wecom.sync;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -10,10 +11,11 @@ import java.util.List;
*
不发起真实企微 API 调用,仅返回固定样例数据,供开发与测试使用。
*/
@Component
+@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "true")
public class MockWeComSyncClient implements WeComSyncClient {
@Override
- public List
syncContacts(WeComSyncConfig config) {
+ public List syncEnterpriseContacts(WeComSyncConfig config) {
return List.of(
WeComContact.builder()
.userId("user001")
@@ -21,7 +23,13 @@ public class MockWeComSyncClient implements WeComSyncClient {
.mobile("13800138001")
.type(WeComContactType.INTERNAL)
.departmentIds(List.of(1L, 2L))
- .build(),
+ .build()
+ );
+ }
+
+ @Override
+ public List syncAccountContacts(WeComSyncConfig config, String userId) {
+ return List.of(
WeComContact.builder()
.userId("wmexternal001")
.name("客户李四")
@@ -32,15 +40,15 @@ public class MockWeComSyncClient implements WeComSyncClient {
}
@Override
- public List syncConversations(WeComSyncConfig config) {
+ public List syncAccountConversations(WeComSyncConfig config, String userId) {
return List.of(
WeComConversation.builder()
.chatId("chat001")
.chatName("测试客户群")
- .ownerUserId("user001")
+ .ownerUserId(userId)
.memberList(List.of(
WeComConversationMember.builder()
- .userId("user001")
+ .userId(userId)
.name("张三")
.build(),
WeComConversationMember.builder()
diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/RealWeComSyncClient.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/RealWeComSyncClient.java
new file mode 100644
index 0000000..52da478
--- /dev/null
+++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/RealWeComSyncClient.java
@@ -0,0 +1,258 @@
+package com.sino.mci.channel.wecom.sync;
+
+import com.sino.mci.channel.wecom.support.WeComServiceFactory;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.cp.api.WxCpService;
+import me.chanjar.weixin.cp.bean.WxCpDepart;
+import me.chanjar.weixin.cp.bean.WxCpUser;
+import me.chanjar.weixin.cp.bean.external.WxCpUserExternalGroupChatInfo;
+import me.chanjar.weixin.cp.bean.external.WxCpUserExternalGroupChatList;
+import me.chanjar.weixin.cp.bean.external.contact.WxCpExternalContactInfo;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 企业微信通讯录与群聊真实同步客户端。
+ *
+ * 基于 {@link WxCpService} 调用企微开放接口,拉取企业内部成员、外部联系人及客户群。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "false", matchIfMissing = true)
+public class RealWeComSyncClient implements WeComSyncClient {
+
+ private final WeComServiceFactory serviceFactory;
+
+ @Override
+ public List syncEnterpriseContacts(WeComSyncConfig config) {
+ WxCpService service = resolveService(config);
+ if (service == null) {
+ log.warn("[WeComSync] 无法创建 WxCpService,跳过企业通讯录同步");
+ return Collections.emptyList();
+ }
+
+ List contacts = new ArrayList<>();
+ contacts.addAll(syncInternalUsers(service, config));
+ contacts.addAll(syncExternalContacts(service, config));
+ log.info("[WeComSync] 同步企业通讯录完成,共 {} 条", contacts.size());
+ return contacts;
+ }
+
+ @Override
+ public List syncAccountContacts(WeComSyncConfig config, String userId) {
+ WxCpService service = resolveService(config);
+ if (service == null) {
+ log.warn("[WeComSync] 无法创建 WxCpService,跳过账号客户同步");
+ return Collections.emptyList();
+ }
+
+ List contacts = syncExternalContactsForUser(service, config, userId);
+ log.info("[WeComSync] 同步账号 {} 的客户完成,共 {} 条", userId, contacts.size());
+ return contacts;
+ }
+
+ @Override
+ public List syncAccountConversations(WeComSyncConfig config, String userId) {
+ WxCpService service = resolveService(config);
+ if (service == null) {
+ log.warn("[WeComSync] 无法创建 WxCpService,跳过账号群聊同步");
+ return Collections.emptyList();
+ }
+
+ List conversations = new ArrayList<>();
+ try {
+ String cursor = "";
+ do {
+ WxCpUserExternalGroupChatList result = service.getExternalContactService()
+ .listGroupChat(1000, cursor, 0, new String[]{userId});
+ if (result == null || result.getGroupChatList() == null) {
+ break;
+ }
+ for (WxCpUserExternalGroupChatList.ChatStatus chat : result.getGroupChatList()) {
+ WeComConversation conversation = fetchGroupChatDetail(service, chat.getChatId());
+ if (conversation != null) {
+ conversations.add(conversation);
+ }
+ }
+ cursor = result.getNextCursor();
+ } while (cursor != null && !cursor.isEmpty());
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 拉取账号 {} 的客户群列表失败,可能应用缺少客户联系权限: {}", userId, e.getMessage());
+ } catch (Exception e) {
+ log.warn("[WeComSync] 拉取账号 {} 的客户群列表异常", userId, e);
+ }
+ log.info("[WeComSync] 同步账号 {} 的群聊完成,共 {} 条", userId, conversations.size());
+ return conversations;
+ }
+
+ private WxCpService resolveService(WeComSyncConfig config) {
+ if (config == null || config.getCorpId() == null || config.getAgentId() == null || config.getSecret() == null) {
+ return null;
+ }
+ try {
+ return serviceFactory.getOrCreateService(
+ config.getCorpId(), Integer.valueOf(config.getAgentId()), config.getSecret());
+ } catch (Exception e) {
+ log.warn("[WeComSync] 创建 WxCpService 失败", e);
+ return null;
+ }
+ }
+
+ private List syncInternalUsers(WxCpService service, WeComSyncConfig config) {
+ List contacts = new ArrayList<>();
+ try {
+ List departments = service.getDepartmentService().list(null);
+ if (departments == null || departments.isEmpty()) {
+ return contacts;
+ }
+ for (WxCpDepart dept : departments) {
+ try {
+ List users = service.getUserService().listByDepartment(dept.getId(), true, 0);
+ if (users == null) {
+ continue;
+ }
+ for (WxCpUser user : users) {
+ contacts.add(WeComContact.builder()
+ .userId(user.getUserId())
+ .name(user.getName())
+ .mobile(user.getMobile())
+ .type(WeComContactType.INTERNAL)
+ .departmentIds(user.getDepartIds() == null
+ ? Collections.emptyList()
+ : Arrays.stream(user.getDepartIds()).collect(Collectors.toList()))
+ .build());
+ }
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 获取部门 {} 成员失败: {}", dept.getId(), e.getMessage());
+ }
+ }
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 拉取部门列表失败,可能应用缺少通讯录权限: {}", e.getMessage());
+ } catch (Exception e) {
+ log.warn("[WeComSync] 拉取内部成员异常", e);
+ }
+ return contacts;
+ }
+
+ private List syncExternalContacts(WxCpService service, WeComSyncConfig config) {
+ List contacts = new ArrayList<>();
+ try {
+ List followUsers = service.getExternalContactService().listFollowers();
+ if (followUsers == null || followUsers.isEmpty()) {
+ return contacts;
+ }
+ for (String userId : followUsers) {
+ contacts.addAll(syncExternalContactsForUser(service, config, userId));
+ }
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 拉取客户联系成员失败,可能应用缺少客户联系权限: {}", e.getMessage());
+ } catch (Exception e) {
+ log.warn("[WeComSync] 拉取外部联系人异常", e);
+ }
+ return contacts;
+ }
+
+ private List syncExternalContactsForUser(WxCpService service, WeComSyncConfig config, String userId) {
+ List contacts = new ArrayList<>();
+ try {
+ List externalUserIds = service.getExternalContactService().listExternalContacts(userId);
+ if (externalUserIds == null) {
+ return contacts;
+ }
+ for (String externalUserId : externalUserIds) {
+ try {
+ WxCpExternalContactInfo info = service.getExternalContactService()
+ .getContactDetail(externalUserId, null);
+ if (info == null || info.getExternalContact() == null) {
+ continue;
+ }
+ contacts.add(WeComContact.builder()
+ .userId(externalUserId)
+ .name(info.getExternalContact().getName())
+ .mobile(null)
+ .type(WeComContactType.EXTERNAL)
+ .departmentIds(Collections.emptyList())
+ .build());
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 获取外部联系人 {} 详情失败: {}", externalUserId, e.getMessage());
+ }
+ }
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 获取成员 {} 的客户列表失败: {}", userId, e.getMessage());
+ }
+ return contacts;
+ }
+
+ private WeComConversation fetchGroupChatDetail(WxCpService service, String chatId) {
+ try {
+ WxCpUserExternalGroupChatInfo detail = service.getExternalContactService().getGroupChat(chatId, 0);
+ if (detail == null || detail.getGroupChat() == null) {
+ return null;
+ }
+ WxCpUserExternalGroupChatInfo.GroupChat groupChat = detail.getGroupChat();
+ List members = new ArrayList<>();
+ if (groupChat.getMemberList() != null) {
+ for (WxCpUserExternalGroupChatInfo.GroupMember member : groupChat.getMemberList()) {
+ members.add(WeComConversationMember.builder()
+ .userId(member.getUserId())
+ .name(resolveMemberName(service, member))
+ .build());
+ }
+ }
+ return WeComConversation.builder()
+ .chatId(groupChat.getChatId())
+ .chatName(groupChat.getName())
+ .ownerUserId(groupChat.getOwner())
+ .memberList(members)
+ .build();
+ } catch (WxErrorException e) {
+ log.warn("[WeComSync] 获取群聊 {} 详情失败: {}", chatId, e.getMessage());
+ return null;
+ } catch (Exception e) {
+ log.warn("[WeComSync] 获取群聊 {} 详情异常", chatId, e);
+ return null;
+ }
+ }
+
+ private String resolveMemberName(WxCpService service, WxCpUserExternalGroupChatInfo.GroupMember member) {
+ if (StringUtils.isNotBlank(member.getName())) {
+ return member.getName();
+ }
+ String userId = member.getUserId();
+ if (StringUtils.isBlank(userId)) {
+ return null;
+ }
+ // 外部联系人:通过客户详情接口补全名称
+ if (userId.startsWith("wm")) {
+ try {
+ WxCpExternalContactInfo info = service.getExternalContactService().getContactDetail(userId, null);
+ if (info != null && info.getExternalContact() != null
+ && StringUtils.isNotBlank(info.getExternalContact().getName())) {
+ return info.getExternalContact().getName();
+ }
+ } catch (WxErrorException e) {
+ log.debug("[WeComSync] 获取外部联系人 {} 名称失败: {}", userId, e.getMessage());
+ }
+ }
+ // 内部成员:通过通讯录接口补全名称
+ try {
+ WxCpUser user = service.getUserService().getById(userId);
+ if (user != null && StringUtils.isNotBlank(user.getName())) {
+ return user.getName();
+ }
+ } catch (WxErrorException e) {
+ log.debug("[WeComSync] 获取内部成员 {} 名称失败: {}", userId, e.getMessage());
+ }
+ return userId;
+ }
+}
diff --git a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java
index ea5873c..6f341e1 100644
--- a/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java
+++ b/backend/mci-channel-wecom/src/main/java/com/sino/mci/channel/wecom/sync/WeComSyncClient.java
@@ -12,18 +12,28 @@ import java.util.List;
public interface WeComSyncClient {
/**
- * 同步联系人(企业内部成员 + 外部联系人)。
+ * 同步企业通讯录(部门 + 内部成员)。
*
* @param config 企微配置
* @return 联系人列表
*/
- List syncContacts(WeComSyncConfig config);
+ List syncEnterpriseContacts(WeComSyncConfig config);
/**
- * 同步群聊(客户群)。
+ * 同步某个成员的客户(外部联系人)。
*
* @param config 企微配置
+ * @param userId 成员 userId
+ * @return 联系人列表
+ */
+ List syncAccountContacts(WeComSyncConfig config, String userId);
+
+ /**
+ * 同步某个成员的客户群。
+ *
+ * @param config 企微配置
+ * @param userId 成员 userId
* @return 群聊列表
*/
- List syncConversations(WeComSyncConfig config);
+ List syncAccountConversations(WeComSyncConfig config, String userId);
}
diff --git a/backend/mci-server/pom.xml b/backend/mci-server/pom.xml
index 0369174..04718e3 100644
--- a/backend/mci-server/pom.xml
+++ b/backend/mci-server/pom.xml
@@ -70,6 +70,11 @@
sa-token-spring-boot3-starter
1.39.0
+
+ cn.dev33
+ sa-token-redis-jackson
+ 1.39.0
+
org.springframework.ai
spring-ai-openai-spring-boot-starter
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java
index 4a392d5..b186378 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java
@@ -4,9 +4,9 @@ import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.ChannelAccountInitRequest;
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
import com.sino.mci.channel.dto.HeartbeatRequest;
-import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
+import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.service.ChannelAccountService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
@@ -78,16 +78,16 @@ public class ChannelAccountController {
}
@PostMapping("/{account_id}/sync-contacts")
- public ApiResponse syncContacts(
+ public ApiResponse syncContacts(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
- return ApiResponse.ok(accountService.syncContacts(accountId));
+ return ApiResponse.ok(accountService.submitContactsSyncTask(accountId));
}
@PostMapping("/{account_id}/sync-conversations")
- public ApiResponse syncConversations(
+ public ApiResponse syncConversations(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
- return ApiResponse.ok(accountService.syncConversations(accountId));
+ return ApiResponse.ok(accountService.submitConversationsSyncTask(accountId));
}
}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java
index 564edc9..62917a4 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java
@@ -54,4 +54,11 @@ public class ChannelSubjectController {
public ApiResponse> listSubjects() {
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));
}
+
+ @PostMapping("/{id}/sync-contacts")
+ public ApiResponse syncEnterpriseContacts(
+ @RequestHeader("X-Tenant-Code") String tenantCode,
+ @PathVariable("id") Long id) {
+ return ApiResponse.ok(subjectService.submitEnterpriseContactsSyncTask(id, tenantCode));
+ }
}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/SyncTaskController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/SyncTaskController.java
new file mode 100644
index 0000000..5e50ef1
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/SyncTaskController.java
@@ -0,0 +1,22 @@
+package com.sino.mci.channel.controller;
+
+import com.sino.mci.channel.entity.SyncTask;
+import com.sino.mci.channel.service.SyncTaskService;
+import com.sino.mci.shared.web.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/sync-task")
+@RequiredArgsConstructor
+public class SyncTaskController {
+
+ private final SyncTaskService syncTaskService;
+
+ @GetMapping("/{id}")
+ public ApiResponse getTask(
+ @RequestHeader("X-Tenant-Code") String tenantCode,
+ @PathVariable("id") Long id) {
+ return ApiResponse.ok(syncTaskService.getTask(id, tenantCode));
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java
index 12bcf32..9158c8a 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java
@@ -85,7 +85,7 @@ public class WeComCallbackController {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
- command.setSecret(subject.getCorpSecret());
+ command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
rabbitTemplate.convertAndSend(RabbitConfig.SESSION_ARCHIVE_EXCHANGE,
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java
index d8182f6..d4bafd8 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java
@@ -16,7 +16,6 @@ public class CreateChannelSubjectRequest {
private String subjectName;
private String corpId;
- private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java
index a6f0ef2..59d86f9 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java
@@ -15,7 +15,6 @@ public class ChannelSubject extends BaseEntity {
private String subjectCode;
private String subjectName;
private String corpId;
- private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/SyncTask.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/SyncTask.java
new file mode 100644
index 0000000..c239b8c
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/SyncTask.java
@@ -0,0 +1,30 @@
+package com.sino.mci.channel.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.sino.mci.shared.entity.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("mci_sync_task")
+public class SyncTask extends BaseEntity {
+
+ public static final int STATUS_PENDING = 0;
+ public static final int STATUS_RUNNING = 1;
+ public static final int STATUS_SUCCESS = 2;
+ public static final int STATUS_FAILED = 3;
+
+ private String tenantCode;
+ private String taskType;
+ private String channelType;
+ private Long targetId;
+ private String targetCode;
+ private Integer taskStatus;
+ private Integer syncedCount;
+ private Integer failedCount;
+ private String errorMessage;
+ private Integer retryCount;
+ private java.time.LocalDateTime finishTime;
+ private Integer deleted;
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/SyncTaskMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/SyncTaskMapper.java
new file mode 100644
index 0000000..24b868a
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/SyncTaskMapper.java
@@ -0,0 +1,9 @@
+package com.sino.mci.channel.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sino.mci.channel.entity.SyncTask;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SyncTaskMapper extends BaseMapper {
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
index 0b44fef..fa31c78 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
@@ -13,27 +13,12 @@ import com.sino.mci.channel.dto.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
-import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.ChannelSubject;
-import com.sino.mci.channel.entity.Conversation;
-import com.sino.mci.channel.entity.ConversationParticipant;
-import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
+import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
-import com.sino.mci.channel.repository.ConversationMapper;
-import com.sino.mci.channel.repository.ConversationParticipantMapper;
-import com.sino.mci.channel.wecom.sync.WeComContact;
-import com.sino.mci.channel.wecom.sync.WeComContactType;
-import com.sino.mci.channel.wecom.sync.WeComConversation;
-import com.sino.mci.channel.wecom.sync.WeComConversationMember;
import com.sino.mci.channel.wecom.sync.WeComSyncClient;
-import com.sino.mci.channel.wecom.sync.WeComSyncConfig;
-import com.sino.mci.crm.entity.ChannelIdentity;
-import com.sino.mci.crm.entity.Person;
-import com.sino.mci.crm.repository.ChannelIdentityMapper;
-import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.exception.BusinessException;
-import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -51,16 +36,6 @@ public class ChannelAccountService {
private static final String CHANNEL_WECOM = ChannelType.WECOM.name().toLowerCase();
- private static final int BINDING_SEND_MASTER = 1;
- private static final int BINDING_RECEIVE_MASTER = 3;
-
- private static final int PERSON_TYPE_INTERNAL = 1;
- private static final int PERSON_TYPE_CUSTOMER = 3;
-
- private static final int CONVERSATION_TYPE_GROUP = 2;
- private static final int PARTICIPANT_TYPE_MEMBER = 1;
- private static final int PARTICIPANT_TYPE_OWNER = 2;
-
private static final int ONLINE_STATUS_OFFLINE = 0;
private static final int ONLINE_STATUS_ONLINE = 1;
private static final int ACCOUNT_STATUS_ENABLED = 1;
@@ -68,13 +43,10 @@ public class ChannelAccountService {
private final ChannelAccountMapper accountMapper;
private final ChannelSubjectMapper subjectMapper;
- private final PersonMapper personMapper;
- private final ChannelIdentityMapper channelIdentityMapper;
- private final ConversationMapper conversationMapper;
- private final ConversationParticipantMapper conversationParticipantMapper;
- private final ChannelAccountConversationMapper accountConversationMapper;
private final WeComSyncClient weComSyncClient;
private final ChannelAdapterRegistry adapterRegistry;
+ private final SyncTaskService syncTaskService;
+ private final SyncTaskExecutor syncTaskExecutor;
public List listAccounts(String tenantCode) {
return accountMapper.selectList(
@@ -119,7 +91,7 @@ public class ChannelAccountService {
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
- account.setExtInfo(request.getExtInfo());
+ account.setExtInfo(normalizeExtInfo(request.getExtInfo()));
account.setLoginStatus(0);
account.setOnlineStatus(0);
account.setRiskLevel(0);
@@ -157,11 +129,15 @@ public class ChannelAccountService {
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
- account.setExtInfo(request.getExtInfo());
+ account.setExtInfo(normalizeExtInfo(request.getExtInfo()));
accountMapper.updateById(account);
return account;
}
+ private String normalizeExtInfo(String extInfo) {
+ return (extInfo == null || extInfo.isBlank()) ? null : extInfo;
+ }
+
@Transactional
public void deleteAccount(Long id, String tenantCode) {
ChannelAccount account = getAccount(id, tenantCode);
@@ -268,8 +244,8 @@ public class ChannelAccountService {
if (subject.getCorpId() != null) {
config.putIfAbsent("corp_id", subject.getCorpId());
}
- if (subject.getCorpSecret() != null) {
- config.putIfAbsent("secret", subject.getCorpSecret());
+ if (subject.getAgentSecret() != null) {
+ config.putIfAbsent("secret", subject.getAgentSecret());
}
if (subject.getAgentId() != null) {
config.putIfAbsent("agent_id", subject.getAgentId());
@@ -322,32 +298,20 @@ public class ChannelAccountService {
return result;
}
- @Transactional
- public SyncResult syncContacts(Long accountId) {
+ public SyncTask submitContactsSyncTask(Long accountId) {
ChannelAccount account = getWeComAccount(accountId);
- ChannelSubject subject = getSubject(account);
- WeComSyncConfig config = buildSyncConfig(subject);
-
- List contacts = weComSyncClient.syncContacts(config);
- for (WeComContact contact : contacts) {
- upsertContact(account.getTenantCode(), account.getSubjectId(), contact);
- }
- return SyncResult.builder().syncedCount(contacts.size()).build();
+ SyncTask task = syncTaskService.createTask(
+ account.getTenantCode(), "account_contacts", account.getChannelType(), accountId, account.getAccountId());
+ syncTaskExecutor.executeAccountContactsSync(task.getId());
+ return task;
}
- @Transactional
- public SyncResult syncConversations(Long accountId) {
+ public SyncTask submitConversationsSyncTask(Long accountId) {
ChannelAccount account = getWeComAccount(accountId);
- ChannelSubject subject = getSubject(account);
- WeComSyncConfig config = buildSyncConfig(subject);
-
- List conversations = weComSyncClient.syncConversations(config);
- for (WeComConversation conversation : conversations) {
- Conversation saved = upsertConversation(account, subject.getId(), conversation);
- refreshParticipants(account.getTenantCode(), account.getSubjectId(), saved, conversation);
- bindAccountAsDefault(accountId, saved.getId());
- }
- return SyncResult.builder().syncedCount(conversations.size()).build();
+ SyncTask task = syncTaskService.createTask(
+ account.getTenantCode(), "account_conversations", account.getChannelType(), accountId, account.getAccountId());
+ syncTaskExecutor.executeAccountConversationsSync(task.getId());
+ return task;
}
private ChannelAccount getWeComAccount(Long accountId) {
@@ -361,193 +325,4 @@ public class ChannelAccountService {
return account;
}
- private ChannelSubject getSubject(ChannelAccount account) {
- if (account.getSubjectId() == null) {
- throw new BusinessException("企微账号未绑定渠道主体");
- }
- ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
- if (subject == null) {
- throw new BusinessException("渠道主体不存在: " + account.getSubjectId());
- }
- if (subject.getCorpId() == null || subject.getCorpSecret() == null) {
- throw new BusinessException("渠道主体缺少 corp_id / corp_secret");
- }
- return subject;
- }
-
- private WeComSyncConfig buildSyncConfig(ChannelSubject subject) {
- return WeComSyncConfig.builder()
- .corpId(subject.getCorpId())
- .agentId(subject.getAgentId())
- .secret(subject.getCorpSecret())
- .build();
- }
-
- private void upsertContact(String tenantCode, Long subjectId, WeComContact contact) {
- ChannelIdentity identity = channelIdentityMapper.selectOne(
- new LambdaQueryWrapper()
- .eq(ChannelIdentity::getTenantCode, tenantCode)
- .eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
- .eq(ChannelIdentity::getChannelUserId, contact.getUserId()));
-
- Person person;
- if (identity != null) {
- person = personMapper.selectById(identity.getPersonId());
- if (person == null) {
- log.warn("ChannelIdentity {} references missing person {}", identity.getId(), identity.getPersonId());
- return;
- }
- Long originalInstitutionId = person.getInstitutionId();
- Long originalSupplierId = person.getSupplierId();
- Integer originalPersonType = person.getPersonType();
- person.setPersonName(contact.getName());
- person.setPhone(contact.getMobile());
- person.setInstitutionId(originalInstitutionId);
- person.setSupplierId(originalSupplierId);
- person.setPersonType(originalPersonType);
- personMapper.updateById(person);
-
- identity.setChannelUserName(contact.getName());
- identity.setExtInfo(JsonUtils.toJson(contact));
- channelIdentityMapper.updateById(identity);
- } else {
- person = new Person();
- person.setTenantCode(tenantCode);
- person.setPersonCode(contact.getUserId());
- person.setPersonName(contact.getName());
- person.setPhone(contact.getMobile());
- person.setPersonType(resolvePersonType(contact));
- person.setStatus(1);
- personMapper.insert(person);
-
- ChannelIdentity newIdentity = new ChannelIdentity();
- newIdentity.setTenantCode(tenantCode);
- newIdentity.setPersonId(person.getId());
- newIdentity.setChannelType(CHANNEL_WECOM);
- newIdentity.setChannelUserId(contact.getUserId());
- newIdentity.setChannelUserName(contact.getName());
- newIdentity.setSubjectId(subjectId);
- newIdentity.setStatus(1);
- newIdentity.setExtInfo(JsonUtils.toJson(contact));
- channelIdentityMapper.insert(newIdentity);
- }
- }
-
- private Conversation upsertConversation(ChannelAccount account, Long subjectId, WeComConversation conv) {
- Conversation conversation = conversationMapper.selectOne(
- new LambdaQueryWrapper()
- .eq(Conversation::getTenantCode, account.getTenantCode())
- .eq(Conversation::getChannelType, CHANNEL_WECOM)
- .eq(Conversation::getChannelConversationId, conv.getChatId()));
-
- if (conversation != null) {
- Long originalInstitutionId = conversation.getInstitutionId();
- Long originalSupplierId = conversation.getSupplierId();
- Long originalContractId = conversation.getContractId();
- String originalChannelCode = conversation.getChannelCode();
- conversation.setConversationName(conv.getChatName());
- conversation.setOwnerAccountId(account.getId());
- conversation.setSubjectId(subjectId);
- conversation.setInstitutionId(originalInstitutionId);
- conversation.setSupplierId(originalSupplierId);
- conversation.setContractId(originalContractId);
- conversation.setChannelCode(originalChannelCode);
- conversationMapper.updateById(conversation);
- return conversation;
- }
-
- Conversation newConversation = new Conversation();
- newConversation.setTenantCode(account.getTenantCode());
- newConversation.setConversationType(CONVERSATION_TYPE_GROUP);
- newConversation.setChannelType(CHANNEL_WECOM);
- newConversation.setChannelConversationId(conv.getChatId());
- newConversation.setOwnerAccountId(account.getId());
- newConversation.setSubjectId(subjectId);
- newConversation.setConversationName(conv.getChatName());
- newConversation.setCreateSource(1);
- newConversation.setStatus(1);
- conversationMapper.insert(newConversation);
- return newConversation;
- }
-
- private void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) {
- conversationParticipantMapper.delete(
- new LambdaQueryWrapper()
- .eq(ConversationParticipant::getConversationId, conversation.getId()));
-
- for (WeComConversationMember member : conv.getMemberList()) {
- ChannelIdentity identity = findOrCreateIdentity(tenantCode, subjectId, member);
-
- ConversationParticipant participant = new ConversationParticipant();
- participant.setConversationId(conversation.getId());
- participant.setPersonId(identity.getPersonId());
- participant.setChannelIdentityId(identity.getId());
- participant.setParticipantType(resolveParticipantType(member, conv.getOwnerUserId()));
- participant.setJoinTime(LocalDateTime.now());
- conversationParticipantMapper.insert(participant);
- }
- }
-
- private ChannelIdentity findOrCreateIdentity(String tenantCode, Long subjectId, WeComConversationMember member) {
- ChannelIdentity identity = channelIdentityMapper.selectOne(
- new LambdaQueryWrapper()
- .eq(ChannelIdentity::getTenantCode, tenantCode)
- .eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
- .eq(ChannelIdentity::getChannelUserId, member.getUserId()));
-
- if (identity != null) {
- return identity;
- }
-
- Person person = new Person();
- person.setTenantCode(tenantCode);
- person.setPersonCode(member.getUserId());
- person.setPersonName(member.getName());
- person.setPersonType(PERSON_TYPE_CUSTOMER);
- person.setStatus(1);
- personMapper.insert(person);
-
- ChannelIdentity newIdentity = new ChannelIdentity();
- newIdentity.setTenantCode(tenantCode);
- newIdentity.setPersonId(person.getId());
- newIdentity.setChannelType(CHANNEL_WECOM);
- newIdentity.setChannelUserId(member.getUserId());
- newIdentity.setChannelUserName(member.getName());
- newIdentity.setSubjectId(subjectId);
- newIdentity.setStatus(1);
- channelIdentityMapper.insert(newIdentity);
- return newIdentity;
- }
-
- private void bindAccountAsDefault(Long accountId, Long conversationId) {
- ensureBinding(accountId, conversationId, BINDING_SEND_MASTER);
- ensureBinding(accountId, conversationId, BINDING_RECEIVE_MASTER);
- }
-
- private void ensureBinding(Long accountId, Long conversationId, int bindingType) {
- ChannelAccountConversation existing = accountConversationMapper.selectOne(
- new LambdaQueryWrapper()
- .eq(ChannelAccountConversation::getAccountId, accountId)
- .eq(ChannelAccountConversation::getConversationId, conversationId)
- .eq(ChannelAccountConversation::getBindingType, bindingType));
- if (existing != null) {
- return;
- }
-
- ChannelAccountConversation binding = new ChannelAccountConversation();
- binding.setAccountId(accountId);
- binding.setConversationId(conversationId);
- binding.setBindingType(bindingType);
- binding.setSortOrder(0);
- binding.setStatus(1);
- accountConversationMapper.insert(binding);
- }
-
- private int resolvePersonType(WeComContact contact) {
- return contact.getType() == WeComContactType.INTERNAL ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER;
- }
-
- private int resolveParticipantType(WeComConversationMember member, String ownerUserId) {
- return ownerUserId != null && ownerUserId.equals(member.getUserId()) ? PARTICIPANT_TYPE_OWNER : PARTICIPANT_TYPE_MEMBER;
- }
}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java
index 6205617..0f27eba 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
+import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
import lombok.RequiredArgsConstructor;
@@ -17,6 +18,8 @@ import java.util.List;
public class ChannelSubjectService {
private final ChannelSubjectMapper subjectMapper;
+ private final SyncTaskService syncTaskService;
+ private final SyncTaskExecutor syncTaskExecutor;
public List listSubjects(String tenantCode) {
return subjectMapper.selectList(
@@ -51,7 +54,6 @@ public class ChannelSubjectService {
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
- subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
@@ -80,7 +82,6 @@ public class ChannelSubjectService {
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
- subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
@@ -103,4 +104,12 @@ public class ChannelSubjectService {
subjectMapper.updateById(subject);
return subject;
}
+
+ public SyncTask submitEnterpriseContactsSyncTask(Long id, String tenantCode) {
+ ChannelSubject subject = getSubject(id, tenantCode);
+ SyncTask task = syncTaskService.createTask(
+ tenantCode, "enterprise_contacts", subject.getChannelType(), id, subject.getSubjectCode());
+ syncTaskExecutor.executeEnterpriseContactsSync(task.getId());
+ return task;
+ }
}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncDataPersister.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncDataPersister.java
new file mode 100644
index 0000000..e194f41
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncDataPersister.java
@@ -0,0 +1,226 @@
+package com.sino.mci.channel.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sino.mci.channel.entity.ChannelAccount;
+import com.sino.mci.channel.entity.ChannelAccountConversation;
+import com.sino.mci.channel.entity.Conversation;
+import com.sino.mci.channel.entity.ConversationParticipant;
+import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
+import com.sino.mci.channel.repository.ConversationMapper;
+import com.sino.mci.channel.repository.ConversationParticipantMapper;
+import com.sino.mci.channel.wecom.sync.WeComContact;
+import com.sino.mci.channel.wecom.sync.WeComContactType;
+import com.sino.mci.channel.wecom.sync.WeComConversation;
+import com.sino.mci.channel.wecom.sync.WeComConversationMember;
+import com.sino.mci.crm.entity.ChannelIdentity;
+import com.sino.mci.crm.entity.Person;
+import com.sino.mci.crm.repository.ChannelIdentityMapper;
+import com.sino.mci.crm.repository.PersonMapper;
+import com.sino.mci.shared.util.JsonUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SyncDataPersister {
+
+ private static final String CHANNEL_WECOM = "wecom";
+ private static final int BINDING_SEND_MASTER = 1;
+ private static final int BINDING_RECEIVE_MASTER = 3;
+ private static final int PERSON_TYPE_INTERNAL = 1;
+ private static final int PERSON_TYPE_CUSTOMER = 3;
+ private static final int CONVERSATION_TYPE_GROUP = 2;
+ private static final int PARTICIPANT_TYPE_MEMBER = 1;
+ private static final int PARTICIPANT_TYPE_OWNER = 2;
+
+ private final PersonMapper personMapper;
+ private final ChannelIdentityMapper channelIdentityMapper;
+ private final ConversationMapper conversationMapper;
+ private final ConversationParticipantMapper conversationParticipantMapper;
+ private final ChannelAccountConversationMapper accountConversationMapper;
+
+ @Transactional
+ public void upsertContact(String tenantCode, Long subjectId, WeComContact contact) {
+ ChannelIdentity identity = channelIdentityMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(ChannelIdentity::getTenantCode, tenantCode)
+ .eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
+ .eq(ChannelIdentity::getChannelUserId, contact.getUserId()));
+
+ Person person;
+ if (identity != null) {
+ person = personMapper.selectById(identity.getPersonId());
+ if (person == null) {
+ log.warn("ChannelIdentity {} references missing person {}", identity.getId(), identity.getPersonId());
+ return;
+ }
+ Long originalInstitutionId = person.getInstitutionId();
+ Long originalSupplierId = person.getSupplierId();
+ Integer originalPersonType = person.getPersonType();
+ person.setPersonName(contact.getName());
+ person.setPhone(contact.getMobile());
+ person.setInstitutionId(originalInstitutionId);
+ person.setSupplierId(originalSupplierId);
+ person.setPersonType(originalPersonType);
+ personMapper.updateById(person);
+
+ identity.setChannelUserName(contact.getName());
+ identity.setExtInfo(JsonUtils.toJson(contact));
+ channelIdentityMapper.updateById(identity);
+ } else {
+ person = new Person();
+ person.setTenantCode(tenantCode);
+ person.setPersonCode(contact.getUserId());
+ person.setPersonName(contact.getName());
+ person.setPhone(contact.getMobile());
+ person.setPersonType(resolvePersonType(contact));
+ person.setStatus(1);
+ personMapper.insert(person);
+
+ ChannelIdentity newIdentity = new ChannelIdentity();
+ newIdentity.setTenantCode(tenantCode);
+ newIdentity.setPersonId(person.getId());
+ newIdentity.setChannelType(CHANNEL_WECOM);
+ newIdentity.setChannelUserId(contact.getUserId());
+ newIdentity.setChannelUserName(contact.getName());
+ newIdentity.setSubjectId(subjectId);
+ newIdentity.setStatus(1);
+ newIdentity.setExtInfo(JsonUtils.toJson(contact));
+ channelIdentityMapper.insert(newIdentity);
+ }
+ }
+
+ @Transactional
+ public Conversation upsertConversation(ChannelAccount account, Long subjectId, WeComConversation conv) {
+ Conversation conversation = conversationMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(Conversation::getTenantCode, account.getTenantCode())
+ .eq(Conversation::getChannelType, CHANNEL_WECOM)
+ .eq(Conversation::getChannelConversationId, conv.getChatId()));
+
+ if (conversation != null) {
+ Long originalInstitutionId = conversation.getInstitutionId();
+ Long originalSupplierId = conversation.getSupplierId();
+ Long originalContractId = conversation.getContractId();
+ String originalChannelCode = conversation.getChannelCode();
+ conversation.setConversationName(conv.getChatName());
+ conversation.setOwnerAccountId(account.getId());
+ conversation.setSubjectId(subjectId);
+ conversation.setInstitutionId(originalInstitutionId);
+ conversation.setSupplierId(originalSupplierId);
+ conversation.setContractId(originalContractId);
+ conversation.setChannelCode(originalChannelCode);
+ conversationMapper.updateById(conversation);
+ return conversation;
+ }
+
+ Conversation newConversation = new Conversation();
+ newConversation.setTenantCode(account.getTenantCode());
+ newConversation.setConversationType(CONVERSATION_TYPE_GROUP);
+ newConversation.setChannelType(CHANNEL_WECOM);
+ newConversation.setChannelConversationId(conv.getChatId());
+ newConversation.setOwnerAccountId(account.getId());
+ newConversation.setSubjectId(subjectId);
+ newConversation.setConversationName(conv.getChatName());
+ newConversation.setCreateSource(1);
+ newConversation.setStatus(1);
+ conversationMapper.insert(newConversation);
+ return newConversation;
+ }
+
+ @Transactional
+ public void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) {
+ conversationParticipantMapper.delete(
+ new LambdaQueryWrapper()
+ .eq(ConversationParticipant::getConversationId, conversation.getId()));
+
+ for (WeComConversationMember member : conv.getMemberList()) {
+ ChannelIdentity identity = findOrCreateIdentity(tenantCode, subjectId, member);
+
+ ConversationParticipant participant = new ConversationParticipant();
+ participant.setConversationId(conversation.getId());
+ participant.setPersonId(identity.getPersonId());
+ participant.setChannelIdentityId(identity.getId());
+ participant.setParticipantType(resolveParticipantType(member, conv.getOwnerUserId()));
+ participant.setJoinTime(LocalDateTime.now());
+ conversationParticipantMapper.insert(participant);
+ }
+ }
+
+ @Transactional
+ public void bindAccountAsDefault(Long accountId, Long conversationId) {
+ ensureBinding(accountId, conversationId, BINDING_SEND_MASTER);
+ ensureBinding(accountId, conversationId, BINDING_RECEIVE_MASTER);
+ }
+
+ private ChannelIdentity findOrCreateIdentity(String tenantCode, Long subjectId, WeComConversationMember member) {
+ ChannelIdentity identity = channelIdentityMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(ChannelIdentity::getTenantCode, tenantCode)
+ .eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
+ .eq(ChannelIdentity::getChannelUserId, member.getUserId()));
+
+ if (identity != null) {
+ return identity;
+ }
+
+ String displayName = resolveDisplayName(member.getName(), member.getUserId());
+ Person person = new Person();
+ person.setTenantCode(tenantCode);
+ person.setPersonCode(member.getUserId());
+ person.setPersonName(displayName);
+ person.setPersonType(PERSON_TYPE_CUSTOMER);
+ person.setStatus(1);
+ personMapper.insert(person);
+
+ ChannelIdentity newIdentity = new ChannelIdentity();
+ newIdentity.setTenantCode(tenantCode);
+ newIdentity.setPersonId(person.getId());
+ newIdentity.setChannelType(CHANNEL_WECOM);
+ newIdentity.setChannelUserId(member.getUserId());
+ newIdentity.setChannelUserName(displayName);
+ newIdentity.setSubjectId(subjectId);
+ newIdentity.setStatus(1);
+ channelIdentityMapper.insert(newIdentity);
+ return newIdentity;
+ }
+
+ private String resolveDisplayName(String name, String userId) {
+ if (name != null && !name.isBlank()) {
+ return name;
+ }
+ return userId != null && !userId.isBlank() ? userId : "未知";
+ }
+
+ private void ensureBinding(Long accountId, Long conversationId, int bindingType) {
+ ChannelAccountConversation existing = accountConversationMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(ChannelAccountConversation::getAccountId, accountId)
+ .eq(ChannelAccountConversation::getConversationId, conversationId)
+ .eq(ChannelAccountConversation::getBindingType, bindingType));
+ if (existing != null) {
+ return;
+ }
+
+ ChannelAccountConversation binding = new ChannelAccountConversation();
+ binding.setAccountId(accountId);
+ binding.setConversationId(conversationId);
+ binding.setBindingType(bindingType);
+ binding.setSortOrder(0);
+ binding.setStatus(1);
+ accountConversationMapper.insert(binding);
+ }
+
+ private int resolvePersonType(WeComContact contact) {
+ return contact.getType() == WeComContactType.INTERNAL ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER;
+ }
+
+ private int resolveParticipantType(WeComConversationMember member, String ownerUserId) {
+ return ownerUserId != null && ownerUserId.equals(member.getUserId()) ? PARTICIPANT_TYPE_OWNER : PARTICIPANT_TYPE_MEMBER;
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncTaskExecutor.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncTaskExecutor.java
new file mode 100644
index 0000000..02c7245
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncTaskExecutor.java
@@ -0,0 +1,124 @@
+package com.sino.mci.channel.service;
+
+import com.sino.mci.channel.entity.ChannelAccount;
+import com.sino.mci.channel.entity.ChannelSubject;
+import com.sino.mci.channel.entity.Conversation;
+import com.sino.mci.channel.entity.SyncTask;
+import com.sino.mci.channel.repository.ChannelAccountMapper;
+import com.sino.mci.channel.repository.ChannelSubjectMapper;
+import com.sino.mci.channel.wecom.sync.WeComContact;
+import com.sino.mci.channel.wecom.sync.WeComConversation;
+import com.sino.mci.channel.wecom.sync.WeComSyncClient;
+import com.sino.mci.channel.wecom.sync.WeComSyncConfig;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SyncTaskExecutor {
+
+ private final SyncTaskService syncTaskService;
+ private final ChannelAccountMapper accountMapper;
+ private final ChannelSubjectMapper subjectMapper;
+ private final SyncDataPersister syncDataPersister;
+ private final WeComSyncClient weComSyncClient;
+
+ @Async("syncTaskPool")
+ public void executeEnterpriseContactsSync(Long taskId) {
+ SyncTask task = syncTaskService.getTask(taskId, null);
+ if (task == null) {
+ return;
+ }
+ syncTaskService.markRunning(taskId);
+ try {
+ ChannelSubject subject = subjectMapper.selectById(task.getTargetId());
+ if (subject == null) {
+ throw new RuntimeException("渠道主体不存在");
+ }
+ WeComSyncConfig config = WeComSyncConfig.builder()
+ .corpId(subject.getCorpId())
+ .agentId(subject.getAgentId())
+ .secret(subject.getAgentSecret())
+ .build();
+ List contacts = weComSyncClient.syncEnterpriseContacts(config);
+ for (WeComContact contact : contacts) {
+ syncDataPersister.upsertContact(subject.getTenantCode(), subject.getId(), contact);
+ }
+ syncTaskService.markSuccess(taskId, contacts.size());
+ } catch (Exception e) {
+ log.error("[SyncTask] 企业通讯录同步失败 taskId={}", taskId, e);
+ syncTaskService.markFailed(taskId, e.getMessage());
+ }
+ }
+
+ @Async("syncTaskPool")
+ public void executeAccountContactsSync(Long taskId) {
+ SyncTask task = syncTaskService.getTask(taskId, null);
+ if (task == null) {
+ return;
+ }
+ syncTaskService.markRunning(taskId);
+ try {
+ ChannelAccount account = accountMapper.selectById(task.getTargetId());
+ if (account == null) {
+ throw new RuntimeException("渠道账号不存在");
+ }
+ ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
+ if (subject == null) {
+ throw new RuntimeException("渠道主体不存在");
+ }
+ WeComSyncConfig config = WeComSyncConfig.builder()
+ .corpId(subject.getCorpId())
+ .agentId(subject.getAgentId())
+ .secret(subject.getAgentSecret())
+ .build();
+ List contacts = weComSyncClient.syncAccountContacts(config, account.getAccountId());
+ for (WeComContact contact : contacts) {
+ syncDataPersister.upsertContact(account.getTenantCode(), account.getSubjectId(), contact);
+ }
+ syncTaskService.markSuccess(taskId, contacts.size());
+ } catch (Exception e) {
+ log.error("[SyncTask] 账号客户同步失败 taskId={}", taskId, e);
+ syncTaskService.markFailed(taskId, e.getMessage());
+ }
+ }
+
+ @Async("syncTaskPool")
+ public void executeAccountConversationsSync(Long taskId) {
+ SyncTask task = syncTaskService.getTask(taskId, null);
+ if (task == null) {
+ return;
+ }
+ syncTaskService.markRunning(taskId);
+ try {
+ ChannelAccount account = accountMapper.selectById(task.getTargetId());
+ if (account == null) {
+ throw new RuntimeException("渠道账号不存在");
+ }
+ ChannelSubject subject = subjectMapper.selectById(account.getSubjectId());
+ if (subject == null) {
+ throw new RuntimeException("渠道主体不存在");
+ }
+ WeComSyncConfig config = WeComSyncConfig.builder()
+ .corpId(subject.getCorpId())
+ .agentId(subject.getAgentId())
+ .secret(subject.getAgentSecret())
+ .build();
+ List conversations = weComSyncClient.syncAccountConversations(config, account.getAccountId());
+ for (WeComConversation conversation : conversations) {
+ Conversation saved = syncDataPersister.upsertConversation(account, subject.getId(), conversation);
+ syncDataPersister.refreshParticipants(account.getTenantCode(), account.getSubjectId(), saved, conversation);
+ syncDataPersister.bindAccountAsDefault(account.getId(), saved.getId());
+ }
+ syncTaskService.markSuccess(taskId, conversations.size());
+ } catch (Exception e) {
+ log.error("[SyncTask] 账号群聊同步失败 taskId={}", taskId, e);
+ syncTaskService.markFailed(taskId, e.getMessage());
+ }
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncTaskService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncTaskService.java
new file mode 100644
index 0000000..70729aa
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/SyncTaskService.java
@@ -0,0 +1,97 @@
+package com.sino.mci.channel.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sino.mci.channel.entity.SyncTask;
+import com.sino.mci.channel.repository.SyncTaskMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class SyncTaskService {
+
+ private final SyncTaskMapper syncTaskMapper;
+
+ @Transactional
+ public SyncTask createTask(String tenantCode, String taskType, String channelType, Long targetId, String targetCode) {
+ SyncTask task = new SyncTask();
+ task.setTenantCode(tenantCode);
+ task.setTaskType(taskType);
+ task.setChannelType(channelType);
+ task.setTargetId(targetId);
+ task.setTargetCode(targetCode);
+ task.setTaskStatus(SyncTask.STATUS_PENDING);
+ task.setSyncedCount(0);
+ task.setFailedCount(0);
+ task.setRetryCount(0);
+ task.setDeleted(0);
+ syncTaskMapper.insert(task);
+ return task;
+ }
+
+ @Transactional
+ public void markRunning(Long taskId) {
+ SyncTask task = syncTaskMapper.selectById(taskId);
+ if (task == null) {
+ return;
+ }
+ task.setTaskStatus(SyncTask.STATUS_RUNNING);
+ task.setUpdateTime(LocalDateTime.now());
+ syncTaskMapper.updateById(task);
+ }
+
+ @Transactional
+ public void markSuccess(Long taskId, int syncedCount) {
+ SyncTask task = syncTaskMapper.selectById(taskId);
+ if (task == null) {
+ return;
+ }
+ task.setTaskStatus(SyncTask.STATUS_SUCCESS);
+ task.setSyncedCount(syncedCount);
+ task.setFinishTime(LocalDateTime.now());
+ task.setUpdateTime(LocalDateTime.now());
+ syncTaskMapper.updateById(task);
+ }
+
+ @Transactional
+ public void markFailed(Long taskId, String errorMessage) {
+ SyncTask task = syncTaskMapper.selectById(taskId);
+ if (task == null) {
+ return;
+ }
+ task.setTaskStatus(SyncTask.STATUS_FAILED);
+ task.setErrorMessage(errorMessage);
+ task.setFinishTime(LocalDateTime.now());
+ task.setUpdateTime(LocalDateTime.now());
+ syncTaskMapper.updateById(task);
+ }
+
+ public SyncTask getTask(Long taskId, String tenantCode) {
+ SyncTask task = syncTaskMapper.selectById(taskId);
+ if (task == null) {
+ return null;
+ }
+ if (tenantCode != null && !tenantCode.equals(task.getTenantCode())) {
+ return null;
+ }
+ return task;
+ }
+
+ public SyncTask getTaskById(Long taskId) {
+ return syncTaskMapper.selectById(taskId);
+ }
+
+ public List listRecentTasks(String tenantCode, String taskType, Long targetId, int limit) {
+ return syncTaskMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(SyncTask::getTenantCode, tenantCode)
+ .eq(SyncTask::getTaskType, taskType)
+ .eq(targetId != null, SyncTask::getTargetId, targetId)
+ .orderByDesc(SyncTask::getId)
+ .last("LIMIT " + limit));
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/config/AsyncConfig.java b/backend/mci-server/src/main/java/com/sino/mci/config/AsyncConfig.java
new file mode 100644
index 0000000..999e8cc
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/config/AsyncConfig.java
@@ -0,0 +1,26 @@
+package com.sino.mci.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean("syncTaskPool")
+ public Executor syncTaskPool() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(4);
+ executor.setMaxPoolSize(16);
+ executor.setQueueCapacity(200);
+ executor.setThreadNamePrefix("sync-task-");
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/backend/mci-server/src/main/resources/db/migration/V20__drop_channel_subject_corp_secret.sql b/backend/mci-server/src/main/resources/db/migration/V20__drop_channel_subject_corp_secret.sql
new file mode 100644
index 0000000..6d17ab7
--- /dev/null
+++ b/backend/mci-server/src/main/resources/db/migration/V20__drop_channel_subject_corp_secret.sql
@@ -0,0 +1 @@
+ALTER TABLE mci_channel_subject DROP COLUMN corp_secret;
diff --git a/backend/mci-server/src/main/resources/db/migration/V21__add_sync_task.sql b/backend/mci-server/src/main/resources/db/migration/V21__add_sync_task.sql
new file mode 100644
index 0000000..ec87adb
--- /dev/null
+++ b/backend/mci-server/src/main/resources/db/migration/V21__add_sync_task.sql
@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS mci_sync_task (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户',
+ task_type VARCHAR(32) NOT NULL COMMENT '任务类型:enterprise_contacts / account_contacts / account_conversations',
+ channel_type VARCHAR(32) NOT NULL COMMENT '渠道:wecom / wechat_personal / dingtalk / feishu',
+ target_id BIGINT COMMENT '关联目标 ID:主体 ID 或账号 ID',
+ target_code VARCHAR(128) COMMENT '关联目标编码:如 userId',
+ task_status TINYINT NOT NULL DEFAULT 0 COMMENT '任务状态:0待执行 1执行中 2成功 3失败',
+ synced_count INT NOT NULL DEFAULT 0 COMMENT '已同步数量',
+ failed_count INT NOT NULL DEFAULT 0 COMMENT '失败数量',
+ error_message VARCHAR(1024) COMMENT '错误信息',
+ retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
+ finish_time DATETIME COMMENT '完成时间',
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ deleted TINYINT NOT NULL DEFAULT 0,
+ KEY idx_tenant_status (tenant_code, task_status),
+ KEY idx_target (task_type, target_id),
+ KEY idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步任务表';
diff --git a/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSessionArchiveTest.java b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSessionArchiveTest.java
index 6103928..f901896 100644
--- a/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSessionArchiveTest.java
+++ b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSessionArchiveTest.java
@@ -48,7 +48,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.main.allow-bean-definition-overriding=true",
- "sino.channel.wecom.session-archive.listener-enabled=false"
+ "sino.channel.wecom.session-archive.listener-enabled=false",
+ "mci.wecom.sync.mock=true"
})
@Transactional
class WeComSessionArchiveTest {
@@ -133,7 +134,7 @@ class WeComSessionArchiveTest {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
- command.setSecret(subject.getCorpSecret());
+ command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
@@ -177,7 +178,7 @@ class WeComSessionArchiveTest {
command.setChannelSubjectId(subject.getId());
command.setTenantCode(subject.getTenantCode());
command.setCorpId(subject.getCorpId());
- command.setSecret(subject.getCorpSecret());
+ command.setSecret(subject.getAgentSecret());
command.setPrivateKey(subject.getSessionArchivePrivateKey());
sessionArchivePullService.onPullCommand(command);
@@ -202,7 +203,7 @@ class WeComSessionArchiveTest {
subject.setSubjectCode("archive");
subject.setSubjectName("会话存档主体");
subject.setCorpId(CORP_ID);
- subject.setCorpSecret("secret-" + UUID.randomUUID());
+ subject.setAgentSecret("secret-" + UUID.randomUUID());
subject.setSessionArchiveEnabled(1);
subject.setSessionArchivePrivateKey("private-key");
subject.setStatus(1);
diff --git a/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java
index 1cfe7db..fa0a962 100644
--- a/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java
+++ b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/WeComSyncTest.java
@@ -1,16 +1,19 @@
package com.sino.mci.channel.controller;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationParticipant;
+import com.sino.mci.channel.entity.SyncTask;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationParticipantMapper;
+import com.sino.mci.channel.service.SyncTaskService;
import com.sino.mci.channel.wecom.sync.WeComContact;
import com.sino.mci.channel.wecom.sync.WeComContactType;
import com.sino.mci.channel.wecom.sync.WeComConversation;
@@ -29,7 +32,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.transaction.annotation.Transactional;
+import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.UUID;
@@ -47,14 +50,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
- "spring.main.allow-bean-definition-overriding=true"
+ "spring.main.allow-bean-definition-overriding=true",
+ "mci.wecom.sync.mock=true"
})
-@Transactional
class WeComSyncTest {
- private static final String TENANT_CODE = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8);
+ private final String tenantCode = "wecom-sync-" + UUID.randomUUID().toString().substring(0, 8);
private static final String CHANNEL_TYPE = ChannelType.WECOM.name().toLowerCase();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
@Autowired
private MockMvc mockMvc;
@@ -79,23 +84,31 @@ class WeComSyncTest {
@Autowired
private ChannelAccountConversationMapper accountConversationMapper;
- @Test
- void shouldSyncContactsAndPersistPersonAndIdentity() throws Exception {
- String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
- ChannelAccount account = createWeComAccount();
+ @Autowired
+ private SyncTaskService syncTaskService;
- mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
+ @Test
+ void shouldSyncEnterpriseContactsViaSubjectAndPersistInternalPerson() throws Exception {
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
+ ChannelSubject subject = createWeComSubject();
+
+ MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-subject/{id}/sync-contacts", subject.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
- .header("X-Tenant-Code", TENANT_CODE)
+ .header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
- .andExpect(jsonPath("$.data.syncedCount").value(2));
+ .andReturn();
+
+ Long taskId = extractTaskId(result);
+ SyncTask task = waitForTask(taskId);
+ assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
+ assertThat(task.getSyncedCount()).isEqualTo(2);
Person internalPerson = personMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(Person::getTenantCode, TENANT_CODE)
+ .eq(Person::getTenantCode, tenantCode)
.eq(Person::getPersonCode, "user001"));
assertThat(internalPerson).isNotNull();
assertThat(internalPerson.getPersonName()).isEqualTo("张三");
@@ -104,23 +117,42 @@ class WeComSyncTest {
ChannelIdentity internalIdentity = channelIdentityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(ChannelIdentity::getTenantCode, TENANT_CODE)
+ .eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_TYPE)
.eq(ChannelIdentity::getChannelUserId, "user001"));
assertThat(internalIdentity).isNotNull();
assertThat(internalIdentity.getPersonId()).isEqualTo(internalPerson.getId());
assertThat(internalIdentity.getChannelUserName()).isEqualTo("张三");
+ }
+
+ @Test
+ void shouldSyncAccountContactsAndPersistExternalPerson() throws Exception {
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
+ ChannelAccount account = createWeComAccount();
+
+ MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
+ .contextPath("/msg-platform")
+ .header("Authorization", authToken)
+ .header("X-Tenant-Code", tenantCode)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn();
+
+ Long taskId = extractTaskId(result);
+ SyncTask task = waitForTask(taskId);
+ assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Person externalPerson = personMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(Person::getTenantCode, TENANT_CODE)
+ .eq(Person::getTenantCode, tenantCode)
.eq(Person::getPersonCode, "wmexternal001"));
assertThat(externalPerson).isNotNull();
assertThat(externalPerson.getPersonType()).isEqualTo(3);
ChannelIdentity externalIdentity = channelIdentityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(ChannelIdentity::getTenantCode, TENANT_CODE)
+ .eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_TYPE)
.eq(ChannelIdentity::getChannelUserId, "wmexternal001"));
assertThat(externalIdentity).isNotNull();
@@ -129,21 +161,25 @@ class WeComSyncTest {
@Test
void shouldSyncConversationsAndPersistParticipantsAndBindings() throws Exception {
- String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
- mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
+ MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
- .header("X-Tenant-Code", TENANT_CODE)
+ .header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
- .andExpect(jsonPath("$.data.syncedCount").value(1));
+ .andReturn();
+
+ Long taskId = extractTaskId(result);
+ SyncTask task = waitForTask(taskId);
+ assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Conversation conversation = conversationMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
- .eq(Conversation::getTenantCode, TENANT_CODE)
+ .eq(Conversation::getTenantCode, tenantCode)
.eq(Conversation::getChannelType, CHANNEL_TYPE)
.eq(Conversation::getChannelConversationId, "chat001"));
assertThat(conversation).isNotNull();
@@ -181,53 +217,57 @@ class WeComSyncTest {
@Test
void shouldPreservePersonBusinessFieldsDuringContactSync() throws Exception {
- String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
Person person = new Person();
- person.setTenantCode(TENANT_CODE);
- person.setPersonCode("user001");
+ person.setTenantCode(tenantCode);
+ person.setPersonCode("wmexternal001");
person.setPersonName("原始姓名");
- person.setPhone("13800138001");
- // Seed with a personType that differs from what resolvePersonType(user001/INTERNAL) would assign (1).
- person.setPersonType(3);
+ person.setPhone("13900139001");
+ person.setPersonType(1);
person.setInstitutionId(100L);
person.setSupplierId(200L);
person.setStatus(1);
personMapper.insert(person);
ChannelIdentity identity = new ChannelIdentity();
- identity.setTenantCode(TENANT_CODE);
+ identity.setTenantCode(tenantCode);
identity.setPersonId(person.getId());
identity.setChannelType(CHANNEL_TYPE);
- identity.setChannelUserId("user001");
+ identity.setChannelUserId("wmexternal001");
identity.setChannelUserName("原始姓名");
identity.setSubjectId(account.getSubjectId());
identity.setStatus(1);
channelIdentityMapper.insert(identity);
- mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
+ MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-contacts", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
- .header("X-Tenant-Code", TENANT_CODE)
+ .header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.code").value(0));
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn();
+
+ Long taskId = extractTaskId(result);
+ SyncTask task = waitForTask(taskId);
+ assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Person updated = personMapper.selectById(person.getId());
- assertThat(updated.getPersonName()).isEqualTo("张三");
+ assertThat(updated.getPersonName()).isEqualTo("客户李四");
assertThat(updated.getInstitutionId()).isEqualTo(100L);
assertThat(updated.getSupplierId()).isEqualTo(200L);
- assertThat(updated.getPersonType()).isEqualTo(3);
+ assertThat(updated.getPersonType()).isEqualTo(1);
}
@Test
void shouldPreserveConversationBusinessFieldsDuringConversationSync() throws Exception {
- String authToken = createTenantAndLogin(mockMvc, TENANT_CODE, "企微同步租户");
+ String authToken = createTenantAndLogin(mockMvc, tenantCode, "企微同步租户");
ChannelAccount account = createWeComAccount();
Conversation conversation = new Conversation();
- conversation.setTenantCode(TENANT_CODE);
+ conversation.setTenantCode(tenantCode);
conversation.setConversationType(2);
conversation.setChannelType(CHANNEL_TYPE);
conversation.setChannelConversationId("chat001");
@@ -242,13 +282,18 @@ class WeComSyncTest {
conversation.setStatus(1);
conversationMapper.insert(conversation);
- mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
+ MvcResult result = mockMvc.perform(post("/msg-platform/api/v1/channel-account/{account_id}/sync-conversations", account.getId())
.contextPath("/msg-platform")
.header("Authorization", authToken)
- .header("X-Tenant-Code", TENANT_CODE)
+ .header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.code").value(0));
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn();
+
+ Long taskId = extractTaskId(result);
+ SyncTask task = waitForTask(taskId);
+ assertThat(task.getTaskStatus()).isEqualTo(SyncTask.STATUS_SUCCESS);
Conversation updated = conversationMapper.selectById(conversation.getId());
assertThat(updated.getConversationName()).isEqualTo("测试客户群");
@@ -258,20 +303,40 @@ class WeComSyncTest {
assertThat(updated.getChannelCode()).isEqualTo("ORIGINAL");
}
- private ChannelAccount createWeComAccount() {
+ private Long extractTaskId(MvcResult result) throws Exception {
+ return objectMapper.readTree(result.getResponse().getContentAsString()).path("data").path("id").asLong();
+ }
+
+ private SyncTask waitForTask(Long taskId) throws InterruptedException {
+ for (int i = 0; i < 50; i++) {
+ SyncTask task = syncTaskService.getTaskById(taskId);
+ if (task != null && (task.getTaskStatus() == SyncTask.STATUS_SUCCESS || task.getTaskStatus() == SyncTask.STATUS_FAILED)) {
+ return task;
+ }
+ Thread.sleep(200);
+ }
+ throw new RuntimeException("同步任务未在预期时间内完成: taskId=" + taskId);
+ }
+
+ private ChannelSubject createWeComSubject() {
ChannelSubject subject = new ChannelSubject();
- subject.setTenantCode(TENANT_CODE);
+ subject.setTenantCode(tenantCode);
subject.setChannelType(CHANNEL_TYPE);
subject.setSubjectCode("sino");
subject.setSubjectName("Sino");
subject.setCorpId("ww-sync-" + UUID.randomUUID().toString().substring(0, 8));
- subject.setCorpSecret("secret-" + UUID.randomUUID());
subject.setAgentId("1000002");
+ subject.setAgentSecret("secret-" + UUID.randomUUID());
subject.setStatus(1);
subjectMapper.insert(subject);
+ return subject;
+ }
+
+ private ChannelAccount createWeComAccount() {
+ ChannelSubject subject = createWeComSubject();
ChannelAccount account = new ChannelAccount();
- account.setTenantCode(TENANT_CODE);
+ account.setTenantCode(tenantCode);
account.setAccountType(1);
account.setChannelType(CHANNEL_TYPE);
account.setSubjectId(subject.getId());
@@ -288,7 +353,7 @@ class WeComSyncTest {
@Bean
public WeComSyncClient weComSyncClient() {
WeComSyncClient client = mock(WeComSyncClient.class);
- when(client.syncContacts(any())).thenReturn(List.of(
+ when(client.syncEnterpriseContacts(any())).thenReturn(List.of(
WeComContact.builder()
.userId("user001")
.name("张三")
@@ -302,7 +367,15 @@ class WeComSyncTest {
.type(WeComContactType.EXTERNAL)
.build()
));
- when(client.syncConversations(any())).thenReturn(List.of(
+ when(client.syncAccountContacts(any(), any())).thenReturn(List.of(
+ WeComContact.builder()
+ .userId("wmexternal001")
+ .name("客户李四")
+ .mobile("13900139001")
+ .type(WeComContactType.EXTERNAL)
+ .build()
+ ));
+ when(client.syncAccountConversations(any(), any())).thenReturn(List.of(
WeComConversation.builder()
.chatId("chat001")
.chatName("测试客户群")
diff --git a/backend/pom.xml b/backend/pom.xml
index 2557d3a..c8ea295 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -84,6 +84,28 @@
+
+
+ org.apache.maven.plugins
+ maven-toolchains-plugin
+ 3.2.0
+
+
+
+ 21
+ zulu
+
+
+
+
+
+
+ toolchain
+
+
+
+
+
diff --git a/docs/superpowers/plans/2026-07-10-channel-service-mci-server-integration-plan.md b/docs/superpowers/plans/2026-07-10-channel-service-mci-server-integration-plan.md
new file mode 100644
index 0000000..2601309
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-channel-service-mci-server-integration-plan.md
@@ -0,0 +1,1931 @@
+# channel-service 与 mci-server 双向集成 Implementation Plan
+
+> **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:** 在 `channel-service`(Python)中实现本地 SQLite 持久化的事件上报道具,并新增 `mci-server`(Java)的 `/api/v1/channel-account/event` 接收端点,完成心跳、登录/在线状态、发送结果的双向 HTTP 集成。
+
+**Architecture:** channel-service 所有状态变更与发送操作先写本地 `outbound_events`(SQLite),再由单线程后台 worker 按指数退避推送到 mci-server;mci-server 用 `event_id` 幂等并更新 `mci_channel_account` / `mci_send_record`。
+
+**Tech Stack:** Python 3.11 + FastAPI + SQLite + httpx;Java 21 + Spring Boot 4.1.x + MyBatis Plus + MySQL 8。
+
+---
+
+## 0. 文件结构总览
+
+### channel-service(Python)
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `channel-service/app/models/event.py` | 新建 | 事件枚举、数据类、payload schema |
+| `channel-service/app/repositories/event_store.py` | 新建 | SQLite `outbound_events` 表及 CRUD |
+| `channel-service/app/clients/mci_server_client.py` | 新建 | HTTP 客户端,POST 事件到 mci-server |
+| `channel-service/app/services/event_publisher.py` | 新建 | 单线程后台 worker、重试、死信 |
+| `channel-service/app/config.py` | 修改 | 新增事件存储/重试/清理配置 |
+| `channel-service/app/clients/pywechat_client.py` | 修改 | 增加登录/在线状态变更辅助方法 |
+| `channel-service/app/services/heartbeat_service.py` | 修改 | 心跳时写事件 |
+| `channel-service/app/services/login_service.py` | 修改 | 登录流程写事件 |
+| `channel-service/app/services/send_service.py` | 修改 | 发送改为异步受理并写 send_result 事件 |
+| `channel-service/app/api/wechat_personal.py` | 修改 | send 请求增加 `record_id`,新增 expire 路由 |
+| `channel-service/app/api/monitor.py` | 修改 | heartbeat 路由透传 `tenant_code` |
+| `channel-service/app/main.py` | 修改 | 启动/停止 EventPublisher worker |
+| `channel-service/tests/test_event_store.py` | 新建 | 事件存储测试 |
+| `channel-service/tests/test_event_publisher.py` | 新建 | worker 重试/死信测试 |
+| `channel-service/tests/test_send_async.py` | 新建 | 发送异步化测试 |
+
+### mci-server(Java)
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelEventRecord.java` | 新建 | 幂等去重表实体 |
+| `backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelEventRecordMapper.java` | 新建 | MyBatis Plus Mapper |
+| `backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountEventRequest.java` | 新建 | 事件接收 DTO |
+| `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountEventService.java` | 新建 | 事件处理逻辑 |
+| `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountEventController.java` | 新建 | 接收端点 |
+| `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java` | 修改 | dispatchRecord 时把 `record_id` 放入 extra |
+| `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapter.java` | 修改 | 把 `record_id` 传给 channel-service |
+| `backend/mci-server/src/main/resources/db/migration/V22__add_channel_event_record.sql` | 新建 | 幂等表 DDL |
+| `backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelAccountEventControllerTest.java` | 新建 | 控制器测试 |
+| `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapterTest.java` | 修改 | 验证 record_id 透传 |
+
+---
+
+## Phase 1: channel-service 事件基础设施
+
+### Task 1: 创建事件模型 `app/models/event.py`
+
+**Files:**
+- Create: `channel-service/app/models/event.py`
+- Test: `channel-service/tests/test_event_store.py`(后续步骤编写)
+
+- [ ] **Step 1: 编写事件枚举与数据类**
+
+```python
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass, field, asdict
+from datetime import datetime, timezone
+from enum import Enum
+from typing import Any, Optional
+
+
+class EventType(str, Enum):
+ HEARTBEAT = "heartbeat"
+ LOGIN_STATUS_CHANGED = "login_status_changed"
+ ONLINE_STATUS_CHANGED = "online_status_changed"
+ SEND_RESULT = "send_result"
+ ERROR = "error"
+
+
+class EventStatus(str, Enum):
+ PENDING = "pending"
+ RETRYING = "retrying"
+ DELIVERED = "delivered"
+ DEAD_LETTER = "dead_letter"
+
+
+@dataclass
+class OutboundEvent:
+ event_type: EventType
+ account_id: str
+ tenant_code: str
+ data: dict[str, Any]
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ def to_payload(self) -> dict[str, Any]:
+ return {
+ "event_id": self.event_id,
+ "event_type": self.event_type.value,
+ "account_id": self.account_id,
+ "tenant_code": self.tenant_code,
+ "timestamp": self.timestamp.isoformat(),
+ "data": self.data,
+ }
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci/channel-service
+git add app/models/event.py
+git commit -m "feat(event): add outbound event models"
+```
+
+---
+
+### Task 2: 创建 SQLite 事件存储 `app/repositories/event_store.py`
+
+**Files:**
+- Create: `channel-service/app/repositories/event_store.py`
+- Test: `channel-service/tests/test_event_store.py`
+
+- [ ] **Step 1: 编写测试 — 验证建表与保存事件**
+
+```python
+import os
+import tempfile
+
+import pytest
+
+from app.models.event import EventType, OutboundEvent
+from app.repositories.event_store import EventStore
+
+
+@pytest.fixture
+def store():
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ store = EventStore(path)
+ yield store
+ os.unlink(path)
+
+
+def test_save_and_fetch_pending(store):
+ event = OutboundEvent(
+ event_type=EventType.HEARTBEAT,
+ account_id="bot-001",
+ tenant_code="rescue",
+ data={"online_status": "online"},
+ )
+ store.save(event)
+ pending = store.fetch_pending(limit=10)
+ assert len(pending) == 1
+ assert pending[0].event_id == event.event_id
+
+
+def test_mark_delivered(store):
+ event = OutboundEvent(
+ event_type=EventType.HEARTBEAT,
+ account_id="bot-001",
+ tenant_code="rescue",
+ data={},
+ )
+ store.save(event)
+ store.mark_delivered(event.event_id)
+ pending = store.fetch_pending(limit=10)
+ assert len(pending) == 0
+```
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci/channel-service
+pytest tests/test_event_store.py -v
+```
+
+Expected: `ModuleNotFoundError` 或 `ImportError`。
+
+- [ ] **Step 3: 实现 EventStore**
+
+```python
+from __future__ import annotations
+
+import json
+import sqlite3
+import threading
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+from app.models.event import EventStatus, EventType, OutboundEvent
+
+
+class EventStore:
+ def __init__(self, db_path: str):
+ self.db_path = db_path
+ self._local = threading.local()
+ self._init_db()
+
+ def _now(self) -> datetime:
+ return datetime.now(timezone.utc).replace(tzinfo=None)
+
+ def _connection(self) -> sqlite3.Connection:
+ if not hasattr(self._local, "conn") or self._local.conn is None:
+ self._local.conn = sqlite3.connect(self.db_path, check_same_thread=False)
+ self._local.conn.row_factory = sqlite3.Row
+ return self._local.conn
+
+ def _init_db(self) -> None:
+ with sqlite3.connect(self.db_path) as conn:
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS outbound_events (
+ id TEXT PRIMARY KEY,
+ event_type TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempt_count INTEGER NOT NULL DEFAULT 0,
+ next_retry_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_error TEXT
+ )
+ """
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_status_next_retry ON outbound_events(status, next_retry_at)"
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_created_at ON outbound_events(created_at)"
+ )
+
+ def save(self, event: OutboundEvent) -> None:
+ now = self._now()
+ with self._connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO outbound_events
+ (id, event_type, payload, status, attempt_count, next_retry_at, created_at, updated_at, last_error)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ event.event_id,
+ event.event_type.value,
+ json.dumps(event.to_payload(), default=str),
+ EventStatus.PENDING.value,
+ 0,
+ now,
+ now,
+ now,
+ None,
+ ),
+ )
+
+ def fetch_pending(self, limit: int = 100) -> list[OutboundEvent]:
+ now = self._now()
+ cursor = self._connection().execute(
+ """
+ SELECT id, event_type, payload, status, attempt_count
+ FROM outbound_events
+ WHERE status IN ('pending', 'retrying') AND next_retry_at <= ?
+ ORDER BY next_retry_at ASC
+ LIMIT ?
+ """,
+ (now, limit),
+ )
+ return [self._row_to_event(row) for row in cursor.fetchall()]
+
+ def _row_to_event(self, row: sqlite3.Row) -> OutboundEvent:
+ payload = json.loads(row["payload"])
+ return OutboundEvent(
+ event_id=payload["event_id"],
+ event_type=EventType(payload["event_type"]),
+ account_id=payload["account_id"],
+ tenant_code=payload["tenant_code"],
+ data=payload["data"],
+ status=EventStatus(row["status"]),
+ attempt_count=row["attempt_count"],
+ )
+
+ def mark_delivered(self, event_id: str) -> None:
+ now = self._now()
+ with self._connection() as conn:
+ conn.execute(
+ "UPDATE outbound_events SET status = 'delivered', updated_at = ? WHERE id = ?",
+ (now, event_id),
+ )
+
+ def mark_retry(self, event_id: str, status: EventStatus, attempt_count: int, next_retry_at: datetime, last_error: Optional[str]) -> None:
+ now = self._now()
+ with self._connection() as conn:
+ conn.execute(
+ """
+ UPDATE outbound_events
+ SET status = ?, attempt_count = ?, next_retry_at = ?, updated_at = ?, last_error = ?
+ WHERE id = ?
+ """,
+ (status.value, attempt_count, next_retry_at, now, last_error, event_id),
+ )
+
+ def cleanup(self, retention_days: int) -> int:
+ threshold = self._now() - timedelta(days=retention_days)
+ with self._connection() as conn:
+ cursor = conn.execute(
+ """
+ DELETE FROM outbound_events
+ WHERE status IN ('delivered', 'dead_letter') AND created_at < ?
+ """,
+ (threshold,),
+ )
+ return cursor.rowcount
+```
+
+- [ ] **Step 4: 运行测试,确认通过**
+
+```bash
+pytest tests/test_event_store.py -v
+```
+
+Expected: PASS。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/repositories/event_store.py tests/test_event_store.py
+git commit -m "feat(event): add SQLite outbound event store"
+```
+
+---
+
+### Task 3: 创建 mci-server HTTP 客户端 `app/clients/mci_server_client.py`
+
+**Files:**
+- Create: `channel-service/app/clients/mci_server_client.py`
+- Test: `channel-service/tests/test_event_publisher.py`(后续步骤)
+
+- [ ] **Step 1: 实现客户端**
+
+```python
+from __future__ import annotations
+
+import httpx
+
+from app.models.event import OutboundEvent
+
+
+class MciServerClient:
+ def __init__(self, base_url: str, timeout: float = 10.0):
+ self.base_url = base_url.rstrip("/")
+ self.timeout = timeout
+
+ async def post_event(self, event: OutboundEvent) -> None:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ response = await client.post(
+ f"{self.base_url}/api/v1/channel-account/event",
+ json=event.to_payload(),
+ )
+ response.raise_for_status()
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add app/clients/mci_server_client.py
+git commit -m "feat(client): add mci-server event poster"
+```
+
+---
+
+### Task 4: 创建单线程事件发布 worker `app/services/event_publisher.py`
+
+**Files:**
+- Create: `channel-service/app/services/event_publisher.py`
+- Test: `channel-service/tests/test_event_publisher.py`
+
+- [ ] **Step 1: 安装测试依赖 `respx`**
+
+在 `channel-service/requirements.txt` 中追加:
+
+```text
+respx==0.22.0
+```
+
+然后安装:
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci/channel-service
+pip install -r requirements.txt
+```
+
+- [ ] **Step 2: 编写测试 — 验证成功推送与重试**
+
+```python
+import asyncio
+from datetime import datetime, timezone
+
+import httpx
+import pytest
+import respx
+from httpx import Response
+
+from app.clients.mci_server_client import MciServerClient
+from app.models.event import EventType, OutboundEvent
+from app.repositories.event_store import EventStore
+from app.services.event_publisher import EventPublisher
+
+
+@pytest.fixture
+def publisher(tmp_path):
+ db_path = tmp_path / "events.db"
+ store = EventStore(str(db_path))
+ client = MciServerClient("http://mock-mci-server/msg-platform")
+ pub = EventPublisher(store, client, max_retry=2, base_delay_seconds=1, interval_seconds=0.1)
+ yield pub
+
+
+@respx.mock
+def test_publish_delivers_event(publisher):
+ route = respx.post("http://mock-mci-server/msg-platform/api/v1/channel-account/event").mock(return_value=Response(200))
+ event = OutboundEvent(EventType.HEARTBEAT, "bot-001", "rescue", {"online_status": "online"})
+ publisher.store.save(event)
+
+ async def run():
+ await publisher._tick()
+
+ asyncio.run(run())
+ assert route.called
+ pending = publisher.store.fetch_pending(limit=10)
+ assert len(pending) == 0
+
+
+@respx.mock
+def test_publish_retries_then_dead_letter(publisher):
+ route = respx.post("http://mock-mci-server/msg-platform/api/v1/channel-account/event").mock(return_value=Response(500))
+ event = OutboundEvent(EventType.HEARTBEAT, "bot-001", "rescue", {})
+ publisher.store.save(event)
+
+ async def run():
+ await publisher._tick()
+
+ asyncio.run(run())
+ assert route.call_count == 1
+ updated = publisher.store.fetch_pending(limit=10)
+ assert len(updated) == 1
+ assert updated[0].status == EventStatus.RETRYING
+```
+
+> 注意:`EventStore._row_to_event` 目前未保留 status,需要给 OutboundEvent 增加 status 字段或在 store 返回元组。为保持简单,可以在 `fetch_pending` 返回 `(OutboundEvent, status, attempt_count)` 元组,或在 OutboundEvent 上增加 `status` 与 `attempt_count` 字段。下面选择给 OutboundEvent 增加字段。
+
+- [ ] **Step 2: 运行测试,确认失败**
+
+```bash
+pytest tests/test_event_publisher.py -v
+```
+
+Expected: ImportError / AttributeError。
+
+- [ ] **Step 3: 修改 `app/models/event.py`,给 OutboundEvent 增加状态字段**
+
+```python
+@dataclass
+class OutboundEvent:
+ event_type: EventType
+ account_id: str
+ tenant_code: str
+ data: dict[str, Any]
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ status: EventStatus = EventStatus.PENDING
+ attempt_count: int = 0
+```
+
+- [ ] **Step 4: 修改 `app/repositories/event_store.py` 的 `_row_to_event`,回填 status 与 attempt_count**
+
+```python
+ def _row_to_event(self, row: sqlite3.Row) -> OutboundEvent:
+ payload = json.loads(row["payload"])
+ return OutboundEvent(
+ event_id=payload["event_id"],
+ event_type=EventType(payload["event_type"]),
+ account_id=payload["account_id"],
+ tenant_code=payload["tenant_code"],
+ data=payload["data"],
+ status=EventStatus(row["status"]),
+ attempt_count=row["attempt_count"],
+ )
+```
+
+- [ ] **Step 5: 实现 EventPublisher**
+
+```python
+from __future__ import annotations
+
+import asyncio
+import math
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+from app.clients.mci_server_client import MciServerClient
+from app.models.event import EventStatus, OutboundEvent
+from app.repositories.event_store import EventStore
+
+
+class EventPublisher:
+ def __init__(
+ self,
+ store: EventStore,
+ client: MciServerClient,
+ max_retry: int = 5,
+ base_delay_seconds: int = 5,
+ interval_seconds: float = 5.0,
+ batch_size: int = 100,
+ ):
+ self.store = store
+ self.client = client
+ self.max_retry = min(max_retry, 10)
+ self.base_delay_seconds = base_delay_seconds
+ self.interval_seconds = interval_seconds
+ self.batch_size = batch_size
+ self._task: Optional[asyncio.Task] = None
+ self._stop_event = asyncio.Event()
+
+ def start(self) -> None:
+ self._task = asyncio.create_task(self._loop())
+
+ async def stop(self) -> None:
+ self._stop_event.set()
+ if self._task:
+ try:
+ await asyncio.wait_for(self._task, timeout=5.0)
+ except asyncio.TimeoutError:
+ self._task.cancel()
+
+ async def _loop(self) -> None:
+ while not self._stop_event.is_set():
+ try:
+ await self._tick()
+ except Exception:
+ pass
+ try:
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds)
+ except asyncio.TimeoutError:
+ pass
+
+ async def _tick(self) -> None:
+ events = self.store.fetch_pending(limit=self.batch_size)
+ for event in events:
+ try:
+ await self.client.post_event(event)
+ self.store.mark_delivered(event.event_id)
+ except Exception as e:
+ next_attempt = event.attempt_count + 1
+ delay = self.base_delay_seconds * (2 ** (next_attempt - 1))
+ next_retry_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=delay)
+ status = EventStatus.DEAD_LETTER if next_attempt >= self.max_retry else EventStatus.RETRYING
+ self.store.mark_retry(event.event_id, status, next_attempt, next_retry_at, str(e)[:500])
+
+ async def cleanup_old_events(self, retention_days: int) -> int:
+ return self.store.cleanup(retention_days)
+```
+
+- [ ] **Step 6: 运行测试,确认通过**
+
+```bash
+pytest tests/test_event_publisher.py -v
+```
+
+Expected: PASS。
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/models/event.py app/repositories/event_store.py app/services/event_publisher.py tests/test_event_publisher.py
+git commit -m "feat(event): add single-thread event publisher with retry"
+```
+
+---
+
+### Task 5: 扩展配置 `app/config.py`
+
+**Files:**
+- Modify: `channel-service/app/config.py`
+
+- [ ] **Step 1: 添加配置项**
+
+```python
+class Settings(BaseSettings):
+ app_name: str = "sino-mci-channel-service"
+ debug: bool = os.getenv("DEBUG", "false").lower() == "true"
+ host: str = "0.0.0.0"
+ port: int = int(os.getenv("PORT", "8000"))
+ mci_server_url: str = os.getenv("MCI_SERVER_URL", "http://localhost:8080/msg-platform")
+ heartbeat_interval_seconds: int = int(os.getenv("HEARTBEAT_INTERVAL_SECONDS", "30"))
+ pywechat_mock_mode: bool = os.getenv("PYWECHAT_MOCK_MODE", "true").lower() == "true"
+
+ event_store_path: str = os.getenv("EVENT_STORE_PATH", "./data/events.db")
+ max_retry: int = int(os.getenv("MAX_RETRY", "5"))
+ base_retry_delay_seconds: int = int(os.getenv("BASE_RETRY_DELAY_SECONDS", "5"))
+ event_retention_days: int = int(os.getenv("EVENT_RETENTION_DAYS", "10"))
+ event_publish_interval_seconds: float = float(os.getenv("EVENT_PUBLISH_INTERVAL_SECONDS", "5"))
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add app/config.py
+git commit -m "feat(config): add event publisher settings"
+```
+
+---
+
+## Phase 2: channel-service 业务服务接入事件
+
+### Task 6: 改造心跳服务,发送 heartbeat 事件
+
+**Files:**
+- Modify: `channel-service/app/services/heartbeat_service.py`
+- Modify: `channel-service/app/api/monitor.py`
+- Test: `channel-service/tests/test_api.py`
+
+- [ ] **Step 1: 修改 HeartbeatRequest,增加 tenant_code**
+
+在 `app/api/monitor.py` 中:
+
+```python
+class HeartbeatRequest(BaseModel):
+ account_id: str = Field(..., description="渠道账号唯一标识")
+ account_name: str | None = Field(None)
+ tenant_code: str = Field("default", description="业务系统租户编码")
+ risk_level: int = Field(0, ge=0, le=100)
+ sent_count_today: int = Field(0, ge=0)
+```
+
+- [ ] **Step 2: 修改 HeartbeatService,注入 EventStore 并写事件**
+
+```python
+from app.models.event import EventType, OutboundEvent
+from app.repositories.event_store import EventStore
+
+
+class HeartbeatService:
+ def __init__(self, registry: ClientRegistry, store: EventStore):
+ self.registry = registry
+ self.store = store
+
+ def heartbeat(self, account_id: str, payload: dict) -> dict:
+ client = self.registry.get_or_create(account_id, payload.get("account_name"))
+ client.heartbeat()
+ if "risk_level" in payload:
+ client.state.risk_level = payload["risk_level"]
+ if "sent_count_today" in payload:
+ client.state.sent_count_today = payload["sent_count_today"]
+
+ event = OutboundEvent(
+ event_type=EventType.HEARTBEAT,
+ account_id=account_id,
+ tenant_code=payload.get("tenant_code", "default"),
+ data={
+ "login_status": client.state.login_status.value,
+ "online_status": client.state.online_status.value,
+ "risk_level": client.state.risk_level,
+ "sent_count_today": client.state.sent_count_today,
+ },
+ )
+ self.store.save(event)
+ return {"success": True, "account_id": account_id}
+```
+
+- [ ] **Step 3: 修改 monitor.py heartbeat 路由,传入 tenant_code**
+
+```python
+@router.post("/v1/heartbeat")
+def heartbeat(request: HeartbeatRequest):
+ return heartbeat_service.heartbeat(request.account_id, request.model_dump(exclude_none=True))
+```
+
+- [ ] **Step 4: 修改 `app/services/__init__.py` 或 `app/main.py` 中初始化方式,注入 EventStore 单例**
+
+先创建全局 event_store:
+
+```python
+# app/repositories/event_store.py 底部追加
+event_store = EventStore(settings.event_store_path)
+```
+
+然后修改 `app/services/heartbeat_service.py` 底部:
+
+```python
+from app.repositories.event_store import event_store
+heartbeat_service = HeartbeatService(registry, event_store)
+```
+
+- [ ] **Step 5: 更新测试 `tests/test_api.py` 中的 heartbeat 测试,验证事件写入**
+
+```python
+def test_heartbeat_creates_event():
+ from app.repositories.event_store import event_store
+ event_store.cleanup(0)
+ client.post("/v1/heartbeat", json={
+ "account_id": "bot-heartbeat",
+ "tenant_code": "rescue",
+ "risk_level": 10,
+ "sent_count_today": 5,
+ })
+ pending = event_store.fetch_pending(limit=10)
+ assert any(e.event_type == EventType.HEARTBEAT and e.tenant_code == "rescue" for e in pending)
+```
+
+- [ ] **Step 6: 运行 channel-service 全部测试**
+
+```bash
+pytest -v
+```
+
+Expected: PASS(可能需要调整旧测试)。
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/services/heartbeat_service.py app/api/monitor.py app/repositories/event_store.py tests/test_api.py
+git commit -m "feat(heartbeat): emit heartbeat event to SQLite"
+```
+
+---
+
+### Task 7: 改造登录服务,发送 login/online 状态事件
+
+**Files:**
+- Modify: `channel-service/app/services/login_service.py`
+- Modify: `channel-service/app/clients/pywechat_client.py`
+- Test: `channel-service/tests/test_api.py`
+
+- [ ] **Step 1: 给 PyWeChatClient 增加状态变更辅助方法**
+
+在 `app/clients/pywechat_client.py` 中:
+
+```python
+ def set_login_status(self, status: LoginStatus, error_message: Optional[str] = None) -> None:
+ old = self.state.login_status
+ self.state.login_status = status
+ if status == LoginStatus.SUCCESS:
+ self.state.online_status = OnlineStatus.ONLINE
+ self.state.last_heartbeat_at = time.time()
+ elif status in (LoginStatus.EXPIRED, LoginStatus.FAILED):
+ self.state.online_status = OnlineStatus.OFFLINE
+ if error_message:
+ self.state.error_message = error_message
+
+ def set_online_status(self, status: OnlineStatus) -> None:
+ self.state.online_status = status
+```
+
+- [ ] **Step 2: 修改 LoginService,注入 EventStore 并写事件**
+
+```python
+from app.models.event import EventType, OutboundEvent
+from app.repositories.event_store import EventStore
+
+
+class LoginService:
+ def __init__(self, registry: ClientRegistry, store: EventStore):
+ self.registry = registry
+ self.store = store
+
+ def start_login(self, account_id: str, account_name: Optional[str] = None, tenant_code: str = "default") -> dict:
+ client = self.registry.get_or_create(account_id, account_name)
+ login_id = client.start_login()
+ self._emit_login_changed(account_id, tenant_code, LoginStatus.PENDING, client.state.login_status, client.state.qr_code_url)
+ return {
+ "login_id": login_id,
+ "qr_code_url": client.state.qr_code_url,
+ "status": client.state.login_status.value,
+ }
+
+ def confirm_login(self, account_id: str, tenant_code: str = "default") -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"success": False, "error": "account not found"}
+ previous_login = client.state.login_status
+ previous_online = client.state.online_status
+ client.set_login_status(LoginStatus.SUCCESS)
+ self._emit_login_changed(account_id, tenant_code, previous_login, client.state.login_status)
+ self._emit_online_changed(account_id, tenant_code, previous_online, client.state.online_status)
+ return {"success": True, "status": client.state.login_status.value}
+
+ def expire_login(self, account_id: str, tenant_code: str = "default") -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return {"success": False, "error": "account not found"}
+ previous = client.state.login_status
+ client.set_login_status(LoginStatus.EXPIRED)
+ self._emit_login_changed(account_id, tenant_code, previous, client.state.login_status)
+ return {"success": True, "status": client.state.login_status.value}
+
+ def _emit_login_changed(self, account_id: str, tenant_code: str, previous: LoginStatus, current: LoginStatus, qr_code_url: Optional[str] = None) -> None:
+ event = OutboundEvent(
+ event_type=EventType.LOGIN_STATUS_CHANGED,
+ account_id=account_id,
+ tenant_code=tenant_code,
+ data={
+ "previous": previous.value,
+ "current": current.value,
+ "qr_code_url": qr_code_url,
+ },
+ )
+ self.store.save(event)
+
+ def _emit_online_changed(self, account_id: str, tenant_code: str, previous: OnlineStatus, current: OnlineStatus) -> None:
+ event = OutboundEvent(
+ event_type=EventType.ONLINE_STATUS_CHANGED,
+ account_id=account_id,
+ tenant_code=tenant_code,
+ data={
+ "previous": previous.value,
+ "current": current.value,
+ },
+ )
+ self.store.save(event)
+```
+
+- [ ] **Step 3: 修改 API 路由传入 tenant_code**
+
+在 `app/api/wechat_personal.py` 中:
+
+```python
+class StartLoginRequest(BaseModel):
+ account_id: str = Field(..., description="渠道账号唯一标识")
+ account_name: Optional[str] = Field(None, description="账号显示名称")
+ tenant_code: str = Field("default", description="业务系统租户编码")
+
+
+@router.post("/login/start")
+def start_login(request: StartLoginRequest):
+ return login_service.start_login(request.account_id, request.account_name, request.tenant_code)
+
+
+class LoginConfirmRequest(BaseModel):
+ tenant_code: str = Field("default", description="业务系统租户编码")
+
+
+@router.post("/login/{account_id}/confirm")
+def confirm_login(account_id: str, request: LoginConfirmRequest):
+ return login_service.confirm_login(account_id, request.tenant_code)
+```
+
+- [ ] **Step 4: 更新 login_service 单例注入 EventStore**
+
+```python
+from app.repositories.event_store import event_store
+login_service = LoginService(registry, event_store)
+```
+
+- [ ] **Step 5: 运行测试并 Commit**
+
+```bash
+pytest -v
+git add app/clients/pywechat_client.py app/services/login_service.py app/api/wechat_personal.py tests/test_api.py
+git commit -m "feat(login): emit login and online status events"
+```
+
+---
+
+### Task 8: 改造发送服务,改为异步受理并发送 send_result 事件
+
+**Files:**
+- Modify: `channel-service/app/services/send_service.py`
+- Modify: `channel-service/app/api/wechat_personal.py`
+- Test: `channel-service/tests/test_send_async.py`
+
+- [ ] **Step 1: 修改 SendTextRequest / SendImageRequest 增加 record_id 与 tenant_code**
+
+```python
+class SendTextRequest(BaseModel):
+ account_id: str
+ conversation_id: str = Field(..., description="目标会话/群 ID")
+ text: str = Field(..., min_length=1, description="消息内容")
+ record_id: str = Field(..., description="mci-server 发送记录 ID")
+ tenant_code: str = Field("default", description="业务系统租户编码")
+
+
+class SendImageRequest(BaseModel):
+ account_id: str
+ conversation_id: str
+ image_url: str
+ record_id: str = Field(..., description="mci-server 发送记录 ID")
+ tenant_code: str = Field("default", description="业务系统租户编码")
+```
+
+- [ ] **Step 2: 修改 SendService 为异步受理并写 send_result 事件**
+
+```python
+import uuid
+
+from app.models.event import EventType, OutboundEvent
+from app.repositories.event_store import EventStore
+
+
+class SendService:
+ def __init__(self, registry: ClientRegistry, store: EventStore):
+ self.registry = registry
+ self.store = store
+
+ def send_text(self, account_id: str, conversation_id: str, text: str, record_id: str, tenant_code: str = "default") -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return self._fail(account_id, record_id, tenant_code, conversation_id, f"account {account_id} not registered")
+ if client.state.online_status != OnlineStatus.ONLINE:
+ return self._fail(account_id, record_id, tenant_code, conversation_id, "account not online")
+ if client.state.risk_level >= 80:
+ return self._fail(account_id, record_id, tenant_code, conversation_id, "account risk level too high", error_code="RISK_CONTROL_BLOCKED")
+
+ message_id = f"mock-msg-{uuid.uuid4().hex[:16]}"
+ result = client.send_text(conversation_id, text)
+ self._emit_send_result(account_id, record_id, tenant_code, conversation_id, True, message_id, result)
+ return {"accepted": True, "message_id": message_id}
+
+ def send_image(self, account_id: str, conversation_id: str, image_url: str, record_id: str, tenant_code: str = "default") -> dict:
+ client = self.registry.get(account_id)
+ if client is None:
+ return self._fail(account_id, record_id, tenant_code, conversation_id, f"account {account_id} not registered")
+ if client.state.online_status != OnlineStatus.ONLINE:
+ return self._fail(account_id, record_id, tenant_code, conversation_id, "account not online")
+
+ message_id = f"mock-img-{image_url.split('/')[-1]}"
+ self._emit_send_result(account_id, record_id, tenant_code, conversation_id, True, message_id, {"image_url": image_url})
+ return {"accepted": True, "message_id": message_id}
+
+ def _fail(self, account_id: str, record_id: str, tenant_code: str, conversation_id: str, error: str, error_code: str = "SEND_FAILED") -> dict:
+ self._emit_send_result(account_id, record_id, tenant_code, conversation_id, False, None, {"error": error, "error_code": error_code})
+ return {"accepted": False, "error": error}
+
+ def _emit_send_result(self, account_id: str, record_id: str, tenant_code: str, conversation_id: str, success: bool, message_id: Optional[str], channel_result: dict) -> None:
+ event = OutboundEvent(
+ event_type=EventType.SEND_RESULT,
+ account_id=account_id,
+ tenant_code=tenant_code,
+ data={
+ "record_id": record_id,
+ "conversation_id": conversation_id,
+ "success": success,
+ "message_id": message_id,
+ "channel_result": channel_result,
+ "error": channel_result.get("error"),
+ },
+ )
+ self.store.save(event)
+```
+
+
+
+- [ ] **Step 3: 修改 API 路由**
+
+```python
+@router.post("/send/text")
+def send_text(request: SendTextRequest):
+ return send_service.send_text(
+ request.account_id,
+ request.conversation_id,
+ request.text,
+ request.record_id,
+ request.tenant_code,
+ )
+
+
+@router.post("/send/image")
+def send_image(request: SendImageRequest):
+ return send_service.send_image(
+ request.account_id,
+ request.conversation_id,
+ request.image_url,
+ request.record_id,
+ request.tenant_code,
+ )
+```
+
+- [ ] **Step 4: 更新 send_service 单例注入 EventStore**
+
+```python
+from app.repositories.event_store import event_store
+send_service = SendService(registry, event_store)
+```
+
+- [ ] **Step 5: 更新旧测试**
+
+`tests/test_api.py` 中 `test_send_after_login` 和 `test_send_without_login_fails` 需要增加 `record_id`:
+
+```python
+{"account_id": "bot-002", "conversation_id": "room-001", "text": "hello", "record_id": "rec-001"}
+```
+
+并把 `assert r.json()["success"] is True` 改为 `assert r.json()["accepted"] is True`。
+
+- [ ] **Step 6: 新增 `tests/test_send_async.py`**
+
+```python
+from fastapi.testclient import TestClient
+
+from app.main import app
+from app.models.event import EventType
+from app.repositories.event_store import event_store
+
+client = TestClient(app)
+
+
+def test_send_text_creates_send_result_event():
+ event_store.cleanup(0)
+ client.post("/v1/wechat-personal/login/start", json={"account_id": "send-bot", "tenant_code": "rescue"})
+ client.post("/v1/wechat-personal/login/send-bot/confirm", json={"tenant_code": "rescue"})
+
+ r = client.post("/v1/wechat-personal/send/text", json={
+ "account_id": "send-bot",
+ "conversation_id": "room-001",
+ "text": "hello",
+ "record_id": "rec-123",
+ "tenant_code": "rescue",
+ })
+ assert r.status_code == 200
+ assert r.json()["accepted"] is True
+
+ pending = event_store.fetch_pending(limit=10)
+ assert any(e.event_type == EventType.SEND_RESULT and e.data.get("record_id") == "rec-123" for e in pending)
+```
+
+- [ ] **Step 7: 运行测试并 Commit**
+
+```bash
+pytest -v
+git add app/services/send_service.py app/api/wechat_personal.py tests/test_api.py tests/test_send_async.py
+git commit -m "feat(send): async acceptance and send_result event"
+```
+
+---
+
+### Task 9: 在 `app/main.py` 启动/停止 EventPublisher worker
+
+**Files:**
+- Modify: `channel-service/app/main.py`
+- Modify: `channel-service/app/clients/mci_server_client.py`(可选,创建单例)
+
+- [ ] **Step 1: 在 `app/repositories/event_store.py` 创建全局 event_store 单例(已完成)**
+
+- [ ] **Step 2: 修改 `app/main.py`**
+
+```python
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+from app.api import health, monitor, wechat_personal
+from app.clients.mci_server_client import MciServerClient
+from app.config import settings
+from app.repositories.event_store import event_store
+from app.services.event_publisher import EventPublisher
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ client = MciServerClient(settings.mci_server_url)
+ publisher = EventPublisher(
+ event_store,
+ client,
+ max_retry=settings.max_retry,
+ base_delay_seconds=settings.base_retry_delay_seconds,
+ interval_seconds=settings.event_publish_interval_seconds,
+ )
+ publisher.start()
+ yield
+ await publisher.stop()
+
+
+app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
+
+app.include_router(health.router)
+app.include_router(wechat_personal.router)
+app.include_router(monitor.router)
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=settings.debug)
+```
+
+- [ ] **Step 3: 运行应用并验证启动无报错**
+
+```bash
+python -c "from app.main import app; print('ok')"
+```
+
+Expected: 输出 `ok`。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/main.py
+git commit -m "feat(main): start and stop event publisher worker"
+```
+
+---
+
+## Phase 3: channel-service 补充测试与清理任务
+
+### Task 10: 添加事件清理测试与每日清理触发
+
+**Files:**
+- Modify: `channel-service/app/services/event_publisher.py`
+- Test: `channel-service/tests/test_event_store.py`
+
+- [ ] **Step 1: 给 EventPublisher 增加定时清理**
+
+在 `_loop` 中每 24 小时调用一次 `cleanup_old_events`:
+
+```python
+ async def _loop(self) -> None:
+ cleanup_counter = 0
+ while not self._stop_event.is_set():
+ try:
+ await self._tick()
+ except Exception:
+ pass
+ cleanup_counter += 1
+ if cleanup_counter >= 17280: # 5s * 17280 = 24h
+ try:
+ await self.cleanup_old_events(10)
+ except Exception:
+ pass
+ cleanup_counter = 0
+ try:
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds)
+ except asyncio.TimeoutError:
+ pass
+```
+
+- [ ] **Step 2: 补充清理测试**
+
+```python
+from datetime import datetime, timedelta, timezone
+
+from app.models.event import EventType, OutboundEvent
+
+
+def test_cleanup_old_delivered_events(store):
+ old_event = OutboundEvent(EventType.HEARTBEAT, "bot-001", "rescue", {})
+ store.save(old_event)
+ store.mark_delivered(old_event.event_id)
+
+ # 修改 created_at 为 11 天前
+ with store._connection() as conn:
+ conn.execute(
+ "UPDATE outbound_events SET created_at = ? WHERE id = ?",
+ (datetime.now(timezone.utc) - timedelta(days=11), old_event.event_id),
+ )
+
+ deleted = store.cleanup(10)
+ assert deleted == 1
+```
+
+- [ ] **Step 3: 运行测试并 Commit**
+
+```bash
+pytest -v
+git add app/services/event_publisher.py tests/test_event_store.py
+git commit -m "feat(event): add daily cleanup for delivered/dead-letter events"
+```
+
+---
+
+## Phase 4: mci-server 事件接收端
+
+### Task 11: 新增幂等去重表 `mci_channel_event_record`
+
+**Files:**
+- Create: `backend/mci-server/src/main/resources/db/migration/V22__add_channel_event_record.sql`
+- Create: `backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelEventRecord.java`
+- Create: `backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelEventRecordMapper.java`
+
+- [ ] **Step 1: 编写 Flyway 脚本**
+
+```sql
+CREATE TABLE IF NOT EXISTS mci_channel_event_record (
+ event_id VARCHAR(64) PRIMARY KEY,
+ account_id VARCHAR(64) NOT NULL,
+ event_type VARCHAR(32) NOT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_channel_event_account_time ON mci_channel_event_record(account_id, create_time);
+```
+
+- [ ] **Step 2: 创建实体类**
+
+```java
+package com.sino.mci.channel.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("mci_channel_event_record")
+public class ChannelEventRecord {
+
+ @TableId(type = IdType.INPUT)
+ private String eventId;
+
+ private String accountId;
+
+ private String eventType;
+
+ private LocalDateTime createTime;
+}
+```
+
+- [ ] **Step 3: 创建 Mapper**
+
+```java
+package com.sino.mci.channel.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sino.mci.channel.entity.ChannelEventRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ChannelEventRecordMapper extends BaseMapper {
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci
+git add backend/mci-server/src/main/resources/db/migration/V22__add_channel_event_record.sql \
+ backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelEventRecord.java \
+ backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelEventRecordMapper.java
+git commit -m "feat(db): add channel event record idempotency table"
+```
+
+---
+
+### Task 12: 创建事件处理服务 `ChannelAccountEventService`
+
+**Files:**
+- Create: `backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountEventRequest.java`
+- Create: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountEventService.java`
+
+- [ ] **Step 1: 创建请求 DTO**
+
+```java
+package com.sino.mci.channel.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class ChannelAccountEventRequest {
+
+ @NotBlank
+ private String eventId;
+
+ @NotBlank
+ private String eventType;
+
+ @NotBlank
+ private String accountId;
+
+ @NotBlank
+ private String tenantCode;
+
+ private String timestamp;
+
+ @NotNull
+ private Map data;
+}
+```
+
+- [ ] **Step 2: 创建服务类**
+
+```java
+package com.sino.mci.channel.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sino.mci.channel.dto.ChannelAccountEventRequest;
+import com.sino.mci.channel.entity.ChannelAccount;
+import com.sino.mci.channel.entity.ChannelEventRecord;
+import com.sino.mci.channel.repository.ChannelAccountMapper;
+import com.sino.mci.channel.repository.ChannelEventRecordMapper;
+import com.sino.mci.exception.BusinessException;
+import com.sino.mci.send.entity.SendRecord;
+import com.sino.mci.send.repository.SendRecordMapper;
+import com.sino.mci.send.service.SendCallbackService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ChannelAccountEventService {
+
+ private static final int LOGIN_STATUS_PENDING = 0;
+ private static final int LOGIN_STATUS_SUCCESS = 1;
+ private static final int LOGIN_STATUS_EXPIRED = 2;
+ private static final int LOGIN_STATUS_FAILED = 3;
+
+ private static final int ONLINE_STATUS_OFFLINE = 0;
+ private static final int ONLINE_STATUS_ONLINE = 1;
+ private static final int ONLINE_STATUS_ERROR = 2;
+
+ private static final int SEND_STATUS_SUCCESS = 2;
+ private static final int SEND_STATUS_FAILED = 3;
+
+ private final ChannelAccountMapper accountMapper;
+ private final ChannelEventRecordMapper eventRecordMapper;
+ private final SendRecordMapper sendRecordMapper;
+ private final SendCallbackService sendCallbackService;
+
+ @Transactional
+ public void handleEvent(ChannelAccountEventRequest request) {
+ if (isDuplicate(request.getEventId())) {
+ log.debug("重复事件已忽略: eventId={}", request.getEventId());
+ return;
+ }
+
+ ChannelAccount account = findAccount(request.getTenantCode(), request.getAccountId());
+
+ switch (request.getEventType()) {
+ case "heartbeat":
+ handleHeartbeat(account, request.getData());
+ break;
+ case "login_status_changed":
+ handleLoginStatusChanged(account, request.getData());
+ break;
+ case "online_status_changed":
+ handleOnlineStatusChanged(account, request.getData());
+ break;
+ case "send_result":
+ handleSendResult(request.getData());
+ break;
+ case "error":
+ log.warn("channel-service 上报错误: accountId={}, data={}", request.getAccountId(), request.getData());
+ break;
+ default:
+ log.warn("未知事件类型: {}", request.getEventType());
+ }
+
+ saveEventRecord(request);
+ }
+
+ private boolean isDuplicate(String eventId) {
+ return eventRecordMapper.selectById(eventId) != null;
+ }
+
+ private void saveEventRecord(ChannelAccountEventRequest request) {
+ ChannelEventRecord record = new ChannelEventRecord();
+ record.setEventId(request.getEventId());
+ record.setAccountId(request.getAccountId());
+ record.setEventType(request.getEventType());
+ record.setCreateTime(LocalDateTime.now());
+ eventRecordMapper.insert(record);
+ }
+
+ private ChannelAccount findAccount(String tenantCode, String accountId) {
+ ChannelAccount account = accountMapper.selectOne(
+ new LambdaQueryWrapper()
+ .eq(ChannelAccount::getTenantCode, tenantCode)
+ .eq(ChannelAccount::getAccountId, accountId));
+ if (account == null) {
+ throw new BusinessException("渠道账号不存在: " + accountId);
+ }
+ return account;
+ }
+
+ private void handleHeartbeat(ChannelAccount account, Map data) {
+ account.setLoginStatus(parseLoginStatus(getString(data, "login_status")));
+ account.setOnlineStatus(parseOnlineStatus(getString(data, "online_status")));
+ account.setLastHeartbeat(LocalDateTime.now());
+ Integer riskLevel = getInteger(data, "risk_level");
+ if (riskLevel != null) {
+ account.setRiskLevel(riskLevel);
+ }
+ accountMapper.updateById(account);
+ }
+
+ private void handleLoginStatusChanged(ChannelAccount account, Map data) {
+ String current = getString(data, "current");
+ account.setLoginStatus(parseLoginStatus(current));
+ if ("success".equalsIgnoreCase(current)) {
+ account.setOnlineStatus(ONLINE_STATUS_ONLINE);
+ }
+ accountMapper.updateById(account);
+ }
+
+ private void handleOnlineStatusChanged(ChannelAccount account, Map data) {
+ account.setOnlineStatus(parseOnlineStatus(getString(data, "current")));
+ accountMapper.updateById(account);
+ }
+
+ private void handleSendResult(Map data) {
+ Long recordId = getLong(data, "record_id");
+ if (recordId == null) {
+ log.warn("send_result 事件缺少 record_id");
+ return;
+ }
+ SendRecord record = sendRecordMapper.selectById(recordId);
+ if (record == null) {
+ log.warn("发送记录不存在: recordId={}", recordId);
+ return;
+ }
+
+ Boolean success = getBoolean(data, "success");
+ record.setSendStatus(Boolean.TRUE.equals(success) ? SEND_STATUS_SUCCESS : SEND_STATUS_FAILED);
+ record.setFinishTime(LocalDateTime.now());
+ record.setChannelResult(toJson(data.get("channel_result")));
+ sendRecordMapper.updateById(record);
+
+ sendCallbackService.sendCallback(record);
+ }
+
+ private int parseLoginStatus(String status) {
+ if ("success".equalsIgnoreCase(status)) {
+ return LOGIN_STATUS_SUCCESS;
+ }
+ if ("expired".equalsIgnoreCase(status)) {
+ return LOGIN_STATUS_EXPIRED;
+ }
+ if ("failed".equalsIgnoreCase(status)) {
+ return LOGIN_STATUS_FAILED;
+ }
+ return LOGIN_STATUS_PENDING;
+ }
+
+ private int parseOnlineStatus(String status) {
+ if ("online".equalsIgnoreCase(status)) {
+ return ONLINE_STATUS_ONLINE;
+ }
+ if ("error".equalsIgnoreCase(status)) {
+ return ONLINE_STATUS_ERROR;
+ }
+ return ONLINE_STATUS_OFFLINE;
+ }
+
+ private String getString(Map map, String key) {
+ Object value = map.get(key);
+ return value == null ? null : value.toString();
+ }
+
+ private Integer getInteger(Map map, String key) {
+ Object value = map.get(key);
+ if (value instanceof Number n) {
+ return n.intValue();
+ }
+ return null;
+ }
+
+ private Long getLong(Map map, String key) {
+ Object value = map.get(key);
+ if (value instanceof Number n) {
+ return n.longValue();
+ }
+ if (value instanceof String s) {
+ try {
+ return Long.parseLong(s);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private Boolean getBoolean(Map map, String key) {
+ Object value = map.get(key);
+ if (value instanceof Boolean b) {
+ return b;
+ }
+ return null;
+ }
+
+ private String toJson(Object value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(value);
+ } catch (Exception e) {
+ return value.toString();
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountEventRequest.java \
+ backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountEventService.java
+git commit -m "feat(channel): add event handling service"
+```
+
+---
+
+### Task 13: 创建事件接收 Controller
+
+**Files:**
+- Create: `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountEventController.java`
+- Test: `backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelAccountEventControllerTest.java`
+
+- [ ] **Step 1: 创建 Controller**
+
+```java
+package com.sino.mci.channel.controller;
+
+import com.sino.mci.channel.dto.ChannelAccountEventRequest;
+import com.sino.mci.channel.service.ChannelAccountEventService;
+import com.sino.mci.shared.web.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/channel-account")
+@RequiredArgsConstructor
+public class ChannelAccountEventController {
+
+ private final ChannelAccountEventService eventService;
+
+ @PostMapping("/event")
+ public ApiResponse> receiveEvent(@Valid @RequestBody ChannelAccountEventRequest request) {
+ eventService.handleEvent(request);
+ return ApiResponse.ok(null);
+ }
+}
+```
+
+- [ ] **Step 2: 编写 Controller 测试**
+
+```java
+package com.sino.mci.channel.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sino.mci.channel.dto.ChannelAccountEventRequest;
+import com.sino.mci.channel.entity.ChannelAccount;
+import com.sino.mci.channel.repository.ChannelAccountMapper;
+import com.sino.mci.channel.repository.ChannelEventRecordMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@Sql(scripts = "/sql/test-channel-account.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
+class ChannelAccountEventControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private ChannelAccountMapper accountMapper;
+
+ @Autowired
+ private ChannelEventRecordMapper eventRecordMapper;
+
+ @Test
+ void shouldUpdateAccountOnHeartbeat() throws Exception {
+ ChannelAccountEventRequest request = new ChannelAccountEventRequest();
+ request.setEventId("evt-001");
+ request.setEventType("heartbeat");
+ request.setAccountId("wx-bot-01");
+ request.setTenantCode("rescue");
+ request.setData(Map.of(
+ "login_status", "success",
+ "online_status", "online",
+ "risk_level", 10));
+
+ mockMvc.perform(post("/api/v1/channel-account/event")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+
+ ChannelAccount account = accountMapper.selectById(1L);
+ assertThat(account.getOnlineStatus()).isEqualTo(1);
+ assertThat(account.getRiskLevel()).isEqualTo(10);
+ assertThat(eventRecordMapper.selectById("evt-001")).isNotNull();
+ }
+
+ @Test
+ void shouldIgnoreDuplicateEvent() throws Exception {
+ ChannelAccountEventRequest request = new ChannelAccountEventRequest();
+ request.setEventId("evt-dup-001");
+ request.setEventType("heartbeat");
+ request.setAccountId("wx-bot-01");
+ request.setTenantCode("rescue");
+ request.setData(Map.of("online_status", "online"));
+
+ mockMvc.perform(post("/api/v1/channel-account/event")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ mockMvc.perform(post("/api/v1/channel-account/event")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ assertThat(eventRecordMapper.selectCount(null)).isEqualTo(1);
+ }
+}
+```
+
+- [ ] **Step 3: 创建测试 SQL 文件 `backend/mci-server/src/test/resources/sql/test-channel-account.sql`**
+
+```sql
+DELETE FROM mci_channel_event_record;
+DELETE FROM mci_channel_account;
+DELETE FROM mci_tenant;
+
+INSERT INTO mci_tenant (id, tenant_code, tenant_name, status, create_time)
+VALUES (1, 'rescue', '救援', 1, NOW());
+
+INSERT INTO mci_channel_account (id, tenant_code, account_type, channel_type, account_id, account_name, login_status, online_status, risk_level, status, create_time)
+VALUES (1, 'rescue', 3, 'wechat_personal', 'wx-bot-01', '测试号', 0, 0, 0, 1, NOW());
+```
+
+- [ ] **Step 4: 运行 Java 测试**
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
+./mvnw test -Dtest=ChannelAccountEventControllerTest
+```
+
+Expected: BUILD SUCCESS / tests passed。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountEventController.java \
+ backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelAccountEventControllerTest.java \
+ backend/mci-server/src/test/resources/sql/test-channel-account.sql
+git commit -m "feat(channel): add channel account event receiver endpoint"
+```
+
+---
+
+### Task 14: mci-server 发送流程传入 record_id
+
+**Files:**
+- Modify: `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java`
+- Modify: `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapter.java`
+- Test: `backend/mci-channel-wechat-personal/.../WechatPersonalChannelAdapterTest.java`
+
+- [ ] **Step 1: 在 SendService.dispatchRecord 中把 record_id 与 tenant_code 放入 extra**
+
+找到 `dispatchRecord` 方法中构造 `SendMessageRequest` 的位置,已有类似代码:
+
+```java
+extra.put("account_id", account.getAccountId());
+messageRequest.setExtra(extra);
+```
+
+改为:
+
+```java
+extra.put("account_id", account.getAccountId());
+extra.put("record_id", record.getId());
+extra.put("tenant_code", record.getTenantCode());
+messageRequest.setExtra(extra);
+```
+
+- [ ] **Step 2: 修改 WechatPersonalChannelAdapter.sendMessage 传入 record_id**
+
+```java
+ private Map sendText(String accountId, String conversationId, String recordId, String tenantCode, Map content) {
+ String text = getString(content, "text");
+ if (isBlank(text)) {
+ throw new IllegalArgumentException("文本内容为空");
+ }
+ Map body = new HashMap<>();
+ body.put("account_id", accountId);
+ body.put("conversation_id", conversationId);
+ body.put("text", text);
+ body.put("record_id", recordId);
+ body.put("tenant_code", tenantCode);
+ return post("/v1/wechat-personal/send/text", body);
+ }
+
+ private Map sendImage(String accountId, String conversationId, String recordId, String tenantCode, Map content) {
+ String imageUrl = getString(content, "image_url");
+ if (isBlank(imageUrl)) {
+ throw new IllegalArgumentException("图片地址为空");
+ }
+ Map body = new HashMap<>();
+ body.put("account_id", accountId);
+ body.put("conversation_id", conversationId);
+ body.put("image_url", imageUrl);
+ body.put("record_id", recordId);
+ body.put("tenant_code", tenantCode);
+ return post("/v1/wechat-personal/send/image", body);
+ }
+```
+
+并在 `sendMessage` 中读取 `recordId` 与 `tenantCode`:
+
+```java
+ String recordId = getString(request.getExtra(), "record_id");
+ String tenantCode = defaultString(getString(request.getExtra(), "tenant_code"));
+ ...
+ if ("image".equals(contentType)) {
+ response = sendImage(accountId, conversationId, recordId, tenantCode, request.getContent());
+ } else {
+ response = sendText(accountId, conversationId, recordId, tenantCode, request.getContent());
+ }
+```
+
+- [ ] **Step 3: 修改响应解析,兼容 `accepted` 字段**
+
+channel-service 异步受理后返回 `{"accepted": true, "message_id": "..."}` 而不是 `{"success": true, ...}`。在 `sendMessage` 中把成功判断改为:
+
+```java
+ Boolean success = getBoolean(response, "success");
+ Boolean accepted = getBoolean(response, "accepted");
+ if (!Boolean.TRUE.equals(success) && !Boolean.TRUE.equals(accepted)) {
+ result.setSuccess(false);
+ result.setMessage("个人微信发送失败:" + getString(response, "error"));
+ return result;
+ }
+
+ result.setSuccess(true);
+ result.setChannelMessageId(getString(response, "message_id"));
+```
+
+- [ ] **Step 4: 更新 WechatPersonalChannelAdapterTest**
+
+在 `shouldSendTextMessage` 和 `shouldSendImageMessage` 中:
+
+1. 给 `extra` 增加 `record_id` 与 `tenant_code`:
+
+```java
+extra.put("record_id", "1001");
+extra.put("tenant_code", "rescue");
+```
+
+2. 把 mock 响应从 `success` 改为 `accepted`:
+
+```java
+.setBody(toJson(Map.of(
+ "accepted", true,
+ "message_id", "msg-123")))
+```
+
+3. 断言请求体包含 `record_id` 与 `tenant_code`。
+
+- [ ] **Step 5: 运行相关 Java 测试**
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci
+./mvnw test -pl backend/mci-channel-wechat-personal -Dtest=WechatPersonalChannelAdapterTest
+./mvnw test -pl backend/mci-server -Dtest=SendServiceResolveTargetTest,SendEndToEndTest
+```
+
+Expected: PASS。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java \
+ backend/mci-channel-wechat-personal/src/main/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapter.java \
+ backend/mci-channel-wechat-personal/src/test/java/com/sino/mci/channel/wechat/personal/WechatPersonalChannelAdapterTest.java
+git commit -m "feat(send): pass record_id and tenant_code, accept async response"
+```
+
+---
+
+## Phase 5: 集成与联调
+
+### Task 15: 更新 docker-compose 环境变量
+
+**Files:**
+- Modify: `docker-compose.yml`
+
+- [ ] **Step 1: 给 channel-service service 增加环境变量与数据卷**
+
+```yaml
+ channel-service:
+ build: ./channel-service
+ environment:
+ - MCI_SERVER_URL=http://mci-server:8080/msg-platform
+ - EVENT_STORE_PATH=/app/data/events.db
+ - MAX_RETRY=5
+ - BASE_RETRY_DELAY_SECONDS=5
+ - EVENT_RETENTION_DAYS=10
+ - EVENT_PUBLISH_INTERVAL_SECONDS=5
+ volumes:
+ - channel-service-data:/app/data
+```
+
+- [ ] **Step 2: 新增顶层 volume**
+
+```yaml
+volumes:
+ channel-service-data:
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docker-compose.yml
+git commit -m "chore(docker): add channel-service event store config and volume"
+```
+
+---
+
+### Task 16: 端到端冒烟测试
+
+- [ ] **Step 1: 启动基础设施与两个服务**
+
+```bash
+cd /Users/marsal/Projects/sino-project/sino-mci
+docker-compose up -d mysql redis rabbitmq mci-server channel-service
+```
+
+- [ ] **Step 2: 在 mci-server 创建渠道账号并初始化登录**
+
+```bash
+curl -X POST http://localhost:8080/msg-platform/api/v1/channel-account \
+ -H "X-Tenant-Code: rescue" \
+ -H "Content-Type: application/json" \
+ -d '{"accountType":3,"channelType":"wechat_personal","accountId":"wx-bot-01","accountName":"测试号"}'
+
+curl -X POST http://localhost:8080/msg-platform/api/v1/channel-account/init \
+ -H "X-Tenant-Code: rescue" \
+ -H "Content-Type: application/json" \
+ -d '{"channelType":"wechat_personal","accountType":3,"config":{"account_id":"wx-bot-01"}}'
+```
+
+- [ ] **Step 3: 调用发送接口**
+
+```bash
+curl -X POST http://localhost:8080/msg-platform/api/v1/send \
+ -H "X-Tenant-Code: rescue" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "target":{"type":"conversation","conversationId":1},
+ "content":{"type":"text","body":{"text":"hello"}},
+ "channelStrategy":{"priority":["wechat_personal"]},
+ "callbackUrl":"http://host.docker.internal:9999/callback"
+ }'
+```
+
+- [ ] **Step 4: 验证 channel-service SQLite 中存在 send_result 事件,且 mci-server SendRecord 状态更新为成功**
+
+```bash
+docker exec -it sino-mci-channel-service-1 sqlite3 /app/data/events.db "SELECT event_type, status FROM outbound_events;"
+```
+
+Expected: 至少一条 `send_result` / `delivered`。
+
+- [ ] **Step 5: 提交联调记录(可选)**
+
+若测试通过,无需代码变更;否则修复问题后提交。
+
+---
+
+## Self-Review Checklist
+
+### Spec Coverage
+
+| Spec 要求 | 对应 Task |
+|-----------|-----------|
+| channel-service SQLite 事件持久化 | Task 2 |
+| 单线程后台 worker | Task 4 |
+| 指数退避重试 + 死信 | Task 4 |
+| 心跳/登录/在线/发送结果/错误事件 | Task 6, 7, 8 |
+| 发送改为异步受理 | Task 8 |
+| 10 天数据清理 | Task 10 |
+| mci-server `/event` 接收端点 | Task 13 |
+| event_id 幂等 | Task 12, 13 |
+| record_id 透传 | Task 14 |
+| 状态字段映射 | Task 12 |
+| 风控处理 | Task 8 |
+| docker-compose 配置 | Task 15 |
+
+### Placeholder Scan
+
+- 无 "TBD" / "TODO"。
+- 每个代码步骤包含完整代码。
+- 每个测试步骤包含可运行命令与期望输出。
+
+### Type Consistency
+
+- `OutboundEvent.status` 与 `attempt_count` 在 Task 3/4 中加入后,后续 store/publisher 均一致使用。
+- `record_id` 在 SendTextRequest / SendImageRequest、SendService、API 路由、WechatPersonalChannelAdapter 中一致使用。
+- `ChannelAccountEventRequest` 字段名与服务中 `getString/getInteger/getLong` 一致。
+
+---
+
+## 执行方式
+
+Plan complete and saved to `docs/superpowers/plans/2026-07-10-channel-service-mci-server-integration-plan.md`.
+
+Two execution options:
+
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration.
+
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+Which approach?
diff --git a/docs/superpowers/specs/2026-07-09-system-menu-redesign-design.md b/docs/superpowers/specs/2026-07-09-system-menu-redesign-design.md
index a7c9875..e623181 100644
--- a/docs/superpowers/specs/2026-07-09-system-menu-redesign-design.md
+++ b/docs/superpowers/specs/2026-07-09-system-menu-redesign-design.md
@@ -1,8 +1,8 @@
-# 消息中台管理后台菜单重构设计
+# 消息中台管理后台菜单重构与界面设计
> **日期**:2026-07-09
> **状态**:已确认,待实现
-> **关联文档**:[消息与客户互动中台架构设计](../architecture/2026-07-06-message-customer-interaction-design.md)
+> **关联文档**:[消息与客户互动中台架构设计](../architecture/2026-07-06-message-customer-interaction-design.md)
---
@@ -20,16 +20,20 @@
## 2. 设计目标
-1. **按工作流组织顶层菜单**:让用户按“接入 → 维护资产 → 运营发送 → 编排流程 → 系统管理”的路径找到功能。
+1. **按工作流组织顶层菜单**:让用户按“接入 → 客户关系 → 运营发送 → 编排流程 → 系统管理”的路径找到功能。
2. **按业务对象聚合二级功能**:同一对象(渠道账号、会话、联系人)的关联操作放在同一模块下。
3. **明确功能边界**:每个菜单项都有清晰的功能定义、面向角色和与现有页面的映射关系。
-4. **预留扩展位**:IVR 流程等 2092 阶段功能先不实现,但保留模块位置说明。
+4. **统一设计语言**:
+ - 列表页 = 筛选 + 表格 + 顶部「新增」按钮 + 分页
+ - 新增 = 弹窗/小抽屉,只填核心字段
+ - 编辑/详情 = 大抽屉,基础信息区 + 标签字段 + 关联内容区
+5. **预留扩展位**:钉钉/飞书/IVR 本期仅保留入口,不实现具体能力。
---
## 3. 设计决策
-采用 **混合式组织(候选 C)**:
+采用 **混合式组织**:
- 顶层按 **工作流/职责域** 划分,降低日常操作路径长度。
- 模块内按 **业务对象** 分组,保持数据模型与 UI 结构一致。
@@ -57,8 +61,8 @@
|---------|------|---------|
| 概览 | 运营总览、快捷入口 | 全部 |
| 渠道接入 | 渠道资源与账号接入 | 租户管理员、运营人员 |
-| 客户与群 | 联系人、会话、标签资产 | 租户管理员、运营人员 |
-| 消息运营 | 发送、记录、策略、消息查询 | 租户管理员、运营人员 |
+| 客户关系 | 联系人、会话、标签资产 | 租户管理员、运营人员 |
+| 消息运营 | 发送、记录、消息查询 | 租户管理员、运营人员 |
| 流程编排 | 入站消息处理配置 | 租户管理员 |
| 系统管理 | 租户、用户、角色 | 平台管理员、租户管理员 |
@@ -73,46 +77,42 @@
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
-| 渠道主体 | 维护企业在各渠道的主体信息:企微 corp_id/secret、钉钉/飞书主体;支持会话存档开关 | 现有 ChannelSubject 页面迁移 |
-| 渠道账号 | 维护用于收发消息的载体账号:新增、编辑、启用/停用、查看在线/登录/风控状态、强制下线 | 现有 ChannelAccount 页面迁移 |
-| 账号初始化 | 统一入口引导初始化:企微员工/应用、个微扫码登录、机器人 webhook | 从 ChannelAccount 页面拆分出的引导流程 |
-| 应用管理 | 企微应用等渠道应用的配置:agent_id、agent_secret、应用消息推送能力 | 新增(原散落在渠道账号中) |
+| 渠道主体 | 维护企业在各渠道的主体信息;企微主体支持会话存档、员工/客户/群聊/会话存档同步 | 现有 ChannelSubject 页面增强 |
+| 渠道账号 | 维护用于收发消息的载体账号;支持按 ID 拉取企微信息、pywechat 扫码登录、同步会话 | 现有 ChannelAccount 页面迁移 |
+| 应用管理 | 企微应用的配置:agent_id、agent_secret、能力验证;本期只实现企微,其他占位 | 新增 |
-### 5.4 客户与群
+### 5.4 客户关系
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
-| 联系人 | 维护 Person(业务身份),含 person_code、person_type、phone、institution_id、supplier_id、contract_id;支持新增/编辑/启用停用 | 现有 Person 页面增强 |
-| 渠道身份 | 管理 Person 在各渠道的身份映射:企微 external_userid、个微 wxid、钉钉 userid、飞书 open_id | 新增独立入口 |
-| 标签体系 | 树形标签管理:新增/编辑/删除、查看人员与会话标签分布;标签表达角色/合同/机构/供应商等业务属性 | 现有 Tag 页面升级为体系化管理 |
-| 会话/群聊 | 维护 Conversation:新增、编辑、同步群成员、查看历史消息、绑定业务字段(contract_id 等) | 现有 Conversation 页面迁移 |
-| 账号-会话绑定 | 为会话配置发送主备号、接收主备号及优先级;单聊约束需校验 channel_type 一致性 | 从 Conversation 页面拆分出的独立管理 |
+| 联系人 | 维护 Person;详情抽屉涵盖基本信息、渠道身份、标签、所属会话 | 现有 Person 页面增强 |
+| 会话/群聊 | 维护 Conversation;详情抽屉涵盖基本信息、成员、收发账号绑定、标签、历史消息 | 现有 Conversation 页面迁移 |
+| 标签体系 | 树形标签管理;标签用于表达角色/合同/机构/供应商等业务属性 | 现有 Tag 页面升级为体系化管理 |
### 5.5 消息运营
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
-| 发送记录 | 按任务查询发送明细、状态、结果、重试记录;支持取消待发送任务 | 现有 SendRecord 页面迁移 |
-| 测试发送 | 模拟发送,不实际调用渠道,用于业务系统联调和发送逻辑验证 | 现有 MessageTest 页面迁移并改名 |
-| 发送策略 | 策略组配置:频率控制、时间窗口、黑名单、重试、主备切换 | 现有 SendPolicy 页面迁移 |
-| 消息查询 | 统一查询入站/出站消息事件(MessageEvent),支持按会话、人员、账号、来源(realtime / session_archive)筛选 | 新增 |
+| 发送记录 | 按任务查询发送明细、状态、结果;支持取消待发送任务;失败固定重试 2 次 | 现有 SendRecord 页面迁移 |
+| 测试发送 | 模拟出站/入站消息,用于业务系统联调和发送逻辑验证 | 现有 MessageTest 页面迁移并改名 |
+| 消息查询 | 聊天界面查看入站/出站消息事件,支持按会话、人员、来源筛选 | 新增 |
### 5.6 流程编排
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
| 入站 Webhook | 配置业务系统接收入站消息的 webhook URL、签名方式、测试连通性 | 现有 InboundWebhook 页面迁移 |
-| 入站流程 | 轻量画布配置入站消息处理流程:receive → intent → condition → webhook/reply | 现有 InboundFlow 页面迁移 |
+| 入站流程 | 画布配置入站消息处理流程,支持多轮上下文与槽位收集 | 现有 InboundFlow 页面增强 |
-> **本期不做**:IVR 流程(2092 阶段接入)。
+> **本期不做**:IVR 流程、发送策略配置。
### 5.7 系统管理
| 二级菜单 | 功能定义 | 页面/备注 |
|---------|---------|----------|
-| 租户管理 | 平台管理员创建/编辑租户;租户管理员维护本租户基础信息、认证配置、入站 webhook | 现有 TenantManagement 页面增强 |
+| 租户管理 | 平台管理员创建/编辑租户;租户管理员维护本租户基础信息 | 现有 TenantManagement 页面增强 |
| 用户管理 | 中台用户增删改查、重置密码、启用/停用、绑定角色 | 现有 UserManagement 页面迁移 |
-| 角色权限 | 平台角色 admin/operator/viewer 的权限配置与分配 | 新增(首期角色固定,可配置权限列表) |
+| 角色权限 | 平台角色 admin/operator/viewer 的权限配置与分配 | 新增 |
---
@@ -121,62 +121,678 @@
| 当前菜单 | 调整方式 | 新位置 |
|---------|---------|--------|
| 概览 | 保留并增强 | 概览 → 运营总览 |
-| 渠道主体 | 保留 | 渠道接入 → 渠道主体 |
-| 渠道账号 | 拆分 | 渠道接入 → 渠道账号 / 账号初始化 / 应用管理 |
-| 会话/群聊 | 拆分 | 客户与群 → 会话/群聊 / 账号-会话绑定 |
-| 联系人 | 保留,增强 | 客户与群 → 联系人 |
-| 标签 | 升级为体系 | 客户与群 → 标签体系 |
+| 渠道主体 | 保留并增强 | 渠道接入 → 渠道主体 |
+| 渠道账号 | 拆分 | 渠道接入 → 渠道账号 / 应用管理 |
+| 会话/群聊 | 保留,绑定功能移入详情 | 客户关系 → 会话/群聊 |
+| 联系人 | 保留,增强 | 客户关系 → 联系人 |
+| 标签 | 升级为体系 | 客户关系 → 标签体系 |
| 发送记录 | 保留 | 消息运营 → 发送记录 |
| 消息测试 | 保留,改名 | 消息运营 → 测试发送 |
-| 发送策略 | 保留 | 消息运营 → 发送策略 |
-| 入站流程 | 保留 | 流程编排 → 入站流程 |
+| 发送策略 | 本期移除 | — |
+| 入站流程 | 保留并增强 | 流程编排 → 入站流程 |
| IVR 流程 | 本期移除 | — |
| 入站 Webhook | 保留 | 流程编排 → 入站 Webhook |
| 流程追踪 | 合并 | 消息运营 → 发送记录(详情)/ 消息查询 |
| 用户管理 | 保留 | 系统管理 → 用户管理 |
| 租户管理 | 保留 | 系统管理 → 租户管理 |
+| 账号初始化 | 删除独立菜单 | 能力分散到渠道主体/账号/联系人/会话 |
+| 渠道身份 | 删除独立菜单 | 并入联系人详情 |
+| 账号-会话绑定 | 删除独立菜单 | 并入会话详情 |
---
-## 7. 新增页面清单
+## 7. 统一页面设计语言
-| 页面 | 所属模块 | 功能说明 |
-|------|---------|---------|
-| 账号初始化 | 渠道接入 | 引导式初始化各渠道账号 |
-| 应用管理 | 渠道接入 | 企微应用等渠道应用配置 |
-| 渠道身份 | 客户与群 | 管理 Person 在各渠道的身份映射 |
-| 账号-会话绑定 | 客户与群 | 会话与收发账号的绑定管理 |
-| 消息查询 | 消息运营 | 统一查询入站/出站消息事件 |
-| 角色权限 | 系统管理 | 中台角色与权限配置 |
+### 7.1 列表页
+
+- 顶部筛选区(可折叠)
+- 右上角「新增」按钮
+- 表格展示核心字段 + 标签 + 关联数量
+- 行内操作:编辑 / 详情 / 删除 / 启用禁用
+- 底部分页
+
+### 7.2 新增
+
+- 弹窗或小抽屉
+- 只收集创建必需的字段
+- 创建成功后刷新列表
+
+### 7.3 编辑/详情
+
+- 大抽屉
+- 基础信息区(可编辑)
+- 标签字段(树形多选,随基础信息一起保存)
+- 关联内容区(只读列表 + 快捷操作)
+- 不同对象按自身关联内容展示,不拆成多个 Tab
---
-## 8. 不在本期范围内的功能
+## 8. 渠道接入模块详细设计
-| 功能 | 原因 | 后续阶段 |
-|------|------|---------|
-| IVR 流程 | 属于 2092 IVR 语音配置需求,本期聚焦 1851 企微场景 | 2092 阶段 |
-| 模板管理 | 中台不保存模板,由业务系统自行维护 | 不涉及 |
-| 业务触发规则 | 中台不承载业务系统触发规则 | 不涉及 |
+### 8.1 渠道主体
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 渠道 | wecom / dingtalk / feishu(本期仅 wecom 可用) |
+| 主体编码 | `subject_code` |
+| 主体名称 | `subject_name` |
+| CorpID | `corp_id` |
+| 会话存档 | 开启 / 关闭 |
+| 状态 | 启用 / 禁用 |
+| 操作 | 编辑 / 详情 / 删除 / 启用禁用 |
+
+#### 新增/编辑
+
+- 渠道类型
+- 主体编码
+- 主体名称
+- CorpID
+- CorpSecret(通讯录同步 secret)
+- AgentID
+- AgentSecret
+- 会话存档开关
+- 会话存档 RSA 私钥
+
+#### 详情抽屉(企微能力区)
+
+| 能力 | 所需凭证 | 操作 |
+|------|---------|------|
+| 验证主体连通 | `corp_id + corp_secret` | 点击验证 |
+| 同步企业员工 | `corp_id + corp_secret` | 拉取通讯录,写入 `mci_person` + `mci_channel_identity` + `department_path` |
+| 同步客户/客户群 | 主体下配置的企微应用 `agent_secret` | 拉取外部联系人和客户群 |
+| 同步会话存档 | 应用管理中配置的会话存档应用 secret | 用 Finance SDK 拉取最近消息 |
+
+每个能力执行后展示状态、上次执行时间、成功/失败数量、错误信息,并写入 `mci_channel_subject_sync_task` 日志表。
+
+### 8.2 渠道账号
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 账号类型 | 企微内部账号 / 企微应用 / pywechat 个人号 |
+| 渠道 | wecom / wechat_personal |
+| 账号 ID | `account_id` |
+| 名称 | `account_name` |
+| 所属主体 | `subject_name` |
+| 登录状态 | 已登录 / 未登录 |
+| 在线状态 | 在线 / 离线 |
+| 状态 | 启用 / 禁用 |
+| 操作 | 编辑 / 删除 / 启用禁用 / 初始化 / 同步会话 |
+
+#### 企微内部账号
+
+- 创建/编辑时输入 `userid` 后,可点击「从企微拉取」回填姓名、部门信息
+- 会话存档开关(标记该员工是否开启存档)
+
+#### 企微应用
+
+- 应用凭证在「应用管理」维护
+- 渠道账号中创建时选择应用,自动带出 `agent_id`
+- 提供「验证应用」按钮
+
+#### pywechat 个人号
+
+- 创建时填写 `wxid / bot_code` 和 `server_host`
+- 行操作「扫码登录」:返回二维码,轮询登录状态
+- 行操作「同步会话」:拉取该个人号所在群聊
+
+### 8.3 应用管理
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 应用名称 | `app_name` |
+| 应用类型 | wecom / wecom_session_archive / dingtalk / feishu |
+| agent_id | 企微应用 ID |
+| 所属主体 | `subject_name` |
+| 状态 | 启用 / 禁用 |
+| 操作 | 编辑 / 验证 / 删除 / 启用禁用 |
+
+#### 新增/编辑
+
+- 应用名称
+- 所属主体
+- 应用类型
+- agent_id
+- agent_secret
+- 状态
+
+#### 操作
+
+- **验证并获取能力**:用 `corp_id + agent_id + agent_secret` 获取 access_token,验证通过更新状态
+- 钉钉/飞书应用仅保留表单入口,验证按钮禁用
---
-## 9. 实现要点
+## 9. 客户关系模块详细设计
-1. **前端路由**:按新菜单结构重排 `admin-web/src/router/index.ts`,路径建议保持语义化,如 `/channel-subject`、`/channel-account/init`、`/customer/person`、`/message/record`。
-2. **布局组件**:`Layout.vue` 从平铺 `el-menu-item` 改为分组 `el-sub-menu`,支持一级展开/收起。
-3. **权限控制**:菜单可见性通过 `authStore.roleCodes` 或后端返回的权限列表控制;URL 级权限同步调整。
-4. **页面拆分**:
- - `Conversation.vue` 中的“绑定账号”功能拆出为 `ConversationAccountBinding.vue`。
- - `ChannelAccount.vue` 中的初始化流程拆出为 `ChannelAccountInit.vue`。
-5. **空状态处理**:新增页面先实现最小可用版本,避免阻塞菜单上线。
+### 9.1 联系人
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 人员编码 | `person_code` |
+| 姓名 | `person_name` |
+| 手机号 | `phone` |
+| 类型 | 内部人员 / 供应商人员 / 客户 / 司机 |
+| 所属机构 | `institution_id` 名称 |
+| 部门路径 | 多个标签式展示 |
+| 标签 | 直接展示绑定的标签 |
+| 渠道身份数 | 几个渠道身份 |
+| 参与会话数 | 该人员在多少个会话中 |
+| 状态 | 启用 / 禁用 |
+| 操作 | 编辑 / 详情 / 删除 / 启用禁用 |
+
+#### 新增
+
+弹窗字段:
+- 人员编码
+- 姓名
+- 手机号
+- 类型
+- 所属机构
+- 状态
+
+#### 编辑/详情抽屉
+
+**基础信息区**
+- 人员编码、姓名、手机号
+- 类型
+- 所属机构、供应商、合同
+- 部门路径(支持多条)
+- 状态
+
+**标签字段**
+- 树形多选,已选标签平铺展示
+
+**渠道身份区**
+- 列表展示所有渠道身份
+- 行内新增 / 编辑 / 删除
+- 新增时:选择渠道 → 选择主体 → 输入渠道用户 ID → 「拉取企微信息」回填昵称
+
+**所属会话区**
+- 展示该人员参与的会话列表
+- 点击会话名称打开会话详情抽屉
+
+### 9.2 会话/群聊
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 会话类型 | 单聊 / 群聊 |
+| 渠道 | wecom / wechat_personal |
+| 渠道会话 ID | `channel_conversation_id` |
+| 会话名称 | `conversation_name` |
+| 群主/负责人 | `owner_account_id` 名称 |
+| 所属主体 | `subject_id` 名称 |
+| 标签 | 直接展示 |
+| 成员数 | 群聊显示成员数量 |
+| 业务绑定 | 机构 / 供应商 / 合同 / 渠道编码 |
+| 状态 | 启用 / 禁用 |
+| 操作 | 编辑 / 详情 / 删除 / 同步成员 |
+
+#### 新增
+
+弹窗字段:
+- 会话类型
+- 渠道
+- 所属主体
+- 渠道会话 ID
+- 会话名称
+- 负责人账号
+- 状态
+
+#### 编辑/详情抽屉
+
+**基础信息区**
+- 会话类型、渠道、所属主体
+- 渠道会话 ID、会话名称
+- 负责人账号
+- 关联机构 / 供应商 / 合同 / 渠道编码
+- 状态
+
+**标签字段**
+- 树形多选
+
+**成员区**
+- 列表展示 `mci_conversation_participant`
+- 字段:人员姓名、渠道身份、角色
+- 操作:同步成员、移除
+- 人员名称可点击进入联系人详情
+
+**收发账号绑定区**
+- 展示发送主号 / 发送备用号 / 接收主号 / 接收备用号
+- 行内新增、编辑优先级、删除
+- 单聊限制:只能绑定同 `channel_type` 的账号
+- 群聊允许跨渠道绑定,但给出提示
+
+**历史消息区**
+- 展示该会话最近消息列表
+- 可跳转消息查询页面
+
+### 9.3 标签体系
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 标签名 | `tag_name` |
+| 路径 | `tag_path` |
+| 层级 | `level` |
+| 来源 | system / business_sync / manual |
+| 状态 | 启用 / 禁用 |
+| 关联人员数 | 使用数量 |
+| 关联会话数 | 使用数量 |
+| 操作 | 新增子标签 / 编辑 / 删除 / 启用禁用 |
+
+#### 新增/编辑
+
+弹窗字段:
+- 标签名
+- 父标签(树形选择,顶级为根)
+- 来源
+- 状态
+
+#### 详情抽屉
+
+- 标签基本信息
+- 关联人员列表(分页)
+- 关联会话列表(分页)
---
-## 10. 验收标准
+## 10. 消息运营模块详细设计
-- [ ] 左侧导航按新 6 大模块分组展示,无 IVR 流程入口。
+### 10.1 发送记录
+
+#### 页面结构
+
+- 顶部统计卡片:今日发送总数 / 成功 / 失败 / 待发送
+- 筛选区:任务 ID、发送时间、渠道、状态、目标类型
+- 记录列表
+- 详情抽屉
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 任务 ID | `task_id` |
+| 目标类型 | 单人会话 / 群聊 / 人员 / 标签 / 规则 |
+| 目标摘要 | 会话名称 / 人员姓名 / 标签名 / 规则描述 |
+| 渠道 | wecom / wechat_personal |
+| 发送账号 | 实际使用的账号名称 |
+| 状态 | 待发送 / 发送中 / 成功 / 失败 |
+| 发送时间 | `send_time` |
+| 完成时间 | `finish_time` |
+| 操作 | 详情 / 取消(仅待发送) |
+
+#### 详情抽屉
+
+- 基础信息:任务 ID、目标、渠道、发送账号、内容摘要
+- 内容区:完整 `content_body`
+- 策略与回调:`channel_strategy`、`callback_url`
+- 渠道结果:`channel_result` 格式化展示
+- 关联跳转:会话 / 人员 / 账号 / 消息事件
+- 时间线:创建 → 发送中 → 完成 / 失败
+
+> 失败固定重试 2 次,由后端策略处理,前台不配置。
+
+### 10.2 测试发送
+
+工具型页面,两个 Tab:
+
+#### Tab 1:出站测试
+
+| 字段 | 说明 |
+|------|------|
+| 目标类型 | 会话 / 人员 / 标签 / 规则 |
+| 目标 | 根据类型选择或输入 |
+| 内容类型 | text / markdown / image / file / news 等 |
+| 内容 | 对应内容编辑器 |
+| 渠道策略 | 优先渠道、备用渠道 |
+| 回调地址 | 可选 |
+
+点击「发送测试」后展示返回的 `task_id`,并刷新发送记录。
+
+#### Tab 2:入站模拟
+
+| 字段 | 说明 |
+|------|------|
+| 事件 ID | 自定义事件 ID |
+| 渠道类型 | wecom / wechat_personal |
+| 消息来源 | realtime / session_archive |
+| 会话 ID | 选择已有会话 |
+| 发送人 | 选择人员或输入 channel_user_id |
+| 接收账号 | 选择接收账号 |
+| 内容类型 | text / image / voice 等 |
+| 内容 | 消息内容 |
+
+点击「模拟入站」后触发入站流程编排,展示处理结果。
+
+### 10.3 消息查询
+
+#### 页面布局
+
+三栏式聊天界面:
+
+```
+┌─────────────┬─────────────────────┬─────────────┐
+│ 会话列表 │ 聊天内容 │ 会话信息 │
+│ 搜索/筛选 │ │ 成员/标签 │
+└─────────────┴─────────────────────┴─────────────┘
+```
+
+#### 左侧:会话列表
+
+- 按时间倒序展示会话
+- 每个会话卡片:会话名称、最后一条消息预览、最后消息时间、标签小圆点
+- 搜索:会话名称、成员姓名、消息内容
+- 筛选:渠道、消息来源(实时/会话存档)、会话类型、标签
+
+#### 中间:聊天内容区
+
+- 按时间顺序展示消息气泡
+- 区分入站消息(左侧)、出站消息(右侧)、系统事件(中间)
+- 根据 `msgtype` 渲染:文本、图片、语音、视频、文件、撤回提示等
+- 支持按时间范围筛选,滚动加载历史消息
+
+#### 右侧:会话信息
+
+- 基础信息:会话名称、渠道、来源、群主
+- 业务绑定:机构 / 供应商 / 合同 / 渠道编码
+- 标签:直接展示,可编辑
+- 成员列表
+- 快捷操作:查看发送记录、跳转联系人详情、同步成员
+
+---
+
+## 11. 流程编排模块详细设计
+
+### 11.1 入站 Webhook
+
+每个租户独立配置一个入站推送地址。
+
+#### 页面内容
+
+| 字段 | 说明 |
+|------|------|
+| Webhook URL | 业务系统接收中台入站消息的地址 |
+| 签名算法 | HMAC-SHA256 / HMAC-SHA512 / 无 |
+| Webhook Secret | 签名密钥 |
+| 超时时间 | 推送超时秒数,默认 10s |
+| 失败重试 | 固定重试 2 次 |
+
+#### 操作
+
+- 保存
+- 测试推送
+- 查看最近推送日志(状态、响应码、响应摘要)
+
+> 这是流程中 Webhook 节点的默认推送地址,只有流程走到该节点才会触发。
+
+### 11.2 入站流程
+
+#### 页面布局
+
+保留三栏画布:
+
+```
+┌────────────┬─────────────────────────┬────────────┐
+│ 流程列表 │ 流程画布 │ 节点配置 │
+│ 组件面板 │ │ │
+└────────────┴─────────────────────────┴────────────┘
+```
+
+#### 流程节点
+
+| 节点 | 作用 |
+|------|------|
+| 接收消息 | 流程入口,指定渠道 |
+| 加载上下文 | 读取当前会话已有的 intent / slots / 轮次 |
+| 意图识别 | LLM / 规则识别本轮意图和槽位 |
+| 合并槽位 | 把本轮新槽位合并到会话上下文 |
+| 槽位校验 | 检查必需槽位是否齐全 |
+| 自动追问 | 缺槽位时自动生成追问文案并回复 |
+| Webhook | 槽位齐全后推送给业务系统 |
+| 回复消息 | 可配置固定回复 |
+| 保存上下文 | 把更新后的 intent / slots 写回 |
+| 清空上下文 | Webhook 成功后结束当前会话 |
+
+#### 多轮流程示例
+
+```
+接收消息
+ │
+ ▼
+加载上下文
+ │
+ ▼
+意图识别(结合上下文)
+ │
+ ▼
+合并槽位
+ │
+ ▼
+槽位校验
+ ├─ 缺槽位 ──▶ 自动追问 ──▶ 保存上下文 ──▶ 结束
+ │
+ └─ 槽位齐全 ──▶ Webhook ──▶ 回复客户 ──▶ 清空上下文 ──▶ 结束
+```
+
+#### 上下文存储
+
+新增 `mci_conversation_context` 表:
+
+| 字段 | 说明 |
+|------|------|
+| conversation_id | 会话 ID |
+| intent | 当前意图 |
+| slots | JSON,已收集槽位 |
+| missing_slots | JSON,还缺哪些槽位 |
+| turn_count | 当前轮次 |
+| status | collecting / complete / expired |
+| expire_time | 上下文过期时间 |
+| update_time | 最后更新时间 |
+
+超过一定时间(如 10 分钟)无新消息,上下文自动过期。
+
+---
+
+## 12. 系统管理模块详细设计
+
+### 12.1 租户管理
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 租户编码 | `tenant_code` |
+| 租户名称 | `tenant_name` |
+| 状态 | 启用 / 禁用 |
+| 用户数量 | 该租户下用户总数 |
+| 创建时间 | `create_time` |
+| 操作 | 编辑 / 删除 / 启用禁用 |
+
+**平台管理员**:可操作全部租户。
+**租户管理员**:只能查看/编辑本租户,不可删除。
+
+#### 新增(仅平台管理员)
+
+- 租户编码
+- 租户名称
+- 入站 Webhook URL
+- 初始化管理员账号(默认 `admin`)
+- 初始化管理员密码
+- 状态
+
+创建成功后自动创建该租户的第一个管理员用户。
+
+#### 编辑
+
+- 租户名称
+- 入站 Webhook URL
+- 状态
+
+默认租户(如 `platform`)不可删除。
+
+### 12.2 用户管理
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 用户名 | `username` |
+| 真实姓名 | `real_name` |
+| 手机号 | `phone` |
+| 所属租户 | `tenant_name`(平台管理员可见) |
+| 角色 | 角色名称,多个用逗号 |
+| 状态 | 启用 / 禁用 |
+| 创建时间 | `create_time` |
+| 操作 | 编辑 / 重置密码 / 删除 / 启用禁用 |
+
+#### 新增
+
+- 所属租户(平台管理员选择,租户管理员固定为本租户)
+- 用户名
+- 密码
+- 真实姓名
+- 手机号
+- 角色(多选)
+- 状态
+
+#### 编辑
+
+可编辑:真实姓名、手机号、角色、状态。
+不可编辑:用户名、所属租户。
+
+### 12.3 角色权限
+
+#### 列表字段
+
+| 字段 | 说明 |
+|------|------|
+| 角色编码 | `role_code` |
+| 角色名称 | `role_name` |
+| 权限数量 | 已配置的权限项数 |
+| 状态 | 启用 / 禁用 |
+| 操作 | 配置权限 / 启用禁用 |
+
+**首期固定角色**:
+
+| 角色编码 | 角色名称 | 默认权限 |
+|---------|---------|---------|
+| `platform_admin` | 平台管理员 | 全部权限 |
+| `admin` | 租户管理员 | 除租户创建外的全部权限 |
+| `operator` | 运营人员 | 发送、查看记录、维护联系人/会话、配置渠道账号 |
+| `viewer` | 只读用户 | 仅查看发送记录、统计、消息查询 |
+
+#### 权限配置
+
+抽屉中展示权限树:
+
+```
+□ 全部
+ □ 概览
+ □ 查看
+ □ 渠道接入
+ □ 渠道主体(查看 / 新增 / 编辑 / 删除)
+ □ 渠道账号(查看 / 新增 / 编辑 / 删除)
+ □ 应用管理(查看 / 新增 / 编辑 / 删除)
+ □ 客户关系
+ □ 联系人(查看 / 新增 / 编辑 / 删除)
+ □ 会话/群聊(查看 / 新增 / 编辑 / 删除)
+ □ 标签体系(查看 / 新增 / 编辑 / 删除)
+ □ 消息运营
+ □ 发送记录(查看 / 取消)
+ □ 测试发送
+ □ 消息查询
+ □ 流程编排
+ □ 入站 Webhook(查看 / 编辑)
+ □ 入站流程(查看 / 新增 / 编辑 / 删除 / 发布)
+ □ 系统管理
+ □ 租户管理(查看 / 新增 / 编辑 / 删除)
+ □ 用户管理(查看 / 新增 / 编辑 / 删除 / 重置密码)
+ □ 角色权限(查看 / 配置)
+```
+
+保存后同步到 `mci_platform_role` 表的 `permissions` JSON。
+
+### 12.4 全局管理员登录
+
+- 默认创建 `platform` 租户
+- 通过 Flyway 初始化 `admin` 用户,角色 `platform_admin`
+- 初始密码在配置文件中设置,首次登录强制修改
+
+---
+
+## 13. 数据模型补充
+
+### 13.1 新增/调整字段
+
+| 表 | 字段 | 类型 | 说明 |
+|---|------|------|------|
+| `mci_person` | `department_path` | JSON | 多个部门路径,如 `["销售中心/销售一部", "销售中心/华北组"]` |
+| `mci_channel_subject_sync_task` | — | 新表 | 记录各渠道主体同步任务 |
+| `mci_conversation_context` | — | 新表 | 入站流程多轮会话上下文 |
+
+### 13.2 同步任务日志表
+
+`mci_channel_subject_sync_task`:
+
+| 字段 | 说明 |
+|------|------|
+| subject_id | 渠道主体 ID |
+| sync_type | employee / external_contact / group / session_archive |
+| status | 0待执行 1执行中 2成功 3失败 |
+| total_count | 总数 |
+| success_count | 成功数 |
+| fail_count | 失败数 |
+| error_msg | 错误信息 |
+| create_time / finish_time | 时间 |
+
+---
+
+## 14. 不在本期范围内的功能
+
+| 功能 | 原因 |
+|------|------|
+| IVR 流程 | 属于 2092 IVR 语音配置需求 |
+| 发送策略配置 | 本期失败固定重试 2 次,不开放前台配置 |
+| 模板管理 | 中台不保存模板 |
+| 业务触发规则 | 中台不承载业务系统触发规则 |
+| 钉钉/飞书具体能力 | 仅保留入口 |
+
+---
+
+## 15. 实现要点
+
+1. **前端路由**:按新菜单结构重排 `admin-web/src/router/index.ts`。
+2. **布局组件**:`Layout.vue` 从平铺 `el-menu-item` 改为分组 `el-sub-menu`。
+3. **权限控制**:菜单可见性通过 `authStore.roleCodes` 或后端返回的权限列表控制。
+4. **页面拆分与合并**:
+ - 删除 `ChannelAccountInit.vue` 独立页面
+ - 删除独立的渠道身份、账号-会话绑定页面
+ - `Conversation.vue` 增强详情抽屉,包含绑定功能
+ - `Person.vue` 增强详情抽屉,包含渠道身份
+5. **消息查询**:新增聊天界面组件。
+6. **入站流程**:增强节点配置,新增上下文存储能力。
+7. **空状态处理**:新增页面先实现最小可用版本。
+
+---
+
+## 16. 验收标准
+
+- [ ] 左侧导航按新 6 大模块分组展示,无 IVR 流程、发送策略、账号初始化入口。
- [ ] 每个二级菜单有明确页面,无 404。
- [ ] 租户管理员、运营人员、平台管理员看到的菜单符合角色定义。
+- [ ] 联系人、会话详情抽屉涵盖所有关联内容。
+- [ ] 标签在列表和编辑表单中直接展示/编辑。
+- [ ] 消息查询实现聊天界面。
+- [ ] 入站流程支持多轮上下文与槽位收集。
- [ ] 现有功能在迁移后行为保持一致。
-- [ ] 新增页面至少具备列表/查询能力,复杂编辑可二期补齐。
diff --git a/docs/superpowers/specs/2026-07-10-channel-service-mci-server-integration-design.md b/docs/superpowers/specs/2026-07-10-channel-service-mci-server-integration-design.md
new file mode 100644
index 0000000..390045c
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-channel-service-mci-server-integration-design.md
@@ -0,0 +1,427 @@
+# channel-service 与 mci-server 双向集成设计
+
+> **需求来源**:继续完成 pywechat 客户端功能,打通 Python 渠道服务与 Java 消息中台的双向通信。
+> **日期**:2026-07-10
+> **状态**:待实现
+> **关联文档**:`docs/architecture/2026-07-06-message-customer-interaction-design.md`
+
+---
+
+## 1. 目标与范围
+
+### 1.1 目标
+
+让 `channel-service`(Python FastAPI)与 `mci-server`(Java Spring Boot)完成双向 HTTP 集成:
+
+- **下行(mci-server → channel-service)**:mci-server 继续通过现有 REST API 调用 channel-service 的发送、登录控制等指令。
+- **上行(channel-service → mci-server)**:channel-service 把心跳、登录/在线状态变更、发送结果、异常事件持久化到本地 SQLite,并由后台 worker 可靠地推送到 mci-server。
+- **可靠性**:服务重启不丢事件;网络异常按指数退避重试;超次进死信;本地事件保留 10 天后自动清理。
+
+### 1.2 范围
+
+- **channel-service**:新增事件模型、SQLite 持久化、后台事件发布 worker、mci-server HTTP 客户端、配置项扩展、API 行为调整。
+- **mci-server**:新增 `/api/v1/channel-account/event` 接收端点,处理心跳/状态/发送结果事件并更新 `mci_channel_account`、`mci_send_record`。
+- **不在本期范围**:
+ - 真实 pywechat/pywinauto 接入(仍为 mock)。
+ - RabbitMQ/MQ 等外部消息总线。
+ - 入站消息从 channel-service 回传(本期只传运行态事件和发送结果)。
+
+---
+
+## 2. 整体架构
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ mci-server │
+│ Java 21 + Spring Boot 4.1.x │
+│ POST /api/v1/channel-account/event │
+└───────────────────────┬─────────────────────────────────────┘
+ │ HTTP 上行事件
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ channel-service │
+│ Python 3.11 + FastAPI │
+│ │
+│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │
+│ │ HTTP API │───►│ 业务操作 + 事件 │───►│ outbound_ │ │
+│ │ │ │ 写入 SQLite │ │ events │ │
+│ └─────────────┘ └──────────────────┘ └─────┬──────┘ │
+│ │ │
+│ ┌────────────────────┘ │
+│ ▼ │
+│ ┌─────────────────┐ │
+│ │ EventPublisher │ │
+│ │ 单线程 worker │ │
+│ └────────┬────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────┐ │
+│ │ mci_server_client│ │
+│ │ HTTP POST │ │
+│ └─────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+核心原则:**状态变更/发送操作与事件写入在同一个 SQLite 事务内完成**,保证原子性。
+
+---
+
+## 3. channel-service 设计
+
+### 3.1 新增模块
+
+| 模块 | 路径 | 职责 |
+|------|------|------|
+| 事件模型 | `app/models/event.py` | `OutboundEvent` 数据类、事件类型枚举、payload schema |
+| 事件存储 | `app/repositories/event_store.py` | SQLite 建表、CRUD、事务、清理 |
+| 事件发布 | `app/services/event_publisher.py` | 后台单线程 worker、重试、死信 |
+| mci-server 客户端 | `app/clients/mci_server_client.py` | HTTP POST 到 mci-server |
+| 配置扩展 | `app/config.py` | 新增事件存储、重试、清理相关配置 |
+
+### 3.2 SQLite 事件表 `outbound_events`
+
+```sql
+CREATE TABLE IF NOT EXISTS outbound_events (
+ id TEXT PRIMARY KEY,
+ event_type TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempt_count INTEGER NOT NULL DEFAULT 0,
+ next_retry_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_error TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_status_next_retry
+ ON outbound_events(status, next_retry_at);
+
+CREATE INDEX IF NOT EXISTS idx_created_at
+ ON outbound_events(created_at);
+```
+
+字段说明:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | TEXT PK | UUID v4,即 `event_id` |
+| `event_type` | TEXT | `heartbeat` / `login_status_changed` / `online_status_changed` / `send_result` / `error` |
+| `payload` | TEXT | JSON 字符串,见第 3.3 节 |
+| `status` | TEXT | `pending` / `retrying` / `delivered` / `dead_letter` |
+| `attempt_count` | INTEGER | 已尝试次数,上限 `max_retry` |
+| `next_retry_at` | TIMESTAMP | 下次重试时间;`pending` 事件为创建时间 |
+| `created_at` | TIMESTAMP | 创建时间,用于 10 天清理 |
+| `updated_at` | TIMESTAMP | 更新时间 |
+| `last_error` | TEXT | 最近一次失败摘要 |
+
+### 3.3 事件 payload 统一结构
+
+所有事件统一外层:
+
+```json
+{
+ "event_id": "evt_20260710_xxx",
+ "event_type": "heartbeat",
+ "account_id": "wx-bot-01",
+ "tenant_code": "rescue",
+ "timestamp": "2026-07-10T10:00:00Z",
+ "data": { }
+}
+```
+
+`data` 按事件类型定义:
+
+#### heartbeat
+
+```json
+{
+ "login_status": "success",
+ "online_status": "online",
+ "risk_level": 10,
+ "sent_count_today": 5,
+ "server_host": "win-svr-01"
+}
+```
+
+#### login_status_changed
+
+```json
+{
+ "previous": "pending",
+ "current": "success",
+ "qr_code_url": "https://mock.pywechat.qrcode/...",
+ "error_message": null
+}
+```
+
+#### online_status_changed
+
+```json
+{
+ "previous": "offline",
+ "current": "online"
+}
+```
+
+#### send_result
+
+```json
+{
+ "task_id": "task_001",
+ "record_id": 1001,
+ "conversation_id": "room-001",
+ "success": true,
+ "message_id": "msg-123",
+ "channel_result": {},
+ "error": null
+}
+```
+
+> 说明:`record_id` 由 mci-server 在调用 send API 时传入;channel-service 原样回传,用于 mci-server 更新 `mci_send_record`。
+
+#### error
+
+```json
+{
+ "operation": "send_text",
+ "error_code": "ACCOUNT_OFFLINE",
+ "error_message": "account wx-bot-01 not online"
+}
+```
+
+### 3.4 API 行为变更
+
+| API | 变更 |
+|------|------|
+| `POST /v1/wechat-personal/login/start` | 创建/更新账号、生成二维码,并在同一事务写入 `login_status_changed` 事件 |
+| `POST /v1/wechat-personal/login/{id}/confirm` | 确认登录后,在同一事务写入 `login_status_changed` + `online_status_changed` |
+| `POST /v1/wechat-personal/login/{id}/expire` | 二维码过期后,写入 `login_status_changed` 事件 |
+| `POST /v1/wechat-personal/send/text` | 改为**异步受理**:立即返回 `{"accepted": true, "message_id": "..."}`;真实发送完成后写入 `send_result` 事件 |
+| `POST /v1/wechat-personal/send/image` | 同上 |
+| `POST /v1/heartbeat` | 更新本地状态,并在同一事务写入 `heartbeat` 事件 |
+| `POST /v1/admin/retry-dead-letter`(新增) | 手动把 `dead_letter` 状态重置为 `retrying`,用于人工恢复 |
+
+### 3.5 发送异步化与风控
+
+当前仍为 mock 客户端,但接口行为需与真实 pywechat 一致:
+
+1. `send_text` / `send_image` 收到请求后,先校验账号在线、风控等级。
+2. 校验通过后返回 `accepted=true`,并立即写入 `send_result` 事件。
+3. mock 场景下该事件直接表示成功;真实 pywechat 场景下,发送完成后用同一 `event_id` 更新 `send_result` 事件内容,或删除旧事件后写入最终结果事件。
+
+**风控处理**:
+
+- 风控等级 `risk_level >= 80` 的账号,发送请求直接返回失败,不进入重试。
+- 因微信风控导致的发送失败(如 `error_code` 为 `RISK_CONTROL_BLOCKED`、`ACCOUNT_BANNED`),事件直接标记为 `dead_letter`,不再重试。
+- 因网络超时、mci-server 不可达等**临时错误**,才进入指数退避重试。
+- `sent_count_today` 每日重置,channel-service 在心跳中上报,mci-server 可据此做策略判断。
+
+### 3.6 EventPublisher 后台 worker
+
+- **单线程执行**:使用一个 `asyncio` 任务循环消费,避免并发导致 SQLite 锁竞争或事件乱序。
+- **扫描频率**:每 5 秒一次。
+- **每次拉取**:最多 100 条 `status IN ('pending','retrying') AND next_retry_at <= now()`,按 `next_retry_at` 升序。
+- **推送目标**:`{mci_server_url}/api/v1/channel-account/event`。
+- **成功**:`status='delivered'`,`attempt_count` 不变。
+- **失败**:
+ - `attempt_count += 1`
+ - `next_retry_at = now + base_delay * 2^(attempt_count - 1)`(首次重试为 `base_delay`,第二次为 `2*base_delay`,依此类推)
+ - `status='retrying'`
+ - `last_error` 记录 HTTP 状态码或异常摘要
+ - 若 `attempt_count >= max_retry`,则 `status='dead_letter'`
+- **最大重试次数**:由配置 `max_retry` 控制,默认 `5`,上限 `10`(代码中硬上限,防止配置错误)。
+- **服务启动**:从 `main.py` 启动时创建 worker;启动后自动恢复 `pending` / `retrying` 事件。
+- **服务关闭**:优雅关闭时等待当前批次处理完成(超时 5 秒)。
+
+### 3.7 数据清理
+
+- 每天凌晨 3:00 启动清理任务(或每次 worker 循环顺带清理)。
+- 删除 `status IN ('delivered','dead_letter')` 且 `created_at < CURRENT_TIMESTAMP - INTERVAL '10 days'` 的事件。
+- 保留 `pending` / `retrying` 事件,无论创建时间。
+
+### 3.8 配置项
+
+```env
+# mci-server 地址
+MCI_SERVER_URL=http://localhost:8080/msg-platform
+
+# SQLite 事件库路径
+EVENT_STORE_PATH=./data/events.db
+
+# 心跳间隔(秒)
+HEARTBEAT_INTERVAL_SECONDS=30
+
+# 最大重试次数(代码硬上限 10)
+MAX_RETRY=5
+
+# 基础重试延迟(秒)
+BASE_RETRY_DELAY_SECONDS=5
+
+# 事件保留天数
+EVENT_RETENTION_DAYS=10
+
+# 后台 worker 扫描间隔(秒)
+EVENT_PUBLISH_INTERVAL_SECONDS=5
+```
+
+---
+
+## 4. mci-server 设计
+
+### 4.1 新增端点
+
+#### POST `/api/v1/channel-account/event`
+
+接收 channel-service 推送的统一事件。
+
+**请求头**:
+
+```http
+Content-Type: application/json
+```
+
+**请求体**:见第 3.3 节统一 payload。
+
+**处理逻辑**:
+
+| event_type | 处理 |
+|------------|------|
+| `heartbeat` | 根据 `account_id` 查询 `mci_channel_account`;更新 `login_status`、`online_status`、`last_heartbeat`、`risk_level`、`sent_count_today` |
+| `login_status_changed` | 更新 `mci_channel_account.login_status`;如果 `current=success` 则同时更新 `online_status=1` |
+| `online_status_changed` | 更新 `mci_channel_account.online_status` |
+| `send_result` | 根据 `record_id` 查询 `mci_send_record`;更新 `send_status`(`success=true` 则为 2,否则 3)、`finish_time`、`channel_result`;成功后调用 `SendCallbackService.sendCallback()` 向业务系统回调 |
+| `error` | 记录告警日志,可扩展发送告警通知 |
+
+**状态字段映射**(channel-service 字符串 → mci-server 整数):
+
+| channel-service 字段 | mci-server 字段 | 映射关系 |
+|----------------------|-----------------|----------|
+| `login_status` | `mci_channel_account.login_status` | `pending=0`, `scanning=0`, `success=1`, `expired=2`, `failed=3` |
+| `online_status` | `mci_channel_account.online_status` | `offline=0`, `online=1`, `error=2` |
+
+账号定位:使用 `tenant_code` + `account_id` 联合查询 `mci_channel_account`,确保租户隔离。
+
+**幂等**:
+
+- 使用 `event_id` 作为幂等键。
+- 第一次收到时写入 `mci_channel_event` 去重表;重复事件直接返回 200。
+
+**响应**:
+
+```json
+{
+ "code": 0,
+ "message": "ok",
+ "data": null,
+ "request_id": "..."
+}
+```
+
+### 4.2 保持现有端点
+
+- `POST /api/v1/channel-account/heartbeat`:保持兼容,但建议新接入走 `/event`。
+- `POST /api/v1/channel-account/init`:继续调用 `WechatPersonalChannelAdapter`,由 adapter 调用 channel-service `/v1/wechat-personal/login/start`。
+
+### 4.3 发送流程补齐
+
+为了让 `send_result` 能回写到正确的 `mci_send_record`,需要在 mci-server 调用 channel-service 发送时传入 `record_id`:
+
+1. `SendService.dispatchRecord(record)` 调用 `ChannelAdapter.sendMessage(request)`。
+2. `SendMessageRequest.extra` 中带上 `record_id`。
+3. `WechatPersonalChannelAdapter.sendMessage()` 把 `record_id` 放入 body,调用 channel-service `/v1/wechat-personal/send/text` 或 `/send/image`。
+4. channel-service 异步完成后,通过 `/event` 回传 `send_result` 并携带 `record_id`。
+
+### 4.4 数据库表扩展
+
+#### 4.4.1 新增幂等表 `mci_channel_event`
+
+用于防止 channel-service 重试导致重复处理。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `event_id` | VARCHAR PK | channel-service 事件 ID |
+| `account_id` | VARCHAR | 账号 ID |
+| `event_type` | VARCHAR | 事件类型 |
+| `create_time` | DATETIME | 接收时间 |
+
+索引:`CREATE INDEX idx_account_time ON mci_channel_event(account_id, create_time);`
+
+清理策略:保留 30 天后删除(可配置)。
+
+---
+
+## 5. 错误处理与边界
+
+| 场景 | 处理 |
+|------|------|
+| mci-server 不可达 | 事件留在 SQLite,worker 按指数退避重试 |
+| 服务重启 | 启动时恢复 worker,继续推送 `pending` / `retrying` 事件 |
+| payload 序列化失败 | 直接标记 `dead_letter`,不阻塞其他事件 |
+| mci-server 返回业务错误(如账号不存在) | 视为失败重试;若连续失败达到上限,进 `dead_letter` |
+| 微信风控/账号被封 | 对应 `error_code` 直接标记 `dead_letter`,不重试 |
+| SQLite 磁盘满/损坏 | worker 记录错误并停止推送;监控告警 |
+| 单条事件推送超时 | 使用 `httpx` timeout(默认 10s),超时即失败进入重试 |
+| 重试次数达到上限 | `status='dead_letter'`,记录 `last_error`,等待人工处理或手动重试 |
+| 事件堆积 | worker 每批最多 100 条;如果持续堆积,考虑后续改为批量 POST 或引入 MQ |
+
+---
+
+## 6. 测试策略
+
+### 6.1 channel-service 单元测试
+
+- `test_event_store.py`:验证 SQLite CRUD、事务、状态流转、清理。
+- `test_event_publisher.py`:mock mci-server HTTP,验证重试、死信、幂等。
+- `test_send_async.py`:验证 send API 改为异步受理后,事件正确写入 `outbound_events`。
+- `test_heartbeat_event.py`:验证心跳接口产生事件。
+
+### 6.2 mci-server 单元测试
+
+- `ChannelAccountEventControllerTest`:使用 MockWebServer 或 `MockMvc` 接收 channel-service 事件,验证:
+ - heartbeat 更新 `mci_channel_account`
+ - send_result 更新 `mci_send_record`
+ - event_id 幂等
+
+### 6.3 联调测试
+
+- docker-compose 启动 mci-server + channel-service。
+- 调用 mci-server 发送 API。
+- 验证 channel-service 收到指令、产生 send_result 事件、推回 mci-server。
+- 验证 mci-server 更新 SendRecord 并向业务系统 callback_url 回调。
+
+---
+
+## 7. 部署与配置
+
+### 7.1 channel-service 新增环境变量
+
+```yaml
+environment:
+ MCI_SERVER_URL: http://mci-server:8080/msg-platform
+ EVENT_STORE_PATH: /app/data/events.db
+ HEARTBEAT_INTERVAL_SECONDS: "30"
+ MAX_RETRY: "5"
+ BASE_RETRY_DELAY_SECONDS: "5"
+ EVENT_RETENTION_DAYS: "10"
+ EVENT_PUBLISH_INTERVAL_SECONDS: "5"
+```
+
+### 7.2 数据卷
+
+```yaml
+volumes:
+ - channel-service-data:/app/data
+```
+
+---
+
+## 8. 后续扩展
+
+- 入站消息从 channel-service 回传 mci-server(复用 `/event` 新增 `message_received` 类型)。
+- 批量事件上报:把多条事件打包为一个 HTTP 请求,降低 mci-server 压力。
+- 当事件量增大时,可考虑用 RabbitMQ 替代 SQLite + HTTP worker。
+- 真实 pywechat 接入时,把 mock 客户端替换为 `pywechat`/`pywinauto` 驱动,事件模型保持不变。
+
+---
+
+*设计完成,待评审。*
diff --git a/docs/verification/conversation-bind-account-popup.png b/docs/verification/conversation-bind-account-popup.png
new file mode 100644
index 0000000..ad5c0db
Binary files /dev/null and b/docs/verification/conversation-bind-account-popup.png differ
diff --git a/docs/verification/send-records-integration.png b/docs/verification/send-records-integration.png
new file mode 100644
index 0000000..af202f3
Binary files /dev/null and b/docs/verification/send-records-integration.png differ