35 Commits

Author SHA1 Message Date
e09024b000 fix(send): add MyBatis Plus pagination interceptor so queryRecords returns correct total 2026-07-15 14:20:44 +08:00
f108465d4f fix(plan/ui): correct screenshot extension and align bot ext_info guard with backend 2026-07-15 14:06:39 +08:00
1378649fc7 docs(plan): correct admin-web routes and reduce webhook URL parse noise 2026-07-15 14:02:21 +08:00
d4b48fb611 docs(testing): e2e verification report for send routing with WECOM_BOT 2026-07-15 13:55:26 +08:00
0ad12c6df6 fix(send): extract webhook_url from JSON ext_info for WECOM_BOT 2026-07-15 13:35:44 +08:00
168cd2de11 fix(send-routing): address re-review issues
- Set conversationScene=EXTERNAL in SendEndToEndTest and align account to WECHAT_PERSONAL
- Replace literal account type code with AccountType.WECHAT_PERSONAL.getCode()
- Use replaceFirst for webhook URL placeholder in WeComBotSender
- Remove unused selectDefaultAccount overload
- Hide scene selector for single-chat conversations in admin-web
2026-07-15 12:08:56 +08:00
0a326e7ff9 fix(send-routing): address final code review findings
- WeComBotSender.resolveWebhookUrl: use custom URL as-is when no %s placeholder
- WeComBotSender: treat unparseable/blank response body as failure
- ConversationService.isAccountCompatibleWithConversation: ignore scene for single chats
- SendService.selectSendAccountCandidates: apply accountTypes filter for non-conversation records
- SendService: replace account type magic numbers with AccountType constants
- WeComChannelAdapter: use AccountType.WECOM_BOT.getCode() instead of literal 6
- admin-web Conversation.vue: fallback to channelType filter when scene is unset
- add tests for single-chat null scene, webhook_url propagation, and custom URL without %s
2026-07-15 11:53:51 +08:00
c24891a82e fix(send): use stored WECOM_BOT webhook URL and preserve single-chat routing without scene
- Copy account.ext_info into extra.webhook_url for WECOM_BOT sends.
- Allow null conversation_scene for single chats; only enforce scene-based routing for group chats.
- Harden WeComBotSender custom URL handling when placeholder is missing.
2026-07-14 18:45:48 +08:00
11ee7384b3 fix(send-routing): address final review findings
- Persist conversationScene in create/update conversation
- Make bindAccount validate by conversation scene (1=WECOM_BOT, 2=WECHAT_PERSONAL)
- Re-derive channelType from conversation_scene in dispatchRecord
- Check WeCom response errcode and HTTP status in WeComBotSender
- Support custom webhook_url from ext_info in WeComBotSender
- Configure RestTemplate with 5s connect / 10s read timeouts
- Expand WeComBotSenderTest with MockRestServiceServer coverage
2026-07-14 18:35:50 +08:00
e1c7c582ee test(send): integration tests for scene-based routing 2026-07-14 18:08:05 +08:00
128df39113 feat(admin): conversation scene and account binding by scene 2026-07-14 18:00:45 +08:00
687269cce6 feat(admin): support WECOM_BOT account creation 2026-07-14 17:56:11 +08:00
c4caf48bad feat(sync): default WeCom-synced groups to external scene 2026-07-14 17:50:51 +08:00
3a8d51b0df fix(send): remove out-of-scope tag/combined-target logic and revert conversationId handling
Task 5 (aeb14cf) correctly added scene/person-based automatic routing,
but also introduced tag/combined-target features and changed doSendAttempt
conversationId handling that were not in the task brief.

This commit reverts those out-of-scope changes while keeping the
scene/person routing and account-type selection that belong to Task 5:
- Revert tag target resolution to single-tag logic (no tag_ids/inner_logic)
- Remove combined target type and all helper methods
- Revert doSendAttempt conversationId to use record.conversationId directly
- Keep conversation_scene routing, person_type routing, and account-type helpers
2026-07-14 17:44:48 +08:00
aeb14cfa30 feat(send): route by conversation_scene and person_type 2026-07-14 17:33:09 +08:00
ed76915f56 feat(selector): filter send accounts by account type 2026-07-14 17:21:55 +08:00
0bef33bbb8 feat(wecom): add group bot webhook sender and dispatch by account type 2026-07-14 17:15:32 +08:00
4bc643024d feat(conversation): add conversation_scene field 2026-07-14 17:08:35 +08:00
895c78d60e feat(channel): add WECOM_BOT account type 2026-07-14 17:05:27 +08:00
93df416954 docs(agents): 补充 IDE 本地调试说明 2026-07-13 14:34:49 +08:00
0e8d3f61d4 docs(agents): 补充 .env 环境切换与本地启动使用说明 2026-07-13 14:26:39 +08:00
0bf48fd449 chore: 统一环境配置,支持 .env 快速切换本地/远程基础设施 2026-07-13 14:17:49 +08:00
8c4deb5be4 chore: 集成初始化 SQL 导入到 local-up.sh,导出默认及全量初始化数据 2026-07-13 10:29:44 +08:00
31d4a34ebb feat: 新增渠道身份菜单、会话成员/标签展示、修复群聊成员 person_type 及重复同步问题 2026-07-13 09:38:03 +08:00
749856c455 fix(test): 为 ChannelAccountEventControllerTest 添加 @Transactional,防止测试 SQL 清空开发库渠道账号数据 2026-07-10 16:29:23 +08:00
758ed73f9d fix: 渠道账号群聊同步缓存 key 及成员名称兜底;统一 Docker/Maven 项目约定 2026-07-10 15:55:30 +08:00
560bd4599b fix(channel): accept snake_case event request fields from channel-service 2026-07-10 12:33:24 +08:00
3b8cde2995 chore(docker): add channel-service event store config and volume 2026-07-10 12:26:31 +08:00
82d1450182 feat(send): pass record_id and tenant_code, accept async response 2026-07-10 12:26:05 +08:00
302e0ab442 feat(channel): add channel account event receiver endpoint 2026-07-10 12:24:00 +08:00
1fabc04d70 feat(channel): add event handling service 2026-07-10 12:21:33 +08:00
e3bae8aa1f feat(db): add channel event record idempotency table 2026-07-10 12:20:35 +08:00
4b0780c181 feat(event): add daily cleanup for delivered/dead-letter events 2026-07-10 12:19:43 +08:00
c3aefc97de feat(main): start and stop event publisher worker 2026-07-10 12:19:14 +08:00
1641553d31 feat(send): async acceptance and send_result event 2026-07-10 12:18:54 +08:00
120 changed files with 11345 additions and 1280 deletions

View File

@@ -1,32 +1,59 @@
# MySQL
# ============================================================
# Sino-MCI 通用环境配置模板
#
# 推荐用法:
# cp .env.local .env # 本机开发,连接 localhost 基础设施
# cp .env.remote .env # 本机开发,连接远程基础设施(如 192.168.1.173
#
# 注意:
# 1. .env 文件不会被提交到仓库(已在 .gitignore 中忽略)。
# 2. scripts/local-up.sh 启动 docker-compose 时,会自动把 host 覆盖为容器名,
# 因此无论 .env 中是 localhost 还是远程 IPdocker-compose 内部都能正确互联。
# 3. 若手动执行 docker compose请确保当前 .env 中的 MYSQL_HOST/REDIS_HOST/
# RABBITMQ_HOST 指向可访问地址,或使用 local-up.sh 启动。
# ============================================================
# --------------------------------------------------
# 基础设施端口与账号
# --------------------------------------------------
MYSQL_PORT=3306
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=sino_mci
MYSQL_USER=root
MYSQL_PORT=3306
# Redis
REDIS_PORT=6379
# RabbitMQ
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MGMT_PORT=15672
# SeaweedFS
SEAWEEDFS_MASTER_PORT=9333
SEAWEEDFS_S3_PORT=8888
# Application ports
# --------------------------------------------------
# 应用服务端口
# --------------------------------------------------
MCI_SERVER_PORT=8080
ADMIN_WEB_PORT=80
CHANNEL_SERVICE_PORT=8000
# JVM options for mci-server
JAVA_OPTS=-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0
# Spring profile
# --------------------------------------------------
# Spring Boot / JVM
# --------------------------------------------------
SPRING_PROFILES_ACTIVE=dev
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
# OpenAI API key (optional)
OPENAI_API_KEY=your-openai-api-key-here
# --------------------------------------------------
# Channel Service
# --------------------------------------------------
MAX_RETRY=5
BASE_RETRY_DELAY_SECONDS=5
EVENT_RETENTION_DAYS=10
EVENT_PUBLISH_INTERVAL_SECONDS=5
# --------------------------------------------------
# OpenAI可选
# --------------------------------------------------
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com

50
.env.local Normal file
View File

@@ -0,0 +1,50 @@
# ============================================================
# Sino-MCI 本地开发环境(本机启动后端/前端时加载)
# 使用方式cp .env.local .env
# 说明:应用服务连接本机 localhost 上的基础设施
# ============================================================
# 基础设施连接地址
MYSQL_HOST=localhost
REDIS_HOST=localhost
RABBITMQ_HOST=localhost
SEAWEEDFS_HOST=localhost
# 基础设施端口与账号
MYSQL_PORT=3306
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=sino_mci
MYSQL_USER=root
REDIS_PORT=6379
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MGMT_PORT=15672
SEAWEEDFS_MASTER_PORT=9333
SEAWEEDFS_S3_PORT=8888
# 应用服务端口
MCI_SERVER_PORT=8080
ADMIN_WEB_PORT=80
CHANNEL_SERVICE_PORT=8000
# 应用服务地址
MCI_SERVER_URL=http://localhost:8080/msg-platform
VITE_API_BASE_URL=http://localhost:8080
# Spring Boot / JVM
SPRING_PROFILES_ACTIVE=dev
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
# Channel Service
MAX_RETRY=5
BASE_RETRY_DELAY_SECONDS=5
EVENT_RETENTION_DAYS=10
EVENT_PUBLISH_INTERVAL_SECONDS=5
# OpenAI可选
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com

50
.env.remote Normal file
View File

@@ -0,0 +1,50 @@
# ============================================================
# Sino-MCI 远程基础设施环境(本机启动后端/前端时加载)
# 使用方式cp .env.remote .env
# 说明:只启动本机应用服务,连接远程 MySQL/Redis/RabbitMQ/SeaweedFS
# ============================================================
# 基础设施连接地址
MYSQL_HOST=192.168.1.173
REDIS_HOST=192.168.1.173
RABBITMQ_HOST=192.168.1.173
SEAWEEDFS_HOST=192.168.1.173
# 基础设施端口与账号
MYSQL_PORT=3306
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=sino_mci
MYSQL_USER=root
REDIS_PORT=6379
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MGMT_PORT=15672
SEAWEEDFS_MASTER_PORT=9333
SEAWEEDFS_S3_PORT=8888
# 应用服务端口
MCI_SERVER_PORT=8080
ADMIN_WEB_PORT=80
CHANNEL_SERVICE_PORT=8000
# 应用服务地址
MCI_SERVER_URL=http://192.168.1.173:8080/msg-platform
VITE_API_BASE_URL=http://192.168.1.173:8080
# Spring Boot / JVM
SPRING_PROFILES_ACTIVE=dev
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
# Channel Service
MAX_RETRY=5
BASE_RETRY_DELAY_SECONDS=5
EVENT_RETENTION_DAYS=10
EVENT_PUBLISH_INTERVAL_SECONDS=5
# OpenAI可选
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com

