75 KiB
admin-web 补全与登录/租户管理实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为 sino-mci 补齐登录鉴权、多租户管理、管理后台列表与绑定、入站流程图、消息测试模块,使管理后台达到可交付状态。
Architecture: 后端使用 JWT + Spring Interceptor 实现无状态鉴权,前端通过 localStorage 存储 token 并做路由守卫;管理后台各页面通过新增列表接口加载真实数据;入站流程使用 Vue Flow 可视化编排并记录执行轨迹;消息测试模块统一处理出站测试与入站模拟。
Tech Stack: Java 21, Spring Boot 4.1, JJWT, Vue 3, Element Plus, Vue Flow, MyBatis-Plus, MySQL
功能点清单
后端功能点
- JWT Token 生成与解析
- 鉴权拦截器与登录上下文
- 登录接口改造(返回 token)
- 当前登录用户信息接口
- 租户列表接口
- 用户列表接口
- 租户隔离校验
- 渠道主体列表接口
- 渠道账号列表接口
- 会话列表接口
- 联系人列表接口
- 发送记录列表接口
- Dashboard 统计接口
- 入站流程执行轨迹记录与查询
- Webhook 测试接收端点
前端功能点
- 登录页
- 前端路由守卫
- API Token 注入
- 租户管理页
- 用户管理页
- Dashboard 真实统计
- 渠道主体页补充字段与列表
- 渠道账号页补充字段与列表
- 会话/群聊页绑定账号
- 联系人页列表
- 发送记录页 UX 优化
- 标签层级展示
- 入站流程可视化编辑器
- 入站流程执行追踪
- 消息测试模块(出站 + 入站)
文件结构
后端新增/修改文件
| 文件 | 职责 |
|---|---|
backend/mci-server/pom.xml |
添加 jjwt 依赖 |
backend/mci-server/src/main/java/com/sino/mci/admin/security/JwtTokenProvider.java |
JWT 生成与解析 |
backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java |
当前登录用户 ThreadLocal |
backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java |
鉴权拦截器 |
backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java |
注册拦截器 |
backend/mci-server/src/main/java/com/sino/mci/admin/dto/LoginResponse.java |
改造为包含 token |
backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java |
新增 me/tenant list/user list 接口,改造 login |
backend/mci-server/src/main/java/com/sino/mci/admin/service/AccountService.java |
新增 list 方法 |
backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java |
新增 list 接口 |
backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java |
新增 list 方法 |
backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java |
新增 list 接口 |
backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java |
新增 list 方法 |
backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java |
新增 list 接口 |
backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java |
新增 list 方法 |
backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java |
新增 list 接口 |
backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java |
新增 list 方法 |
backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java |
新增 list 接口 |
backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java |
新增 list 方法 |
backend/mci-server/src/main/java/com/sino/mci/admin/controller/DashboardController.java |
Dashboard 统计 |
backend/mci-server/src/main/java/com/sino/mci/inbound/entity/FlowExecutionTrace.java |
执行轨迹实体 |
backend/mci-server/src/main/java/com/sino/mci/inbound/repository/FlowExecutionTraceMapper.java |
轨迹 Mapper |
backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java |
新增 trace 接口 |
backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java |
记录执行轨迹 |
backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql |
轨迹表迁移 |
backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql |
平台管理员角色 |
前端新增/修改文件
| 文件 | 职责 |
|---|---|
admin-web/src/api/client.ts |
注入 Authorization token |
admin-web/src/api/account.ts |
登录/租户/用户 API |
admin-web/src/api/channel.ts |
列表 API |
admin-web/src/api/send.ts |
发送列表 API |
admin-web/src/api/dashboard.ts |
统计 API |
admin-web/src/api/inbound.ts |
入站/轨迹 API |
admin-web/src/stores/auth.ts |
Pinia 登录状态 |
admin-web/src/views/Login.vue |
登录页 |
admin-web/src/views/TenantManagement.vue |
租户管理 |
admin-web/src/views/UserManagement.vue |
用户管理 |
admin-web/src/views/Dashboard.vue |
真实统计 |
admin-web/src/views/ChannelSubject.vue |
列表 + 补充字段 |
admin-web/src/views/ChannelAccount.vue |
列表 + 补充字段 |
admin-web/src/views/Conversation.vue |
列表 + 绑定账号 |
admin-web/src/views/Person.vue |
列表 |
admin-web/src/views/SendRecord.vue |
UX 优化 |
admin-web/src/views/Tag.vue |
层级展示 |
admin-web/src/views/InboundFlow.vue |
Vue Flow 编辑器 |
admin-web/src/views/FlowTrace.vue |
流程追踪 |
admin-web/src/views/MessageTest.vue |
消息测试 |
admin-web/src/router/index.ts |
登录路由 + 守卫 |
admin-web/src/components/Layout.vue |
顶部显示当前用户/退出 |
admin-web/package.json |
添加 @vue-flow/core、pinia |
Task 1: 后端 Sa-Token 与鉴权基础设施
Files:
-
Modify:
backend/mci-server/pom.xml -
Create:
backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java -
Create:
backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java -
Create:
backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java -
Modify:
backend/mci-server/src/main/resources/application.yml -
Step 1: 添加 Sa-Token 依赖
修改 backend/mci-server/pom.xml 的 <dependencies> 节点,添加:
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
- Step 2: 创建 AuthContext
基于 Sa-Token StpUtil 创建 backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java:
package com.sino.mci.admin.security;
import cn.dev33.satoken.stp.StpUtil;
import lombok.Data;
import java.util.List;
public class AuthContext {
@Data
public static class AuthUser {
private Long userId;
private String tenantCode;
private String username;
private List<String> roleCodes;
}
public static AuthUser get() {
if (!StpUtil.isLogin()) {
return null;
}
AuthUser user = new AuthUser();
user.setUserId(StpUtil.getLoginIdAsLong());
user.setTenantCode((String) StpUtil.getSession().get("tenantCode"));
user.setUsername((String) StpUtil.getSession().get("username"));
user.setRoleCodes((List<String>) StpUtil.getSession().get("roleCodes"));
return user;
}
public static String currentTenantCode() {
AuthUser user = get();
return user == null ? null : user.getTenantCode();
}
}
- Step 3: 创建 AuthInterceptor
创建 backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java,使用 Sa-Token 校验登录:
package com.sino.mci.admin.security;
import cn.dev33.satoken.exception.SaTokenException;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String path = request.getRequestURI();
if (path.contains("/api/v1/account/login")) {
return true;
}
try {
StpUtil.checkLogin();
} catch (SaTokenException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return true;
}
}
- Step 4: 注册拦截器并配置 Sa-Token
创建 backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java:
package com.sino.mci.config;
import com.sino.mci.admin.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/msg-platform/api/v1/**", "/api/v1/**")
.excludePathPatterns("/msg-platform/api/v1/account/login", "/api/v1/account/login");
}
}
在 backend/mci-server/src/main/resources/application.yml 中添加 Sa-Token 基础配置:
sa-token:
token-name: Authorization
token-prefix: Bearer
timeout: 86400
active-timeout: -1
is-concurrent: true
is-share: true
token-style: uuid
is-log: false
- Step 5: 编译验证
Run:
cd /Users/marsal/Projects/sino-project/sino-mci/backend
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn -f mci-server/pom.xml compile -DskipTests
Expected: BUILD SUCCESS
- Step 6: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add backend/mci-server/pom.xml backend/mci-server/src/main/java/com/sino/mci/admin/security/ backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java backend/mci-server/src/main/resources/application.yml
git commit -m "feat(auth): add Sa-Token auth context and interceptor"
Task 2: 后端登录与账号管理 API 改造
Files:
-
Modify:
backend/mci-server/src/main/java/com/sino/mci/admin/dto/LoginResponse.java -
Create:
backend/mci-server/src/main/java/com/sino/mci/admin/dto/TokenResponse.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/admin/service/AccountService.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java -
Create:
backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql -
Step 1: 创建 TokenResponse
创建 backend/mci-server/src/main/java/com/sino/mci/admin/dto/TokenResponse.java:
package com.sino.mci.admin.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponse {
private String token;
private LoginResponse userInfo;
}
- Step 2: 改造 AccountService.login 返回 TokenResponse
修改 backend/mci-server/src/main/java/com/sino/mci/admin/service/AccountService.java:
引入 Sa-Token:
import cn.dev33.satoken.stp.StpUtil;
改造 login 方法:
public TokenResponse login(String tenantCode, String username, String password) {
PlatformUser user = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
.eq(PlatformUser::getTenantCode, tenantCode)
.eq(PlatformUser::getUsername, username)
.eq(PlatformUser::getStatus, 1));
if (user == null || !BCrypt.checkpw(password, user.getPassword())) {
throw new UnauthorizedException("用户名或密码错误");
}
LoginResponse userInfo = new LoginResponse();
userInfo.setId(user.getId());
userInfo.setTenantCode(user.getTenantCode());
userInfo.setUsername(user.getUsername());
userInfo.setRealName(user.getRealName());
userInfo.setPhone(user.getPhone());
userInfo.setRoleCodes(loadRoleCodes(user.getId()));
StpUtil.login(user.getId());
StpUtil.getSession().set("tenantCode", user.getTenantCode());
StpUtil.getSession().set("username", user.getUsername());
StpUtil.getSession().set("roleCodes", userInfo.getRoleCodes());
String token = StpUtil.getTokenValue();
return new TokenResponse(token, userInfo);
}
- Step 3: 新增 list 方法到 AccountService
在 AccountService.java 末尾添加:
public List<Tenant> listTenants() {
return tenantMapper.selectList(new LambdaQueryWrapper<Tenant>().eq(Tenant::getStatus, 1));
}
public List<PlatformUser> listUsers(String tenantCode) {
return userMapper.selectList(
new LambdaQueryWrapper<PlatformUser>()
.eq(PlatformUser::getTenantCode, tenantCode)
.eq(PlatformUser::getStatus, 1));
}
- Step 4: 改造 AccountController
修改 backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java:
package com.sino.mci.admin.controller;
import com.sino.mci.admin.dto.*;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.admin.service.AccountService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/account")
@RequiredArgsConstructor
public class AccountController {
private final AccountService accountService;
@PostMapping("/tenant")
public ApiResponse<Tenant> createTenant(@Valid @RequestBody CreateTenantRequest request) {
return ApiResponse.ok(accountService.createTenant(request));
}
@GetMapping("/tenant/list")
public ApiResponse<List<Tenant>> listTenants() {
return ApiResponse.ok(accountService.listTenants());
}
@PostMapping("/user")
public ApiResponse<UserResponse> createUser(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody CreateUserRequest request) {
return ApiResponse.ok(accountService.createUser(tenantCode, request));
}
@GetMapping("/user/list")
public ApiResponse<List<PlatformUserResponse>> listUsers() {
return ApiResponse.ok(accountService.listUsers(AuthContext.currentTenantCode()));
}
@PostMapping("/login")
public ApiResponse<TokenResponse> login(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(accountService.login(tenantCode, request.getUsername(), request.getPassword()));
}
@GetMapping("/me")
public ApiResponse<AuthContext.AuthUser> me() {
return ApiResponse.ok(AuthContext.get());
}
}
- Step 5: 新增 PlatformUserResponse
创建 backend/mci-server/src/main/java/com/sino/mci/admin/dto/PlatformUserResponse.java:
package com.sino.mci.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class PlatformUserResponse {
private Long id;
private String tenantCode;
private String username;
private String realName;
private String phone;
private Integer status;
private List<String> roleCodes;
private LocalDateTime createTime;
}
并调整 AccountService.listUsers 返回 List<PlatformUserResponse>,使用 toUserResponse 转换。
- Step 6: 添加平台管理员角色种子数据
创建 backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql:
INSERT IGNORE INTO mci_platform_role (role_code, role_name, permissions) VALUES
('platform_admin', '平台管理员', '["tenant:manage", "user:manage", "*"]');
- Step 7: 运行测试
Run:
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
Expected: 7 tests pass
- Step 8: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add backend/mci-server/src/main/java/com/sino/mci/admin/ backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql
git commit -m "feat(auth): return JWT on login and add tenant/user list APIs"
Task 3: 后端列表接口与 Dashboard 统计
Files:
-
Modify:
backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java -
Create:
backend/mci-server/src/main/java/com/sino/mci/admin/controller/DashboardController.java -
Create:
backend/mci-server/src/main/java/com/sino/mci/admin/dto/DashboardStatsResponse.java -
Step 1: ChannelSubject 列表
修改 ChannelSubjectController.java:
@GetMapping("/list")
public ApiResponse<List<ChannelSubject>> listSubjects(
@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(subjectService.listSubjects(tenantCode));
}
修改 ChannelSubjectService.java:
public List<ChannelSubject> listSubjects(String tenantCode) {
return subjectMapper.selectList(
new LambdaQueryWrapper<ChannelSubject>()
.eq(ChannelSubject::getTenantCode, tenantCode)
.eq(ChannelSubject::getStatus, 1)
.orderByDesc(ChannelSubject::getId));
}
- Step 2: ChannelAccount 列表
修改 ChannelAccountController.java:
@GetMapping("/list")
public ApiResponse<List<ChannelAccount>> listAccounts(
@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(accountService.listAccounts(tenantCode));
}
修改 ChannelAccountService.java:
public List<ChannelAccount> listAccounts(String tenantCode) {
return accountMapper.selectList(
new LambdaQueryWrapper<ChannelAccount>()
.eq(ChannelAccount::getTenantCode, tenantCode)
.eq(ChannelAccount::getStatus, 1)
.orderByDesc(ChannelAccount::getId));
}
- Step 3: Conversation 列表
修改 ConversationController.java:
@GetMapping("/list")
public ApiResponse<List<Conversation>> listConversations(
@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(conversationService.listConversations(tenantCode));
}
修改 ConversationService.java:
public List<Conversation> listConversations(String tenantCode) {
return conversationMapper.selectList(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getTenantCode, tenantCode)
.eq(Conversation::getStatus, 1)
.orderByDesc(Conversation::getId));
}
- Step 4: Person 列表
修改 PersonController.java:
@GetMapping("/list")
public ApiResponse<List<Person>> listPersons(
@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(personService.listPersons(tenantCode));
}
修改 PersonService.java:
public List<Person> listPersons(String tenantCode) {
return personMapper.selectList(
new LambdaQueryWrapper<Person>()
.eq(Person::getTenantCode, tenantCode)
.eq(Person::getStatus, 1)
.orderByDesc(Person::getId));
}
- Step 5: SendRecord 列表
修改 SendController.java:
@GetMapping("/list")
public ApiResponse<List<SendRecordResponse>> listSendRecords(
@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(sendService.listRecentRecords(tenantCode));
}
修改 SendService.java:
public List<SendRecordResponse> listRecentRecords(String tenantCode) {
List<SendRecord> records = sendRecordMapper.selectList(
new LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.orderByDesc(SendRecord::getId)
.last("LIMIT 50"));
return records.stream().map(this::toResponse).collect(Collectors.toList());
}
- Step 6: Dashboard 统计
创建 backend/mci-server/src/main/java/com/sino/mci/admin/dto/DashboardStatsResponse.java:
package com.sino.mci.admin.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DashboardStatsResponse {
private long subjectCount;
private long accountCount;
private long conversationCount;
private long sentToday;
}
创建 backend/mci-server/src/main/java/com/sino/mci/admin/controller/DashboardController.java:
package com.sino.mci.admin.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.dto.DashboardStatsResponse;
import com.sino.mci.admin.security.AuthContext;
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.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.send.entity.SendRecord;
import com.sino.mci.send.repository.SendRecordMapper;
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.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@RestController
@RequestMapping("/api/v1/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final ChannelSubjectMapper subjectMapper;
private final ChannelAccountMapper accountMapper;
private final ConversationMapper conversationMapper;
private final SendRecordMapper sendRecordMapper;
@GetMapping("/stats")
public ApiResponse<DashboardStatsResponse> stats() {
String tenantCode = AuthContext.currentTenantCode();
LocalDateTime startOfDay = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
LocalDateTime endOfDay = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
long subjects = subjectMapper.selectCount(
new LambdaQueryWrapper<ChannelSubject>()
.eq(ChannelSubject::getTenantCode, tenantCode)
.eq(ChannelSubject::getStatus, 1));
long accounts = accountMapper.selectCount(
new LambdaQueryWrapper<ChannelAccount>()
.eq(ChannelAccount::getTenantCode, tenantCode)
.eq(ChannelAccount::getStatus, 1));
long conversations = conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getTenantCode, tenantCode)
.eq(Conversation::getStatus, 1));
long sentToday = sendRecordMapper.selectCount(
new LambdaQueryWrapper<SendRecord>()
.eq(SendRecord::getTenantCode, tenantCode)
.between(SendRecord::getSendTime, startOfDay, endOfDay));
return ApiResponse.ok(new DashboardStatsResponse(subjects, accounts, conversations, sentToday));
}
}
- Step 7: 运行测试
Run:
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
Expected: 7 tests pass
- Step 8: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add backend/mci-server/src/main/java/com/sino/mci/
git commit -m "feat(api): add list endpoints and dashboard stats"
Task 4: 入站流程执行轨迹
Files:
-
Create:
backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql -
Create:
backend/mci-server/src/main/java/com/sino/mci/inbound/entity/FlowExecutionTrace.java -
Create:
backend/mci-server/src/main/java/com/sino/mci/inbound/repository/FlowExecutionTraceMapper.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java -
Modify:
backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java -
Step 1: 创建轨迹表迁移
创建 backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql:
CREATE TABLE IF NOT EXISTS mci_flow_execution_trace (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(128) NOT NULL COMMENT '入站事件ID',
flow_definition_id BIGINT COMMENT '流程定义ID',
node_type VARCHAR(64) NOT NULL COMMENT '节点类型',
node_config JSON COMMENT '节点配置快照',
input TEXT COMMENT '节点输入',
output TEXT COMMENT '节点输出',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1成功 2失败',
duration_ms INT COMMENT '执行耗时毫秒',
error_msg VARCHAR(512) COMMENT '错误信息',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_event_id (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程执行轨迹表';
- Step 2: 创建实体与 Mapper
创建 FlowExecutionTrace.java:
package com.sino.mci.inbound.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("mci_flow_execution_trace")
public class FlowExecutionTrace {
private Long id;
private String eventId;
private Long flowDefinitionId;
private String nodeType;
private String nodeConfig;
private String input;
private String output;
private Integer status;
private Integer durationMs;
private String errorMsg;
private LocalDateTime createTime;
}
创建 FlowExecutionTraceMapper.java:
package com.sino.mci.inbound.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sino.mci.inbound.entity.FlowExecutionTrace;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FlowExecutionTraceMapper extends BaseMapper<FlowExecutionTrace> {
}
- Step 3: FlowEngine 记录轨迹
修改 FlowEngine.java,注入 FlowExecutionTraceMapper。
当前 FlowEngine.executeInboundFlow 实际执行固定三步:意图识别、Webhook 推送、标记已处理。轨迹按以下固定节点记录:
private final FlowExecutionTraceMapper traceMapper;
private void trace(String eventId, Long flowDefinitionId, String nodeType,
String input, String output, int status, long durationMs, String errorMsg) {
FlowExecutionTrace trace = new FlowExecutionTrace();
trace.setEventId(eventId);
trace.setFlowDefinitionId(flowDefinitionId);
trace.setNodeType(nodeType);
trace.setInput(input);
trace.setOutput(output);
trace.setStatus(status);
trace.setDurationMs((int) durationMs);
trace.setErrorMsg(errorMsg);
traceMapper.insert(trace);
}
改造 executeInboundFlow:
public void executeInboundFlow(Long eventId) {
MessageEvent event = messageEventMapper.selectById(eventId);
if (event == null) {
log.warn("Message event not found: {}", eventId);
return;
}
FlowDefinition flow = findPublishedFlow(event.getTenantCode());
Long flowDefinitionId = flow == null ? null : flow.getId();
long flowStart = System.currentTimeMillis();
trace(event.getEventId(), flowDefinitionId, "flow_start", null,
"tenant=" + event.getTenantCode(), 1, 0, null);
if (flow == null) {
log.info("No published inbound flow for tenant {}, skipping flow execution", event.getTenantCode());
markProcessed(event, flowDefinitionId);
return;
}
try {
long intentStart = System.currentTimeMillis();
IntentRecognitionService.IntentResult intent = intentRecognitionService.recognize(event);
trace(event.getEventId(), flowDefinitionId, "intent_recognition",
event.getContent(), JsonUtils.toJson(intent), 1,
System.currentTimeMillis() - intentStart, null);
long webhookStart = System.currentTimeMillis();
webhookPushService.pushToBusiness(event.getTenantCode(), event, intent);
trace(event.getEventId(), flowDefinitionId, "webhook_push",
JsonUtils.toJson(intent), "pushed", 1,
System.currentTimeMillis() - webhookStart, null);
markProcessed(event, flowDefinitionId);
} catch (Exception e) {
log.error("Flow execution failed for event {}", eventId, e);
trace(event.getEventId(), flowDefinitionId, "webhook_push",
event.getContent(), null, 2, System.currentTimeMillis() - flowStart,
e.getMessage());
}
}
private void markProcessed(MessageEvent event, Long flowDefinitionId) {
event.setIsProcessed(1);
messageEventMapper.updateById(event);
trace(event.getEventId(), flowDefinitionId, "mark_processed", null, "done", 1, 0, null);
}
同时在 InboundMessageService.receiveMessage 保存消息后、调用 FlowEngine 前记录 receive 节点:
private final FlowExecutionTraceMapper traceMapper;
// 在 messageEventMapper.insert(event); 之后
FlowExecutionTrace receiveTrace = new FlowExecutionTrace();
receiveTrace.setEventId(event.getEventId());
receiveTrace.setNodeType("receive");
receiveTrace.setInput(JsonUtils.toJson(request));
receiveTrace.setOutput("event_id=" + event.getId());
receiveTrace.setStatus(1);
traceMapper.insert(receiveTrace);
这样单条事件轨迹为:receive -> flow_start -> intent_recognition -> webhook_push -> mark_processed。
- Step 4: 新增 trace 查询接口
修改 InboundMessageController.java:
@GetMapping("/{eventId}/trace")
public ApiResponse<List<FlowExecutionTrace>> trace(@PathVariable String eventId) {
return ApiResponse.ok(inboundMessageService.listTraces(eventId));
}
在 InboundMessageService.java 新增:
public List<FlowExecutionTrace> listTraces(String eventId) {
return traceMapper.selectList(
new LambdaQueryWrapper<FlowExecutionTrace>()
.eq(FlowExecutionTrace::getEventId, eventId)
.orderByAsc(FlowExecutionTrace::getId));
}
- Step 5: 运行测试
Run:
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
Expected: 7 tests pass
- Step 6: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add backend/mci-server/src/main/java/com/sino/mci/inbound/ backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql
git commit -m "feat(inbound): add flow execution trace recording and query"
Task 5: 前端依赖与 API 层改造
Files:
-
Modify:
admin-web/package.json -
Modify:
admin-web/src/api/client.ts -
Modify:
admin-web/src/api/account.ts -
Modify:
admin-web/src/api/channel.ts -
Modify:
admin-web/src/api/crm.ts -
Modify:
admin-web/src/api/send.ts -
Create:
admin-web/src/api/dashboard.ts -
Create:
admin-web/src/api/inbound.ts -
Step 1: 添加依赖
修改 admin-web/package.json:
"dependencies": {
"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"
}
Run:
cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
npm install
- Step 2: 改造 client.ts
修改 admin-web/src/api/client.ts:
import axios from 'axios'
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/msg-platform',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('mci-token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
const tenantCode = localStorage.getItem('mci-tenant-code')
if (tenantCode) {
config.headers['X-Tenant-Code'] = tenantCode
}
return config
})
client.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('mci-token')
localStorage.removeItem('mci-tenant-code')
window.location.href = '/login'
}
const message = error.response?.data?.message || error.message || '请求失败'
return Promise.reject(new Error(message))
}
)
export default client
- Step 3: 改造 account.ts
import client from './client'
export function login(data: { tenantCode: string; username: string; password: string }) {
return client.post('/api/v1/account/login', data)
}
export function createTenant(data: { tenantCode: string; tenantName: string }) {
return client.post('/api/v1/account/tenant', data)
}
export function listTenants() {
return client.get('/api/v1/account/tenant/list')
}
export function createUser(data: { username: string; password: string; realName?: string; roleCodes?: string[] }) {
return client.post('/api/v1/account/user', data)
}
export function listUsers() {
return client.get('/api/v1/account/user/list')
}
export function me() {
return client.get('/api/v1/account/me')
}
- Step 4: 改造 channel.ts
import client from './client'
export function createChannelSubject(data: any) {
return client.post('/api/v1/channel-subject', data)
}
export function listChannelSubjects() {
return client.get('/api/v1/channel-subject/list')
}
export function createChannelAccount(data: any) {
return client.post('/api/v1/channel-account', data)
}
export function listChannelAccounts() {
return client.get('/api/v1/channel-account/list')
}
export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export function createConversation(data: any) {
return client.post('/api/v1/conversation', data)
}
export function listConversations() {
return client.get('/api/v1/conversation/list')
}
export function bindConversationAccount(conversationId: number, data: any) {
return client.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
}
export function bindConversationTags(conversationId: number, data: any) {
return client.post(`/api/v1/conversation/${conversationId}/bind-tags`, data)
}
- Step 5: 改造 crm.ts
import client from './client'
export function createPerson(data: any) {
return client.post('/api/v1/person', data)
}
export function listPersons() {
return client.get('/api/v1/person/list')
}
export function listTags() {
return client.get('/api/v1/tag/list')
}
export function createTag(data: any) {
return client.post('/api/v1/tag', data)
}
- Step 6: 改造 send.ts
import client from './client'
export function sendMessage(data: any) {
return client.post('/api/v1/send', data)
}
export function testSendMessage(data: any) {
return client.post('/api/v1/send/test', data)
}
export function listSendRecords(taskId?: string) {
if (taskId) {
return client.get(`/api/v1/send/${taskId}/records`)
}
return client.get('/api/v1/send/list')
}
- Step 7: 创建 dashboard.ts 和 inbound.ts
admin-web/src/api/dashboard.ts:
import client from './client'
export function getDashboardStats() {
return client.get('/api/v1/dashboard/stats')
}
admin-web/src/api/inbound.ts:
import client from './client'
export function receiveMessage(data: any) {
return client.post('/api/v1/inbound/receive', data)
}
export function getFlowTraces(eventId: string) {
return client.get(`/api/v1/inbound/${eventId}/trace`)
}
admin-web/src/api/flow.ts(供 Vue Flow 编辑器调用):
import client from './client'
export function createFlowDefinition(data: any) {
return client.post('/api/v1/inbound/flow', data)
}
export function listFlowDefinitions() {
return client.get('/api/v1/inbound/flow/list')
}
- Step 8: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/package.json admin-web/package-lock.json admin-web/src/api/
git commit -m "feat(web): add pinia/vue-flow deps and update API layer"
Task 6: 前端登录与路由守卫
Files:
-
Create:
admin-web/src/stores/auth.ts -
Create:
admin-web/src/views/Login.vue -
Modify:
admin-web/src/router/index.ts -
Modify:
admin-web/src/components/Layout.vue -
Step 1: 创建 Pinia auth store
创建 admin-web/src/stores/auth.ts:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('mci-token') || '')
const tenantCode = ref(localStorage.getItem('mci-tenant-code') || '')
const username = ref('')
const roleCodes = ref<string[]>([])
const isLoggedIn = computed(() => !!token.value)
function setAuth(t: string, tc: string, user: string, roles: string[]) {
token.value = t
tenantCode.value = tc
username.value = user
roleCodes.value = roles
localStorage.setItem('mci-token', t)
localStorage.setItem('mci-tenant-code', tc)
}
function clearAuth() {
token.value = ''
tenantCode.value = ''
username.value = ''
roleCodes.value = []
localStorage.removeItem('mci-token')
localStorage.removeItem('mci-tenant-code')
}
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
})
- Step 2: 注册 Pinia
修改 admin-web/src/main.ts,在现有 Element Plus 注册基础上加入 Pinia:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
app.use(router)
app.mount('#app')
- Step 3: 创建 Login.vue
创建 admin-web/src/views/Login.vue:
<template>
<div class="login-container">
<el-card title="Sino MCI 登录" style="width: 400px;">
<el-form :model="form" label-width="80px">
<el-form-item label="租户编码">
<el-input v-model="form.tenantCode" placeholder="tenant" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin" style="width: 100%;">登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '@/api/account'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = reactive({
tenantCode: '',
username: '',
password: '',
})
async function handleLogin() {
try {
const res: any = await login(form)
authStore.setAuth(res.data.token, res.data.userInfo.tenantCode, res.data.userInfo.username, res.data.userInfo.roleCodes || [])
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e: any) {
ElMessage.error(e.message)
}
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #f5f7fa;
}
</style>
- Step 4: 改造路由
修改 admin-web/src/router/index.ts:
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Layout from '../components/Layout.vue'
import Dashboard from '../views/Dashboard.vue'
import ChannelSubject from '../views/ChannelSubject.vue'
import ChannelAccount from '../views/ChannelAccount.vue'
import Conversation from '../views/Conversation.vue'
import Person from '../views/Person.vue'
import Tag from '../views/Tag.vue'
import SendRecord from '../views/SendRecord.vue'
import InboundFlow from '../views/InboundFlow.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 Login from '../views/Login.vue'
const routes = [
{ path: '/login', component: Login, meta: { public: true } },
{
path: '/',
component: Layout,
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: 'flow-trace', component: FlowTrace, meta: { title: '流程追踪' } },
{ path: 'message-test', component: MessageTest, meta: { title: '消息测试' } },
{ path: 'tenant-management', component: TenantManagement, meta: { title: '租户管理' } },
{ path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (!to.meta.public && !authStore.isLoggedIn) {
next('/login')
} else if (to.path === '/login' && authStore.isLoggedIn) {
next('/dashboard')
} else {
next()
}
})
export default router
- Step 5: 改造 Layout.vue 显示用户和退出
修改 admin-web/src/components/Layout.vue(若不存在则创建):
<template>
<el-container style="height: 100vh;">
<el-aside width="200px">
<div class="logo">Sino MCI</div>
<el-menu :default-active="$route.path" router>
<el-menu-item index="/dashboard">概览</el-menu-item>
<el-menu-item index="/channel-subject">渠道主体</el-menu-item>
<el-menu-item index="/channel-account">渠道账号</el-menu-item>
<el-menu-item index="/conversation">会话/群聊</el-menu-item>
<el-menu-item index="/person">联系人</el-menu-item>
<el-menu-item index="/tag">标签</el-menu-item>
<el-menu-item index="/send-record">发送记录</el-menu-item>
<el-menu-item index="/inbound-flow">入站流程</el-menu-item>
<el-menu-item index="/flow-trace">流程追踪</el-menu-item>
<el-menu-item index="/message-test">消息测试</el-menu-item>
<el-menu-item index="/tenant-management" v-if="isPlatformAdmin">租户管理</el-menu-item>
<el-menu-item index="/user-management">用户管理</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ $route.meta.title }}</span>
<div>
<span>{{ authStore.username }} ({{ authStore.tenantCode }})</span>
<el-button type="text" @click="logout" style="margin-left: 16px;">退出</el-button>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
function logout() {
authStore.clearAuth()
router.push('/login')
}
</script>
<style scoped>
.logo {
height: 60px;
line-height: 60px;
text-align: center;
font-weight: bold;
font-size: 18px;
border-bottom: 1px solid #e4e7ed;
}
</style>
- Step 6: 构建验证
Run:
cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
npm test
Expected: BUILD SUCCESS
- Step 7: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/src/stores/ admin-web/src/views/Login.vue admin-web/src/router/index.ts admin-web/src/components/Layout.vue admin-web/src/main.ts
git commit -m "feat(web): add login, auth store and router guard"
Task 7: 前端租户/用户管理页
Files:
-
Create:
admin-web/src/views/TenantManagement.vue -
Create:
admin-web/src/views/UserManagement.vue -
Step 1: 创建 TenantManagement.vue
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="租户编码">
<el-input v-model="form.tenantCode" placeholder="tenant" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="form.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<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="status" label="状态" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createTenant, listTenants } from '@/api/account'
const form = reactive({ tenantCode: '', tenantName: '' })
const tenants = ref<any[]>([])
async function loadTenants() {
try {
const res: any = await listTenants()
tenants.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCreate() {
try {
await createTenant(form)
ElMessage.success('创建成功')
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadTenants)
</script>
- Step 2: 创建 UserManagement.vue
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="password" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" placeholder="姓名" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<el-table :data="users" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realName" label="姓名" />
<el-table-column prop="roleCodes" label="角色" />
<el-table-column prop="status" label="状态" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createUser, listUsers } from '@/api/account'
const form = reactive({ username: '', password: '', realName: '' })
const users = ref<any[]>([])
async function loadUsers() {
try {
const res: any = await listUsers()
users.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCreate() {
try {
await createUser({ ...form, roleCodes: ['tenant_admin'] })
ElMessage.success('创建成功')
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadUsers)
</script>
- Step 3: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/src/views/TenantManagement.vue admin-web/src/views/UserManagement.vue
git commit -m "feat(web): add tenant and user management pages"
Task 8: 前端 Dashboard 与各列表页
Files:
-
Modify:
admin-web/src/views/Dashboard.vue -
Modify:
admin-web/src/views/ChannelSubject.vue -
Modify:
admin-web/src/views/ChannelAccount.vue -
Modify:
admin-web/src/views/Conversation.vue -
Modify:
admin-web/src/views/Person.vue -
Modify:
admin-web/src/views/Tag.vue -
Step 1: Dashboard.vue
<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>
<p>1. 先在「渠道主体」录入企微主体信息。</p>
<p>2. 在「渠道账号」添加发送/接收账号。</p>
<p>3. 在「会话/群聊」维护业务群并绑定发送账号。</p>
<p>4. 在「发送记录」查看业务系统调用发送 API 的结果。</p>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { getDashboardStats } from '@/api/dashboard'
const stats = reactive({ subjectCount: 0, accountCount: 0, conversationCount: 0, sentToday: 0 })
onMounted(async () => {
const res: any = await getDashboardStats()
Object.assign(stats, res.data)
})
</script>
<style scoped>
.mt-4 { margin-top: 20px; }
</style>
- Step 2: ChannelSubject.vue 改造
补充表单字段并加载列表:
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="渠道">
<el-select v-model="form.channelType">
<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="form.subjectCode" /></el-form-item>
<el-form-item label="主体名称"><el-input v-model="form.subjectName" /></el-form-item>
<el-form-item label="CorpID"><el-input v-model="form.corpId" /></el-form-item>
<el-form-item label="AgentID"><el-input v-model="form.agentId" /></el-form-item>
<el-form-item label="AgentSecret"><el-input v-model="form.agentSecret" /></el-form-item>
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
</el-form>
<el-table :data="subjects" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道" />
<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="状态" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createChannelSubject, listChannelSubjects } from '@/api/channel'
const form = reactive({ channelType: 'wecom', subjectCode: '', subjectName: '', corpId: '', agentId: '', agentSecret: '' })
const subjects = ref<any[]>([])
async function loadSubjects() {
const res: any = await listChannelSubjects()
subjects.value = res.data || []
}
async function handleCreate() {
await createChannelSubject(form)
ElMessage.success('创建成功')
await loadSubjects()
}
onMounted(loadSubjects)
</script>
- Step 3: ChannelAccount.vue 改造
补充 subjectId、sessionArchiveEnabled 并加载列表:
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="账号类型">
<el-select v-model="form.accountType">
<el-option label="企微内部账号" :value="1" />
<el-option label="企微应用" :value="2" />
<el-option label="pywechat 个人号" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="渠道"><el-input v-model="form.channelType" /></el-form-item>
<el-form-item label="所属主体">
<el-select v-model="form.subjectId" placeholder="选择主体">
<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="账号ID"><el-input v-model="form.accountId" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.accountName" /></el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="form.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
</el-form>
<el-table :data="accounts" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="accountType" label="类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="accountId" label="账号ID" />
<el-table-column prop="accountName" label="名称" />
<el-table-column prop="loginStatus" label="登录状态" />
<el-table-column prop="onlineStatus" label="在线状态" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createChannelAccount, listChannelAccounts, listChannelSubjects } from '@/api/channel'
const form = reactive({ accountType: 1, channelType: 'wecom', subjectId: null, accountId: '', accountName: '', sessionArchiveEnabled: 0 })
const accounts = ref<any[]>([])
const subjects = ref<any[]>([])
async function loadData() {
const [a, s] = await Promise.all([listChannelAccounts(), listChannelSubjects()])
accounts.value = a.data || []
subjects.value = s.data || []
}
async function handleCreate() {
await createChannelAccount(form)
ElMessage.success('创建成功')
await loadData()
}
onMounted(loadData)
</script>
- Step 4: Conversation.vue 改造
补充 ownerAccountId 和绑定账号弹窗:
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="会话类型">
<el-select v-model="form.conversationType"><el-option label="单聊" :value="1" /><el-option label="群聊" :value="2" /></el-select>
</el-form-item>
<el-form-item label="渠道"><el-input v-model="form.channelType" /></el-form-item>
<el-form-item label="渠道会话ID"><el-input v-model="form.channelConversationId" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.conversationName" /></el-form-item>
<el-form-item label="群主">
<el-select v-model="form.ownerAccountId" placeholder="选择账号">
<el-option v-for="a in accounts" :key="a.id" :label="a.accountName" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
</el-form>
<el-table :data="conversations" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="conversationType" label="类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelConversationId" label="渠道会话ID" />
<el-table-column prop="conversationName" label="名称" />
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" @click="openBind(scope.row)">绑定账号</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="bindVisible" title="绑定账号" width="400px">
<el-form :model="bindForm" label-width="100px">
<el-form-item label="账号">
<el-select v-model="bindForm.accountId">
<el-option v-for="a in accounts" :key="a.id" :label="a.accountName" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="绑定类型">
<el-select v-model="bindForm.bindingType">
<el-option label="发送主号" :value="1" />
<el-option label="发送备用号" :value="2" />
<el-option label="接收主号" :value="3" />
<el-option label="接收备用号" :value="4" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindVisible = false">取消</el-button>
<el-button type="primary" @click="handleBind">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createConversation, listConversations, bindConversationAccount, listChannelAccounts } from '@/api/channel'
const form = reactive({ conversationType: 2, channelType: 'wecom', channelConversationId: '', conversationName: '', ownerAccountId: null })
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
const bindVisible = ref(false)
const currentConversation = ref<any>(null)
const bindForm = reactive({ accountId: null, bindingType: 1, sortOrder: 0 })
async function loadData() {
const [c, a] = await Promise.all([listConversations(), listChannelAccounts()])
conversations.value = c.data || []
accounts.value = a.data || []
}
async function handleCreate() {
await createConversation(form)
ElMessage.success('创建成功')
await loadData()
}
function openBind(row: any) {
currentConversation.value = row
bindForm.accountId = null
bindVisible.value = true
}
async function handleBind() {
if (!currentConversation.value || !bindForm.accountId) return
await bindConversationAccount(currentConversation.value.id, bindForm)
ElMessage.success('绑定成功')
bindVisible.value = false
}
onMounted(loadData)
</script>
- Step 5: Person.vue 改造
加载人员列表:
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="编码"><el-input v-model="form.personCode" /></el-form-item>
<el-form-item label="姓名"><el-input v-model="form.personName" /></el-form-item>
<el-form-item label="手机"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="类型">
<el-select v-model="form.personType">
<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><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
</el-form>
<el-table :data="persons" border>
<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="类型" />
<el-table-column prop="status" label="状态" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createPerson, listPersons } from '@/api/crm'
const form = reactive({ personCode: '', personName: '', phone: '', personType: 3 })
const persons = ref<any[]>([])
async function loadPersons() {
const res: any = await listPersons()
persons.value = res.data || []
}
async function handleCreate() {
await createPerson(form)
ElMessage.success('创建成功')
await loadPersons()
}
onMounted(loadPersons)
</script>
- Step 6: Tag.vue 改造
树形展示层级:
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="标签名"><el-input v-model="form.tagName" /></el-form-item>
<el-form-item label="父标签">
<el-select v-model="form.parentId" clearable>
<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><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
</el-form>
<el-table :data="tags" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tagName" label="名称" />
<el-table-column prop="tagPath" label="路径" />
<el-table-column prop="level" label="层级" />
<el-table-column prop="status" label="状态" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createTag, listTags } from '@/api/crm'
const form = reactive({ tagName: '', parentId: 0 })
const tags = ref<any[]>([])
async function loadTags() {
const res: any = await listTags()
tags.value = res.data || []
}
async function handleCreate() {
await createTag(form)
ElMessage.success('创建成功')
await loadTags()
}
onMounted(loadTags)
</script>
- Step 7: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/src/views/
git commit -m "feat(web): load real data in dashboard and list pages"
Task 9: 发送记录页 UX 优化
Files:
-
Modify:
admin-web/src/views/SendRecord.vue -
Step 1: 优化 SendRecord.vue
<template>
<div>
<el-form :model="form" label-width="80px">
<el-form-item label="目标类型">
<el-select v-model="form.targetType">
<el-option label="会话" value="conversation" />
<el-option label="人员" value="person" />
</el-select>
</el-form-item>
<el-form-item label="目标" v-if="form.targetType === 'conversation'">
<el-select v-model="selectedConversationId" placeholder="选择会话">
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="内容类型"><el-input v-model="form.contentType" disabled /></el-form-item>
<el-form-item label="内容">
<el-input v-model="textContent" type="textarea" :rows="3" placeholder="输入文本消息" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
</el-form-item>
</el-form>
<el-divider />
<el-table :data="records" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="taskId" label="任务ID" />
<el-table-column prop="targetType" label="目标类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="contentType" label="内容类型" />
<el-table-column prop="sendStatus" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="scope.row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">待发送</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { testSendMessage, listSendRecords } from '@/api/send'
import { listConversations } from '@/api/channel'
const form = reactive({ targetType: 'conversation', contentType: 'text', channelStrategy: { channel_type: 'wecom', strategy: 'primary' } })
const selectedConversationId = ref<number | null>(null)
const textContent = ref('')
const records = ref<any[]>([])
const conversations = ref<any[]>([])
const targetValue = computed(() => {
if (form.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
}
return {}
})
async function loadRecords() {
const res: any = await listSendRecords()
records.value = res.data || []
}
async function loadConversations() {
const res: any = await listConversations()
conversations.value = res.data || []
}
async function handleTestSend() {
try {
await testSendMessage({
targetType: form.targetType,
targetValue: targetValue.value,
contentType: 'text',
content: { text: textContent.value },
channelStrategy: form.channelStrategy,
})
ElMessage.success('测试发送已提交')
await loadRecords()
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadRecords()
loadConversations()
})
</script>
- Step 2: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/src/views/SendRecord.vue
git commit -m "feat(web): improve send record UX with dropdowns"
Task 10: 入站流程可视化编辑器与追踪
Files:
-
Modify:
admin-web/src/views/InboundFlow.vue -
Create:
admin-web/src/views/FlowTrace.vue -
Step 1: 安装 Vue Flow
已在 Task 5 添加依赖,无需重复。
- Step 2: 改造 InboundFlow.vue
使用 Vue Flow 实现流程图编辑:
<template>
<div style="height: 600px;">
<div style="margin-bottom: 12px;">
<el-input v-model="form.flowCode" placeholder="流程编码" style="width: 150px;" />
<el-input v-model="form.flowName" placeholder="流程名称" style="width: 150px; margin-left: 8px;" />
<el-button type="primary" @click="handleSave" style="margin-left: 8px;">保存流程</el-button>
</div>
<VueFlow v-model="elements" fit-view-on-init>
<Background />
<Controls />
</VueFlow>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import { ElMessage } from 'element-plus'
import { createFlowDefinition } from '@/api/flow'
const form = reactive({ flowCode: '', flowName: '', nodes: [] as any[], edges: [] as any[] })
const elements = ref([
{ id: '1', type: 'input', label: '接收消息', position: { x: 250, y: 5 }, data: { nodeType: 'receive' } },
{ id: '2', label: '意图识别', position: { x: 250, y: 120 }, data: { nodeType: 'intent', intent: 'rescue_request' } },
{ id: '3', label: 'Webhook 推送', position: { x: 250, y: 240 }, data: { nodeType: 'webhook', webhookUrl: '' } },
{ id: 'e1-2', source: '1', target: '2', animated: true },
{ id: 'e2-3', source: '2', target: '3', animated: true },
])
async function handleSave() {
const nodes = elements.value.filter(e => !e.source).map(n => ({
nodeType: n.data?.nodeType,
config: n.data,
position: n.position,
}))
const edges = elements.value.filter(e => e.source).map(e => ({
source: e.source,
target: e.target,
}))
await createFlowDefinition({
flowCode: form.flowCode,
flowName: form.flowName,
flowType: 'inbound',
definitionJson: { nodes, edges },
})
ElMessage.success('保存成功')
}
</script>
说明:后端 FlowDefinition.definitionJson 为字符串字段,但在 CreateFlowRequest 中对应 Map<String, Object>,前端按 { nodes, edges } 结构提交,由后端序列化为 JSON 字符串持久化。
- Step 3: 创建 FlowTrace.vue
<template>
<div>
<el-form inline>
<el-form-item label="事件ID"><el-input v-model="eventId" /></el-form-item>
<el-form-item><el-button type="primary" @click="loadTrace">查询</el-button></el-form-item>
</el-form>
<el-timeline>
<el-timeline-item v-for="t in traces" :key="t.id" :type="t.status === 1 ? 'success' : 'danger'">
<p>{{ t.nodeType }} - {{ t.status === 1 ? '成功' : '失败' }} - {{ t.durationMs }}ms</p>
<p v-if="t.errorMsg" style="color: red;">{{ t.errorMsg }}</p>
</el-timeline-item>
</el-timeline>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getFlowTraces } from '@/api/inbound'
const eventId = ref('')
const traces = ref<any[]>([])
async function loadTrace() {
try {
const res: any = await getFlowTraces(eventId.value)
traces.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
</script>
- Step 4: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/src/views/InboundFlow.vue admin-web/src/views/FlowTrace.vue
git commit -m "feat(web): add Vue Flow editor and flow trace viewer"
Task 11: 消息测试模块
Files:
-
Create:
admin-web/src/views/MessageTest.vue -
Step 1: 创建 MessageTest.vue
<template>
<div>
<el-tabs v-model="activeTab">
<el-tab-pane label="出站测试" name="outbound">
<el-form :model="outbound" label-width="80px">
<el-form-item label="目标类型">
<el-select v-model="outbound.targetType">
<el-option label="会话" value="conversation" />
<el-option label="人员" value="person" />
</el-select>
</el-form-item>
<el-form-item label="目标会话" v-if="outbound.targetType === 'conversation'">
<el-select v-model="outbound.conversationId">
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="文本内容"><el-input v-model="outbound.text" type="textarea" /></el-form-item>
<el-form-item><el-button type="primary" @click="handleOutbound">测试发送</el-button></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="入站模拟" name="inbound">
<el-form :model="inbound" label-width="100px">
<el-form-item label="事件ID"><el-input v-model="inbound.eventId" /></el-form-item>
<el-form-item label="渠道"><el-input v-model="inbound.channelType" /></el-form-item>
<el-form-item label="事件类型"><el-input v-model="inbound.eventType" /></el-form-item>
<el-form-item label="消息来源"><el-input v-model="inbound.messageSource" /></el-form-item>
<el-form-item label="会话ID"><el-input v-model="inbound.conversationId" /></el-form-item>
<el-form-item label="文本内容"><el-input v-model="inbound.text" type="textarea" /></el-form-item>
<el-form-item><el-button type="primary" @click="handleInbound">模拟入站</el-button></el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { testSendMessage } from '@/api/send'
import { receiveMessage } from '@/api/inbound'
import { listConversations } from '@/api/channel'
const activeTab = ref('outbound')
const conversations = ref<any[]>([])
const outbound = reactive({ targetType: 'conversation', conversationId: null, text: '' })
const inbound = reactive({ eventId: '', channelType: 'wecom', eventType: 'message', messageSource: 'realtime', conversationId: '', text: '' })
async function loadConversations() {
const res: any = await listConversations()
conversations.value = res.data || []
}
async function handleOutbound() {
try {
await testSendMessage({
targetType: outbound.targetType,
targetValue: { conversation_id: outbound.conversationId },
contentType: 'text',
content: { text: outbound.text },
channelStrategy: { channel_type: 'wecom', strategy: 'primary' },
})
ElMessage.success('发送成功')
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleInbound() {
try {
await receiveMessage({
eventId: inbound.eventId,
channelType: inbound.channelType,
eventType: inbound.eventType,
messageSource: inbound.messageSource,
conversationId: Number(inbound.conversationId),
content: { text: inbound.text },
rawPayload: {},
businessContext: {},
})
ElMessage.success('入站模拟成功')
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadConversations)
</script>
- Step 2: Commit
cd /Users/marsal/Projects/sino-project/sino-mci
git add admin-web/src/views/MessageTest.vue
git commit -m "feat(web): add message test module"
Task 12: 联调与验证
- Step 1: 启动基础设施
cd /Users/marsal/Projects/sino-project/sino-mci
./scripts/local-up.sh --with-apps
- Step 2: 创建初始租户和管理员
curl -X POST http://localhost/msg-platform/api/v1/account/tenant \
-H "Content-Type: application/json" \
-d '{"tenantCode":"platform","tenantName":"平台管理"}'
curl -X POST http://localhost/msg-platform/api/v1/account/user \
-H "Content-Type: application/json" \
-H "X-Tenant-Code: platform" \
-d '{"username":"admin","password":"admin123","realName":"平台管理员","roleCodes":["platform_admin"]}'
- Step 3: 登录验证
打开 http://localhost/login,使用 platform / admin / admin123 登录,确认进入 Dashboard。
- Step 4: 运行测试
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server && JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
cd /Users/marsal/Projects/sino-project/sino-mci/channel-service && source .venv/bin/activate && pytest
cd /Users/marsal/Projects/sino-project/sino-mci/admin-web && npm test
bash /Users/marsal/Projects/sino-project/sino-mci/scripts/test-external-api.sh
Expected: 全部通过
- Step 5: Commit any fixes
cd /Users/marsal/Projects/sino-project/sino-mci
git add .
git commit -m "fix: integration fixes for auth and admin-web"
计划自检
Spec coverage:
- 登录鉴权:Task 1-2
- 多租户管理:Task 2、Task 7
- 管理后台列表:Task 3、Task 8
- 会话绑定:Task 8
- Dashboard 统计:Task 3、Task 8
- 入站流程图:Task 4、Task 10
- 消息测试模块:Task 11
- 前端路由守卫:Task 6
Placeholder scan: 无 TBD/TODO/实现 later。
Type consistency: LoginResponse 复用现有字段;AuthContext.AuthUser 与 Controller 返回一致;SendRecordResponse 复用现有转换逻辑。