116
.stitch/DESIGN.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,656 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sino-MCI 消息中台 - 完整交互原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { 50: '#eff6ff', 100: '#dbeafe', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' },
slate: { 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 900: '#0f172a' },
},
fontFamily: {
sans: ['Inter', 'Noto Sans SC', 'system-ui', 'sans-serif'],
},
},
},
};
</script>
<style>
body { font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif; }
.nav-link.active { background: #eff6ff; color: #2563eb; border-right: 3px solid #2563eb; }
.drawer { transform: translateX(100%); transition: transform .25s ease; }
.drawer.open { transform: translateX(0); }
.scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
</style>
</head>
<body class="bg-slate-50 text-slate-900 h-screen overflow-hidden flex">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-slate-200 flex flex-col flex-shrink-0">
<div class="h-16 flex items-center px-6 border-b border-slate-200">
<div class="w-8 h-8 rounded-lg bg-primary-600 flex items-center justify-center text-white font-bold mr-3">M</div>
<div>
<div class="font-bold text-slate-900 leading-tight">Sino-MCI</div>
<div class="text-xs text-slate-500">消息中台</div>
</div>
</div>
<nav class="flex-1 overflow-y-auto scrollbar py-3 text-sm" id="sidebar">
<a href="#" data-target="dashboard" class="nav-link active flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
概览
</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">渠道接入</div>
<a href="#" data-target="channels" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>渠道主体</a>
<a href="#" data-target="channel-apps" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>渠道应用</a>
<a href="#" data-target="channel-accounts" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>渠道账号</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">客户关系</div>
<a href="#" data-target="contacts" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>联系人</a>
<a href="#" data-target="groups" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>群聊</a>
<a href="#" data-target="tags" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>标签体系</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">消息运营</div>
<a href="#" data-target="send-records" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>发送记录</a>
<a href="#" data-target="messages" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>消息查询</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">流程编排</div>
<a href="#" data-target="inbound-flow" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/></svg>入站流程</a>
<div class="px-6 pt-4 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">系统管理</div>
<a href="#" data-target="tenants" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>租户管理</a>
<a href="#" data-target="users" class="nav-link flex items-center px-6 py-2.5 text-slate-600 hover:bg-slate-50"><svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>用户管理</a>
</nav>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 flex-shrink-0">
<div class="text-lg font-semibold text-slate-900" id="page-title">概览</div>
<div class="flex items-center gap-4">
<button class="p-2 text-slate-500 hover:text-primary-600"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg></button>
<div class="flex items-center gap-2 pr-4 border-r border-slate-200">
<label class="text-sm text-slate-500">租户</label>
<select id="tenant-selector" class="border border-slate-200 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500">
<option value="">全部租户</option>
<option value="default">默认租户</option>
<option value="sinocare">Sinocare</option>
</select>
</div>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center font-semibold text-sm"></div>
<div class="text-sm">
<div class="font-medium text-slate-900">平台管理员</div>
<div class="text-xs text-slate-500">admin@sino-mci</div>
</div>
</div>
</div>
</header>
<!-- Content Views -->
<div class="flex-1 overflow-y-auto scrollbar p-6" id="content">
<!-- Dashboard -->
<section id="dashboard" class="view space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">今日发送消息</div><div class="text-3xl font-bold text-slate-900 mt-1">12,458</div><div class="text-xs text-emerald-600 mt-2">↑ 12% 较昨日</div></div>
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">今日接收消息</div><div class="text-3xl font-bold text-slate-900 mt-1">8,932</div><div class="text-xs text-emerald-600 mt-2">↑ 8% 较昨日</div></div>
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">渠道账号在线</div><div class="text-3xl font-bold text-slate-900 mt-1">24 / 26</div><div class="text-xs text-slate-400 mt-2">2 个离线</div></div>
<div class="bg-white rounded-xl border border-slate-200 p-5"><div class="text-sm text-slate-500">待处理入站会话</div><div class="text-3xl font-bold text-slate-900 mt-1">156</div><div class="text-xs text-amber-600 mt-2">需人工跟进</div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-white rounded-xl border border-slate-200 p-5">
<div class="flex items-center justify-between mb-4"><h3 class="font-semibold text-slate-900">最近消息</h3><button class="text-sm text-primary-600 hover:underline">查看全部</button></div>
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-3 py-2 font-medium">时间</th><th class="px-3 py-2 font-medium">渠道</th><th class="px-3 py-2 font-medium">发送方</th><th class="px-3 py-2 font-medium">内容摘要</th><th class="px-3 py-2 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50"><td class="px-3 py-3 text-slate-500">10:23</td><td class="px-3 py-3">企微</td><td class="px-3 py-3">客户 A</td><td class="px-3 py-3 truncate max-w-xs">我要查询订单状态,订单号是 2025...</td><td class="px-3 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-amber-50 text-amber-700">待处理</span></td></tr>
<tr class="hover:bg-slate-50"><td class="px-3 py-3 text-slate-500">10:18</td><td class="px-3 py-3">个微</td><td class="px-3 py-3">客户 B</td><td class="px-3 py-3 truncate max-w-xs">[图片] 车牌号 京 A12345</td><td class="px-3 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已归档</span></td></tr>
<tr class="hover:bg-slate-50"><td class="px-3 py-3 text-slate-500">10:05</td><td class="px-3 py-3">企微</td><td class="px-3 py-3">客户 C</td><td class="px-3 py-3 truncate max-w-xs">帮我发一份报价单到邮箱</td><td class="px-3 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-blue-50 text-blue-700">处理中</span></td></tr>
</tbody>
</table>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-5">
<h3 class="font-semibold text-slate-900 mb-4">渠道状态</h3>
<div class="space-y-3">
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-emerald-500"></span><span class="text-sm">企业微信主体-01</span></div><span class="text-xs text-emerald-600">正常</span></div>
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-emerald-500"></span><span class="text-sm">个人微信机器人-A</span></div><span class="text-xs text-emerald-600">正常</span></div>
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-slate-300"></span><span class="text-sm">钉钉(预留)</span></div><span class="text-xs text-slate-400">未启用</span></div>
<div class="flex items-center justify-between"><div class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-slate-300"></span><span class="text-sm">飞书(预留)</span></div><span class="text-xs text-slate-400">未启用</span></div>
</div>
</div>
</div>
</section>
<!-- Channel Subjects -->
<section id="channels" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="搜索主体名称 / corpId" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary-500" /><button class="bg-white border border-slate-200 text-slate-600 px-3 py-2 rounded-lg text-sm hover:bg-slate-50">筛选</button></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700" onclick="openModal('channel-subject-modal')">新增主体</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left">
<thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">主体名称</th><th class="px-4 py-3 font-medium">渠道类型</th><th class="px-4 py-3 font-medium">CorpID / 标识</th><th class="px-4 py-3 font-medium">应用数</th><th class="px-4 py-3 font-medium">账号数</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-detail')">
<td class="px-4 py-3 font-medium text-slate-900">Sinocare 企业微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">企微</span></td>
<td class="px-4 py-3 text-slate-500">ww1234567890abcdef</td>
<td class="px-4 py-3">2</td>
<td class="px-4 py-3">18</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已连接</span></td>
</tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-detail')">
<td class="px-4 py-3 font-medium text-slate-900">Sinocare 个人微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-green-50 text-green-700">个微</span></td>
<td class="px-4 py-3 text-slate-500">pywechat-001</td>
<td class="px-4 py-3">-</td>
<td class="px-4 py-3">3</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已连接</span></td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2">
<button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button>
<button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button>
<button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button>
</div>
</div>
</section>
<!-- Channel Apps -->
<section id="channel-apps" class="view hidden space-y-4">
<div class="flex items-center justify-between"><h3 class="font-semibold text-slate-900">企微应用列表</h3><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增应用</button></div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-sm transition">
<div class="flex items-center justify-between mb-3"><div class="font-semibold text-slate-900">会话存档应用</div><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
<div class="text-sm text-slate-500 space-y-1"><div>AgentID: 1000002</div><div>Secret: ••••••••</div><div>会话存档: 已开启</div></div>
<div class="mt-4 flex gap-2"><button class="text-sm text-primary-600 hover:underline">编辑</button><button class="text-sm text-slate-500 hover:text-slate-700">初始化账号</button></div>
</div>
<div class="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-sm transition">
<div class="flex items-center justify-between mb-3"><div class="font-semibold text-slate-900">消息推送应用</div><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
<div class="text-sm text-slate-500 space-y-1"><div>AgentID: 1000005</div><div>Secret: ••••••••</div><div>主动推送: 已开启</div></div>
<div class="mt-4 flex gap-2"><button class="text-sm text-primary-600 hover:underline">编辑</button><button class="text-sm text-slate-500 hover:text-slate-700">初始化账号</button></div>
</div>
</div>
</section>
<!-- Channel Accounts -->
<section id="channel-accounts" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="搜索账号 / UserID" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary-500" /><select class="border border-slate-200 rounded-lg px-3 py-2 text-sm"><option>全部主体</option><option>Sinocare 企业微信</option></select></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增账号</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">账号昵称</th><th class="px-4 py-3 font-medium">UserID</th><th class="px-4 py-3 font-medium">所属主体</th><th class="px-4 py-3 font-medium">部门路径</th><th class="px-4 py-3 font-medium">会话存档</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-account-detail')"><td class="px-4 py-3 flex items-center gap-2"><div class="w-7 h-7 rounded-full bg-slate-200"></div><span class="font-medium text-slate-900">张三</span></td><td class="px-4 py-3 text-slate-500">zhangsan</td><td class="px-4 py-3">Sinocare 企业微信</td><td class="px-4 py-3 text-slate-500">/销售部/华东区</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已开启</span></td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">在线</span></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('channel-account-detail')"><td class="px-4 py-3 flex items-center gap-2"><div class="w-7 h-7 rounded-full bg-slate-200"></div><span class="font-medium text-slate-900">李四</span></td><td class="px-4 py-3 text-slate-500">lisi</td><td class="px-4 py-3">Sinocare 企业微信</td><td class="px-4 py-3 text-slate-500">/客服部</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600">未开启</span></td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600">离线</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Contacts -->
<section id="contacts" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="搜索姓名 / 手机号 / UserID" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /><button class="bg-white border border-slate-200 text-slate-600 px-3 py-2 rounded-lg text-sm hover:bg-slate-50">标签筛选</button></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增联系人</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">联系人</th><th class="px-4 py-3 font-medium">渠道身份</th><th class="px-4 py-3 font-medium">所属主体</th><th class="px-4 py-3 font-medium">标签</th><th class="px-4 py-3 font-medium">最近会话</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('contact-detail')">
<td class="px-4 py-3"><div class="flex items-center gap-3"><div class="w-9 h-9 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center font-semibold">A</div><div><div class="font-medium text-slate-900">艾先生</div><div class="text-xs text-slate-500">138****1234</div></div></div></td>
<td class="px-4 py-3 text-slate-500">wmABC123</td>
<td class="px-4 py-3">Sinocare 企业微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">VIP客户</span> <span class="px-2 py-0.5 rounded text-xs bg-amber-50 text-amber-700">待回访</span></td>
<td class="px-4 py-3 text-slate-500">10 分钟前</td>
</tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('contact-detail')">
<td class="px-4 py-3"><div class="flex items-center gap-3"><div class="w-9 h-9 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center font-semibold">B</div><div><div class="font-medium text-slate-900">白女士</div><div class="text-xs text-slate-500">139****5678</div></div></div></td>
<td class="px-4 py-3 text-slate-500">wmDEF456</td>
<td class="px-4 py-3">Sinocare 企业微信</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600">普通客户</span></td>
<td class="px-4 py-3 text-slate-500">2 小时前</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Groups -->
<section id="groups" class="view hidden space-y-4">
<div class="flex items-center justify-between"><div class="flex gap-2"><input type="text" placeholder="搜索群名 / 群ID" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /></div><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">抓取已有群聊</button></div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">群名称</th><th class="px-4 py-3 font-medium">群ID</th><th class="px-4 py-3 font-medium">所属主体</th><th class="px-4 py-3 font-medium">人数</th><th class="px-4 py-3 font-medium">标签</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('group-detail')"><td class="px-4 py-3 font-medium text-slate-900">华东销售群</td><td class="px-4 py-3 text-slate-500">wrABC123</td><td class="px-4 py-3">Sinocare 企业微信</td><td class="px-4 py-3">128</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">销售群</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 1 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Send Records -->
<section id="send-records" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="任务ID / 回调地址" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /><select class="border border-slate-200 rounded-lg px-3 py-2 text-sm"><option>全部状态</option><option>发送中</option><option>成功</option><option>失败</option></select></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">测试发送</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">任务ID</th><th class="px-4 py-3 font-medium">业务系统</th><th class="px-4 py-3 font-medium">目标类型</th><th class="px-4 py-3 font-medium">目标数</th><th class="px-4 py-3 font-medium">发送时间</th><th class="px-4 py-3 font-medium">回调地址</th><th class="px-4 py-3 font-medium">状态</th><th class="px-4 py-3 font-medium text-right">日志</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer"><td class="px-4 py-3 font-medium text-slate-900">SND-20250709-001</td><td class="px-4 py-3">CRM</td><td class="px-4 py-3">单聊</td><td class="px-4 py-3">1,200</td><td class="px-4 py-3 text-slate-500">2025-07-09 09:30</td><td class="px-4 py-3 text-slate-500 truncate max-w-xs">https://crm.example.com/callback</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">成功</span></td><td class="px-4 py-3 text-right"><button class="text-slate-400 hover:text-primary-600">查看</button></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer"><td class="px-4 py-3 font-medium text-slate-900">SND-20250709-002</td><td class="px-4 py-3">ERP</td><td class="px-4 py-3">群聊</td><td class="px-4 py-3">45</td><td class="px-4 py-3 text-slate-500">2025-07-09 09:45</td><td class="px-4 py-3 text-slate-500">-</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-red-50 text-red-700">失败</span></td><td class="px-4 py-3 text-right"><button class="text-slate-400 hover:text-primary-600">查看</button></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Messages -->
<section id="messages" class="view hidden h-[calc(100vh-140px)]">
<div class="bg-white rounded-xl border border-slate-200 h-full flex overflow-hidden">
<!-- Conversation list -->
<div class="w-80 border-r border-slate-200 flex flex-col">
<div class="p-3 border-b border-slate-200"><input type="text" placeholder="搜索会话" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm" /></div>
<div class="flex-1 overflow-y-auto scrollbar">
<div class="p-3 border-b border-slate-100 hover:bg-slate-50 cursor-pointer bg-slate-50">
<div class="flex justify-between items-center"><div class="font-medium text-slate-900">艾先生</div><div class="text-xs text-slate-400">10:23</div></div>
<div class="text-sm text-slate-500 truncate">我要查询订单状态</div>
</div>
<div class="p-3 border-b border-slate-100 hover:bg-slate-50 cursor-pointer">
<div class="flex justify-between items-center"><div class="font-medium text-slate-900">华东销售群</div><div class="text-xs text-slate-400">09:58</div></div>
<div class="text-sm text-slate-500 truncate">[图片] 车牌号 京A12345</div>
</div>
</div>
</div>
<!-- Chat area -->
<div class="flex-1 flex flex-col">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-4">
<div class="font-medium text-slate-900">艾先生</div>
<div class="flex gap-2"><button class="text-sm text-slate-500 hover:text-primary-600">打标签</button><button class="text-sm text-slate-500 hover:text-primary-600">会话详情</button></div>
</div>
<div class="flex-1 overflow-y-auto scrollbar p-4 space-y-4 bg-slate-50">
<div class="flex justify-start"><div class="max-w-md bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-4 py-2 text-sm text-slate-800 shadow-sm">我要查询订单状态</div></div>
<div class="flex justify-start"><div class="max-w-md bg-white border border-slate-200 rounded-2xl rounded-tl-sm px-4 py-2 text-sm text-slate-800 shadow-sm">订单号是 20250709001</div></div>
<div class="flex justify-end"><div class="max-w-md bg-primary-600 text-white rounded-2xl rounded-tr-sm px-4 py-2 text-sm shadow-sm">好的,已为您查询到订单正在发货中。</div></div>
</div>
<div class="p-3 border-t border-slate-200 flex gap-2">
<input type="text" placeholder="输入消息..." class="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm" />
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium">发送</button>
</div>
</div>
</div>
</section>
<!-- Inbound Flow -->
<section id="inbound-flow" class="view hidden space-y-4">
<div class="flex items-center justify-between">
<div class="flex gap-2"><input type="text" placeholder="规则名称" class="border border-slate-200 rounded-lg px-3 py-2 text-sm w-64" /></div>
<button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新建流程</button>
</div>
<div class="bg-white rounded-xl border border-slate-200 h-[calc(100vh-220px)] flex">
<div class="w-72 border-r border-slate-200 p-4 overflow-y-auto">
<h4 class="font-semibold text-slate-900 mb-3">规则列表</h4>
<div class="space-y-2">
<div class="p-3 rounded-lg bg-primary-50 border border-primary-200 cursor-pointer"><div class="font-medium text-primary-700 text-sm">订单咨询自动分拣</div><div class="text-xs text-slate-500 mt-1">命中“订单”关键词</div></div>
<div class="p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer"><div class="font-medium text-slate-900 text-sm">地址/车牌号收集</div><div class="text-xs text-slate-500 mt-1">多轮消息确认</div></div>
</div>
</div>
<div class="flex-1 bg-slate-50 relative p-8">
<div class="absolute top-6 left-1/2 -translate-x-1/2 px-4 py-2 bg-white border border-slate-200 rounded-lg shadow-sm text-sm font-medium">入站消息触发</div>
<div class="absolute top-24 left-1/2 -translate-x-1/2 w-0.5 h-12 bg-slate-300"></div>
<div class="absolute top-40 left-1/2 -translate-x-1/2 px-4 py-2 bg-white border border-slate-200 rounded-lg shadow-sm text-sm font-medium">LLM 意图识别</div>
<div class="absolute top-56 left-1/2 -translate-x-1/2 w-0.5 h-12 bg-slate-300"></div>
<div class="absolute top-72 left-1/2 -translate-x-1/2 flex gap-8">
<div class="px-4 py-2 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">自动回复</div>
<div class="px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700">转人工</div>
<div class="px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">调用业务系统</div>
</div>
<div class="absolute bottom-6 right-6 text-xs text-slate-400">注:画布仅示意,真实实现使用 @vue-flow</div>
</div>
</div>
</section>
<!-- Tenants -->
<section id="tenants" class="view hidden space-y-4">
<div class="flex items-center justify-between"><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700" onclick="openModal('tenant-modal')">新增租户</button></div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">租户编码</th><th class="px-4 py-3 font-medium">租户名称</th><th class="px-4 py-3 font-medium">负责人</th><th class="px-4 py-3 font-medium">业务系统数</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('tenant-detail')"><td class="px-4 py-3 font-medium text-slate-900">TENANT_DEFAULT</td><td class="px-4 py-3">默认租户</td><td class="px-4 py-3">system</td><td class="px-4 py-3">3</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('tenant-detail')"><td class="px-4 py-3 font-medium text-slate-900">TENANT_001</td><td class="px-4 py-3">Sinocare</td><td class="px-4 py-3">张三</td><td class="px-4 py-3">2</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Users -->
<section id="users" class="view hidden space-y-4">
<div class="flex items-center justify-between"><div class="flex gap-2"><select class="border border-slate-200 rounded-lg px-3 py-2 text-sm"><option>全部租户</option><option>默认租户</option></select></div><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700" onclick="openModal('user-modal')">新增用户</button></div>
<div class="bg-white rounded-xl border border-slate-200 overflow-hidden">
<table class="w-full text-sm text-left"><thead class="text-xs text-slate-500 bg-slate-50"><tr><th class="px-4 py-3 font-medium">用户名</th><th class="px-4 py-3 font-medium">角色</th><th class="px-4 py-3 font-medium">所属租户</th><th class="px-4 py-3 font-medium">手机号</th><th class="px-4 py-3 font-medium">状态</th></tr></thead>
<tbody class="divide-y divide-slate-100">
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('user-detail')"><td class="px-4 py-3 font-medium text-slate-900">admin</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-purple-50 text-purple-700">平台管理员</span></td><td class="px-4 py-3">全部</td><td class="px-4 py-3 text-slate-500">138****0000</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
<tr class="hover:bg-slate-50 cursor-pointer" onclick="openDrawer('user-detail')"><td class="px-4 py-3 font-medium text-slate-900">ops_01</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">租户管理员</span></td><td class="px-4 py-3">Sinocare</td><td class="px-4 py-3 text-slate-500">139****1111</td><td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></td></tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between bg-white rounded-xl border border-slate-200 px-4 py-3 text-sm">
<div class="text-slate-500">共 2 条</div>
<div class="flex items-center gap-2"><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&lt;</button><button class="px-2.5 py-1 rounded bg-primary-600 text-white">1</button><button class="px-2 py-1 rounded border border-slate-200 text-slate-400" disabled>&gt;</button></div>
</div>
</section>
<!-- Tags -->
<section id="tags" class="view hidden space-y-4">
<div class="flex items-center justify-between"><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700">新增标签</button></div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 bg-white rounded-xl border border-slate-200 p-4">
<h4 class="font-semibold text-slate-900 mb-3">标签树</h4>
<div class="space-y-2 text-sm">
<div class="p-2 rounded bg-slate-50 font-medium text-slate-900">客户等级</div>
<div class="pl-4 space-y-1 text-slate-600">
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">VIP客户</div>
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">普通客户</div>
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">潜在客户</div>
</div>
<div class="p-2 rounded bg-slate-50 font-medium text-slate-900">跟进状态</div>
<div class="pl-4 space-y-1 text-slate-600">
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">待回访</div>
<div class="p-2 rounded hover:bg-slate-50 cursor-pointer">已成交</div>
</div>
</div>
</div>
<div class="lg:col-span-2 bg-white rounded-xl border border-slate-200 p-5">
<h4 class="font-semibold text-slate-900 mb-4">标签详情</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><label class="block text-slate-500 mb-1">标签名称</label><input type="text" value="VIP客户" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">上级标签</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>客户等级</option><option></option></select></div>
<div class="col-span-2"><label class="block text-slate-500 mb-1">备注</label><textarea class="w-full border border-slate-200 rounded-lg px-3 py-2" rows="3">高价值客户标签</textarea></div>
</div>
<div class="mt-4 flex gap-2"><button class="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm">保存</button><button class="bg-white border border-slate-200 text-slate-600 px-4 py-2 rounded-lg text-sm">删除</button></div>
</div>
</div>
</section>
</div>
</main>
<!-- Drawer Overlay -->
<div id="drawer-overlay" class="fixed inset-0 bg-black/20 z-40 hidden" onclick="closeDrawer()"></div>
<!-- Channel Detail Drawer -->
<aside id="channel-detail" class="drawer fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6">
<div class="font-semibold text-slate-900">渠道主体详情</div>
<button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button>
</div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">主体名称</span><span class="font-medium text-slate-900">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">渠道类型</span><span class="px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">企微</span></div>
<div class="flex justify-between"><span class="text-slate-500">CorpID</span><span class="text-slate-700">ww1234567890abcdef</span></div>
<div class="flex justify-between"><span class="text-slate-500">状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已连接</span></div>
</div>
<div>
<div class="flex items-center justify-between mb-2"><h4 class="font-semibold text-slate-900">应用</h4><button class="text-sm text-primary-600 hover:underline">新增应用</button></div>
<div class="bg-slate-50 rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div class="flex justify-between"><span class="font-medium">会话存档应用</span><span class="text-xs text-emerald-600">启用</span></div>
<div class="flex justify-between"><span class="font-medium">消息推送应用</span><span class="text-xs text-emerald-600">启用</span></div>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2"><h4 class="font-semibold text-slate-900">账号</h4><button class="text-sm text-primary-600 hover:underline">初始化账号</button></div>
<div class="bg-slate-50 rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div class="flex justify-between"><span>张三 (zhangsan)</span><span class="text-xs text-slate-500">会话存档 开</span></div>
<div class="flex justify-between"><span>李四 (lisi)</span><span class="text-xs text-slate-500">会话存档 关</span></div>
</div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2">
<button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">保存</button>
<button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">取消</button>
</div>
</aside>
<!-- Contact Detail Drawer -->
<aside id="contact-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6">
<div class="font-semibold text-slate-900">联系人详情</div>
<button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button>
</div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-2xl font-bold">A</div>
<div>
<div class="text-lg font-bold text-slate-900">艾先生</div>
<div class="text-sm text-slate-500">138****1234</div>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">渠道身份</span><span class="text-slate-700">wmABC123</span></div>
<div class="flex justify-between"><span class="text-slate-500">所属主体</span><span class="text-slate-700">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">来源</span><span class="text-slate-700">会话存档</span></div>
</div>
<div>
<div class="font-semibold text-slate-900 mb-2">标签</div>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 rounded text-xs bg-blue-50 text-blue-700">VIP客户</span>
<span class="px-2 py-1 rounded text-xs bg-amber-50 text-amber-700">待回访</span>
<button class="px-2 py-1 rounded text-xs border border-dashed border-slate-300 text-slate-500 hover:border-primary-500 hover:text-primary-600">+ 编辑标签</button>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2"><div class="font-semibold text-slate-900">关联会话</div><div class="text-xs text-slate-500">12 条</div></div>
<div class="space-y-2">
<div class="p-3 rounded-lg border border-slate-200 text-sm hover:bg-slate-50 cursor-pointer"><div class="text-slate-500 text-xs mb-1">2025-07-09 10:23</div><div class="text-slate-800">我要查询订单状态</div></div>
<div class="p-3 rounded-lg border border-slate-200 text-sm hover:bg-slate-50 cursor-pointer"><div class="text-slate-500 text-xs mb-1">2025-07-08 16:45</div><div class="text-slate-800">帮我发一份报价单</div></div>
</div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2">
<button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button>
<button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">发消息</button>
</div>
</aside>
<!-- Channel Account Detail Drawer -->
<aside id="channel-account-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">渠道账号详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="flex items-center gap-4"><div class="w-16 h-16 rounded-full bg-slate-200 flex items-center justify-center text-2xl font-bold text-slate-500"></div><div><div class="text-lg font-bold text-slate-900">张三</div><div class="text-sm text-slate-500">zhangsan</div></div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">所属主体</span><span class="text-slate-700">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">部门路径</span><span class="text-slate-700">/销售部/华东区</span></div>
<div class="flex justify-between"><span class="text-slate-500">会话存档</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">已开启</span></div>
<div class="flex justify-between"><span class="text-slate-500">在线状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">在线</span></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">同步企微信息</button></div>
</aside>
<!-- Group Detail Drawer -->
<aside id="group-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">群聊详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div><div class="text-lg font-bold text-slate-900">华东销售群</div><div class="text-sm text-slate-500">wrABC123 · 128 人</div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">所属主体</span><span class="text-slate-700">Sinocare 企业微信</span></div>
<div class="flex justify-between"><span class="text-slate-500">群主</span><span class="text-slate-700">张三</span></div>
<div class="flex justify-between"><span class="text-slate-500">创建时间</span><span class="text-slate-700">2025-01-15</span></div>
</div>
<div>
<div class="font-semibold text-slate-900 mb-2">标签</div>
<div class="flex flex-wrap gap-2"><span class="px-2 py-1 rounded text-xs bg-blue-50 text-blue-700">销售群</span><button class="px-2 py-1 rounded text-xs border border-dashed border-slate-300 text-slate-500 hover:border-primary-500 hover:text-primary-600">+ 编辑标签</button></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-slate-200 text-slate-600 py-2 rounded-lg text-sm">查看会话</button></div>
</aside>
<!-- Tenant Detail Drawer -->
<aside id="tenant-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">租户详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div><div class="text-lg font-bold text-slate-900">Sinocare</div><div class="text-sm text-slate-500">TENANT_001</div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">负责人</span><span class="text-slate-700">张三</span></div>
<div class="flex justify-between"><span class="text-slate-500">业务系统数</span><span class="text-slate-700">2</span></div>
<div class="flex justify-between"><span class="text-slate-500">状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
</div>
<div>
<div class="font-semibold text-slate-900 mb-2">业务系统</div>
<div class="space-y-2"><div class="p-3 rounded-lg border border-slate-200 text-sm">CRM</div><div class="p-3 rounded-lg border border-slate-200 text-sm">ERP</div></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-red-200 text-red-600 py-2 rounded-lg text-sm">删除</button></div>
</aside>
<!-- User Detail Drawer -->
<aside id="user-detail" class="drawer fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-50 border-l border-slate-200 flex flex-col">
<div class="h-16 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">用户详情</div><button class="text-slate-400 hover:text-slate-600" onclick="closeDrawer()"></button></div>
<div class="flex-1 overflow-y-auto scrollbar p-6 space-y-6">
<div class="flex items-center gap-4"><div class="w-16 h-16 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-2xl font-bold"></div><div><div class="text-lg font-bold text-slate-900">admin</div><div class="text-sm text-slate-500">平台管理员</div></div></div>
<div class="space-y-3 text-sm">
<div class="flex justify-between"><span class="text-slate-500">所属租户</span><span class="text-slate-700">全部</span></div>
<div class="flex justify-between"><span class="text-slate-500">手机号</span><span class="text-slate-700">138****0000</span></div>
<div class="flex justify-between"><span class="text-slate-500">状态</span><span class="px-2 py-0.5 rounded-full text-xs bg-emerald-50 text-emerald-700">启用</span></div>
</div>
</div>
<div class="p-4 border-t border-slate-200 flex gap-2"><button class="flex-1 bg-primary-600 text-white py-2 rounded-lg text-sm font-medium">编辑</button><button class="flex-1 bg-white border border-red-200 text-red-600 py-2 rounded-lg text-sm">删除</button></div>
</aside>
<!-- Modal: Add Tenant -->
<div id="tenant-modal" class="fixed inset-0 bg-black/30 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-2xl w-[480px] max-w-[90%] max-h-[90vh] overflow-y-auto">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">新增租户</div><button class="text-slate-400 hover:text-slate-600" onclick="closeModal('tenant-modal')"></button></div>
<div class="p-6 space-y-4 text-sm">
<div><label class="block text-slate-500 mb-1">租户编码</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">租户名称</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">负责人</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">备注</label><textarea class="w-full border border-slate-200 rounded-lg px-3 py-2" rows="3"></textarea></div>
</div>
<div class="p-4 border-t border-slate-200 flex justify-end gap-2">
<button class="px-4 py-2 rounded-lg border border-slate-200 text-slate-600 text-sm" onclick="closeModal('tenant-modal')">取消</button>
<button class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium" onclick="closeModal('tenant-modal')">确认</button>
</div>
</div>
</div>
<!-- Modal: Add User -->
<div id="user-modal" class="fixed inset-0 bg-black/30 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-2xl w-[480px] max-w-[90%] max-h-[90vh] overflow-y-auto">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">新增用户</div><button class="text-slate-400 hover:text-slate-600" onclick="closeModal('user-modal')"></button></div>
<div class="p-6 space-y-4 text-sm">
<div><label class="block text-slate-500 mb-1">用户名</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">角色</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>平台管理员</option><option>租户管理员</option><option>运营人员</option></select></div>
<div><label class="block text-slate-500 mb-1">所属租户</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>全部租户</option><option>默认租户</option><option>Sinocare</option></select></div>
<div><label class="block text-slate-500 mb-1">手机号</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
</div>
<div class="p-4 border-t border-slate-200 flex justify-end gap-2">
<button class="px-4 py-2 rounded-lg border border-slate-200 text-slate-600 text-sm" onclick="closeModal('user-modal')">取消</button>
<button class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium" onclick="closeModal('user-modal')">确认</button>
</div>
</div>
</div>
<!-- Modal: Add Channel Subject -->
<div id="channel-subject-modal" class="fixed inset-0 bg-black/30 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-2xl w-[480px] max-w-[90%] max-h-[90vh] overflow-y-auto">
<div class="h-14 border-b border-slate-200 flex items-center justify-between px-6"><div class="font-semibold text-slate-900">新增渠道主体</div><button class="text-slate-400 hover:text-slate-600" onclick="closeModal('channel-subject-modal')"></button></div>
<div class="p-6 space-y-4 text-sm">
<div><label class="block text-slate-500 mb-1">主体名称</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">渠道类型</label><select class="w-full border border-slate-200 rounded-lg px-3 py-2"><option>企业微信</option><option>个人微信</option><option>钉钉(预留)</option><option>飞书(预留)</option></select></div>
<div><label class="block text-slate-500 mb-1">CorpID / 标识</label><input type="text" class="w-full border border-slate-200 rounded-lg px-3 py-2" /></div>
<div><label class="block text-slate-500 mb-1">备注</label><textarea class="w-full border border-slate-200 rounded-lg px-3 py-2" rows="3"></textarea></div>
</div>
<div class="p-4 border-t border-slate-200 flex justify-end gap-2">
<button class="px-4 py-2 rounded-lg border border-slate-200 text-slate-600 text-sm" onclick="closeModal('channel-subject-modal')">取消</button>
<button class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium" onclick="closeModal('channel-subject-modal')">确认</button>
</div>
</div>
</div>
<script>
const links = document.querySelectorAll('.nav-link');
const views = document.querySelectorAll('.view');
const titleMap = {
dashboard: '概览',
channels: '渠道主体',
'channel-apps': '渠道应用',
'channel-accounts': '渠道账号',
contacts: '联系人',
groups: '群聊',
'send-records': '发送记录',
messages: '消息查询',
'inbound-flow': '入站流程',
tenants: '租户管理',
users: '用户管理',
tags: '标签体系',
};
links.forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const target = link.dataset.target;
showView(target);
links.forEach(l => l.classList.remove('active'));
link.classList.add('active');
});
});
function showView(id) {
views.forEach(v => v.classList.add('hidden'));
const el = document.getElementById(id);
if (el) el.classList.remove('hidden');
document.getElementById('page-title').textContent = titleMap[id] || '';
}
function openDrawer(id) {
document.getElementById('drawer-overlay').classList.remove('hidden');
document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open'));
document.getElementById(id).classList.add('open');
}
function closeDrawer() {
document.getElementById('drawer-overlay').classList.add('hidden');
document.querySelectorAll('.drawer').forEach(d => d.classList.remove('open'));
}
function openModal(id) {
document.getElementById(id).classList.remove('hidden');
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
}
</script>
</body>
</html>

196
AGENTS.md Normal file
View File

@@ -0,0 +1,196 @@
# 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` 统一管理 MySQL、Redis、RabbitMQ、SeaweedFS 以及应用服务的连接地址。
### 5.1 快速切换本地/远程基础设施
```bash
cd /Users/marsal/Projects/sino-project/sino-mci
# 方案 A连接本机 localhost 上的基础设施(默认)
cp .env.local .env
# 方案 B连接远程基础设施如 192.168.1.173
cp .env.remote .env
```
`.env` 文件不会被提交(已加入 `.gitignore`),可以按需要随时切换。
### 5.2 本地启动后端/前端/Channel Service
```bash
# 后端
./scripts/run-local.sh backend
# 前端
./scripts/run-local.sh frontend
# Channel Service
./scripts/run-local.sh channel
```
`run-local.sh` 会自动加载项目根目录的 `.env`,并把 `MYSQL_HOST`、`REDIS_HOST`、`RABBITMQ_HOST`、`MCI_SERVER_URL`、`VITE_API_BASE_URL` 等变量注入到对应进程中。
### 5.3 Docker Compose 启动(不受 .env 中 host 影响)
```bash
# 只启动基础设施
./scripts/local-up.sh
# 启动基础设施 + 应用服务
./scripts/local-up.sh --with-apps
```
`local-up.sh` 会自动把 host 覆盖为 Docker 容器名(`mci-mysql`、`mci-redis`、`mci-rabbitmq`、`mci-server`),因此无论 `.env` 中是 `localhost` 还是远程 IP容器内部都能正确互联。
### 5.4 手动执行 docker compose 的注意事项
若直接运行 `docker compose` 命令,容器会使用当前 `.env` 中的 `MYSQL_HOST` 等变量。请确保 `.env` 中的 host 对容器可访问,或改用 `local-up.sh` 启动。
### 5.5 默认账号
- MySQL root 密码:`root`(仅本地/内网开发使用)
- 平台管理员:`superadmin` / `admin123`
### 5.6 新增环境/自定义地址
复制任意一份模板后修改:
```bash
cp .env.local .env
# 编辑 .env 中的 MYSQL_HOST、REDIS_HOST、RABBITMQ_HOST、MCI_SERVER_URL、VITE_API_BASE_URL
```
## 6. 本地调试IDE
### 6.1 后端 Spring Boot 调试
1. 切换环境:
```bash
cd /Users/marsal/Projects/sino-project/sino-mci
cp .env.local .env # 连接本地 docker-compose 基础设施
# 或 cp .env.remote .env # 连接远程基础设施(如 192.168.1.173
```
2. 在 IntelliJ IDEA 中创建 Run Configuration
- **Main class**: `com.sino.mci.server.MciServerApplication`
- **Active profiles**: `dev`
- **Environment variables**: 填入 `.env` 中的关键变量,例如:
```
MYSQL_HOST=localhost;REDIS_HOST=localhost;RABBITMQ_HOST=localhost;MYSQL_PORT=3306;MYSQL_ROOT_PASSWORD=root;MYSQL_DATABASE=sino_mci;MYSQL_USER=root;REDIS_PORT=6379;RABBITMQ_DEFAULT_USER=guest;RABBITMQ_DEFAULT_PASS=guest;RABBITMQ_AMQP_PORT=5672
```
> 也可安装 EnvFile / .env 插件让 IDE 自动加载 `.env`。
3. 启动 Debug访问 `http://localhost:8080/msg-platform`。
### 6.2 前端 Vite 调试
```bash
cd admin-web
npm run dev
```
代理目标由 `VITE_API_BASE_URL` 决定(定义在 `.env` 中):
- `.env.local`:指向 `http://localhost:8080`
- `.env.remote`:指向 `http://192.168.1.173:8080`
### 6.3 Channel Service 调试
```bash
cd channel-service
source .venv/bin/activate
python -m app.main
```
`MCI_SERVER_URL` 从 `.env` 加载,指向后端地址。

View File

@@ -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 调用和业务逻辑,只改模板和样式。
- 使用 `<script setup lang="ts">`
- 继续使用 Element Plus 组件,不引入新依赖。
- 每个视图文件底部使用 `<style scoped>` 自定义局部样式,避免污染全局。
- TypeScript 编译不能报错;重构后应能通过 `npm run build`
### 抽屉内容示例
- 对象名称/标题
- 基本信息(描述列表或表单)
- 关联信息(应用、账号、标签、会话等)
- 底部:保存 / 取消 / 删除

View File

@@ -8,6 +8,7 @@
"name": "admin-web",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
@@ -395,6 +396,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",

View File

@@ -10,14 +10,15 @@
"test": "vue-tsc -b && npm run build"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.42.1",
"axios": "^1.18.1",
"element-plus": "^2.14.2",
"pinia": "^3.0.1",
"vue": "^3.5.39",
"vue-router": "^4.6.4",
"@vue-flow/core": "^1.42.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -2,6 +2,10 @@ import client from './client'
export * from './channelSubject'
export * from './channelAccount'
export interface ChannelIdentityQuery {
keyword?: string
}
export function createConversation(data: any) {
return client.post('/api/v1/conversation', data)
}
@@ -25,3 +29,15 @@ export function bindConversationAccount(conversationId: number, data: any) {
export function bindConversationTags(conversationId: number, data: any) {
return client.post(`/api/v1/conversation/${conversationId}/bind-tags`, data)
}
export function listChannelIdentities(params?: ChannelIdentityQuery) {
return client.get('/api/v1/channel-identity/list', { params })
}
export function listConversationParticipants(conversationId: number) {
return client.get(`/api/v1/conversation/${conversationId}/participants`)
}
export function listConversationTags(conversationId: number) {
return client.get(`/api/v1/conversation/${conversationId}/tags`)
}

View File

@@ -28,6 +28,10 @@ export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export function syncChannelAccountContacts(id: number) {
return client.post(`/api/v1/channel-account/${id}/sync-contacts`)
}
export function syncChannelAccountConversations(id: number) {
return client.post(`/api/v1/channel-account/${id}/sync-conversations`)
}

View File

@@ -0,0 +1,9 @@
import client from './client'
export function getSyncTask(id: number) {
return client.get(`/api/v1/sync-task/${id}`)
}
export function syncSubjectEnterpriseContacts(id: number) {
return client.post(`/api/v1/channel-subject/${id}/sync-contacts`)
}

View File

@@ -1,64 +1,120 @@
<template>
<el-container class="layout">
<el-aside width="200px" class="sidebar">
<div class="logo">Sino MCI</div>
<el-menu :default-active="$route.path" router>
<el-aside width="240px" class="sidebar">
<div class="logo">
<div class="logo-icon">M</div>
<div class="logo-text">
<div class="logo-title">Sino-MCI</div>
<div class="logo-sub">消息中台</div>
</div>
</div>
<el-menu
:default-active="$route.path"
router
class="menu"
:collapse-transition="false"
>
<el-menu-item index="/dashboard">
<el-icon><DataLine /></el-icon>
<span>概览</span>
</el-menu-item>
<el-menu-item index="/channel-subject">
<span>渠道主体</span>
</el-menu-item>
<el-menu-item index="/channel-account">
<span>渠道账号</span>
</el-menu-item>
<el-menu-item index="/conversation">
<span>会话/群聊</span>
</el-menu-item>
<el-menu-item index="/person">
<span>联系人</span>
</el-menu-item>
<el-menu-item index="/tag">
<span>标签</span>
</el-menu-item>
<el-menu-item index="/send-record">
<span>发送记录</span>
</el-menu-item>
<el-menu-item index="/inbound-flow">
<span>入站流程</span>
</el-menu-item>
<el-menu-item index="/ivr-flow">
<span>IVR 流程</span>
</el-menu-item>
<el-menu-item index="/flow-trace">
<span>流程追踪</span>
</el-menu-item>
<el-menu-item index="/message-test">
<span>消息测试</span>
</el-menu-item>
<el-menu-item index="/inbound-webhook">
<span>入站 Webhook</span>
</el-menu-item>
<el-menu-item index="/send-policy">
<span>发送策略</span>
</el-menu-item>
<el-menu-item v-if="authStore.roleCodes.includes('platform_admin')" index="/tenant-management">
<span>租户管理</span>
</el-menu-item>
<el-menu-item index="/user-management">
<span>用户管理</span>
</el-menu-item>
<el-sub-menu index="/channel">
<template #title>
<el-icon><Connection /></el-icon>
<span>渠道接入</span>
</template>
<el-menu-item index="/channel/subject">渠道主体</el-menu-item>
<el-menu-item index="/channel/app">渠道应用</el-menu-item>
<el-menu-item index="/channel/account">渠道账号</el-menu-item>
<el-menu-item index="/channel/identity">渠道身份</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/customer">
<template #title>
<el-icon><User /></el-icon>
<span>客户关系</span>
</template>
<el-menu-item index="/customer/person">联系人</el-menu-item>
<el-menu-item index="/customer/group">群聊</el-menu-item>
<el-menu-item index="/customer/tag">标签体系</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/message">
<template #title>
<el-icon><ChatDotRound /></el-icon>
<span>消息运营</span>
</template>
<el-menu-item index="/message/send-record">发送记录</el-menu-item>
<el-menu-item index="/message/query">消息查询</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/flow">
<template #title>
<el-icon><Share /></el-icon>
<span>流程编排</span>
</template>
<el-menu-item index="/flow/inbound">入站流程</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item v-if="isPlatformAdmin" index="/system/tenant">租户管理</el-menu-item>
<el-menu-item index="/system/user">用户管理</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span>{{ $route.meta.title || '消息中台' }}</span>
<span class="page-title">{{ $route.meta.title || '消息中台' }}</span>
<div class="header-right">
<span class="user-info">{{ authStore.tenantCode }} / {{ authStore.username }}</span>
<el-button type="primary" text @click="handleLogout">退出登录</el-button>
<el-badge :value="0" class="notify-badge">
<el-icon class="notify-icon" :size="20"><Bell /></el-icon>
</el-badge>
<div v-if="isPlatformAdmin" class="tenant-wrap">
<span class="tenant-label">租户</span>
<el-select
v-model="selectedTenant"
placeholder="选择租户"
size="small"
class="tenant-select"
@change="handleTenantChange"
>
<el-option label="全部租户" value="" />
<el-option
v-for="t in tenantOptions"
:key="t.tenantCode"
:label="t.tenantName"
:value="t.tenantCode"
/>
</el-select>
</div>
<div v-else class="tenant-wrap">
<span class="tenant-name">{{ authStore.tenantCode || '-' }}</span>
</div>
<el-dropdown @command="handleUserCommand">
<div class="user-info">
<el-avatar :size="32" class="user-avatar">{{ avatarText }}</el-avatar>
<div class="user-meta">
<div class="user-role">{{ roleText }}</div>
<div class="user-name">{{ authStore.username }}</div>
</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main>
<el-main class="main">
<router-view />
</el-main>
</el-container>
@@ -66,51 +122,182 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { listTenants } from '../api/account'
import {
DataLine,
Connection,
User,
ChatDotRound,
Share,
Setting,
Bell,
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
function handleLogout() {
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
const roleText = computed(() => {
if (authStore.roleCodes.includes('platform_admin')) return '平台管理员'
if (authStore.roleCodes.includes('tenant_admin')) return '租户管理员'
return '运营人员'
})
const avatarText = computed(() => {
const name = authStore.username || 'U'
return name.slice(0, 1).toUpperCase()
})
const selectedTenant = ref(authStore.tenantCode || '')
const tenantOptions = ref<any[]>([])
async function loadTenants() {
if (!isPlatformAdmin.value) return
try {
const res: any = await listTenants()
tenantOptions.value = res.data || []
} catch {
tenantOptions.value = []
}
}
function handleTenantChange(code: string) {
authStore.setTenant(code)
window.location.reload()
}
function handleUserCommand(command: string) {
if (command === 'logout') {
authStore.clearAuth()
router.push('/login')
}
}
onMounted(loadTenants)
</script>
<style scoped>
.layout {
height: 100vh;
background: #f8fafc;
}
.sidebar {
background: #fff;
border-right: 1px solid #e4e7ed;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
height: 64px;
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid #e2e8f0;
gap: 12px;
}
.logo-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #2563eb;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #e4e7ed;
}
.logo-title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
line-height: 1.2;
}
.logo-sub {
font-size: 12px;
color: #64748b;
line-height: 1.2;
}
.menu {
border-right: none;
flex: 1;
overflow-y: auto;
}
.header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
padding: 0 24px;
height: 64px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #0f172a;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
gap: 20px;
}
.notify-badge {
cursor: pointer;
}
.notify-icon {
color: #64748b;
}
.tenant-wrap {
display: flex;
align-items: center;
gap: 8px;
padding-right: 20px;
border-right: 1px solid #e2e8f0;
}
.tenant-label {
font-size: 14px;
color: #64748b;
}
.tenant-select {
width: 140px;
}
.tenant-name {
font-size: 14px;
color: #334155;
font-weight: 500;
}
.user-info {
color: #606266;
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
outline: none;
}
.user-avatar {
background: #eff6ff;
color: #2563eb;
font-weight: 600;
}
.user-meta {
line-height: 1.2;
}
.user-role {
font-size: 13px;
font-weight: 500;
color: #0f172a;
}
.user-name {
font-size: 12px;
color: #64748b;
}
.main {
padding: 24px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div class="sino-page">
<div v-if="hasToolbar" class="sino-toolbar">
<div class="sino-toolbar-left">
<slot name="toolbar-left" />
</div>
<div class="sino-toolbar-right">
<slot name="toolbar-right" />
</div>
</div>
<div class="sino-card">
<slot />
</div>
<div v-if="$slots.pagination" class="sino-pagination">
<slot name="pagination" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
const slots = useSlots()
const hasToolbar = computed(() => !!slots['toolbar-left'] || !!slots['toolbar-right'])
</script>
<style scoped>
.sino-page {
display: flex;
flex-direction: column;
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;
flex: 1;
}
.sino-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px 20px;
background: #ffffff;
border-top: 1px solid #f1f5f9;
}
</style>

View File

@@ -2,19 +2,17 @@ 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 ChannelIdentity from '../views/ChannelIdentity.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 +28,18 @@ 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: 'channel/identity', component: ChannelIdentity, 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: '用户管理' } },
],
},
]

View File

@@ -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 }
})

View File

@@ -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;
}

View File

@@ -1,10 +1,22 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道账号</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索账号 / 名称"
clearable
style="width: 240px"
/>
<el-select v-model="filterStatus" placeholder="全部状态" clearable style="width: 140px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增账号</el-button>
</template>
<el-table :data="accounts" border>
<el-table :data="pagedAccounts" class="sino-table" @row-click="handleRowClick">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="accountType" label="类型" width="140">
<template #default="{ row }">
@@ -16,54 +28,57 @@
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="accountId" label="账号ID" />
<el-table-column prop="accountName" label="名称" />
<el-table-column prop="accountId" label="账号ID" show-overflow-tooltip />
<el-table-column prop="accountName" label="名称" show-overflow-tooltip />
<el-table-column prop="loginStatus" label="登录状态" width="100">
<template #default="{ row }">
<el-tag :type="row.loginStatus === 1 ? 'success' : 'info'">
<el-tag :type="row.loginStatus === 1 ? 'success' : 'info'" size="small">
{{ row.loginStatus === 1 ? '已登录' : '未登录' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="在线状态" width="160">
<template #default="{ row }">
<div class="online-status">
<span class="status-dot" :class="row.onlineStatus === 1 ? 'online' : 'offline'" />
{{ row.onlineStatus === 1 ? '在线' : '离线' }}
</div>
<div v-if="row.lastHeartbeat" class="heartbeat">{{ row.lastHeartbeat }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="420">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 8px"
size="small"
@click.stop
@change="(val: any) => handleStatusChange(row, val)"
/>
<el-button size="small" type="primary" @click="handleInit(row)">初始化</el-button>
<el-button size="small" @click="handleSync(row)">同步会话</el-button>
<el-button size="small" @click="handleRefreshStatus(row)">刷新状态</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道账号' : '创建渠道账号'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filteredAccounts.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="创建渠道账号" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="账号类型">
<el-select v-model="dialogForm.accountType" placeholder="类型">
<el-option label="企微内部账号" :value="1" />
<el-option label="企微应用" :value="2" />
<el-option label="pywechat 个人号" :value="3" />
<el-option label="企微群机器人" :value="6" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
@@ -79,7 +94,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="账号ID">
<el-form-item :label="dialogForm.accountType === 6 ? 'Webhook Key / Robot ID' : '账号ID'">
<el-input v-model="dialogForm.accountId" placeholder="账号唯一标识" />
</el-form-item>
<el-form-item label="名称">
@@ -91,8 +106,8 @@
<el-form-item label="Server Host">
<el-input v-model="dialogForm.serverHost" placeholder="服务地址" />
</el-form-item>
<el-form-item label="扩展信息">
<el-input v-model="dialogForm.extInfo" type="textarea" :rows="3" placeholder="JSON 扩展信息" />
<el-form-item :label="dialogForm.accountType === 6 ? 'Webhook URL可选默认使用官方 URL' : '扩展信息'">
<el-input v-model="dialogForm.extInfo" type="textarea" :rows="3" :placeholder="dialogForm.accountType === 6 ? 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' : 'JSON 扩展信息'" />
</el-form-item>
</el-form>
<template #footer>
@@ -101,6 +116,64 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="渠道账号详情" size="420px">
<div class="drawer-body">
<div class="drawer-actions">
<el-button size="small" type="primary" @click="handleInit(currentAccount)">初始化</el-button>
<el-button size="small" @click="handleSyncContacts(currentAccount)">同步联系人</el-button>
<el-button size="small" @click="handleSync(currentAccount)">同步会话</el-button>
<el-button size="small" @click="handleRefreshStatus(currentAccount)">刷新状态</el-button>
</div>
<el-form :model="drawerForm" label-width="120px">
<el-form-item label="账号类型">
<el-select v-model="drawerForm.accountType" placeholder="类型">
<el-option label="企微内部账号" :value="1" />
<el-option label="企微应用" :value="2" />
<el-option label="pywechat 个人号" :value="3" />
<el-option label="企微群机器人" :value="6" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="drawerForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="所属主体">
<el-select v-model="drawerForm.subjectId" placeholder="选择主体" clearable>
<el-option
v-for="s in subjects"
:key="s.id"
:label="s.subjectName"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="drawerForm.accountType === 6 ? 'Webhook Key / Robot ID' : '账号ID'">
<el-input v-model="drawerForm.accountId" placeholder="账号唯一标识" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="drawerForm.accountName" placeholder="名称" />
</el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="drawerForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="Server Host">
<el-input v-model="drawerForm.serverHost" placeholder="服务地址" />
</el-form-item>
<el-form-item :label="drawerForm.accountType === 6 ? 'Webhook URL可选默认使用官方 URL' : '扩展信息'">
<el-input v-model="drawerForm.extInfo" type="textarea" :rows="3" :placeholder="drawerForm.accountType === 6 ? 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' : 'JSON 扩展信息'" />
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button type="danger" plain @click="handleDelete(currentAccount)">删除</el-button>
<div class="drawer-footer-right">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleDrawerSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
<el-dialog v-model="initDialogVisible" title="初始化结果" width="480px">
<div v-if="initResult">
<el-result
@@ -114,11 +187,11 @@
</div>
</div>
</el-dialog>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listChannelSubjects } from '@/api/channelSubject'
import {
@@ -129,14 +202,15 @@ import {
updateChannelAccountStatus,
getChannelAccountStatus,
initChannelAccount,
syncChannelAccountContacts,
syncChannelAccountConversations,
} from '@/api/channelAccount'
import { getSyncTask } from '@/api/syncTask'
import PageCard from '@/components/PageCard.vue'
const accounts = ref<any[]>([])
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
accountType: 1,
@@ -149,6 +223,45 @@ const dialogForm = reactive({
extInfo: '',
})
const drawerVisible = ref(false)
const currentAccount = ref<any>({})
const drawerForm = reactive({
accountType: 1,
channelType: 'wecom',
subjectId: undefined as number | undefined,
accountId: '',
accountName: '',
sessionArchiveEnabled: 0,
serverHost: '',
extInfo: '',
})
const searchKeyword = ref('')
const filterStatus = ref<number | ''>('')
const page = ref(1)
const pageSize = ref(10)
const filteredAccounts = computed(() => {
let list = accounts.value
const kw = searchKeyword.value.trim().toLowerCase()
if (kw) {
list = list.filter(
(item) =>
(item.accountId || '').toLowerCase().includes(kw) ||
(item.accountName || '').toLowerCase().includes(kw)
)
}
if (filterStatus.value !== '') {
list = list.filter((item) => item.status === filterStatus.value)
}
return list
})
const pagedAccounts = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredAccounts.value.slice(start, start + pageSize.value)
})
const initDialogVisible = ref(false)
const initResult = ref<any>(null)
@@ -163,29 +276,51 @@ function resetDialogForm() {
dialogForm.extInfo = ''
}
function fillDialogForm(row: any) {
dialogForm.accountType = row.accountType ?? 1
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.subjectId = row.subjectId
dialogForm.accountId = row.accountId ?? ''
dialogForm.accountName = row.accountName ?? ''
dialogForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
dialogForm.serverHost = row.serverHost ?? ''
dialogForm.extInfo = row.extInfo ?? ''
function resetDrawerForm() {
drawerForm.accountType = 1
drawerForm.channelType = 'wecom'
drawerForm.subjectId = undefined
drawerForm.accountId = ''
drawerForm.accountName = ''
drawerForm.sessionArchiveEnabled = 0
drawerForm.serverHost = ''
drawerForm.extInfo = ''
}
function parseBotExtInfo(extInfo: string | undefined): string {
if (!extInfo) return ''
if (!extInfo.trim().startsWith('{')) return extInfo
try {
const parsed = JSON.parse(extInfo)
if (parsed && typeof parsed === 'object' && parsed.webhook_url) {
return parsed.webhook_url
}
} catch {
// fallback to legacy plain URL
}
return extInfo
}
function fillDrawerForm(row: any) {
drawerForm.accountType = row.accountType ?? 1
drawerForm.channelType = row.channelType ?? 'wecom'
drawerForm.subjectId = row.subjectId
drawerForm.accountId = row.accountId ?? ''
drawerForm.accountName = row.accountName ?? ''
drawerForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
drawerForm.serverHost = row.serverHost ?? ''
drawerForm.extInfo = row.accountType === 6 ? parseBotExtInfo(row.extInfo) : (row.extInfo ?? '')
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
function handleRowClick(row: any) {
currentAccount.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
async function loadAccounts() {
@@ -206,15 +341,18 @@ async function loadSubjects() {
}
}
function buildAccountPayload(form: typeof dialogForm) {
const payload: any = { ...form }
if (form.accountType === 6 && form.extInfo && form.extInfo.trim()) {
payload.extInfo = JSON.stringify({ webhook_url: form.extInfo.trim() })
}
return payload
}
async function handleSave() {
try {
if (isEdit.value && editingId.value != null) {
await updateChannelAccount(editingId.value, dialogForm)
ElMessage.success('更新成功')
} else {
await createChannelAccount(dialogForm)
await createChannelAccount(buildAccountPayload(dialogForm))
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadAccounts()
} catch (e: any) {
@@ -222,11 +360,25 @@ async function handleSave() {
}
}
async function handleDrawerSave() {
if (!currentAccount.value?.id) return
try {
await updateChannelAccount(currentAccount.value.id, buildAccountPayload(drawerForm))
ElMessage.success('更新成功')
drawerVisible.value = false
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
if (!row?.id) return
try {
await ElMessageBox.confirm('确认删除该渠道账号?', '提示', { type: 'warning' })
await deleteChannelAccount(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadAccounts()
} catch (e: any) {
if (e !== 'cancel') {
@@ -259,6 +411,44 @@ async function handleInit(row: any) {
initDialogVisible.value = true
if (initResult.value?.success) {
await loadAccounts()
await handleSyncContacts(row)
await handleSync(row)
}
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function pollTask(taskId: number, label: string) {
const maxAttempts = 60
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000))
try {
const res: any = await getSyncTask(taskId)
const task = res.data
if (!task) continue
if (task.taskStatus === 2) {
ElMessage.success(`${label}同步完成,共 ${task.syncedCount ?? 0}`)
return
}
if (task.taskStatus === 3) {
ElMessage.error(`${label}同步失败:${task.errorMessage || '未知错误'}`)
return
}
} catch (e: any) {
// ignore polling error
}
}
ElMessage.warning(`${label}同步时间较长,请稍后到列表查看`)
}
async function handleSyncContacts(row: any) {
try {
const res: any = await syncChannelAccountContacts(row.id)
const taskId = res.data?.id
if (taskId) {
ElMessage.info('客户同步任务已提交')
await pollTask(taskId, '客户')
}
} catch (e: any) {
ElMessage.error(e.message)
@@ -268,8 +458,11 @@ async function handleInit(row: any) {
async function handleSync(row: any) {
try {
const res: any = await syncChannelAccountConversations(row.id)
ElMessage.success(`同步完成,共 ${res.data?.syncedCount ?? 0} 条会话`)
await loadAccounts()
const taskId = res.data?.id
if (taskId) {
ElMessage.info('群聊同步任务已提交')
await pollTask(taskId, '群聊')
}
} catch (e: any) {
ElMessage.error(e.message)
}
@@ -293,6 +486,7 @@ function accountTypeLabel(type: number) {
1: '企微内部账号',
2: '企微应用',
3: 'pywechat 个人号',
6: '企微群机器人',
}
return map[type] || type
}
@@ -315,9 +509,6 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.status-dot {
display: inline-block;
width: 8px;
@@ -336,6 +527,24 @@ onMounted(() => {
color: #909399;
margin-top: 4px;
}
.drawer-body {
padding: 20px;
}
.drawer-actions {
margin-bottom: 20px;
display: flex;
gap: 8px;
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.drawer-footer-right {
display: flex;
gap: 8px;
}
.qr-code {
text-align: center;
margin-top: 12px;

View File

@@ -0,0 +1,221 @@
<template>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索应用名称 / AgentID"
clearable
style="width: 260px"
/>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreate">新增应用</el-button>
</template>
<el-table :data="pagedApps" class="sino-table" @row-click="handleRowClick">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="appName" label="应用名称" />
<el-table-column prop="agentId" label="AgentID" />
<el-table-column prop="secret" label="Secret">
<template #default="{ row }">
<span v-if="row.secret"></span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="sessionArchiveEnabled" label="会话存档" width="120">
<template #default="{ row }">
<el-tag :type="row.sessionArchiveEnabled === 1 ? 'success' : 'info'" size="small">
{{ row.sessionArchiveEnabled === 1 ? '已开启' : '未开启' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="filteredApps.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="新增应用" width="520px">
<el-form :model="form" label-width="100px">
<el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item>
<el-form-item label="AgentID"><el-input v-model="form.agentId" /></el-form-item>
<el-form-item label="Secret"><el-input v-model="form.secret" type="password" show-password /></el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="form.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="应用详情" size="420px">
<div class="drawer-body">
<div class="drawer-actions">
<el-button size="small" type="primary" @click="initAccounts(currentApp!)">初始化账号</el-button>
</div>
<el-form :model="drawerForm" label-width="100px">
<el-form-item label="应用名称"><el-input v-model="drawerForm.appName" /></el-form-item>
<el-form-item label="AgentID"><el-input v-model="drawerForm.agentId" /></el-form-item>
<el-form-item label="Secret"><el-input v-model="drawerForm.secret" type="password" show-password /></el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="drawerForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="saveDrawer">保存</el-button>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue'
interface AppItem {
id: number
appName: string
agentId: string
secret: string
sessionArchiveEnabled: number
status: number
}
const apps = ref<AppItem[]>([
{ id: 1, appName: '会话存档应用', agentId: '1000002', secret: 'xxx', sessionArchiveEnabled: 1, status: 1 },
{ id: 2, appName: '消息推送应用', agentId: '1000005', secret: 'xxx', sessionArchiveEnabled: 0, status: 1 },
])
const searchKeyword = ref('')
const page = ref(1)
const pageSize = ref(10)
const filteredApps = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return apps.value
return apps.value.filter(
(item) =>
item.appName.toLowerCase().includes(kw) ||
item.agentId.toLowerCase().includes(kw)
)
})
const pagedApps = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredApps.value.slice(start, start + pageSize.value)
})
const dialogVisible = ref(false)
const form = reactive({
appName: '',
agentId: '',
secret: '',
sessionArchiveEnabled: 0,
})
const drawerVisible = ref(false)
const currentApp = ref<AppItem | null>(null)
const drawerForm = reactive({
appName: '',
agentId: '',
secret: '',
sessionArchiveEnabled: 0,
})
function resetForm() {
form.appName = ''
form.agentId = ''
form.secret = ''
form.sessionArchiveEnabled = 0
}
function fillDrawerForm(app: AppItem) {
drawerForm.appName = app.appName
drawerForm.agentId = app.agentId
drawerForm.secret = app.secret
drawerForm.sessionArchiveEnabled = app.sessionArchiveEnabled
}
function openCreate() {
resetForm()
dialogVisible.value = true
}
function handleRowClick(row: AppItem) {
currentApp.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
function save() {
apps.value.push({
id: Date.now(),
appName: form.appName || '未命名应用',
agentId: form.agentId,
secret: form.secret,
sessionArchiveEnabled: form.sessionArchiveEnabled,
status: 1,
})
ElMessage.success('创建成功')
dialogVisible.value = false
}
function saveDrawer() {
if (!currentApp.value) return
const idx = apps.value.findIndex((a) => a.id === currentApp.value!.id)
if (idx >= 0) {
apps.value[idx] = {
...currentApp.value,
appName: drawerForm.appName,
agentId: drawerForm.agentId,
secret: drawerForm.secret,
sessionArchiveEnabled: drawerForm.sessionArchiveEnabled,
}
}
ElMessage.success('更新成功')
drawerVisible.value = false
}
function initAccounts(app: AppItem) {
ElMessage.info(`开始初始化账号:${app.appName}`)
}
</script>
<style scoped>
.text-secondary {
color: #94a3b8;
}
.drawer-body {
padding: 20px;
}
.drawer-actions {
margin-bottom: 20px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索渠道用户ID / 渠道用户名 / 联系人姓名"
clearable
style="width: 320px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</template>
<el-table :data="pagedIdentities" class="sino-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道类型" width="110">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="channelUserId" label="渠道用户ID" show-overflow-tooltip />
<el-table-column prop="channelUserName" label="渠道用户名" show-overflow-tooltip />
<el-table-column prop="subjectName" label="所属主体" show-overflow-tooltip />
<el-table-column prop="personName" label="关联联系人" show-overflow-tooltip />
<el-table-column prop="personType" label="人员类型" width="110">
<template #default="{ row }">
{{ formatPersonType(row.personType) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="filteredIdentities.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</template>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { listChannelIdentities } from '@/api/channel'
import PageCard from '@/components/PageCard.vue'
const identities = ref<any[]>([])
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const filteredIdentities = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return identities.value
return identities.value.filter((item) =>
[item.channelUserId, item.channelUserName, item.personName].some((v) =>
String(v || '').toLowerCase().includes(kw)
)
)
})
const pagedIdentities = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredIdentities.value.slice(start, start + pageSize.value)
})
watch([searchKeyword], () => {
currentPage.value = 1
})
function channelLabel(type: string | undefined) {
const map: Record<string, string> = {
wecom: '企微',
wechat_personal: '个微',
dingtalk: '钉钉',
feishu: '飞书',
ivr: 'IVR',
}
return map[type || ''] || type || '-'
}
function formatPersonType(type: number | undefined) {
if (type === 3) return '客户'
if (type == null) return '-'
return '内部'
}
async function loadIdentities() {
try {
const res: any = await listChannelIdentities({ keyword: searchKeyword.value.trim() })
identities.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadIdentities)
</script>

View File

@@ -1,46 +1,64 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道主体</el-button>
</div>
<el-table :data="subjects" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索主体名称 / 编码 / CorpID"
clearable
class="search-input"
@keyup.enter="handleSearch"
/>
<el-button @click="handleSearch">查询</el-button>
</template>
</el-table-column>
<el-table-column prop="subjectCode" label="编码" />
<el-table-column prop="subjectName" label="名称" />
<el-table-column prop="corpId" label="CorpID" />
<el-table-column prop="status" label="状态" width="100">
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增主体</el-button>
</template>
<el-table
:data="pagedSubjects"
class="sino-table"
@row-click="handleRowClick"
>
<el-table-column prop="subjectName" label="主体名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="channelType" label="渠道类型" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
<el-tag :type="row.channelType === 'wecom' ? 'primary' : 'info'" size="small">
{{ channelLabel(row.channelType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="340">
<el-table-column prop="subjectCode" label="主体编码" min-width="140" show-overflow-tooltip />
<el-table-column prop="corpId" label="CorpID" min-width="220" show-overflow-tooltip />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<el-button size="small" @click="openDetail(row)">详情</el-button>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道主体' : '创建渠道主体'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="filteredSubjects.length"
layout="total, prev, pager, next"
background
/>
</template>
<!-- 新增弹窗 -->
<el-dialog v-model="dialogVisible" title="新增渠道主体" width="600px" destroy-on-close>
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道" style="width: 100%">
<el-option label="企微" value="wecom" />
<el-option label="钉钉" value="dingtalk" />
<el-option label="飞书" value="feishu" />
@@ -55,9 +73,6 @@
<el-form-item label="CorpID">
<el-input v-model="dialogForm.corpId" placeholder="CorpID" />
</el-form-item>
<el-form-item label="CorpSecret">
<el-input v-model="dialogForm.corpSecret" placeholder="CorpSecret" />
</el-form-item>
<el-form-item label="AgentID">
<el-input v-model="dialogForm.agentId" placeholder="AgentID" />
</el-form-item>
@@ -77,26 +92,61 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="渠道主体详情" size="420px">
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">{{ currentSubject.id }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ channelLabel(currentSubject.channelType) }}</el-descriptions-item>
<el-descriptions-item label="主体编码">{{ currentSubject.subjectCode }}</el-descriptions-item>
<el-descriptions-item label="主体名称">{{ currentSubject.subjectName }}</el-descriptions-item>
<el-descriptions-item label="CorpID">{{ mask(currentSubject.corpId) }}</el-descriptions-item>
<el-descriptions-item label="CorpSecret">{{ mask(currentSubject.corpSecret) }}</el-descriptions-item>
<el-descriptions-item label="AgentID">{{ mask(currentSubject.agentId) }}</el-descriptions-item>
<el-descriptions-item label="AgentSecret">{{ mask(currentSubject.agentSecret) }}</el-descriptions-item>
<el-descriptions-item label="会话存档">{{ currentSubject.sessionArchiveEnabled === 1 ? '开启' : '关闭' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ currentSubject.status === 1 ? '启用' : '禁用' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ currentSubject.createTime }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
<!-- 详情/编辑右侧抽屉 -->
<el-drawer
v-model="drawerVisible"
:title="`渠道主体详情${currentSubject.subjectName ? ' - ' + currentSubject.subjectName : ''}`"
size="480px"
destroy-on-close
>
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道" style="width: 100%">
<el-option label="企微" value="wecom" />
<el-option label="钉钉" value="dingtalk" />
<el-option label="飞书" value="feishu" />
</el-select>
</el-form-item>
<el-form-item label="主体编码">
<el-input v-model="dialogForm.subjectCode" placeholder="主体编码" />
</el-form-item>
<el-form-item label="主体名称">
<el-input v-model="dialogForm.subjectName" placeholder="主体名称" />
</el-form-item>
<el-form-item label="CorpID">
<el-input v-model="dialogForm.corpId" placeholder="CorpID" />
</el-form-item>
<el-form-item label="AgentID">
<el-input v-model="dialogForm.agentId" placeholder="AgentID" />
</el-form-item>
<el-form-item label="AgentSecret">
<el-input v-model="dialogForm.agentSecret" placeholder="AgentSecret" />
</el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="dialogForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="会话存档私钥">
<el-input v-model="dialogForm.sessionArchivePrivateKey" type="textarea" :rows="3" placeholder="会话存档私钥" />
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<div class="drawer-actions-left">
<el-button v-if="currentSubject.channelType === 'wecom'" type="warning" plain @click="handleSyncEnterpriseContacts(currentSubject)">同步企业通讯录</el-button>
<el-button type="danger" plain @click="handleDrawerDelete">删除</el-button>
</div>
<div class="drawer-actions">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createChannelSubject,
@@ -105,6 +155,9 @@ import {
deleteChannelSubject,
updateChannelSubjectStatus,
} from '@/api/channelSubject'
import { syncSubjectEnterpriseContacts } from '@/api/syncTask'
import { getSyncTask } from '@/api/syncTask'
import PageCard from '@/components/PageCard.vue'
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
@@ -116,7 +169,6 @@ const dialogForm = reactive({
subjectCode: '',
subjectName: '',
corpId: '',
corpSecret: '',
agentId: '',
agentSecret: '',
sessionArchiveEnabled: 0,
@@ -126,12 +178,35 @@ const dialogForm = reactive({
const drawerVisible = ref(false)
const currentSubject = ref<any>({})
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const filteredSubjects = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return subjects.value
return subjects.value.filter(
(s) =>
(s.subjectName || '').toLowerCase().includes(kw) ||
(s.subjectCode || '').toLowerCase().includes(kw) ||
(s.corpId || '').toLowerCase().includes(kw)
)
})
const pagedSubjects = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSubjects.value.slice(start, start + pageSize.value)
})
function handleSearch() {
currentPage.value = 1
}
function resetDialogForm() {
dialogForm.channelType = 'wecom'
dialogForm.subjectCode = ''
dialogForm.subjectName = ''
dialogForm.corpId = ''
dialogForm.corpSecret = ''
dialogForm.agentId = ''
dialogForm.agentSecret = ''
dialogForm.sessionArchiveEnabled = 0
@@ -143,7 +218,6 @@ function fillDialogForm(row: any) {
dialogForm.subjectCode = row.subjectCode ?? ''
dialogForm.subjectName = row.subjectName ?? ''
dialogForm.corpId = row.corpId ?? ''
dialogForm.corpSecret = row.corpSecret ?? ''
dialogForm.agentId = row.agentId ?? ''
dialogForm.agentSecret = row.agentSecret ?? ''
dialogForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
@@ -157,15 +231,11 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function handleRowClick(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
function openDetail(row: any) {
currentSubject.value = { ...row }
fillDialogForm(row)
drawerVisible.value = true
}
@@ -188,6 +258,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadSubjects()
} catch (e: any) {
ElMessage.error(e.message)
@@ -207,6 +278,12 @@ async function handleDelete(row: any) {
}
}
async function handleDrawerDelete() {
if (!currentSubject.value?.id) return
await handleDelete(currentSubject.value)
drawerVisible.value = false
}
async function handleStatusChange(row: any, val: any) {
try {
await updateChannelSubjectStatus(row.id, { status: val })
@@ -218,6 +295,42 @@ async function handleStatusChange(row: any, val: any) {
}
}
async function pollTask(taskId: number, label: string) {
const maxAttempts = 60
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000))
try {
const res: any = await getSyncTask(taskId)
const task = res.data
if (!task) continue
if (task.taskStatus === 2) {
ElMessage.success(`${label}同步完成,共 ${task.syncedCount ?? 0}`)
return
}
if (task.taskStatus === 3) {
ElMessage.error(`${label}同步失败:${task.errorMessage || '未知错误'}`)
return
}
} catch (e: any) {
// ignore polling error
}
}
ElMessage.warning(`${label}同步时间较长,请稍后到列表查看`)
}
async function handleSyncEnterpriseContacts(row: any) {
try {
const res: any = await syncSubjectEnterpriseContacts(row.id)
const taskId = res.data?.id
if (taskId) {
ElMessage.info('企业通讯录同步任务已提交')
await pollTask(taskId, '企业通讯录')
}
} catch (e: any) {
ElMessage.error(e.message)
}
}
function channelLabel(type: string) {
const map: Record<string, string> = {
wecom: '企微',
@@ -237,7 +350,19 @@ onMounted(loadSubjects)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.search-input {
width: 280px;
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.drawer-actions {
display: flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,10 +1,26 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建会话</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchQuery"
placeholder="搜索名称 / 渠道 / 会话ID"
clearable
style="width: 260px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="typeFilter" placeholder="全部类型" clearable style="width: 140px">
<el-option label="单聊" :value="1" />
<el-option label="群聊" :value="2" />
</el-select>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增会话</el-button>
</template>
<el-table :data="conversations" border>
<el-table :data="pagedConversations" class="sino-table" @row-click="openDrawer">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="conversationType" label="类型" width="100">
<template #default="{ row }">
@@ -12,6 +28,11 @@
</template>
</el-table-column>
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="conversationScene" label="场景" width="100">
<template #default="{ row }">
{{ formatConversationScene(row.conversationScene, row.conversationType) }}
</template>
</el-table-column>
<el-table-column prop="channelConversationId" label="渠道会话ID" show-overflow-tooltip />
<el-table-column prop="conversationName" label="名称" />
<el-table-column prop="ownerAccountId" label="负责人">
@@ -19,35 +40,32 @@
{{ formatAccountName(row.ownerAccountId) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
<div class="op-row" style="margin-top: 6px;">
<el-button link type="primary" size="small" @click="openBindAccount(row)">绑定账号</el-button>
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑会话' : '创建会话'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredConversations.length"
layout="total, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="新增会话" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="会话类型">
<el-select v-model="dialogForm.conversationType" placeholder="类型" style="width: 100%">
@@ -58,8 +76,14 @@
<el-form-item label="渠道">
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item v-if="dialogForm.conversationType === 2" label="场景">
<el-select v-model="dialogForm.conversationScene" placeholder="选择场景" style="width: 100%">
<el-option label="内部群" :value="1" />
<el-option label="外部群" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="渠道会话ID">
<el-input v-model="dialogForm.channelConversationId" :disabled="isEdit" placeholder="roomid_xxx" />
<el-input v-model="dialogForm.channelConversationId" placeholder="roomid_xxx" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
@@ -90,6 +114,99 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="会话详情" size="520px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="会话类型">
<el-select v-model="dialogForm.conversationType" placeholder="类型" style="width: 100%">
<el-option label="单聊" :value="1" />
<el-option label="群聊" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item v-if="dialogForm.conversationType === 2" label="场景">
<el-select v-model="dialogForm.conversationScene" placeholder="选择场景" style="width: 100%">
<el-option label="内部群" :value="1" />
<el-option label="外部群" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="渠道会话ID">
<el-input v-model="dialogForm.channelConversationId" :disabled="isEdit" placeholder="roomid_xxx" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="dialogForm.ownerAccountId" placeholder="选择负责人账号" style="width: 100%">
<el-option
v-for="a in accounts"
:key="a.id"
:label="a.accountName"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<el-divider />
<div class="relation-section">
<div class="section-title">关联信息</div>
<div class="relation-actions">
<el-button type="primary" link @click="openBindAccount(currentConversation)">绑定账号</el-button>
<el-button type="primary" link @click="openBindTags(currentConversation)">绑定标签</el-button>
</div>
</div>
<el-divider />
<div class="detail-section">
<div class="section-title">群成员</div>
<el-table :data="participants" class="sino-table" size="small" max-height="320">
<el-table-column prop="participantType" label="类型" width="90">
<template #default="{ row }">
{{ formatParticipantType(row.participantType) }}
</template>
</el-table-column>
<el-table-column prop="channelUserId" label="渠道用户ID" show-overflow-tooltip />
<el-table-column prop="personName" label="姓名" show-overflow-tooltip />
<el-table-column prop="personType" label="人员类型" width="100">
<template #default="{ row }">
{{ formatParticipantPersonType(row.personType) }}
</template>
</el-table-column>
</el-table>
</div>
<el-divider />
<div class="detail-section">
<div class="section-title">已绑定标签</div>
<div v-if="boundTags.length" class="tag-list">
<el-tag v-for="t in boundTags" :key="t.id" class="tag-item" size="small">
{{ t.tagName }}
</el-tag>
</div>
<el-empty v-else description="暂无标签" :image-size="60" />
</div>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentConversation)">删除</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="bindAccountVisible" title="绑定账号" width="500px">
<el-form :model="bindAccountForm" label-width="100px">
<el-form-item label="账号">
@@ -138,11 +255,12 @@
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindConversationAccount,
@@ -150,10 +268,13 @@ import {
createConversation,
deleteConversation,
listChannelAccounts,
listConversationParticipants,
listConversationTags,
listConversations,
updateConversation,
} from '@/api/channel'
import { listTags } from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
@@ -166,15 +287,55 @@ const editingId = ref<number | null>(null)
const dialogForm = reactive({
conversationType: 2,
channelType: 'wecom',
conversationScene: undefined as number | undefined,
channelConversationId: '',
conversationName: '',
ownerAccountId: undefined as number | undefined,
status: 1,
})
const searchQuery = ref('')
const typeFilter = ref<number | ''>('')
const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentConversation = ref<any>({})
const participants = ref<any[]>([])
const boundTags = ref<any[]>([])
const filteredConversations = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return conversations.value.filter((c) => {
const matchesQuery =
!q ||
[c.conversationName, c.channelType, c.channelConversationId].some((v) =>
String(v || '').toLowerCase().includes(q)
)
const matchesType =
typeFilter.value === '' || c.conversationType === typeFilter.value
return matchesQuery && matchesType
})
})
const pagedConversations = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredConversations.value.slice(start, start + pageSize.value)
})
watch([searchQuery, typeFilter], () => {
currentPage.value = 1
})
watch(() => dialogForm.conversationType, (val) => {
if (val === 1) {
dialogForm.conversationScene = undefined
}
})
function resetDialogForm() {
dialogForm.conversationType = 2
dialogForm.channelType = 'wecom'
dialogForm.conversationScene = undefined
dialogForm.channelConversationId = ''
dialogForm.conversationName = ''
dialogForm.ownerAccountId = undefined
@@ -184,6 +345,7 @@ function resetDialogForm() {
function fillDialogForm(row: any) {
dialogForm.conversationType = row.conversationType ?? 2
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.conversationScene = row.conversationScene ?? undefined
dialogForm.channelConversationId = row.channelConversationId ?? ''
dialogForm.conversationName = row.conversationName ?? ''
dialogForm.ownerAccountId = row.ownerAccountId
@@ -197,11 +359,16 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function openDrawer(row: any) {
currentConversation.value = row
currentBindConversation.value = row
currentConversationId.value = row.id
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
drawerVisible.value = true
loadParticipants(row.id)
loadBoundTags(row.id)
}
const accountMap = computed(() => {
@@ -218,11 +385,20 @@ function formatAccountName(id: number | undefined) {
}
const filteredBindAccounts = computed(() => {
const scene = currentBindConversation.value?.conversationScene
const channelType = currentBindConversation.value?.channelType
if (!channelType) return accounts.value
if (scene === 1) {
return accounts.value.filter((a: any) => a.accountType === 6)
}
if (scene === 2) {
return accounts.value.filter((a: any) => a.accountType === 3)
}
if (channelType) {
return accounts.value.filter((a: any) =>
a.channelType?.toLowerCase() === channelType?.toLowerCase()
)
}
return accounts.value
})
function formatConversationType(type: number | undefined) {
@@ -231,6 +407,52 @@ function formatConversationType(type: number | undefined) {
return type ?? '-'
}
function formatConversationScene(scene: number | undefined, type?: number) {
if (type === 1) return '-'
if (scene === 1) return '内部群'
if (scene === 2) return '外部群'
return scene ?? '-'
}
function formatParticipantType(type: number | undefined) {
if (type === 1) return '群主'
return '成员'
}
function formatParticipantPersonType(type: number | undefined) {
if (type === 3) return '客户'
if (type == null) return '-'
return '内部'
}
async function loadParticipants(conversationId: number | undefined) {
if (!conversationId) {
participants.value = []
return
}
try {
const res: any = await listConversationParticipants(conversationId)
participants.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
participants.value = []
}
}
async function loadBoundTags(conversationId: number | undefined) {
if (!conversationId) {
boundTags.value = []
return
}
try {
const res: any = await listConversationTags(conversationId)
boundTags.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
boundTags.value = []
}
}
async function loadConversations() {
try {
const res: any = await listConversations()
@@ -267,6 +489,7 @@ async function handleSave() {
const payload = {
conversationType: dialogForm.conversationType,
channelType: dialogForm.channelType,
conversationScene: dialogForm.conversationScene,
channelConversationId: dialogForm.channelConversationId,
conversationName: dialogForm.conversationName,
ownerAccountId: dialogForm.ownerAccountId,
@@ -281,6 +504,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
@@ -292,6 +516,7 @@ async function handleStatusChange(row: any, val: any) {
await updateConversation(row.id, {
conversationType: row.conversationType,
channelType: row.channelType,
conversationScene: row.conversationScene,
channelConversationId: row.channelConversationId,
conversationName: row.conversationName,
ownerAccountId: row.ownerAccountId,
@@ -314,6 +539,7 @@ async function handleDelete(row: any) {
)
await deleteConversation(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadConversations()
} catch (e: any) {
if (e !== 'cancel') {
@@ -353,7 +579,6 @@ async function handleBindAccount() {
}
}
const currentConversationId = ref<number | undefined>()
const bindTagsVisible = ref(false)
const bindTagsForm = reactive({
@@ -385,7 +610,33 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.relation-section {
margin-top: 8px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #0F172A;
margin-bottom: 12px;
}
.relation-actions {
display: flex;
gap: 16px;
}
.detail-section {
margin-bottom: 8px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
margin-right: 0;
}
</style>

View File

@@ -1,41 +1,50 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="渠道主体" :value="stats.subjectCount" />
</el-col>
<el-col :span="6">
<el-statistic title="渠道账号" :value="stats.accountCount" />
</el-col>
<el-col :span="6">
<el-statistic title="会话/群聊" :value="stats.conversationCount" />
</el-col>
<el-col :span="6">
<el-statistic title="今日发送" :value="stats.sentToday" />
</el-col>
</el-row>
<el-card class="mt-4">
<template #header>使用说明</template>
<div class="sino-page">
<div class="stats-grid">
<div v-for="item in statItems" :key="item.key" class="stat-card">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
</div>
</div>
<div class="sino-card">
<div class="card-header">使用说明</div>
<div class="card-body">
<p>1. 先在渠道主体录入企微主体信息</p>
<p>2. 渠道账号添加发送/接收账号</p>
<p>3. 会话/群聊维护业务群并绑定发送账号</p>
<p>4. 发送记录查看业务系统调用发送 API 的结果</p>
</el-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { getDashboardStats } from '@/api/dashboard'
const stats = reactive({
interface Stats {
subjectCount: number
accountCount: number
conversationCount: number
sentToday: number
}
const stats = reactive<Stats>({
subjectCount: 0,
accountCount: 0,
conversationCount: 0,
sentToday: 0,
})
const statItems = computed(() => [
{ key: 'subjectCount', label: '渠道主体', value: stats.subjectCount },
{ key: 'accountCount', label: '渠道账号', value: stats.accountCount },
{ key: 'conversationCount', label: '会话/群聊', value: stats.conversationCount },
{ key: 'sentToday', label: '今日发送', value: stats.sentToday },
])
async function loadStats() {
try {
const res: any = await getDashboardStats()
@@ -49,7 +58,64 @@ onMounted(loadStats)
</script>
<style scoped>
.mt-4 {
margin-top: 20px;
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
padding: 20px;
}
.stat-label {
font-size: 13px;
color: #64748B;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #0F172A;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #E2E8F0;
font-size: 16px;
font-weight: 600;
color: #0F172A;
}
.card-body {
padding: 20px;
font-size: 14px;
color: #475569;
line-height: 1.8;
}
.card-body p {
margin: 0 0 8px;
}
.card-body p:last-child {
margin-bottom: 0;
}
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,18 +1,32 @@
<template>
<div class="inbound-flow">
<div class="sino-page inbound-page">
<el-card class="toolbar-card" shadow="never">
<div class="toolbar">
<el-input
v-model="flowKeyword"
placeholder="搜索流程编码 / 名称"
clearable
style="width: 260px"
@keyup.enter="handleFlowSearch"
/>
<el-button type="primary" @click="openCreateDialog">新建流程</el-button>
</div>
</el-card>
<el-card class="flow-card" shadow="never">
<div class="flow-layout">
<!-- Left sidebar: flow list + node palette -->
<div class="left-panel">
<div class="panel-section flow-list">
<div class="section-header">
<span class="section-title">流程列表</span>
<el-button type="primary" size="small" @click="createNewFlow">新建</el-button>
</div>
<el-table
:data="flows"
:data="pagedFlows"
highlight-current-row
size="small"
@row-click="handleFlowRowClick"
class="sino-table"
>
<el-table-column prop="flowCode" label="编码" show-overflow-tooltip />
<el-table-column prop="flowName" label="名称" show-overflow-tooltip />
@@ -29,6 +43,17 @@
</template>
</el-table-column>
</el-table>
<div class="pagination-bar small">
<el-pagination
v-model:current-page="flowPage"
v-model:page-size="flowPageSize"
:total="totalFlowCount"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next, sizes"
@size-change="handleFlowSizeChange"
@current-change="handleFlowPageChange"
/>
</div>
</div>
<div class="panel-section node-palette">
@@ -199,11 +224,58 @@
</template>
</div>
</div>
</el-card>
<!-- 新建流程弹窗 -->
<el-dialog v-model="createDialogVisible" title="新建流程" width="520px">
<el-form :model="createForm" label-width="100px">
<el-form-item label="流程编码">
<el-input v-model="createForm.flowCode" placeholder="flow_xxx" />
</el-form-item>
<el-form-item label="流程名称">
<el-input v-model="createForm.flowName" placeholder="新建入站流程" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmCreateFlow">确认</el-button>
</template>
</el-dialog>
<!-- 流程详情抽屉 -->
<el-drawer v-model="drawerVisible" title="流程详情" size="460px">
<div v-if="selectedFlow" class="flow-detail">
<div class="detail-item">
<span class="detail-label">流程编码</span>
<span class="detail-value">{{ selectedFlow.flowCode }}</span>
</div>
<div class="detail-item">
<span class="detail-label">流程名称</span>
<span class="detail-value">{{ selectedFlow.flowName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">状态</span>
<el-tag :type="statusTagType(selectedFlow.status)" size="small">
{{ statusText(selectedFlow.status) }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">版本</span>
<span class="detail-value">v{{ selectedFlow.version || 1 }}</span>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">关闭</el-button>
<el-button type="primary" @click="editSelectedFlow">编辑画布</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueFlow, Handle, Position, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
@@ -238,6 +310,18 @@ const selectedNode = ref<any>(null)
const selectedEdge = ref<any>(null)
const isEdit = ref(false)
const flowKeyword = ref('')
const flowPage = ref(1)
const flowPageSize = ref(10)
const createDialogVisible = ref(false)
const drawerVisible = ref(false)
const selectedFlow = ref<any>(null)
const createForm = reactive({
flowCode: '',
flowName: '',
})
const paletteNodes = [
{ type: 'receive', label: '接收消息' },
{ type: 'intent', label: '意图识别' },
@@ -270,6 +354,23 @@ const nodeTypeIcons: Record<string, string> = {
reply: '💬',
}
const filteredFlows = computed(() => {
if (!flowKeyword.value) return flows.value
const kw = flowKeyword.value.toLowerCase()
return flows.value.filter(
(f) =>
(f.flowCode || '').toLowerCase().includes(kw) ||
(f.flowName || '').toLowerCase().includes(kw)
)
})
const totalFlowCount = computed(() => filteredFlows.value.length)
const pagedFlows = computed(() => {
const start = (flowPage.value - 1) * flowPageSize.value
return filteredFlows.value.slice(start, start + flowPageSize.value)
})
function nodeTypeLabel(type: string) {
return nodeTypeLabels[type] || type
}
@@ -377,16 +478,51 @@ function resetCanvas() {
selectedEdge.value = null
}
function createNewFlow() {
function openCreateDialog() {
createForm.flowCode = `flow_${Date.now()}`
createForm.flowName = '新建入站流程'
createDialogVisible.value = true
}
function confirmCreateFlow() {
if (!createForm.flowCode.trim()) {
ElMessage.warning('请输入流程编码')
return
}
if (!createForm.flowName.trim()) {
ElMessage.warning('请输入流程名称')
return
}
resetCanvas()
form.flowCode = `flow_${Date.now()}`
form.flowName = '新建入站流程'
form.flowCode = createForm.flowCode
form.flowName = createForm.flowName
addNode('receive')
addNode('intent')
createDialogVisible.value = false
}
function handleFlowRowClick(row: any) {
loadFlow(row.flowCode)
selectedFlow.value = row
drawerVisible.value = true
}
function editSelectedFlow() {
if (!selectedFlow.value) return
loadFlow(selectedFlow.value.flowCode)
drawerVisible.value = false
}
function handleFlowSearch() {
flowPage.value = 1
}
function handleFlowPageChange(val: number) {
flowPage.value = val
}
function handleFlowSizeChange(val: number) {
flowPageSize.value = val
flowPage.value = 1
}
function parseDefinitionJson(row: any): { nodes: any[]; edges: any[] } {
@@ -561,135 +697,146 @@ onMounted(() => {
</script>
<style scoped>
.inbound-flow {
height: calc(100vh - 120px);
.sino-page {
--el-color-primary: #2563eb;
--el-color-primary-light-3: #1d4ed8;
display: flex;
flex-direction: column;
gap: 16px;
}
.inbound-page {
height: calc(100vh - 112px);
}
.toolbar-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.flow-card {
flex: 1;
min-height: 0;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.flow-card :deep(.el-card__body) {
height: 100%;
padding: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.flow-layout {
display: flex;
flex: 1;
gap: 12px;
gap: 16px;
height: 100%;
min-height: 0;
}
.left-panel {
width: 260px;
width: 280px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
}
.panel-section {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
overflow: auto;
}
.flow-list {
flex: 1;
min-height: 0;
}
.node-palette {
flex-shrink: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title {
font-weight: 500;
font-weight: 600;
font-size: 14px;
color: #0f172a;
}
.palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.palette-item {
padding: 10px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
text-align: center;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
color: #475569;
background: #fff;
}
.palette-item:hover {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e4e7ed;
border-radius: 4px;
border-left: 1px solid #e2e8f0;
border-right: 1px solid #e2e8f0;
background: #fff;
}
.flow-toolbar {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-canvas {
flex: 1;
min-height: 0;
}
.right-panel {
width: 300px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
padding: 16px;
overflow: auto;
}
.config-form {
margin-top: 12px;
}
.form-hint {
font-size: 12px;
color: #909399;
color: #64748b;
line-height: 1.5;
padding: 0 0 12px 80px;
}
.node-type-tag {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
color: #2563eb;
background: #eff6ff;
padding: 2px 8px;
border-radius: 4px;
}
.flow-node {
padding: 10px 16px;
border-radius: 6px;
border: 1px solid #dcdfe6;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
@@ -697,38 +844,59 @@ onMounted(() => {
min-width: 110px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.flow-node.node-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
.node-icon {
font-size: 16px;
}
.node-label {
font-size: 13px;
font-weight: 500;
}
.node-receive {
border-left: 4px solid #67c23a;
border-left: 4px solid #22c55e;
}
.node-intent {
border-left: 4px solid #e6a23c;
border-left: 4px solid #f59e0b;
}
.node-condition {
border-left: 4px solid #f56c6c;
border-left: 4px solid #ef4444;
}
.node-webhook {
border-left: 4px solid #409eff;
border-left: 4px solid #2563eb;
}
.node-reply {
border-left: 4px solid #909399;
border-left: 4px solid #94a3b8;
}
.pagination-bar {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.pagination-bar.small :deep(.el-pagination) {
justify-content: flex-end;
}
.flow-detail {
font-size: 14px;
}
.detail-item {
margin-bottom: 16px;
}
.detail-label {
display: block;
color: #64748b;
font-size: 13px;
margin-bottom: 6px;
}
.detail-value {
color: #0f172a;
word-break: break-all;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<div class="messages-layout">
<div class="conversation-list">
<div class="conv-search">
<el-input v-model="search" placeholder="搜索会话" prefix-icon="Search" />
</div>
<div class="conv-items">
<div
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ active: current?.id === conv.id }"
@click="current = conv"
>
<div class="conv-title">{{ conv.name }}</div>
<div class="conv-preview">{{ conv.preview }}</div>
</div>
</div>
</div>
<div class="chat-area" v-if="current">
<div class="chat-header">
<span class="chat-name">{{ current.name }}</span>
<div class="chat-actions">
<el-button link type="primary">打标签</el-button>
<el-button link type="primary">会话详情</el-button>
</div>
</div>
<div class="chat-body">
<div
v-for="(msg, idx) in current.messages"
:key="idx"
class="chat-msg"
:class="msg.self ? 'self' : 'other'"
>
<div class="chat-bubble">{{ msg.content }}</div>
</div>
</div>
<div class="chat-footer">
<el-input v-model="reply" placeholder="输入消息..." @keyup.enter="send" />
<el-button type="primary" @click="send">发送</el-button>
</div>
</div>
<div class="chat-empty" v-else>
<el-empty description="请选择会话" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const search = ref('')
const reply = ref('')
const current = ref<any>(null)
const conversations = ref([
{
id: 1,
name: '艾先生',
preview: '我要查询订单状态',
messages: [
{ content: '我要查询订单状态', self: false },
{ content: '订单号是 20250709001', self: false },
{ content: '好的,已为您查询到订单正在发货中。', self: true },
],
},
{
id: 2,
name: '华东销售群',
preview: '[图片] 车牌号 京A12345',
messages: [
{ content: '[图片] 车牌号 京A12345', self: false },
],
},
])
function send() {
if (!reply.value.trim() || !current.value) return
current.value.messages.push({ content: reply.value, self: true })
current.value.preview = reply.value
reply.value = ''
}
</script>
<style scoped>
.messages-layout {
display: flex;
height: calc(100vh - 140px);
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.conversation-list {
width: 280px;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.conv-search {
padding: 12px;
border-bottom: 1px solid #f1f5f9;
}
.conv-items {
flex: 1;
overflow-y: auto;
}
.conv-item {
padding: 12px 16px;
border-bottom: 1px solid #f8fafc;
cursor: pointer;
}
.conv-item:hover,
.conv-item.active {
background: #f8fafc;
}
.conv-title {
font-weight: 500;
font-size: 14px;
color: #0f172a;
}
.conv-preview {
font-size: 13px;
color: #64748b;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
height: 56px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.chat-name {
font-weight: 600;
}
.chat-body {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f8fafc;
}
.chat-msg {
display: flex;
margin-bottom: 12px;
}
.chat-msg.self {
justify-content: flex-end;
}
.chat-bubble {
max-width: 60%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
}
.chat-msg.other .chat-bubble {
background: #fff;
border: 1px solid #e2e8f0;
color: #0f172a;
border-top-left-radius: 4px;
}
.chat-msg.self .chat-bubble {
background: #2563eb;
color: #fff;
border-top-right-radius: 4px;
}
.chat-footer {
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
display: flex;
gap: 12px;
}
.chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,48 +1,63 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建联系人</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchQuery"
placeholder="搜索姓名 / 手机 / 编码"
clearable
style="width: 260px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="personTypeFilter" placeholder="全部类型" clearable style="width: 140px">
<el-option label="内部人员" :value="1" />
<el-option label="供应商人员" :value="2" />
<el-option label="客户" :value="3" />
<el-option label="司机" :value="4" />
</el-select>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增联系人</el-button>
</template>
<el-table :data="persons" border>
<el-table :data="pagedPersons" class="sino-table" @row-click="openDrawer">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="personCode" label="编码" />
<el-table-column prop="personName" label="姓名" />
<el-table-column prop="phone" label="手机" />
<el-table-column prop="personType" label="类型" width="100">
<el-table-column prop="personType" label="类型" width="120">
<template #default="{ row }">
{{ formatPersonType(row.personType) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
<div class="op-row" style="margin-top: 6px;">
<el-button link type="primary" size="small" @click="openIdentities(row)">渠道身份</el-button>
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑联系人' : '创建联系人'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredPersons.length"
layout="total, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" title="新增联系人" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="编码">
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
@@ -61,7 +76,7 @@
<el-option label="司机" :value="4" />
</el-select>
</el-form-item>
<el-form-item v-if="isEdit" label="状态">
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
@@ -77,8 +92,55 @@
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" title="联系人详情" size="520px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="编码">
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="dialogForm.personName" placeholder="姓名" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="dialogForm.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="dialogForm.personType" placeholder="类型" style="width: 100%">
<el-option label="内部人员" :value="1" />
<el-option label="供应商人员" :value="2" />
<el-option label="客户" :value="3" />
<el-option label="司机" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<el-divider />
<div class="relation-section">
<div class="section-title">关联信息</div>
<div class="relation-actions">
<el-button type="primary" link @click="openIdentities(currentPerson)">管理渠道身份</el-button>
<el-button type="primary" link @click="openBindTags(currentPerson)">绑定标签</el-button>
</div>
</div>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentPerson)">删除</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="identitiesVisible" title="渠道身份" width="700px">
<el-table :data="identities" border style="margin-bottom: 16px;">
<el-table :data="identities" class="sino-table" border style="margin-bottom: 16px;">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelUserId" label="渠道用户ID" />
@@ -128,11 +190,12 @@
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindPersonTags,
@@ -145,6 +208,7 @@ import {
listTags,
updatePerson,
} from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
const persons = ref<any[]>([])
const tags = ref<any[]>([])
@@ -161,6 +225,36 @@ const dialogForm = reactive({
status: 1,
})
const searchQuery = ref('')
const personTypeFilter = ref<number | ''>('')
const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentPerson = ref<any>({})
const filteredPersons = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return persons.value.filter((p) => {
const matchesQuery =
!q ||
[p.personName, p.phone, p.personCode].some((v) =>
String(v || '').toLowerCase().includes(q)
)
const matchesType =
personTypeFilter.value === '' || p.personType === personTypeFilter.value
return matchesQuery && matchesType
})
})
const pagedPersons = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredPersons.value.slice(start, start + pageSize.value)
})
watch([searchQuery, personTypeFilter], () => {
currentPage.value = 1
})
function resetDialogForm() {
dialogForm.personCode = ''
dialogForm.personName = ''
@@ -184,11 +278,13 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function openDrawer(row: any) {
currentPerson.value = row
currentPersonId.value = row.id
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
drawerVisible.value = true
}
function formatPersonType(type: number | undefined) {
@@ -237,6 +333,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadPersons()
} catch (e: any) {
ElMessage.error(e.message)
@@ -269,6 +366,7 @@ async function handleDelete(row: any) {
)
await deletePerson(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadPersons()
} catch (e: any) {
if (e !== 'cancel') {
@@ -382,7 +480,19 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.relation-section {
margin-top: 8px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #0F172A;
margin-bottom: 12px;
}
.relation-actions {
display: flex;
gap: 16px;
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<div>
<div class="send-record-page">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="今日发送总数" :value="statistics.total" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="成功" :value="statistics.successCount">
<template #suffix>
<el-tag type="success" size="small">成功</el-tag>
@@ -17,7 +17,7 @@
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="失败" :value="statistics.failedCount">
<template #suffix>
<el-tag type="danger" size="small">失败</el-tag>
@@ -26,7 +26,7 @@
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-card shadow="hover" class="stat-card">
<el-statistic title="待发送" :value="statistics.pendingCount">
<template #suffix>
<el-tag type="info" size="small">待发送</el-tag>
@@ -36,11 +36,84 @@
</el-col>
</el-row>
<!-- 测试发送 -->
<el-card class="mt-4" shadow="never">
<template #header>
<span>测试发送</span>
<PageCard>
<template #toolbar-left>
<el-form :model="query" inline class="search-form">
<el-form-item label="任务 ID">
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
</el-form-item>
<el-form-item label="发送时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD[T]HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="待发送" :value="0" />
<el-option label="发送中" :value="1" />
<el-option label="成功" :value="2" />
<el-option label="失败" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openTestDialog">测试发送</el-button>
</template>
<el-table
:data="records"
v-loading="loading"
@row-click="openDetail"
class="sino-table"
>
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="targetType" label="目标类型" width="110" />
<el-table-column prop="channelType" label="渠道" width="120" />
<el-table-column prop="sendStatus" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
</el-table>
<template #pagination>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadRecords"
/>
</template>
</PageCard>
<!-- 测试发送弹窗 -->
<el-dialog v-model="testDialogVisible" title="测试发送" width="760px">
<el-form :model="testForm" label-width="80px" inline>
<el-form-item label="目标类型">
<el-select v-model="testForm.targetType" style="width: 140px">
@@ -97,94 +170,12 @@
<el-form-item label="内容">
<el-input v-model="textContent" placeholder="输入文本消息" style="width: 260px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 筛选栏 -->
<el-card class="mt-4" shadow="never">
<el-form :model="query" inline>
<el-form-item label="任务 ID">
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
</el-form-item>
<el-form-item label="发送时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD[T]HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="待发送" :value="0" />
<el-option label="发送中" :value="1" />
<el-option label="成功" :value="2" />
<el-option label="失败" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 记录列表 -->
<el-card class="mt-4" shadow="never">
<el-table :data="records" border v-loading="loading">
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="targetType" label="目标类型" width="110" />
<el-table-column prop="channelType" label="渠道" width="120" />
<el-table-column prop="sendStatus" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">未知</el-tag>
<template #footer>
<el-button @click="testDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleTestSend">确认发送</el-button>
</template>
</el-table-column>
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.sendStatus === 0"
link
type="danger"
@click="handleCancel(row.taskId)"
>
取消
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadRecords"
/>
</div>
</el-card>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="发送详情" size="520px">
@@ -226,6 +217,12 @@
<span class="detail-value">{{ currentRecord.finishTime || '-' }}</span>
</div>
</div>
<template #footer v-if="currentRecord && currentRecord.sendStatus === 0">
<div class="drawer-footer">
<el-button type="danger" @click="handleCancel(currentRecord.taskId)">取消任务</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -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<RecordItem[]>([])
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref<RecordItem | null>(null)
const testDialogVisible = ref(false)
const testForm = reactive({
targetType: 'conversation',
@@ -297,6 +296,10 @@ const textContent = ref('')
const conversations = ref<any[]>([])
const tags = ref<any[]>([])
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(() => {
</script>
<style scoped>
.mt-4 {
margin-top: 16px;
}
.pagination-bar {
margin-top: 16px;
.send-record-page {
display: flex;
justify-content: flex-end;
flex-direction: column;
gap: 16px;
}
.stat-row {
margin: 0 !important;
}
.stat-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.search-form {
margin-bottom: 0;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
}
.detail-content {
font-size: 14px;
@@ -475,20 +490,27 @@ onMounted(() => {
}
.detail-label {
display: block;
color: #606266;
color: #64748b;
font-size: 13px;
margin-bottom: 6px;
}
.detail-value {
color: #303133;
color: #0f172a;
word-break: break-all;
}
pre {
margin: 0;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
background: #f1f5f9;
border-radius: 8px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,10 +1,22 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建标签</el-button>
</div>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchQuery"
placeholder="搜索标签名称"
clearable
style="width: 260px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增标签</el-button>
</template>
<el-table :data="tags" border>
<el-table :data="pagedTags" class="sino-table" @row-click="openDrawer">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tagName" label="名称" />
<el-table-column prop="tagPath" label="路径" show-overflow-tooltip />
@@ -13,31 +25,32 @@
{{ formatLevel(row.level) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<span class="status-text">{{ row.status === 1 ? '启用' : '禁用' }}</span>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '创建标签'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredTags.length"
layout="total, prev, pager, next"
background
/>
</template>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '新增标签'" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="标签名">
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
@@ -68,13 +81,48 @@
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
<el-drawer v-model="drawerVisible" title="标签详情" size="480px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="标签名">
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
</el-form-item>
<el-form-item label="父标签">
<el-select v-model="dialogForm.parentId" placeholder="选择父标签" style="width: 100%">
<el-option label="无" :value="0" />
<el-option
v-for="t in tags"
:key="t.id"
:label="t.tagName"
:value="t.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentRow)">删除</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTag, deleteTag, listTags, updateTag } from '@/api/crm'
import PageCard from '@/components/PageCard.vue'
const tags = ref<any[]>([])
@@ -88,6 +136,31 @@ const dialogForm = reactive({
status: 1,
})
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentRow = ref<any>({})
const filteredTags = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return tags.value
return tags.value.filter(
(t) =>
String(t.tagName || '').toLowerCase().includes(q) ||
String(t.tagPath || '').toLowerCase().includes(q)
)
})
const pagedTags = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredTags.value.slice(start, start + pageSize.value)
})
watch(searchQuery, () => {
currentPage.value = 1
})
function resetDialogForm() {
dialogForm.tagName = ''
dialogForm.parentId = 0
@@ -107,11 +180,12 @@ function openCreateDialog() {
dialogVisible.value = true
}
function openEditDialog(row: any) {
function openDrawer(row: any) {
currentRow.value = row
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
drawerVisible.value = true
}
function formatLevel(level: number | undefined) {
@@ -148,6 +222,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
drawerVisible.value = false
await loadTags()
} catch (e: any) {
ElMessage.error(e.message)
@@ -178,6 +253,7 @@ async function handleDelete(row: any) {
)
await deleteTag(row.id)
ElMessage.success('删除成功')
drawerVisible.value = false
await loadTags()
} catch (e: any) {
if (e !== 'cancel') {
@@ -188,9 +264,3 @@ async function handleDelete(row: any) {
onMounted(loadTags)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,40 +1,57 @@
<template>
<div>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
</div>
<el-table :data="tenants" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="tenantName" label="租户名称" />
<el-table-column prop="inboundWebhookUrl" label="入站 Webhook" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchText"
placeholder="搜索租户编码 / 租户名称"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增租户</el-button>
</template>
<el-table :data="pagedTenants" @row-click="openEditDrawer" class="sino-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" min-width="160" />
<el-table-column prop="tenantName" label="租户名称" min-width="160" />
<el-table-column prop="inboundWebhookUrl" label="入站 Webhook" show-overflow-tooltip min-width="220" />
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
inline-prompt
active-text="启用"
inactive-text="禁用"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑租户' : '创建租户'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="totalCount"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</template>
<!-- 新增租户弹窗 -->
<el-dialog v-model="dialogVisible" title="新增租户" width="600px">
<el-form :model="dialogForm" label-width="140px">
<el-form-item label="租户编码">
<el-input v-model="dialogForm.tenantCode" :disabled="isEdit" placeholder="租户编码" />
<el-input v-model="dialogForm.tenantCode" placeholder="租户编码" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="dialogForm.tenantName" placeholder="租户名称" />
@@ -51,7 +68,6 @@
inactive-text="禁用"
/>
</el-form-item>
<template v-if="!isEdit">
<el-form-item label="初始化管理员">
<el-checkbox v-model="dialogForm.createAdmin">同时创建租户管理员</el-checkbox>
</el-form-item>
@@ -61,20 +77,53 @@
<el-form-item v-if="dialogForm.createAdmin" label="初始密码">
<el-input v-model="dialogForm.initAdminPassword" type="password" placeholder="初始密码" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<!-- 租户详情 / 编辑抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="480px">
<el-form :model="drawerForm" label-width="120px" v-if="currentRow">
<el-form-item label="租户编码">
<el-input v-model="drawerForm.tenantCode" disabled />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="drawerForm.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item label="入站 Webhook">
<el-input v-model="drawerForm.inboundWebhookUrl" placeholder="https://example.com/webhook" />
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="drawerForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button type="danger" plain @click="handleDrawerDelete">删除</el-button>
<div class="drawer-actions">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleDrawerSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTenant, deleteTenant, listTenants, updateTenant } from '@/api/account'
import PageCard from '@/components/PageCard.vue'
const tenants = ref<any[]>([])
const dialogVisible = ref(false)
@@ -91,6 +140,53 @@ const dialogForm = reactive({
initAdminPassword: '',
})
const searchText = ref('')
const drawerVisible = ref(false)
const currentRow = ref<any>(null)
const page = ref(1)
const pageSize = ref(10)
const drawerForm = reactive({
tenantCode: '',
tenantName: '',
inboundWebhookUrl: '',
status: 1,
})
const filteredTenants = computed(() => {
if (!searchText.value) return tenants.value
const kw = searchText.value.toLowerCase()
return tenants.value.filter(
(t) =>
(t.tenantCode || '').toLowerCase().includes(kw) ||
(t.tenantName || '').toLowerCase().includes(kw)
)
})
const totalCount = computed(() => filteredTenants.value.length)
const pagedTenants = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredTenants.value.slice(start, start + pageSize.value)
})
const drawerTitle = computed(() =>
currentRow.value ? `编辑租户 - ${currentRow.value.tenantName}` : '租户详情'
)
function handleSearch() {
page.value = 1
}
function handlePageChange(val: number) {
page.value = val
}
function handleSizeChange(val: number) {
pageSize.value = val
page.value = 1
}
function resetDialogForm() {
dialogForm.tenantCode = ''
dialogForm.tenantName = ''
@@ -108,6 +204,20 @@ function fillDialogForm(row: any) {
dialogForm.status = row.status ?? 1
}
function resetDrawerForm() {
drawerForm.tenantCode = ''
drawerForm.tenantName = ''
drawerForm.inboundWebhookUrl = ''
drawerForm.status = 1
}
function fillDrawerForm(row: any) {
drawerForm.tenantCode = row.tenantCode ?? ''
drawerForm.tenantName = row.tenantName ?? ''
drawerForm.inboundWebhookUrl = row.inboundWebhookUrl ?? ''
drawerForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
@@ -122,6 +232,12 @@ function openEditDialog(row: any) {
dialogVisible.value = true
}
function openEditDrawer(row: any) {
currentRow.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
async function loadTenants() {
try {
const res: any = await listTenants()
@@ -162,6 +278,7 @@ async function handleSave() {
ElMessage.success('创建成功')
}
dialogVisible.value = false
page.value = 1
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
@@ -200,11 +317,40 @@ async function handleDelete(row: any) {
}
}
async function handleDrawerSave() {
if (!currentRow.value) return
try {
await updateTenant(currentRow.value.id, {
tenantName: drawerForm.tenantName,
inboundWebhookUrl: drawerForm.inboundWebhookUrl,
status: drawerForm.status,
})
ElMessage.success('保存成功')
drawerVisible.value = false
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDrawerDelete() {
if (!currentRow.value) return
await handleDelete(currentRow.value)
drawerVisible.value = false
}
onMounted(loadTenants)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.drawer-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.drawer-actions {
display: flex;
gap: 12px;
}
</style>

View File

@@ -1,11 +1,11 @@
<template>
<div>
<div class="toolbar">
<PageCard>
<template #toolbar-left>
<el-select
v-model="selectedTenant"
placeholder="选择租户"
clearable
style="width: 200px; margin-right: 12px"
style="width: 200px"
:disabled="!isPlatformAdmin"
@change="loadUsers"
>
@@ -16,46 +16,63 @@
:value="t.tenantCode"
/>
</el-select>
<el-button type="primary" @click="openCreateDialog">创建用户</el-button>
</div>
<el-input
v-model="searchText"
placeholder="搜索用户名 / 姓名 / 手机号"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
</template>
<template #toolbar-right>
<el-button type="primary" @click="openCreateDialog">新增用户</el-button>
</template>
<el-table :data="users" border>
<el-table :data="pagedUsers" @row-click="openEditDrawer" class="sino-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realName" label="真实姓名" />
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="roleCodes" label="角色">
<el-table-column prop="tenantCode" label="租户编码" min-width="140" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="realName" label="真实姓名" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column prop="roleCodes" label="角色" min-width="180">
<template #default="{ row }">
{{ formatRoles(row.roleCodes) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<div class="status-cell" @click.stop>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
inline-prompt
active-text="启用"
inactive-text="禁用"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '创建用户'" width="600px">
<template #pagination>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="totalCount"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</template>
<!-- 新增用户弹窗 -->
<el-dialog v-model="dialogVisible" title="新增用户" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="租户">
<el-select v-model="dialogForm.tenantCode" :disabled="isEdit" style="width: 100%">
<el-select v-model="dialogForm.tenantCode" style="width: 100%">
<el-option
v-for="t in tenants"
:key="t.id"
@@ -65,9 +82,9 @@
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="dialogForm.username" :disabled="isEdit" placeholder="用户名" />
<el-input v-model="dialogForm.username" placeholder="用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码">
<el-form-item label="密码">
<el-input v-model="dialogForm.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item label="真实姓名">
@@ -101,7 +118,60 @@
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<!-- 用户详情 / 编辑抽屉 -->
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="480px">
<el-form :model="drawerForm" label-width="100px" v-if="currentRow">
<el-form-item label="租户">
<el-select v-model="drawerForm.tenantCode" disabled style="width: 100%">
<el-option
v-for="t in tenants"
:key="t.id"
:label="`${t.tenantName}${t.tenantCode}`"
:value="t.tenantCode"
/>
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="drawerForm.username" disabled />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="drawerForm.realName" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="drawerForm.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="drawerForm.roleCodes" multiple placeholder="选择角色" style="width: 100%">
<el-option
v-for="r in roles"
:key="r.id"
:label="r.roleName"
:value="r.roleCode"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="drawerForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button type="danger" plain @click="handleDrawerDelete">删除</el-button>
<div class="drawer-actions">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="handleDrawerSave">保存</el-button>
</div>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
@@ -116,6 +186,7 @@ import {
listUsers,
updateUser,
} from '@/api/account'
import PageCard from '@/components/PageCard.vue'
const authStore = useAuthStore()
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
@@ -139,6 +210,59 @@ const dialogForm = reactive({
status: 1,
})
const searchText = ref('')
const drawerVisible = ref(false)
const currentRow = ref<any>(null)
const page = ref(1)
const pageSize = ref(10)
const drawerForm = reactive({
tenantCode: '',
username: '',
realName: '',
phone: '',
roleCodes: [] as string[],
status: 1,
})
const filteredUsers = computed(() => {
let list = users.value
if (searchText.value) {
const kw = searchText.value.toLowerCase()
list = list.filter(
(u) =>
(u.username || '').toLowerCase().includes(kw) ||
(u.realName || '').toLowerCase().includes(kw) ||
(u.phone || '').toLowerCase().includes(kw)
)
}
return list
})
const totalCount = computed(() => filteredUsers.value.length)
const pagedUsers = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
const drawerTitle = computed(() =>
currentRow.value ? `编辑用户 - ${currentRow.value.username}` : '用户详情'
)
function handleSearch() {
page.value = 1
}
function handlePageChange(val: number) {
page.value = val
}
function handleSizeChange(val: number) {
pageSize.value = val
page.value = 1
}
function resetDialogForm() {
dialogForm.tenantCode = isPlatformAdmin.value ? '' : (authStore.tenantCode || '')
dialogForm.username = ''
@@ -159,6 +283,24 @@ function fillDialogForm(row: any) {
dialogForm.status = row.status ?? 1
}
function resetDrawerForm() {
drawerForm.tenantCode = ''
drawerForm.username = ''
drawerForm.realName = ''
drawerForm.phone = ''
drawerForm.roleCodes = []
drawerForm.status = 1
}
function fillDrawerForm(row: any) {
drawerForm.tenantCode = row.tenantCode ?? ''
drawerForm.username = row.username ?? ''
drawerForm.realName = row.realName ?? ''
drawerForm.phone = row.phone ?? ''
drawerForm.roleCodes = row.roleCodes || []
drawerForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
@@ -173,6 +315,12 @@ function openEditDialog(row: any) {
dialogVisible.value = true
}
function openEditDrawer(row: any) {
currentRow.value = row
fillDrawerForm(row)
drawerVisible.value = true
}
function formatRoles(codes: string[] | undefined) {
if (!codes || codes.length === 0) return '-'
return codes
@@ -205,6 +353,7 @@ async function loadUsers() {
try {
const res: any = await listUsers(selectedTenant.value || undefined)
users.value = res.data || []
page.value = 1
} catch (e: any) {
ElMessage.error(e.message)
}
@@ -282,6 +431,29 @@ async function handleDelete(row: any) {
}
}
async function handleDrawerSave() {
if (!currentRow.value) return
try {
await updateUser(currentRow.value.id, {
realName: drawerForm.realName,
phone: drawerForm.phone,
status: drawerForm.status,
roleCodes: drawerForm.roleCodes,
})
ElMessage.success('保存成功')
drawerVisible.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDrawerDelete() {
if (!currentRow.value) return
await handleDelete(currentRow.value)
drawerVisible.value = false
}
onMounted(() => {
loadTenants()
loadRoles()
@@ -290,7 +462,14 @@ onMounted(() => {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
.drawer-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.drawer-actions {
display: flex;
gap: 12px;
}
</style>

View File

@@ -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"}
{"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/channelidentity.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"}

View File

@@ -1,9 +1,13 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://localhost:8080'
return {
plugins: [vue()],
resolve: {
alias: {
@@ -11,10 +15,10 @@ export default defineConfig({
},
},
server: {
port: 5173,
port: parseInt(env.VITE_ADMIN_WEB_PORT || '5173', 10),
proxy: {
'/msg-platform': {
target: 'http://localhost:8080',
target: apiBaseUrl,
changeOrigin: true,
},
},
@@ -22,4 +26,5 @@ export default defineConfig({
build: {
outDir: 'dist',
},
}
})

View File

@@ -0,0 +1,2 @@
--toolchains
/Users/marsal/Projects/sino-project/sino-mci/backend/.mvn/toolchains.xml

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<toolchains>
<toolchain>
<type>jdk</type>
<provides>
<version>21</version>
<vendor>zulu</vendor>
</provides>
<configuration>
<jdkHome>/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home</jdkHome>
</configuration>
</toolchain>
</toolchains>

View File

@@ -5,7 +5,8 @@ public enum AccountType {
WECOM_AGENT(2, "企微应用"),
WECHAT_PERSONAL(3, "pywechat 个人号"),
DINGTALK_BOT(4, "钉钉机器人"),
FEISHU_BOT(5, "飞书机器人");
FEISHU_BOT(5, "飞书机器人"),
WECOM_BOT(6, "企微群机器人");
private final int code;
private final String label;

View File

@@ -118,12 +118,14 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
}
String contentType = defaultString(request.getContentType()).toLowerCase();
String recordId = getString(request.getExtra(), "record_id");
String tenantCode = defaultString(getString(request.getExtra(), "tenant_code"));
try {
Map<String, Object> response;
if ("image".equals(contentType)) {
response = sendImage(accountId, conversationId, request.getContent());
response = sendImage(accountId, conversationId, recordId, tenantCode, request.getContent());
} else {
response = sendText(accountId, conversationId, request.getContent());
response = sendText(accountId, conversationId, recordId, tenantCode, request.getContent());
}
if (response == null) {
@@ -133,7 +135,8 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
}
Boolean success = getBoolean(response, "success");
if (!Boolean.TRUE.equals(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;
@@ -157,7 +160,7 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
return payload;
}
private Map<String, Object> sendText(String accountId, String conversationId, Map<String, Object> content) {
private Map<String, Object> sendText(String accountId, String conversationId, String recordId, String tenantCode, Map<String, Object> content) {
String text = getString(content, "text");
if (isBlank(text)) {
throw new IllegalArgumentException("文本内容为空");
@@ -166,10 +169,12 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
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<String, Object> sendImage(String accountId, String conversationId, Map<String, Object> content) {
private Map<String, Object> sendImage(String accountId, String conversationId, String recordId, String tenantCode, Map<String, Object> content) {
String imageUrl = getString(content, "image_url");
if (isBlank(imageUrl)) {
throw new IllegalArgumentException("图片地址为空");
@@ -178,6 +183,8 @@ public class WechatPersonalChannelAdapter implements ChannelAdapter {
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);
}

View File

@@ -99,7 +99,7 @@ class WechatPersonalChannelAdapterTest {
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"success", true,
"accepted", true,
"message_id", "msg-123"))));
SendMessageRequest request = new SendMessageRequest();
@@ -110,6 +110,8 @@ class WechatPersonalChannelAdapterTest {
request.setContent(content);
Map<String, Object> extra = new HashMap<>();
extra.put("account_id", "wx-123");
extra.put("record_id", "1001");
extra.put("tenant_code", "rescue");
request.setExtra(extra);
SendMessageResult result = adapter.sendMessage(request);
@@ -121,7 +123,7 @@ class WechatPersonalChannelAdapterTest {
assertThat(recorded.getMethod()).isEqualTo("POST");
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/text");
String body = recorded.getBody().readUtf8();
assertThat(body).contains("wx-123", "chat-123", "hello");
assertThat(body).contains("wx-123", "chat-123", "hello", "1001", "rescue");
}
@Test
@@ -130,7 +132,7 @@ class WechatPersonalChannelAdapterTest {
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(toJson(Map.of(
"success", true,
"accepted", true,
"message_id", "img-123"))));
SendMessageRequest request = new SendMessageRequest();
@@ -141,6 +143,8 @@ class WechatPersonalChannelAdapterTest {
request.setContent(content);
Map<String, Object> extra = new HashMap<>();
extra.put("account_id", "wx-123");
extra.put("record_id", "1002");
extra.put("tenant_code", "rescue");
request.setExtra(extra);
SendMessageResult result = adapter.sendMessage(request);
@@ -150,6 +154,8 @@ class WechatPersonalChannelAdapterTest {
RecordedRequest recorded = mockWebServer.takeRequest();
assertThat(recorded.getPath()).isEqualTo("/v1/wechat-personal/send/image");
String body = recorded.getBody().readUtf8();
assertThat(body).contains("wx-123", "1002", "rescue", "https://example.com/img.png");
}
@Test

View File

@@ -23,6 +23,10 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>

View File

@@ -0,0 +1,163 @@
package com.sino.mci.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
public class WeComBotSender {
private static final String DEFAULT_WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s";
private static final int CONNECT_TIMEOUT_MS = 5000;
private static final int READ_TIMEOUT_MS = 10000;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public WeComBotSender() {
this.restTemplate = createRestTemplate();
this.objectMapper = new ObjectMapper();
}
public WeComBotSender(RestTemplate restTemplate, ObjectMapper objectMapper) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
public SendMessageResult send(SendMessageRequest request) {
SendMessageResult result = new SendMessageResult();
String key = getString(request.getExtra(), "account_id");
if (key == null || key.isBlank()) {
result.setSuccess(false);
result.setMessage("企微群机器人缺少 webhook key");
return result;
}
String contentType = request.getContentType() == null ? "text" : request.getContentType().toLowerCase();
Map<String, Object> body = buildBody(contentType, request.getContent());
if (body == null) {
result.setSuccess(false);
result.setMessage("不支持的消息类型: " + contentType);
return result;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
try {
String url = resolveWebhookUrl(request.getExtra(), key);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
Map<String, Object> responseBody = parseResponseBody(response.getBody());
if (responseBody == null) {
result.setSuccess(false);
result.setMessage("企微群机器人返回无法解析的响应");
return result;
}
int errcode = parseInt(responseBody.get("errcode"));
if (errcode != 0) {
result.setSuccess(false);
String errmsg = getString(responseBody, "errmsg");
result.setMessage("企微群机器人发送失败: [" + errcode + "] " + errmsg);
return result;
}
result.setSuccess(true);
} catch (HttpStatusCodeException e) {
log.warn("[WeComBot] 发送失败HTTP 状态: {}", e.getStatusCode(), e);
result.setSuccess(false);
result.setMessage("企微群机器人发送失败HTTP 状态: " + e.getStatusCode());
} catch (Exception e) {
log.warn("[WeComBot] 发送失败", e);
result.setSuccess(false);
result.setMessage("企微群机器人发送失败: " + e.getMessage());
}
return result;
}
private String resolveWebhookUrl(Map<String, Object> extra, String key) {
String customUrl = getString(extra, "webhook_url");
if (customUrl != null && !customUrl.isBlank()) {
if (customUrl.contains("%s")) {
return customUrl.replaceFirst("%s", key);
}
return customUrl;
}
return String.format(DEFAULT_WEBHOOK_URL, key);
}
private Map<String, Object> parseResponseBody(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
return objectMapper.readValue(body, Map.class);
} catch (Exception e) {
log.warn("[WeComBot] 解析响应失败: {}", body, e);
return null;
}
}
private int parseInt(Object value) {
if (value == null) {
return 0;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return 0;
}
}
private RestTemplate createRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
return new RestTemplate(factory);
}
private Map<String, Object> buildBody(String contentType, Map<String, Object> content) {
Map<String, Object> body = new HashMap<>();
switch (contentType) {
case "text" -> {
body.put("msgtype", "text");
Map<String, Object> text = new HashMap<>();
text.put("content", getString(content, "text"));
body.put("text", text);
}
case "markdown" -> {
body.put("msgtype", "markdown");
Map<String, Object> markdown = new HashMap<>();
markdown.put("content", getString(content, "text"));
body.put("markdown", markdown);
}
default -> {
return null;
}
}
return body;
}
private String getString(Map<String, Object> map, String key) {
if (map == null) {
return null;
}
Object value = map.get(key);
return value == null ? null : value.toString();
}
}

View File

@@ -5,6 +5,7 @@ import com.sino.mci.channel.common.dto.ChannelInitRequest;
import com.sino.mci.channel.common.dto.ChannelInitResult;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import com.sino.mci.channel.common.model.AccountType;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.common.spi.ChannelAdapter;
import com.sino.mci.channel.wecom.support.WeComServiceFactory;
@@ -35,6 +36,7 @@ public class WeComChannelAdapter implements ChannelAdapter {
private static final Logger log = LoggerFactory.getLogger(WeComChannelAdapter.class);
private final WeComServiceFactory serviceFactory;
private final WeComBotSender weComBotSender;
@Override
public ChannelType getChannelType() {
@@ -84,6 +86,11 @@ public class WeComChannelAdapter implements ChannelAdapter {
return result;
}
Integer accountType = getAccountType(request);
if (accountType != null && accountType == AccountType.WECOM_BOT.getCode()) {
return weComBotSender.send(request);
}
WxCpService service = resolveService(request);
if (service == null) {
result.setSuccess(false);
@@ -277,6 +284,13 @@ public class WeComChannelAdapter implements ChannelAdapter {
return receiverId.replace(',', '|').replaceAll("\\s+", "");
}
private Integer getAccountType(SendMessageRequest request) {
if (request.getExtra() != null && request.getExtra().get("account_type") instanceof Number n) {
return n.intValue();
}
return null;
}
private String getString(Map<?, ?> map, String key) {
if (map == null) {
return null;

View File

@@ -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;
* <p>不发起真实企微 API 调用,仅返回固定样例数据,供开发与测试使用。</p>
*/
@Component
@ConditionalOnProperty(name = "mci.wecom.sync.mock", havingValue = "true")
public class MockWeComSyncClient implements WeComSyncClient {
@Override
public List<WeComContact> syncContacts(WeComSyncConfig config) {
public List<WeComContact> 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<WeComContact> 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<WeComConversation> syncConversations(WeComSyncConfig config) {
public List<WeComConversation> 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()

View File

@@ -0,0 +1,259 @@
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;
/**
* 企业微信通讯录与群聊真实同步客户端。
*
* <p>基于 {@link WxCpService} 调用企微开放接口,拉取企业内部成员、外部联系人及客户群。</p>
*/
@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<WeComContact> syncEnterpriseContacts(WeComSyncConfig config) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过企业通讯录同步");
return Collections.emptyList();
}
List<WeComContact> contacts = new ArrayList<>();
contacts.addAll(syncInternalUsers(service, config));
contacts.addAll(syncExternalContacts(service, config));
log.info("[WeComSync] 同步企业通讯录完成,共 {} 条", contacts.size());
return contacts;
}
@Override
public List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过账号客户同步");
return Collections.emptyList();
}
List<WeComContact> contacts = syncExternalContactsForUser(service, config, userId);
log.info("[WeComSync] 同步账号 {} 的客户完成,共 {} 条", userId, contacts.size());
return contacts;
}
@Override
public List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId) {
WxCpService service = resolveService(config);
if (service == null) {
log.warn("[WeComSync] 无法创建 WxCpService跳过账号群聊同步");
return Collections.emptyList();
}
List<WeComConversation> 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<WeComContact> syncInternalUsers(WxCpService service, WeComSyncConfig config) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<WxCpDepart> departments = service.getDepartmentService().list(null);
if (departments == null || departments.isEmpty()) {
return contacts;
}
for (WxCpDepart dept : departments) {
try {
List<WxCpUser> 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<WeComContact> syncExternalContacts(WxCpService service, WeComSyncConfig config) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<String> 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<WeComContact> syncExternalContactsForUser(WxCpService service, WeComSyncConfig config, String userId) {
List<WeComContact> contacts = new ArrayList<>();
try {
List<String> 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<WeComConversationMember> members = new ArrayList<>();
if (groupChat.getMemberList() != null) {
for (WxCpUserExternalGroupChatInfo.GroupMember member : groupChat.getMemberList()) {
members.add(WeComConversationMember.builder()
.userId(member.getUserId())
.name(resolveMemberName(service, member))
.type(member.getType())
.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;
}
}

View File

@@ -13,4 +13,5 @@ public class WeComConversationMember {
private String userId;
private String name;
private Integer type;
}

View File

@@ -12,18 +12,28 @@ import java.util.List;
public interface WeComSyncClient {
/**
* 同步联系人(企业内部成员 + 外部联系人)。
* 同步企业通讯录(部门 + 内部成员)。
*
* @param config 企微配置
* @return 联系人列表
*/
List<WeComContact> syncContacts(WeComSyncConfig config);
List<WeComContact> syncEnterpriseContacts(WeComSyncConfig config);
/**
* 同步群聊(客户群)。
* 同步某个成员的客户(外部联系人)。
*
* @param config 企微配置
* @param userId 成员 userId
* @return 联系人列表
*/
List<WeComContact> syncAccountContacts(WeComSyncConfig config, String userId);
/**
* 同步某个成员的客户群。
*
* @param config 企微配置
* @param userId 成员 userId
* @return 群聊列表
*/
List<WeComConversation> syncConversations(WeComSyncConfig config);
List<WeComConversation> syncAccountConversations(WeComSyncConfig config, String userId);
}

View File

@@ -0,0 +1,141 @@
package com.sino.mci.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
class WeComBotSenderTest {
private RestTemplate restTemplate;
private MockRestServiceServer server;
private WeComBotSender sender;
@BeforeEach
void setUp() {
restTemplate = new RestTemplate();
server = MockRestServiceServer.createServer(restTemplate);
sender = new WeComBotSender(restTemplate, new ObjectMapper());
}
@Test
void sendRejectsMissingKey() {
SendMessageRequest request = new SendMessageRequest();
request.setExtra(null);
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("webhook key"));
}
@Test
void sendBuildsTextBody() throws Exception {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertTrue(result.isSuccess(), result.getMessage());
server.verify();
}
@Test
void sendRejectsUnsupportedContentType() {
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("image");
request.setContent(Map.of("media_id", "xxx"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("不支持的消息类型"));
}
@Test
void sendReportsWeComErrorResponse() {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andRespond(withSuccess("{\"errcode\":40008,\"errmsg\":\"invalid message type\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("40008"));
assertTrue(result.getMessage().contains("invalid message type"));
}
@Test
void sendReportsHttpErrorStatus() {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andRespond(withStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("HTTP 状态"));
}
@Test
void sendUsesCustomWebhookUrl() throws Exception {
server.expect(requestTo("https://custom.example.com/webhook/send?key=dummy"))
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy", "webhook_url", "https://custom.example.com/webhook/send?key=%s"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertTrue(result.isSuccess(), result.getMessage());
server.verify();
}
@Test
void sendUsesCustomWebhookUrlWithoutPlaceholder() throws Exception {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REALKEY"))
.andExpect(content().json("{\"msgtype\":\"text\",\"text\":{\"content\":\"hello\"}}"))
.andRespond(withSuccess("{\"errcode\":0,\"errmsg\":\"ok\"}", MediaType.APPLICATION_JSON));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy", "webhook_url", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REALKEY"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertTrue(result.isSuccess(), result.getMessage());
server.verify();
}
@Test
void sendFailsOnUnparseableResponseBody() {
server.expect(requestTo("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=dummy"))
.andRespond(withSuccess("not-json", MediaType.TEXT_PLAIN));
SendMessageRequest request = new SendMessageRequest();
request.setExtra(Map.of("account_id", "dummy"));
request.setContentType("text");
request.setContent(Map.of("text", "hello"));
SendMessageResult result = sender.send(request);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("无法解析"));
}
}

View File

@@ -59,7 +59,7 @@ class WeComChannelAdapterTest {
WeComServiceFactory factory = mock(WeComServiceFactory.class);
when(factory.getOrCreateService(any(Map.class))).thenReturn(service);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
SendMessageResult result = adapter.sendMessage(textMessageRequest());
assertThat(result.isSuccess()).isTrue();
@@ -82,7 +82,7 @@ class WeComChannelAdapterTest {
WeComServiceFactory factory = mock(WeComServiceFactory.class);
when(factory.getOrCreateService(any(Map.class))).thenReturn(service);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
ChannelInitRequest request = new ChannelInitRequest();
request.setConfig(validConfig());
@@ -98,7 +98,7 @@ class WeComChannelAdapterTest {
WeComServiceFactory factory = mock(WeComServiceFactory.class);
when(factory.getOrCreateService(any(Map.class))).thenReturn(null);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
ChannelInitRequest request = new ChannelInitRequest();
request.setConfig(new HashMap<>());
@@ -120,7 +120,7 @@ class WeComChannelAdapterTest {
WeComServiceFactory factory = mock(WeComServiceFactory.class);
when(factory.getOrCreateService(any(Map.class))).thenReturn(service);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory);
WeComChannelAdapter adapter = new WeComChannelAdapter(factory, new WeComBotSender());
SendMessageRequest request = textMessageRequest();
request.setConversationId("chat123");

View File

@@ -70,6 +70,11 @@
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>

View File

@@ -18,7 +18,8 @@ public class AuthInterceptor implements HandlerInterceptor {
|| ("POST".equals(method) && isExactPath(path, "/api/v1/account/tenant"))
|| ("POST".equals(method) && isExactPath(path, "/api/v1/account/user"))
|| isExactPath(path, "/api/v1/health")
|| ("POST".equals(method) && isExactPath(path, "/api/v1/channel-account/heartbeat"))) {
|| ("POST".equals(method) && isExactPath(path, "/api/v1/channel-account/heartbeat"))
|| ("POST".equals(method) && isExactPath(path, "/api/v1/channel-account/event"))) {
return true;
}

View File

@@ -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<SyncResult> syncContacts(
public ApiResponse<SyncTask> 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<SyncResult> syncConversations(
public ApiResponse<SyncTask> syncConversations(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("account_id") Long accountId) {
return ApiResponse.ok(accountService.syncConversations(accountId));
return ApiResponse.ok(accountService.submitConversationsSyncTask(accountId));
}
}

View File

@@ -0,0 +1,25 @@
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);
}
}

View File

@@ -0,0 +1,82 @@
package com.sino.mci.channel.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.ChannelIdentityResponse;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
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.web.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/channel-identity")
@RequiredArgsConstructor
public class ChannelIdentityController {
private final ChannelIdentityMapper channelIdentityMapper;
private final PersonMapper personMapper;
private final ChannelSubjectMapper channelSubjectMapper;
@GetMapping("/list")
public ApiResponse<List<ChannelIdentityResponse>> listChannelIdentities() {
String tenantCode = AuthContext.currentTenantCode();
List<ChannelIdentity> identities = channelIdentityMapper.selectList(
new LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.orderByDesc(ChannelIdentity::getId));
if (identities.isEmpty()) {
return ApiResponse.ok(Collections.emptyList());
}
Set<Long> personIds = identities.stream()
.map(ChannelIdentity::getPersonId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Set<Long> subjectIds = identities.stream()
.map(ChannelIdentity::getSubjectId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, Person> personMap = personIds.isEmpty() ? Collections.emptyMap()
: personMapper.selectBatchIds(personIds).stream()
.collect(Collectors.toMap(Person::getId, p -> p));
Map<Long, ChannelSubject> subjectMap = subjectIds.isEmpty() ? Collections.emptyMap()
: channelSubjectMapper.selectBatchIds(subjectIds).stream()
.collect(Collectors.toMap(ChannelSubject::getId, s -> s));
List<ChannelIdentityResponse> responses = identities.stream().map(identity -> {
ChannelIdentityResponse response = new ChannelIdentityResponse();
response.setId(identity.getId());
response.setTenantCode(identity.getTenantCode());
response.setChannelType(identity.getChannelType());
response.setChannelUserId(identity.getChannelUserId());
response.setChannelUserName(identity.getChannelUserName());
response.setSubjectId(identity.getSubjectId());
ChannelSubject subject = identity.getSubjectId() == null ? null : subjectMap.get(identity.getSubjectId());
response.setSubjectName(subject == null ? null : subject.getSubjectName());
response.setPersonId(identity.getPersonId());
Person person = identity.getPersonId() == null ? null : personMap.get(identity.getPersonId());
response.setPersonName(person == null ? null : person.getPersonName());
response.setPersonType(person == null ? null : person.getPersonType());
response.setStatus(identity.getStatus());
return response;
}).collect(Collectors.toList());
return ApiResponse.ok(responses);
}
}

View File

@@ -54,4 +54,11 @@ public class ChannelSubjectController {
public ApiResponse<List<ChannelSubject>> listSubjects() {
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));
}
@PostMapping("/{id}/sync-contacts")
public ApiResponse<com.sino.mci.channel.entity.SyncTask> syncEnterpriseContacts(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(subjectService.submitEnterpriseContactsSyncTask(id, tenantCode));
}
}

View File

@@ -3,7 +3,9 @@ package com.sino.mci.channel.controller;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.BindConversationAccountRequest;
import com.sino.mci.channel.dto.BindConversationTagsRequest;
import com.sino.mci.channel.dto.ConversationParticipantResponse;
import com.sino.mci.channel.dto.CreateConversationRequest;
import com.sino.mci.channel.dto.TagResponse;
import com.sino.mci.channel.dto.UpdateConversationRequest;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.service.ConversationService;
@@ -73,4 +75,18 @@ public class ConversationController {
conversationService.bindTags(tenantCode, conversationId, request);
return ApiResponse.ok();
}
@GetMapping("/{conversation_id}/participants")
public ApiResponse<List<ConversationParticipantResponse>> listParticipants(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("conversation_id") Long conversationId) {
return ApiResponse.ok(conversationService.listParticipants(tenantCode, conversationId));
}
@GetMapping("/{conversation_id}/tags")
public ApiResponse<List<TagResponse>> listTags(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("conversation_id") Long conversationId) {
return ApiResponse.ok(conversationService.listTags(tenantCode, conversationId));
}
}

View File

@@ -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<SyncTask> getTask(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(syncTaskService.getTask(id, tenantCode));
}
}

View File

@@ -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,

View File

@@ -0,0 +1,34 @@
package com.sino.mci.channel.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Map;
@Data
public class ChannelAccountEventRequest {
@NotBlank
@JsonProperty("event_id")
private String eventId;
@NotBlank
@JsonProperty("event_type")
private String eventType;
@NotBlank
@JsonProperty("account_id")
private String accountId;
@NotBlank
@JsonProperty("tenant_code")
private String tenantCode;
@JsonProperty("timestamp")
private String timestamp;
@NotNull
private Map<String, Object> data;
}

View File

@@ -0,0 +1,19 @@
package com.sino.mci.channel.dto;
import lombok.Data;
@Data
public class ChannelIdentityResponse {
private Long id;
private String tenantCode;
private String channelType;
private String channelUserId;
private String channelUserName;
private Long subjectId;
private String subjectName;
private Long personId;
private String personName;
private Integer personType;
private Integer status;
}

View File

@@ -0,0 +1,19 @@
package com.sino.mci.channel.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ConversationParticipantResponse {
private Long id;
private Integer participantType;
private LocalDateTime joinTime;
private Long channelIdentityId;
private String channelUserId;
private String channelUserName;
private Long personId;
private String personName;
private Integer personType;
}

View File

@@ -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;

View File

@@ -21,6 +21,7 @@ public class CreateConversationRequest {
private Long ownerAccountId;
private Long subjectId;
private String conversationName;
private Integer conversationScene;
private Integer createSource;
private Map<String, Object> receiveConfig;
}

View File

@@ -0,0 +1,16 @@
package com.sino.mci.channel.dto;
import lombok.Data;
@Data
public class TagResponse {
private Long id;
private String tenantCode;
private Long parentId;
private String tagPath;
private Integer level;
private String tagName;
private String tagSource;
private Integer status;
}

View File

@@ -13,6 +13,7 @@ public class UpdateConversationRequest {
private Long ownerAccountId;
private Long subjectId;
private Integer conversationScene;
private Integer status;
private Map<String, Object> receiveConfig;
}

View File

@@ -0,0 +1,22 @@
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;
}

View File

@@ -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;

View File

@@ -30,5 +30,6 @@ public class Conversation extends BaseEntity {
private Long contractId;
private String channelCode;
private String receiveConfig;
private Integer conversationScene;
private Integer status;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
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<ChannelEventRecord> {
}

View File

@@ -2,8 +2,12 @@ package com.sino.mci.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sino.mci.channel.entity.ConversationParticipant;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ConversationParticipantMapper extends BaseMapper<ConversationParticipant> {
@Delete("DELETE FROM mci_conversation_participant WHERE conversation_id = #{conversationId}")
void physicalDeleteByConversationId(Long conversationId);
}

View File

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

View File

@@ -16,6 +16,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
@Slf4j
@Service
@@ -39,7 +40,14 @@ public class AccountSelector {
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
}
public List<Long> selectSendAccounts(String tenantCode, Long conversationId, ChannelType channelType, String preferredStrategy) {
public List<Long> selectSendAccounts(String tenantCode, Long conversationId,
ChannelType channelType, String preferredStrategy) {
return selectSendAccounts(tenantCode, conversationId, channelType, null, preferredStrategy);
}
public List<Long> selectSendAccounts(String tenantCode, Long conversationId,
ChannelType channelType, Set<Integer> accountTypes,
String preferredStrategy) {
if (conversationId == null || channelType == null) {
return Collections.emptyList();
}
@@ -64,9 +72,13 @@ public class AccountSelector {
List<Long> healthyAccountIds = new ArrayList<>();
for (ChannelAccountConversation binding : bindings) {
ChannelAccount account = accountMapper.selectById(binding.getAccountId());
if (isHealthy(account, tenantCode, channelType)) {
healthyAccountIds.add(account.getId());
if (!isHealthy(account, tenantCode, channelType)) {
continue;
}
if (accountTypes != null && !accountTypes.contains(account.getAccountType())) {
continue;
}
healthyAccountIds.add(account.getId());
}
return healthyAccountIds;
}

View File

@@ -0,0 +1,217 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.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 static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
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<ChannelAccount>()
.eq(ChannelAccount::getTenantCode, tenantCode)
.eq(ChannelAccount::getAccountId, accountId));
if (account == null) {
throw new BusinessException("渠道账号不存在: " + accountId);
}
return account;
}
private void handleHeartbeat(ChannelAccount account, Map<String, Object> 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<String, Object> 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<String, Object> data) {
account.setOnlineStatus(parseOnlineStatus(getString(data, "current")));
accountMapper.updateById(account);
}
private void handleSendResult(Map<String, Object> 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<String, Object> map, String key) {
Object value = map.get(key);
return value == null ? null : value.toString();
}
private Integer getInteger(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof Number n) {
return n.intValue();
}
return null;
}
private Long getLong(Map<String, Object> 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<String, Object> 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 OBJECT_MAPPER.writeValueAsString(value);
} catch (Exception e) {
return value.toString();
}
}
}

View File

@@ -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<ChannelAccount> 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<WeComContact> 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<WeComConversation> 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<ChannelIdentity>()
.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<Conversation>()
.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<ConversationParticipant>()
.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<ChannelIdentity>()
.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<ChannelAccountConversation>()
.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;
}
}

View File

@@ -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<ChannelSubject> 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;
}
}

View File

@@ -3,17 +3,26 @@ package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.BindConversationAccountRequest;
import com.sino.mci.channel.dto.BindConversationTagsRequest;
import com.sino.mci.channel.dto.ConversationParticipantResponse;
import com.sino.mci.channel.dto.CreateConversationRequest;
import com.sino.mci.channel.dto.TagResponse;
import com.sino.mci.channel.dto.UpdateConversationRequest;
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.common.model.AccountType;
import com.sino.mci.channel.entity.ConversationParticipant;
import com.sino.mci.channel.entity.ConversationTagBinding;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationParticipantMapper;
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.entity.Tag;
import com.sino.mci.crm.repository.ChannelIdentityMapper;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.shared.util.JsonUtils;
@@ -22,7 +31,12 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@@ -34,6 +48,9 @@ public class ConversationService {
private final ConversationTagBindingMapper conversationTagBindingMapper;
private final TagMapper tagMapper;
private final ChannelAccountMapper accountMapper;
private final ConversationParticipantMapper participantMapper;
private final ChannelIdentityMapper channelIdentityMapper;
private final PersonMapper personMapper;
public List<Conversation> listConversations(String tenantCode) {
return conversationMapper.selectList(
@@ -62,6 +79,7 @@ public class ConversationService {
conversation.setOwnerAccountId(request.getOwnerAccountId());
conversation.setSubjectId(request.getSubjectId());
conversation.setConversationName(request.getConversationName());
conversation.setConversationScene(request.getConversationScene());
conversation.setCreateSource(request.getCreateSource() != null ? request.getCreateSource() : 2);
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
conversation.setStatus(1);
@@ -93,6 +111,9 @@ public class ConversationService {
if (request.getReceiveConfig() != null) {
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
}
if (request.getConversationScene() != null) {
conversation.setConversationScene(request.getConversationScene());
}
conversationMapper.updateById(conversation);
return conversation;
}
@@ -111,7 +132,7 @@ public class ConversationService {
if (account == null || !tenantCode.equals(account.getTenantCode())) {
throw new BusinessException("账号不存在或不在当前租户");
}
if (!conversation.getChannelType().equalsIgnoreCase(account.getChannelType())) {
if (!isAccountCompatibleWithConversation(conversation, account)) {
throw new BusinessException("账号渠道类型与会话不一致");
}
@@ -128,6 +149,28 @@ public class ConversationService {
binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
binding.setStatus(1);
bindingMapper.insert(binding);
// 主发送账号作为会话负责人
if (request.getBindingType() != null && request.getBindingType() == 1) {
conversation.setOwnerAccountId(request.getAccountId());
conversationMapper.updateById(conversation);
}
}
private boolean isAccountCompatibleWithConversation(Conversation conversation, ChannelAccount account) {
// 单聊不强制按场景绑定,保持与 SendService 路由逻辑一致
if (conversation.getConversationType() != null && conversation.getConversationType() == 1) {
return conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
}
Integer scene = conversation.getConversationScene();
if (scene != null) {
return switch (scene) {
case 1 -> AccountType.WECOM_BOT.getCode() == account.getAccountType();
case 2 -> AccountType.WECHAT_PERSONAL.getCode() == account.getAccountType();
default -> conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
};
}
return conversation.getChannelType().equalsIgnoreCase(account.getChannelType());
}
@Transactional
@@ -160,4 +203,95 @@ public class ConversationService {
}
}
}
public List<ConversationParticipantResponse> listParticipants(String tenantCode, Long conversationId) {
getConversation(tenantCode, conversationId);
List<ConversationParticipant> participants = participantMapper.selectList(
new LambdaQueryWrapper<ConversationParticipant>()
.eq(ConversationParticipant::getConversationId, conversationId)
.orderByAsc(ConversationParticipant::getId));
if (participants.isEmpty()) {
return Collections.emptyList();
}
Set<Long> channelIdentityIds = participants.stream()
.map(ConversationParticipant::getChannelIdentityId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Set<Long> personIds = participants.stream()
.map(ConversationParticipant::getPersonId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, ChannelIdentity> identityMap = channelIdentityIds.isEmpty() ? Collections.emptyMap()
: channelIdentityMapper.selectBatchIds(channelIdentityIds).stream()
.collect(Collectors.toMap(ChannelIdentity::getId, Function.identity()));
Map<Long, Person> personMap = personIds.isEmpty() ? Collections.emptyMap()
: personMapper.selectBatchIds(personIds).stream()
.collect(Collectors.toMap(Person::getId, Function.identity()));
return participants.stream().map(participant -> {
ConversationParticipantResponse response = new ConversationParticipantResponse();
response.setId(participant.getId());
response.setParticipantType(participant.getParticipantType());
response.setJoinTime(participant.getJoinTime());
response.setChannelIdentityId(participant.getChannelIdentityId());
ChannelIdentity identity = participant.getChannelIdentityId() == null
? null : identityMap.get(participant.getChannelIdentityId());
response.setChannelUserId(identity == null ? null : identity.getChannelUserId());
response.setChannelUserName(identity == null ? null : identity.getChannelUserName());
Person person = participant.getPersonId() == null
? null : personMap.get(participant.getPersonId());
response.setPersonId(participant.getPersonId());
response.setPersonName(person == null ? null : person.getPersonName());
response.setPersonType(person == null ? null : person.getPersonType());
return response;
}).collect(Collectors.toList());
}
public List<TagResponse> listTags(String tenantCode, Long conversationId) {
getConversation(tenantCode, conversationId);
List<ConversationTagBinding> bindings = conversationTagBindingMapper.selectList(
new LambdaQueryWrapper<ConversationTagBinding>()
.eq(ConversationTagBinding::getConversationId, conversationId)
.orderByAsc(ConversationTagBinding::getId));
if (bindings.isEmpty()) {
return Collections.emptyList();
}
Set<Long> tagIds = bindings.stream()
.map(ConversationTagBinding::getTagId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
.collect(Collectors.toMap(Tag::getId, Function.identity()));
return bindings.stream()
.map(binding -> {
Tag tag = tagMap.get(binding.getTagId());
if (tag == null) {
return null;
}
TagResponse response = new TagResponse();
response.setId(tag.getId());
response.setTenantCode(tag.getTenantCode());
response.setParentId(tag.getParentId());
response.setTagPath(tag.getTagPath());
response.setLevel(tag.getLevel());
response.setTagName(tag.getTagName());
response.setTagSource(tag.getTagSource());
response.setStatus(tag.getStatus());
return response;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,240 @@
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 CONVERSATION_SCENE_EXTERNAL = 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<ChannelIdentity>()
.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<Conversation>()
.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();
Integer originalConversationScene = conversation.getConversationScene();
conversation.setConversationName(conv.getChatName());
conversation.setOwnerAccountId(account.getId());
conversation.setSubjectId(subjectId);
conversation.setInstitutionId(originalInstitutionId);
conversation.setSupplierId(originalSupplierId);
conversation.setContractId(originalContractId);
conversation.setChannelCode(originalChannelCode);
conversation.setConversationScene(originalConversationScene);
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.setConversationScene(CONVERSATION_SCENE_EXTERNAL);
newConversation.setCreateSource(1);
newConversation.setStatus(1);
conversationMapper.insert(newConversation);
return newConversation;
}
@Transactional
public void refreshParticipants(String tenantCode, Long subjectId, Conversation conversation, WeComConversation conv) {
conversationParticipantMapper.physicalDeleteByConversationId(conversation.getId());
for (WeComConversationMember member : conv.getMemberList()) {
log.debug("[SyncDataPersister] 群聊成员 userId={}, type={}", member.getUserId(), member.getType());
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<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getChannelType, CHANNEL_WECOM)
.eq(ChannelIdentity::getChannelUserId, member.getUserId()));
int personType = resolveMemberPersonType(member.getType());
if (identity != null) {
Person existPerson = personMapper.selectById(identity.getPersonId());
if (existPerson != null && !existPerson.getPersonType().equals(personType)) {
existPerson.setPersonType(personType);
personMapper.updateById(existPerson);
}
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(personType);
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 int resolveMemberPersonType(Integer memberType) {
// 企微群聊成员类型1=企业成员2=外部联系人
return memberType != null && memberType == 1 ? PERSON_TYPE_INTERNAL : PERSON_TYPE_CUSTOMER;
}
private void ensureBinding(Long accountId, Long conversationId, int bindingType) {
ChannelAccountConversation existing = accountConversationMapper.selectOne(
new LambdaQueryWrapper<ChannelAccountConversation>()
.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;
}
}

View File

@@ -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<WeComContact> 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<WeComContact> 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<WeComConversation> 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());
}
}
}

View File

@@ -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<SyncTask> listRecentTasks(String tenantCode, String taskType, Long targetId, int limit) {
return syncTaskMapper.selectList(
new LambdaQueryWrapper<SyncTask>()
.eq(SyncTask::getTenantCode, tenantCode)
.eq(SyncTask::getTaskType, taskType)
.eq(targetId != null, SyncTask::getTargetId, targetId)
.orderByDesc(SyncTask::getId)
.last("LIMIT " + limit));
}
}

View File

@@ -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;
}
}

View File

@@ -1,7 +1,10 @@
package com.sino.mci.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.config.GlobalConfig;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.annotation.MapperScan;
@@ -14,10 +17,18 @@ import javax.sql.DataSource;
@MapperScan({"com.sino.mci.admin.repository", "com.sino.mci.channel.repository", "com.sino.mci.crm.repository", "com.sino.mci.flow.repository", "com.sino.mci.inbound.repository", "com.sino.mci.message.repository", "com.sino.mci.policy.repository", "com.sino.mci.send.repository"})
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource, MetaObjectHandler metaObjectHandler) throws Exception {
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setPlugins(mybatisPlusInterceptor());
GlobalConfig globalConfig = new GlobalConfig();
globalConfig.setMetaObjectHandler(metaObjectHandler);

View File

@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import com.sino.mci.channel.common.model.AccountType;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.common.spi.ChannelAdapter;
import com.sino.mci.channel.entity.ChannelAccount;
@@ -79,6 +80,14 @@ public class SendService {
private static final int SEND_STATUS_SUCCESS = 2;
private static final int SEND_STATUS_FAILED = 3;
private static final int CONVERSATION_TYPE_SINGLE = 1;
private static final int CONVERSATION_TYPE_GROUP = 2;
private static final int CONVERSATION_SCENE_INTERNAL = 1;
private static final int CONVERSATION_SCENE_EXTERNAL = 2;
private static final Set<Integer> ACCOUNT_TYPES_BOT = Set.of(AccountType.WECOM_BOT.getCode());
private static final Set<Integer> ACCOUNT_TYPES_PYWECHAT = Set.of(AccountType.WECHAT_PERSONAL.getCode());
private static final Set<Integer> ACCOUNT_TYPES_WECOM_AGENT = Set.of(AccountType.WECOM_AGENT.getCode());
private final SendRecordMapper sendRecordMapper;
private final ConversationMapper conversationMapper;
private final ChannelAccountMapper accountMapper;
@@ -322,15 +331,46 @@ public class SendService {
}
}
private ChannelType resolveChannelTypeForScene(Integer scene) {
if (scene == null) {
throw new BusinessException("会话未设置场景,无法自动路由");
}
return scene == CONVERSATION_SCENE_INTERNAL ? ChannelType.WECOM : ChannelType.WECHAT_PERSONAL;
}
private Set<Integer> resolveAccountTypesForScene(Integer scene) {
if (scene == null) {
throw new BusinessException("会话未设置场景,无法自动路由");
}
return scene == CONVERSATION_SCENE_INTERNAL ? ACCOUNT_TYPES_BOT : ACCOUNT_TYPES_PYWECHAT;
}
private List<SendTarget> resolveConversationTarget(String tenantCode, Long conversationId,
ChannelType channelType, Map<String, Object> channelStrategy) {
ChannelType ignoredChannelType,
Map<String, Object> channelStrategy) {
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) {
throw new BusinessException("会话不存在: " + conversationId);
}
Long accountId = selectSendAccount(tenantCode, conversationId, channelType, channelStrategy);
ChannelType channelType;
Set<Integer> accountTypes;
// 单聊未回填场景时保持旧行为,按 channelStrategy 默认 WECOM 路由;群聊必须按场景路由
if (conversation.getConversationType() != null && conversation.getConversationType() == CONVERSATION_TYPE_SINGLE) {
channelType = parseChannelType(extractChannelType(channelStrategy));
accountTypes = null;
} else {
channelType = resolveChannelTypeForScene(conversation.getConversationScene());
accountTypes = resolveAccountTypesForScene(conversation.getConversationScene());
}
Long accountId = selectSendAccount(tenantCode, conversationId, channelType, accountTypes, channelStrategy);
if (accountId == null) {
accountId = conversation.getOwnerAccountId();
ChannelAccount ownerAccount = accountMapper.selectById(accountId);
if (ownerAccount == null || (accountTypes != null && !accountTypes.contains(ownerAccount.getAccountType()))) {
accountId = null;
}
}
if (accountId == null) {
throw new BusinessException("会话未配置发送账号: " + conversationId);
@@ -349,17 +389,22 @@ public class SendService {
return Collections.singletonList(target);
}
private List<SendTarget> resolvePersonTarget(String tenantCode, Long personId, ChannelType channelType) {
private List<SendTarget> resolvePersonTarget(String tenantCode, Long personId, ChannelType ignoredChannelType) {
Person person = personMapper.selectById(personId);
if (person == null || !tenantCode.equals(person.getTenantCode())) {
throw new BusinessException("人员不存在: " + personId);
}
ChannelIdentity identity = channelIdentityMapper.selectOne(
new LambdaQueryWrapper<ChannelIdentity>()
.eq(ChannelIdentity::getTenantCode, tenantCode)
.eq(ChannelIdentity::getPersonId, personId)
.eq(ChannelIdentity::getChannelType, channelType.name().toLowerCase())
.eq(ChannelIdentity::getChannelType, ChannelType.WECOM.name().toLowerCase())
.eq(ChannelIdentity::getStatus, STATUS_ENABLED));
if (identity == null) {
throw new BusinessException("人员未配置渠道身份: person_id=" + personId + ", channel=" + channelType.name().toLowerCase());
throw new BusinessException("人员未配置渠道身份: person_id=" + personId);
}
Long accountId = selectDefaultAccount(tenantCode, channelType);
ChannelType channelType = ChannelType.WECOM;
Long accountId = selectDefaultAccount(tenantCode, channelType, ACCOUNT_TYPES_WECOM_AGENT);
if (accountId == null) {
throw new BusinessException("租户未配置可用发送账号: channel=" + channelType.name().toLowerCase());
}
@@ -687,18 +732,33 @@ public class SendService {
private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType,
Map<String, Object> channelStrategy) {
return selectSendAccount(tenantCode, conversationId, channelType, null, channelStrategy);
}
private Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType,
Set<Integer> accountTypes, Map<String, Object> channelStrategy) {
List<Long> accounts = accountSelector.selectSendAccounts(tenantCode, conversationId, channelType,
extractStrategy(channelStrategy));
accountTypes, extractStrategy(channelStrategy));
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
}
private Long selectDefaultAccount(String tenantCode, ChannelType channelType) {
private Long selectDefaultAccount(String tenantCode, ChannelType channelType, Set<Integer> accountTypes) {
List<ChannelAccount> accounts = accountMapper.selectList(
new LambdaQueryWrapper<ChannelAccount>()
.eq(ChannelAccount::getTenantCode, tenantCode)
.eq(ChannelAccount::getChannelType, channelType.name().toLowerCase())
.eq(ChannelAccount::getStatus, STATUS_ENABLED));
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0).getId();
if (CollectionUtils.isEmpty(accounts)) {
return null;
}
if (accountTypes != null) {
return accounts.stream()
.filter(a -> accountTypes.contains(a.getAccountType()))
.findFirst()
.map(ChannelAccount::getId)
.orElse(null);
}
return accounts.get(0).getId();
}
private String extractChannelType(Map<String, Object> channelStrategy) {
@@ -731,6 +791,24 @@ public class SendService {
}
}
@SuppressWarnings("unchecked")
private String extractWeComBotWebhookUrl(String extInfo) {
if (!StringUtils.hasText(extInfo)) {
return null;
}
if (extInfo.trim().startsWith("{")) {
try {
Map<String, Object> parsed = JsonUtils.fromJson(extInfo, Map.class);
if (parsed != null && parsed.get("webhook_url") != null) {
return String.valueOf(parsed.get("webhook_url"));
}
} catch (Exception e) {
// fallback: treat invalid JSON ext_info as plain URL string
}
}
return extInfo;
}
private RetryConfig loadRetryConfig(String tenantCode, SendRequest request) {
String channelTypeStr = extractChannelType(request.getChannelStrategy());
PolicyGroup policyGroup = loadPolicyGroup(tenantCode, channelTypeStr);
@@ -800,6 +878,22 @@ public class SendService {
public void dispatchRecord(SendRecord record) {
ChannelType channelType = parseChannelType(record.getChannelType());
Set<Integer> accountTypes;
if (record.getConversationId() != null) {
Conversation conversation = conversationMapper.selectById(record.getConversationId());
Integer scene = conversation != null ? conversation.getConversationScene() : null;
if (scene != null) {
channelType = resolveChannelTypeForScene(scene);
accountTypes = resolveAccountTypesForScene(scene);
} else {
accountTypes = null;
}
} else if (record.getPersonId() != null) {
accountTypes = ACCOUNT_TYPES_WECOM_AGENT;
} else {
accountTypes = null;
}
ChannelAdapter adapter = adapterRegistry.getAdapter(channelType);
if (adapter == null) {
record.setSendStatus(SEND_STATUS_FAILED);
@@ -822,7 +916,7 @@ public class SendService {
return;
}
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType);
List<Long> candidateAccountIds = selectSendAccountCandidates(record, channelType, accountTypes);
if (candidateAccountIds.isEmpty()) {
record.setSendStatus(SEND_STATUS_FAILED);
record.setChannelResult(JsonUtils.toJson(Collections.singletonMap("error", "no_healthy_account")));
@@ -865,12 +959,22 @@ public class SendService {
sendRecordMapper.updateById(record);
}
private List<Long> selectSendAccountCandidates(SendRecord record, ChannelType channelType) {
private List<Long> selectSendAccountCandidates(SendRecord record, ChannelType channelType, Set<Integer> accountTypes) {
if (record.getConversationId() == null) {
return record.getAccountId() != null ? Collections.singletonList(record.getAccountId()) : Collections.emptyList();
if (record.getAccountId() == null) {
return Collections.emptyList();
}
ChannelAccount account = accountMapper.selectById(record.getAccountId());
if (account == null || !AccountSelector.isHealthy(account, record.getTenantCode(), channelType)) {
return Collections.emptyList();
}
if (accountTypes != null && !accountTypes.contains(account.getAccountType())) {
return Collections.emptyList();
}
return Collections.singletonList(record.getAccountId());
}
String preferredStrategy = extractStrategy(parseChannelStrategy(record.getChannelStrategy()));
return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, preferredStrategy);
return accountSelector.selectSendAccounts(record.getTenantCode(), record.getConversationId(), channelType, accountTypes, preferredStrategy);
}
private Map<String, Object> parseChannelStrategy(String strategyJson) {
@@ -896,9 +1000,18 @@ public class SendService {
messageRequest.setContent(JsonUtils.fromJson(record.getContentBody(), Map.class));
Map<String, Object> extra = new HashMap<>();
if (account != null && StringUtils.hasText(account.getAccountId())) {
if (account != null) {
extra.put("account_type", account.getAccountType());
if (StringUtils.hasText(account.getAccountId())) {
extra.put("account_id", account.getAccountId());
}
if (account.getAccountType() != null && account.getAccountType() == AccountType.WECOM_BOT.getCode()
&& StringUtils.hasText(account.getExtInfo())) {
extra.put("webhook_url", extractWeComBotWebhookUrl(account.getExtInfo()));
}
}
extra.put("record_id", record.getId());
extra.put("tenant_code", record.getTenantCode());
messageRequest.setExtra(extra);
try {
@@ -914,7 +1027,8 @@ public class SendService {
}
private void markAccountUnhealthy(ChannelAccount account) {
if (account == null || account.getAccountType() == null || account.getAccountType() != 3) {
if (account == null || account.getAccountType() == null
|| account.getAccountType() != AccountType.WECHAT_PERSONAL.getCode()) {
return;
}
account.setRiskLevel(2);

View File

@@ -1,19 +1,19 @@
spring:
datasource:
url: jdbc:mysql://localhost:3306/sino_mci?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:sino_mci}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: ${MYSQL_USER:root}
password: ${MYSQL_ROOT_PASSWORD:root}
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
database: 0
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
host: ${RABBITMQ_HOST:localhost}
port: ${RABBITMQ_AMQP_PORT:5672}
username: ${RABBITMQ_DEFAULT_USER:guest}
password: ${RABBITMQ_DEFAULT_PASS:guest}
ai:
openai:
api-key: ${OPENAI_API_KEY:}

View File

@@ -0,0 +1 @@
ALTER TABLE mci_channel_subject DROP COLUMN corp_secret;

View File

@@ -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='同步任务表';

View File

@@ -0,0 +1,8 @@
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);

View File

@@ -0,0 +1,7 @@
ALTER TABLE mci_conversation
ADD COLUMN conversation_scene TINYINT NULL COMMENT '会话场景1=内部群 2=外部群';
-- 现有数据:群聊默认外部群(客户群)
UPDATE mci_conversation
SET conversation_scene = 2
WHERE conversation_type = 2 AND conversation_scene IS NULL;

View File

@@ -0,0 +1,89 @@
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.webmvc.test.autoconfigure.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 org.springframework.transaction.annotation.Transactional;
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
@Transactional
@Sql(scripts = "/sql/test-channel-account.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class ChannelAccountEventControllerTest {
@Autowired
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new 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("/msg-platform/api/v1/channel-account/event")
.contextPath("/msg-platform")
.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("/msg-platform/api/v1/channel-account/event")
.contextPath("/msg-platform")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
mockMvc.perform(post("/msg-platform/api/v1/channel-account/event")
.contextPath("/msg-platform")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
assertThat(eventRecordMapper.selectCount(null)).isEqualTo(1);
}
}

View File

@@ -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);

View File

@@ -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<Person>()
.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<ChannelIdentity>()
.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<Person>()
.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<ChannelIdentity>()
.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<Conversation>()
.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("测试客户群")

View File

@@ -14,6 +14,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -86,6 +88,20 @@ class AccountSelectorTest {
assertEquals(2L, accountId);
}
@Test
void selectSendAccountsFiltersByAccountType() {
ChannelAccountConversation primary = createBinding(1L, 1, 1);
ChannelAccountConversation backup = createBinding(2L, 2, 1);
when(accountConversationMapper.selectList(any())).thenReturn(Arrays.asList(primary, backup));
when(accountMapper.selectById(1L)).thenReturn(createHealthyAccount(1L, 1));
when(accountMapper.selectById(2L)).thenReturn(createHealthyAccount(2L, 3));
List<Long> accountIds = accountSelector.selectSendAccounts(
"tenant", 100L, ChannelType.WECHAT_PERSONAL, Set.of(1), "primary");
assertEquals(Collections.singletonList(1L), accountIds);
}
private ChannelAccountConversation createBinding(Long accountId, int bindingType, int sortOrder) {
ChannelAccountConversation binding = new ChannelAccountConversation();
binding.setAccountId(accountId);
@@ -96,10 +112,14 @@ class AccountSelectorTest {
}
private ChannelAccount createHealthyAccount(Long id) {
return createHealthyAccount(id, 3);
}
private ChannelAccount createHealthyAccount(Long id, int accountType) {
ChannelAccount account = new ChannelAccount();
account.setId(id);
account.setTenantCode("tenant");
account.setAccountType(3);
account.setAccountType(accountType);
account.setChannelType("wechat_personal");
account.setAccountId("wxid-" + id);
account.setStatus(1);

View File

@@ -0,0 +1,23 @@
package com.sino.mci.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MybatisPlusConfigTest {
@Test
void mybatisPlusInterceptorContainsPaginationInnerInterceptor() {
MybatisPlusConfig config = new MybatisPlusConfig();
MybatisPlusInterceptor interceptor = config.mybatisPlusInterceptor();
assertNotNull(interceptor);
assertTrue(
interceptor.getInterceptors().stream().anyMatch(i -> i instanceof PaginationInnerInterceptor),
"MybatisPlusInterceptor should contain PaginationInnerInterceptor"
);
}
}

View File

@@ -52,10 +52,11 @@ class SendEndToEndTest {
.andReturn().getResponse().getContentAsString();
Long subjectId = ((Number) com.jayway.jsonpath.JsonPath.read(subjectResponse, "$.data.id")).longValue();
// 2. 创建渠道账号
// 2. 创建外部群发送账号pywechat 个人号)
String accountCode = "wxid-e2e-" + UUID.randomUUID().toString().substring(0, 8);
String accountPayload = String.format(
"{\"accountType\":1,\"channelType\":\"wecom\",\"subjectId\":%d,\"accountId\":\"ZhangSan\",\"accountName\":\"张三\"}",
subjectId);
"{\"accountType\":3,\"channelType\":\"wechat_personal\",\"subjectId\":%d,\"accountId\":\"%s\",\"accountName\":\"张三\"}",
subjectId, accountCode);
String accountResponse = mockMvc.perform(post("/msg-platform/api/v1/channel-account")
.contextPath("/msg-platform")
.header("Authorization", authToken)
@@ -67,10 +68,20 @@ class SendEndToEndTest {
.andReturn().getResponse().getContentAsString();
Long accountId = ((Number) com.jayway.jsonpath.JsonPath.read(accountResponse, "$.data.id")).longValue();
// 3. 创建会话
// 个人号需要心跳才能被路由视为可用
mockMvc.perform(post("/msg-platform/api/v1/channel-account/heartbeat")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(String.format("{\"accountId\":\"%s\",\"riskLevel\":1}", accountCode)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 3. 创建外部群会话
String channelConversationId = "room-" + UUID.randomUUID().toString().substring(0, 8);
String conversationPayload = String.format(
"{\"conversationType\":2,\"channelType\":\"wecom\",\"channelConversationId\":\"%s\",\"conversationName\":\"E2E群\",\"subjectId\":%d}",
"{\"conversationType\":2,\"channelType\":\"wechat_personal\",\"conversationScene\":2,\"channelConversationId\":\"%s\",\"conversationName\":\"E2E群\",\"subjectId\":%d}",
channelConversationId, subjectId);
String conversationResponse = mockMvc.perform(post("/msg-platform/api/v1/conversation")
.contextPath("/msg-platform")
@@ -121,7 +132,7 @@ class SendEndToEndTest {
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].taskId").value(taskId))
.andExpect(jsonPath("$.data[0].sendStatus").value(2))
.andExpect(jsonPath("$.data[0].channelType").value("wecom"))
.andExpect(jsonPath("$.data[0].channelType").value("wechat_personal"))
.andExpect(jsonPath("$.data[0].conversationId").value(conversationId))
.andExpect(jsonPath("$.data[0].accountId").value(accountId))
.andExpect(jsonPath("$.data[0].contentType").value("text"))

View File

@@ -0,0 +1,327 @@
package com.sino.mci.send.service;
import com.sino.mci.channel.common.model.AccountType;
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.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.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.send.entity.SendRecord;
import com.sino.mci.send.repository.SendRecordMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
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 java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static com.sino.mci.test.AuthTestHelper.createTenantAndLogin;
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;
/**
* 基于会话场景与目标类型的发送路由集成测试。
*
* <p>覆盖自动路由策略:
* <ul>
* <li>内部群conversation_scene = 1→ WECOM / WECOM_BOT</li>
* <li>外部群conversation_scene = 2→ WECHAT_PERSONAL</li>
* <li>内部人员 → WECOM_AGENT</li>
* <li>外部人员 → WECOM_AGENT</li>
* </ul>
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@TestPropertySource(properties = {
"spring.rabbitmq.listener.simple.auto-startup=false"
})
class SendRoutingIntegrationTest {
private static final String CHANNEL_TYPE_WECOM = ChannelType.WECOM.name().toLowerCase();
private static final String CHANNEL_TYPE_WECHAT_PERSONAL = ChannelType.WECHAT_PERSONAL.name().toLowerCase();
private static final int CONVERSATION_SCENE_INTERNAL = 1;
private static final int CONVERSATION_SCENE_EXTERNAL = 2;
private static final int PERSON_TYPE_INTERNAL = 1;
private static final int PERSON_TYPE_EXTERNAL = 3;
private static final int STATUS_ENABLED = 1;
private static final int BINDING_TYPE_SEND_PRIMARY = 1;
@Autowired
private MockMvc mockMvc;
@Autowired
private ChannelSubjectMapper subjectMapper;
@Autowired
private ChannelAccountMapper accountMapper;
@Autowired
private ConversationMapper conversationMapper;
@Autowired
private ChannelAccountConversationMapper accountConversationMapper;
@Autowired
private PersonMapper personMapper;
@Autowired
private ChannelIdentityMapper channelIdentityMapper;
@Autowired
private SendRecordMapper sendRecordMapper;
@Test
void shouldRouteInternalGroupToWeComBot() throws Exception {
String tenantCode = "routing-int-group-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "内部群路由测试");
ChannelSubject subject = createWeComSubject(tenantCode);
ChannelAccount botAccount = createWeComBotAccount(tenantCode, subject.getId());
Conversation conversation = createConversation(tenantCode, subject.getId(),
CONVERSATION_SCENE_INTERNAL, "内部群");
bindAccountToConversation(botAccount.getId(), conversation.getId());
String taskId = sendTestMessage(authToken, tenantCode,
buildConversationPayload(conversation.getId()));
List<SendRecord> records = sendRecordMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.eq(SendRecord::getTaskId, taskId));
assertThat(records).hasSize(1);
SendRecord record = records.get(0);
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
assertThat(record.getAccountId()).isEqualTo(botAccount.getId());
assertThat(record.getConversationId()).isEqualTo(conversation.getId());
}
@Test
void shouldRouteExternalGroupToWeChatPersonal() throws Exception {
String tenantCode = "routing-ext-group-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "外部群路由测试");
ChannelSubject subject = createWeComSubject(tenantCode);
ChannelAccount pywechatAccount = createWeChatPersonalAccount(tenantCode, subject.getId());
Conversation conversation = createConversation(tenantCode, subject.getId(),
CONVERSATION_SCENE_EXTERNAL, "外部群");
bindAccountToConversation(pywechatAccount.getId(), conversation.getId());
String taskId = sendTestMessage(authToken, tenantCode,
buildConversationPayload(conversation.getId()));
List<SendRecord> records = sendRecordMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.eq(SendRecord::getTaskId, taskId));
assertThat(records).hasSize(1);
SendRecord record = records.get(0);
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECHAT_PERSONAL);
assertThat(record.getAccountId()).isEqualTo(pywechatAccount.getId());
assertThat(record.getConversationId()).isEqualTo(conversation.getId());
}
@Test
void shouldRouteInternalPersonToWeComAgent() throws Exception {
String tenantCode = "routing-int-person-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "内部人员路由测试");
ChannelSubject subject = createWeComSubject(tenantCode);
ChannelAccount agentAccount = createWeComAgentAccount(tenantCode, subject.getId());
Person person = createPerson(tenantCode, "int-user", "内部人员", PERSON_TYPE_INTERNAL);
createChannelIdentity(tenantCode, person.getId(), subject.getId(), "int-user");
String taskId = sendTestMessage(authToken, tenantCode,
buildPersonPayload(person.getId()));
List<SendRecord> records = sendRecordMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.eq(SendRecord::getTaskId, taskId));
assertThat(records).hasSize(1);
SendRecord record = records.get(0);
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
assertThat(record.getAccountId()).isEqualTo(agentAccount.getId());
assertThat(record.getPersonId()).isEqualTo(person.getId());
}
@Test
void shouldRouteExternalPersonToWeComAgent() throws Exception {
String tenantCode = "routing-ext-person-" + UUID.randomUUID().toString().substring(0, 8);
String authToken = createTenantAndLogin(mockMvc, tenantCode, "外部人员路由测试");
ChannelSubject subject = createWeComSubject(tenantCode);
ChannelAccount agentAccount = createWeComAgentAccount(tenantCode, subject.getId());
Person person = createPerson(tenantCode, "ext-user", "外部人员", PERSON_TYPE_EXTERNAL);
createChannelIdentity(tenantCode, person.getId(), subject.getId(), "ext-user");
String taskId = sendTestMessage(authToken, tenantCode,
buildPersonPayload(person.getId()));
List<SendRecord> records = sendRecordMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.eq(SendRecord::getTaskId, taskId));
assertThat(records).hasSize(1);
SendRecord record = records.get(0);
assertThat(record.getChannelType()).isEqualTo(CHANNEL_TYPE_WECOM);
assertThat(record.getAccountId()).isEqualTo(agentAccount.getId());
assertThat(record.getPersonId()).isEqualTo(person.getId());
}
private String sendTestMessage(String authToken, String tenantCode, String payload) throws Exception {
String response = mockMvc.perform(post("/msg-platform/api/v1/send/test")
.contextPath("/msg-platform")
.header("Authorization", authToken)
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.status").value("simulated"))
.andReturn().getResponse().getContentAsString();
return com.jayway.jsonpath.JsonPath.read(response, "$.data.taskId");
}
private String buildConversationPayload(Long conversationId) {
return String.format(
"{\"targetType\":\"conversation\",\"targetValue\":{\"conversation_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}}",
conversationId);
}
private String buildPersonPayload(Long personId) {
return String.format(
"{\"targetType\":\"person\",\"targetValue\":{\"person_id\":%d},\"contentType\":\"text\",\"content\":{\"text\":\"hello\"},\"channelStrategy\":{\"channel_type\":\"wecom\"}}",
personId);
}
private ChannelSubject createWeComSubject(String tenantCode) {
ChannelSubject subject = new ChannelSubject();
subject.setTenantCode(tenantCode);
subject.setChannelType(CHANNEL_TYPE_WECOM);
subject.setSubjectCode("sub-" + UUID.randomUUID().toString().substring(0, 8));
subject.setSubjectName("测试主体");
subject.setCorpId("corp-" + UUID.randomUUID().toString().substring(0, 8));
subject.setAgentId("1000002");
subject.setAgentSecret("secret-" + UUID.randomUUID());
subject.setStatus(STATUS_ENABLED);
subjectMapper.insert(subject);
return subject;
}
private ChannelAccount createWeComBotAccount(String tenantCode, Long subjectId) {
ChannelAccount account = new ChannelAccount();
account.setTenantCode(tenantCode);
account.setAccountType(AccountType.WECOM_BOT.getCode());
account.setChannelType(CHANNEL_TYPE_WECOM);
account.setSubjectId(subjectId);
account.setAccountId("bot-" + UUID.randomUUID().toString().substring(0, 8));
account.setAccountName("企微群机器人");
account.setStatus(STATUS_ENABLED);
accountMapper.insert(account);
return account;
}
private ChannelAccount createWeComAgentAccount(String tenantCode, Long subjectId) {
ChannelAccount account = new ChannelAccount();
account.setTenantCode(tenantCode);
account.setAccountType(AccountType.WECOM_AGENT.getCode());
account.setChannelType(CHANNEL_TYPE_WECOM);
account.setSubjectId(subjectId);
account.setAccountId("agent-" + UUID.randomUUID().toString().substring(0, 8));
account.setAccountName("企微应用");
account.setStatus(STATUS_ENABLED);
accountMapper.insert(account);
return account;
}
private ChannelAccount createWeChatPersonalAccount(String tenantCode, Long subjectId) {
ChannelAccount account = new ChannelAccount();
account.setTenantCode(tenantCode);
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
account.setChannelType(CHANNEL_TYPE_WECHAT_PERSONAL);
account.setSubjectId(subjectId);
account.setAccountId("wxid-" + UUID.randomUUID().toString().substring(0, 8));
account.setAccountName("pywechat 个人号");
account.setStatus(STATUS_ENABLED);
account.setOnlineStatus(STATUS_ENABLED);
account.setRiskLevel(1);
account.setLastHeartbeat(LocalDateTime.now());
accountMapper.insert(account);
return account;
}
private Conversation createConversation(String tenantCode, Long subjectId,
Integer conversationScene, String conversationName) {
Conversation conversation = new Conversation();
conversation.setTenantCode(tenantCode);
conversation.setConversationType(2);
conversation.setChannelType(CHANNEL_TYPE_WECOM);
conversation.setChannelConversationId("chat-" + UUID.randomUUID().toString().substring(0, 8));
conversation.setSubjectId(subjectId);
conversation.setConversationName(conversationName);
conversation.setConversationScene(conversationScene);
conversation.setCreateSource(1);
conversation.setStatus(STATUS_ENABLED);
conversationMapper.insert(conversation);
return conversation;
}
private void bindAccountToConversation(Long accountId, Long conversationId) {
ChannelAccountConversation binding = new ChannelAccountConversation();
binding.setAccountId(accountId);
binding.setConversationId(conversationId);
binding.setBindingType(BINDING_TYPE_SEND_PRIMARY);
binding.setSortOrder(1);
binding.setStatus(STATUS_ENABLED);
accountConversationMapper.insert(binding);
}
private Person createPerson(String tenantCode, String personCode, String personName, Integer personType) {
Person person = new Person();
person.setTenantCode(tenantCode);
person.setPersonCode(personCode + "-" + UUID.randomUUID().toString().substring(0, 8));
person.setPersonName(personName);
person.setPhone("13800138000");
person.setPersonType(personType);
person.setStatus(STATUS_ENABLED);
personMapper.insert(person);
return person;
}
private void createChannelIdentity(String tenantCode, Long personId, Long subjectId, String channelUserId) {
ChannelIdentity identity = new ChannelIdentity();
identity.setTenantCode(tenantCode);
identity.setPersonId(personId);
identity.setChannelType(CHANNEL_TYPE_WECOM);
identity.setChannelUserId(channelUserId + "-" + UUID.randomUUID().toString().substring(0, 8));
identity.setChannelUserName("测试用户");
identity.setSubjectId(subjectId);
identity.setStatus(STATUS_ENABLED);
channelIdentityMapper.insert(identity);
}
}

View File

@@ -3,9 +3,11 @@ package com.sino.mci.send.service;
import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import com.sino.mci.channel.common.model.AccountType;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.common.spi.ChannelAdapter;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
@@ -31,12 +33,14 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -77,7 +81,7 @@ class SendServiceFailoverTest {
@BeforeEach
void setUp() {
when(adapterRegistry.getAdapter(ChannelType.WECHAT_PERSONAL)).thenReturn(adapter);
lenient().when(adapterRegistry.getAdapter(ChannelType.WECHAT_PERSONAL)).thenReturn(adapter);
}
@Test
@@ -86,7 +90,8 @@ class SendServiceFailoverTest {
Long primaryAccountId = 100L;
Long backupAccountId = 200L;
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation());
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
.thenReturn(Arrays.asList(primaryAccountId, backupAccountId));
when(accountMapper.selectById(primaryAccountId)).thenReturn(createPywechatAccount(primaryAccountId, "wxid-primary"));
when(accountMapper.selectById(backupAccountId)).thenReturn(createPywechatAccount(backupAccountId, "wxid-backup"));
@@ -110,7 +115,9 @@ class SendServiceFailoverTest {
verify(adapter, times(2)).sendMessage(requestCaptor.capture());
List<SendMessageRequest> requests = requestCaptor.getAllValues();
assertEquals("wxid-primary", requests.get(0).getExtra().get("account_id"));
assertEquals(Integer.valueOf(AccountType.WECHAT_PERSONAL.getCode()), requests.get(0).getExtra().get("account_type"));
assertEquals("wxid-backup", requests.get(1).getExtra().get("account_id"));
assertEquals(Integer.valueOf(AccountType.WECHAT_PERSONAL.getCode()), requests.get(1).getExtra().get("account_type"));
ArgumentCaptor<ChannelAccount> accountCaptor = ArgumentCaptor.forClass(ChannelAccount.class);
verify(accountMapper, times(1)).updateById(accountCaptor.capture());
@@ -125,7 +132,8 @@ class SendServiceFailoverTest {
SendRecord record = createRecord();
Long accountId = 100L;
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation());
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
.thenReturn(Collections.singletonList(accountId));
when(accountMapper.selectById(accountId)).thenReturn(createPywechatAccount(accountId, "wxid-primary"));
@@ -159,7 +167,8 @@ class SendServiceFailoverTest {
void dispatchRecordFailsWithNoHealthyAccount() {
SendRecord record = createRecord();
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, "primary"))
when(conversationMapper.selectById(1L)).thenReturn(createExternalConversation());
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
.thenReturn(Collections.emptyList());
sendService.dispatchRecord(record);
@@ -172,6 +181,33 @@ class SendServiceFailoverTest {
assertEquals("no_healthy_account", result.get("error"));
}
@Test
void dispatchRecordPropagatesWebhookUrlForWeComBot() {
SendRecord record = createInternalGroupRecord();
Long botAccountId = 100L;
when(adapterRegistry.getAdapter(ChannelType.WECOM)).thenReturn(adapter);
when(conversationMapper.selectById(1L)).thenReturn(createInternalConversation());
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
.thenReturn(Collections.singletonList(botAccountId));
when(accountMapper.selectById(botAccountId)).thenReturn(createWeComBotAccount(botAccountId, "bot-1", "https://custom.example.com/webhook?key=secret"));
SendMessageResult successResult = new SendMessageResult();
successResult.setSuccess(true);
when(adapter.sendMessage(any(SendMessageRequest.class))).thenReturn(successResult);
sendService.dispatchRecord(record);
assertEquals(2, record.getSendStatus().intValue());
assertEquals(botAccountId, record.getAccountId());
ArgumentCaptor<SendMessageRequest> requestCaptor = ArgumentCaptor.forClass(SendMessageRequest.class);
verify(adapter).sendMessage(requestCaptor.capture());
SendMessageRequest request = requestCaptor.getValue();
assertEquals("https://custom.example.com/webhook?key=secret", request.getExtra().get("webhook_url"));
assertEquals(Integer.valueOf(AccountType.WECOM_BOT.getCode()), request.getExtra().get("account_type"));
}
private SendRecord createRecord() {
SendRecord record = new SendRecord();
record.setId(1L);
@@ -187,16 +223,63 @@ class SendServiceFailoverTest {
return record;
}
private SendRecord createInternalGroupRecord() {
SendRecord record = new SendRecord();
record.setId(1L);
record.setTaskId("SND001");
record.setTenantCode("tenant");
record.setConversationId(1L);
record.setAccountId(100L);
record.setChannelType("wecom");
record.setChannelStrategy("{\"channel_type\":\"wecom\",\"strategy\":\"primary\"}");
record.setContentType("text");
record.setContentBody("{\"text\":\"hello\"}");
record.setSendStatus(0);
return record;
}
private Conversation createExternalConversation() {
Conversation conversation = new Conversation();
conversation.setId(1L);
conversation.setTenantCode("tenant");
conversation.setConversationScene(2);
conversation.setChannelConversationId("conv-1");
return conversation;
}
private Conversation createInternalConversation() {
Conversation conversation = new Conversation();
conversation.setId(1L);
conversation.setTenantCode("tenant");
conversation.setConversationType(2);
conversation.setConversationScene(1);
conversation.setChannelConversationId("conv-1");
return conversation;
}
private ChannelAccount createPywechatAccount(Long id, String accountId) {
ChannelAccount account = new ChannelAccount();
account.setId(id);
account.setTenantCode("tenant");
account.setAccountType(3);
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
account.setChannelType("wechat_personal");
account.setAccountId(accountId);
account.setStatus(1);
account.setOnlineStatus(1);
account.setRiskLevel(1);
account.setLastHeartbeat(java.time.LocalDateTime.now());
return account;
}
private ChannelAccount createWeComBotAccount(Long id, String accountId, String webhookUrl) {
ChannelAccount account = new ChannelAccount();
account.setId(id);
account.setTenantCode("tenant");
account.setAccountType(AccountType.WECOM_BOT.getCode());
account.setChannelType("wecom");
account.setAccountId(accountId);
account.setStatus(1);
account.setExtInfo(webhookUrl);
return account;
}
}

View File

@@ -1,6 +1,7 @@
package com.sino.mci.send.service;
import com.sino.mci.channel.adapter.registry.ChannelAdapterRegistry;
import com.sino.mci.channel.common.model.AccountType;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.Conversation;
@@ -11,6 +12,7 @@ import com.sino.mci.channel.repository.ConversationTagBindingMapper;
import com.sino.mci.channel.service.AccountSelector;
import com.sino.mci.crm.repository.ChannelIdentityMapper;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.PersonTagBindingMapper;
import com.sino.mci.crm.repository.TagMapper;
@@ -31,6 +33,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -76,13 +79,13 @@ class SendServiceResolveTargetTest {
@Test
void resolveConversationTarget_selectsHealthyPrimaryAccount() {
Conversation conversation = createConversation();
Conversation conversation = createConversation(1);
Long primaryAccountId = 10L;
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary"))
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
.thenReturn(Collections.singletonList(primaryAccountId));
when(accountMapper.selectById(primaryAccountId)).thenReturn(createAccount(primaryAccountId, "wecom", 1));
when(accountMapper.selectById(primaryAccountId)).thenReturn(createWecomBotAccount(primaryAccountId, 1));
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
@@ -93,13 +96,13 @@ class SendServiceResolveTargetTest {
@Test
void resolveConversationTarget_rejectsUnhealthyOwnerAccount() {
Conversation conversation = createConversation();
Conversation conversation = createConversation(1);
conversation.setOwnerAccountId(20L);
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary"))
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
.thenReturn(Collections.emptyList());
ChannelAccount disabledAccount = createAccount(20L, "wecom", 0);
ChannelAccount disabledAccount = createWecomBotAccount(20L, 0);
when(accountMapper.selectById(20L)).thenReturn(disabledAccount);
BusinessException ex = assertThrows(BusinessException.class,
@@ -109,13 +112,13 @@ class SendServiceResolveTargetTest {
@Test
void resolveConversationTarget_usesAccountReturnedBySelector() {
Conversation conversation = createConversation();
Conversation conversation = createConversation(1);
Long backupAccountId = 20L;
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "primary"))
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "primary"))
.thenReturn(Collections.singletonList(backupAccountId));
when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1));
when(accountMapper.selectById(backupAccountId)).thenReturn(createWecomBotAccount(backupAccountId, 1));
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
@@ -125,13 +128,13 @@ class SendServiceResolveTargetTest {
@Test
void resolveConversationTarget_respectsBackupStrategy() {
Conversation conversation = createConversation();
Conversation conversation = createConversation(1);
Long backupAccountId = 20L;
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, "backup"))
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, Set.of(AccountType.WECOM_BOT.getCode()), "backup"))
.thenReturn(Collections.singletonList(backupAccountId));
when(accountMapper.selectById(backupAccountId)).thenReturn(createAccount(backupAccountId, "wecom", 1));
when(accountMapper.selectById(backupAccountId)).thenReturn(createWecomBotAccount(backupAccountId, 1));
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("backup"));
@@ -139,15 +142,63 @@ class SendServiceResolveTargetTest {
assertEquals(backupAccountId, targets.get(0).getAccountId());
ArgumentCaptor<String> strategyCaptor = ArgumentCaptor.forClass(String.class);
verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), strategyCaptor.capture());
verify(accountSelector).selectSendAccounts(eq("tenant"), eq(1L), eq(ChannelType.WECOM), eq(Set.of(AccountType.WECOM_BOT.getCode())), strategyCaptor.capture());
assertEquals("backup", strategyCaptor.getValue());
}
@Test
void resolvePersonTarget_rejectsDisabledAccount() {
ChannelIdentity identity = createIdentity();
ChannelAccount account = createAccount(30L, "wecom", 0);
void resolveConversationTarget_routesExternalSceneToPywechat() {
Conversation conversation = createConversation(2);
Long pywechatAccountId = 30L;
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECHAT_PERSONAL, Set.of(AccountType.WECHAT_PERSONAL.getCode()), "primary"))
.thenReturn(Collections.singletonList(pywechatAccountId));
when(accountMapper.selectById(pywechatAccountId)).thenReturn(createPywechatAccount(pywechatAccountId, 1));
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
assertEquals(1, targets.size());
assertEquals(pywechatAccountId, targets.get(0).getAccountId());
assertEquals(ChannelType.WECHAT_PERSONAL, targets.get(0).getChannelType());
}
@Test
void resolveConversationTarget_rejectsNullScene() {
Conversation conversation = createConversation(null);
when(conversationMapper.selectById(1L)).thenReturn(conversation);
BusinessException ex = assertThrows(BusinessException.class,
() -> sendService.resolveTestTargets("tenant", createConversationRequest("primary")));
assertEquals("会话未设置场景,无法自动路由", ex.getMessage());
}
@Test
void resolveConversationTarget_singleChatWithNullSceneUsesChannelStrategy() {
Conversation conversation = createConversation(null);
conversation.setConversationType(1);
Long agentAccountId = 40L;
when(conversationMapper.selectById(1L)).thenReturn(conversation);
when(accountSelector.selectSendAccounts("tenant", 1L, ChannelType.WECOM, null, "primary"))
.thenReturn(Collections.singletonList(agentAccountId));
when(accountMapper.selectById(agentAccountId)).thenReturn(createWecomAgentAccount(agentAccountId, 1));
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createConversationRequest("primary"));
assertEquals(1, targets.size());
assertEquals(agentAccountId, targets.get(0).getAccountId());
assertEquals(ChannelType.WECOM, targets.get(0).getChannelType());
}
@Test
void resolvePersonTarget_rejectsDisabledAccount() {
Person person = createPerson();
ChannelIdentity identity = createIdentity();
ChannelAccount account = createWecomAgentAccount(30L, 0);
when(personMapper.selectById(1L)).thenReturn(person);
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
when(accountMapper.selectById(30L)).thenReturn(account);
@@ -159,10 +210,12 @@ class SendServiceResolveTargetTest {
@Test
void resolvePersonTarget_rejectsCrossTenantAccount() {
Person person = createPerson();
ChannelIdentity identity = createIdentity();
ChannelAccount account = createAccount(30L, "wecom", 1);
ChannelAccount account = createWecomAgentAccount(30L, 1);
account.setTenantCode("other");
when(personMapper.selectById(1L)).thenReturn(person);
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
when(accountMapper.selectById(30L)).thenReturn(account);
@@ -174,16 +227,36 @@ class SendServiceResolveTargetTest {
@Test
void resolvePersonTarget_rejectsChannelTypeMismatch() {
Person person = createPerson();
ChannelIdentity identity = createIdentity();
ChannelAccount account = createAccount(30L, "mobile", 1);
ChannelAccount account = createAccount(30L, "wecom", 1);
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
when(personMapper.selectById(1L)).thenReturn(person);
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
BusinessException ex = assertThrows(BusinessException.class,
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
assertEquals("租户未配置可用发送账号: channel=wecom", ex.getMessage());
}
@Test
void resolvePersonTarget_selectsWecomAgentAccount() {
Person person = createPerson();
ChannelIdentity identity = createIdentity();
ChannelAccount account = createWecomAgentAccount(30L, 1);
when(personMapper.selectById(1L)).thenReturn(person);
when(channelIdentityMapper.selectOne(any())).thenReturn(identity);
when(accountMapper.selectList(any())).thenReturn(Collections.singletonList(account));
when(accountMapper.selectById(30L)).thenReturn(account);
BusinessException ex = assertThrows(BusinessException.class,
() -> sendService.resolveTestTargets("tenant", createPersonRequest()));
assertEquals("租户没有可用的发送账号: channel=wecom", ex.getMessage());
List<SendTarget> targets = sendService.resolveTestTargets("tenant", createPersonRequest());
assertEquals(1, targets.size());
assertEquals(Long.valueOf(30L), targets.get(0).getAccountId());
assertEquals(ChannelType.WECOM, targets.get(0).getChannelType());
}
private SendTestRequest createConversationRequest(String strategy) {
@@ -217,16 +290,24 @@ class SendServiceResolveTargetTest {
return request;
}
private Conversation createConversation() {
private Conversation createConversation(Integer scene) {
Conversation conversation = new Conversation();
conversation.setId(1L);
conversation.setTenantCode("tenant");
conversation.setChannelType("wecom");
conversation.setConversationScene(scene);
conversation.setChannelConversationId("conv-1");
conversation.setStatus(1);
return conversation;
}
private Person createPerson() {
Person person = new Person();
person.setId(1L);
person.setTenantCode("tenant");
return person;
}
private ChannelAccount createAccount(Long id, String channelType, int status) {
ChannelAccount account = new ChannelAccount();
account.setId(id);
@@ -236,6 +317,27 @@ class SendServiceResolveTargetTest {
return account;
}
private ChannelAccount createWecomBotAccount(Long id, int status) {
ChannelAccount account = createAccount(id, "wecom", status);
account.setAccountType(AccountType.WECOM_BOT.getCode());
return account;
}
private ChannelAccount createWecomAgentAccount(Long id, int status) {
ChannelAccount account = createAccount(id, "wecom", status);
account.setAccountType(AccountType.WECOM_AGENT.getCode());
return account;
}
private ChannelAccount createPywechatAccount(Long id, int status) {
ChannelAccount account = createAccount(id, "wechat_personal", status);
account.setAccountType(AccountType.WECHAT_PERSONAL.getCode());
account.setOnlineStatus(1);
account.setRiskLevel(1);
account.setLastHeartbeat(java.time.LocalDateTime.now());
return account;
}
private ChannelIdentity createIdentity() {
ChannelIdentity identity = new ChannelIdentity();
identity.setTenantCode("tenant");

Some files were not shown because too many files have changed in this diff Show More