# 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 --- ## 功能点清单 ### 后端功能点 1. JWT Token 生成与解析 2. 鉴权拦截器与登录上下文 3. 登录接口改造(返回 token) 4. 当前登录用户信息接口 5. 租户列表接口 6. 用户列表接口 7. 租户隔离校验 8. 渠道主体列表接口 9. 渠道账号列表接口 10. 会话列表接口 11. 联系人列表接口 12. 发送记录列表接口 13. Dashboard 统计接口 14. 入站流程执行轨迹记录与查询 15. Webhook 测试接收端点 ### 前端功能点 1. 登录页 2. 前端路由守卫 3. API Token 注入 4. 租户管理页 5. 用户管理页 6. Dashboard 真实统计 7. 渠道主体页补充字段与列表 8. 渠道账号页补充字段与列表 9. 会话/群聊页绑定账号 10. 联系人页列表 11. 发送记录页 UX 优化 12. 标签层级展示 13. 入站流程可视化编辑器 14. 入站流程执行追踪 15. 消息测试模块(出站 + 入站) --- ## 文件结构 ### 后端新增/修改文件 | 文件 | 职责 | |---|---| | `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: 后端 JWT 与鉴权基础设施 **Files:** - Modify: `backend/mci-server/pom.xml` - Create: `backend/mci-server/src/main/java/com/sino/mci/admin/security/JwtTokenProvider.java` - 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` - [ ] **Step 1: 添加 jjwt 依赖** 修改 `backend/mci-server/pom.xml` 的 `` 节点,添加: ```xml io.jsonwebtoken jjwt-api 0.12.6 io.jsonwebtoken jjwt-impl 0.12.6 runtime io.jsonwebtoken jjwt-jackson 0.12.6 runtime ``` - [ ] **Step 2: 创建 JwtTokenProvider** 创建 `backend/mci-server/src/main/java/com/sino/mci/admin/security/JwtTokenProvider.java`: ```java package com.sino.mci.admin.security; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; @Component public class JwtTokenProvider { @Value("${mci.jwt.secret:sino-mci-default-secret-key-at-least-32-bytes}") private String secret; @Value("${mci.jwt.expiration-ms:86400000}") private long expirationMs; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(String tenantCode, Long userId, String username, List roleCodes) { Date now = new Date(); Date expiry = new Date(now.getTime() + expirationMs); return Jwts.builder() .subject(String.valueOf(userId)) .claim("tenantCode", tenantCode) .claim("username", username) .claim("roleCodes", roleCodes) .issuedAt(now) .expiration(expiry) .signWith(getKey()) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(getKey()) .build() .parseSignedClaims(token) .getPayload(); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } } ``` - [ ] **Step 3: 创建 AuthContext** 创建 `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java`: ```java package com.sino.mci.admin.security; import lombok.Data; public class AuthContext { private static final ThreadLocal CURRENT = new ThreadLocal<>(); @Data public static class AuthUser { private Long userId; private String tenantCode; private String username; private java.util.List roleCodes; } public static void set(AuthUser user) { CURRENT.set(user); } public static AuthUser get() { return CURRENT.get(); } public static void clear() { CURRENT.remove(); } public static String currentTenantCode() { AuthUser user = get(); return user == null ? null : user.getTenantCode(); } } ``` - [ ] **Step 4: 创建 AuthInterceptor** 创建 `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java`: ```java package com.sino.mci.admin.security; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import java.util.List; @Component @RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { private final JwtTokenProvider jwtTokenProvider; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String path = request.getRequestURI(); if (path.contains("/api/v1/account/login") || path.contains("/api/v1/account/tenant")) { return true; } String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } String token = header.substring(7); if (!jwtTokenProvider.validateToken(token)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } Claims claims = jwtTokenProvider.parseToken(token); AuthContext.AuthUser user = new AuthContext.AuthUser(); user.setUserId(Long.valueOf(claims.getSubject())); user.setTenantCode(claims.get("tenantCode", String.class)); user.setUsername(claims.get("username", String.class)); user.setRoleCodes(claims.get("roleCodes", List.class)); AuthContext.set(user); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { AuthContext.clear(); } } ``` - [ ] **Step 5: 注册拦截器** 创建 `backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java`: ```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"); } } ``` - [ ] **Step 6: 编译验证** Run: ```bash 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 7: Commit** ```bash 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 git commit -m "feat(auth): add JWT provider, 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`: ```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`: 注入 `JwtTokenProvider`: ```java import com.sino.mci.admin.security.JwtTokenProvider; private final JwtTokenProvider jwtTokenProvider; ``` 构造器已使用 `@RequiredArgsConstructor`,无需改动构造器。 改造 `login` 方法: ```java public TokenResponse login(String tenantCode, String username, String password) { PlatformUser user = userMapper.selectOne( new LambdaQueryWrapper() .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())); String token = jwtTokenProvider.generateToken( user.getTenantCode(), user.getId(), user.getUsername(), userInfo.getRoleCodes()); return new TokenResponse(token, userInfo); } ``` - [ ] **Step 3: 新增 list 方法到 AccountService** 在 `AccountService.java` 末尾添加: ```java public List listTenants() { return tenantMapper.selectList(new LambdaQueryWrapper().eq(Tenant::getStatus, 1)); } public List listUsers(String tenantCode) { return userMapper.selectList( new LambdaQueryWrapper() .eq(PlatformUser::getTenantCode, tenantCode) .eq(PlatformUser::getStatus, 1)); } ``` - [ ] **Step 4: 改造 AccountController** 修改 `backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java`: ```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 createTenant(@Valid @RequestBody CreateTenantRequest request) { return ApiResponse.ok(accountService.createTenant(request)); } @GetMapping("/tenant/list") public ApiResponse> listTenants() { return ApiResponse.ok(accountService.listTenants()); } @PostMapping("/user") public ApiResponse createUser( @RequestHeader("X-Tenant-Code") String tenantCode, @Valid @RequestBody CreateUserRequest request) { return ApiResponse.ok(accountService.createUser(tenantCode, request)); } @GetMapping("/user/list") public ApiResponse> listUsers() { return ApiResponse.ok(accountService.listUsers(AuthContext.currentTenantCode())); } @PostMapping("/login") public ApiResponse 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 me() { return ApiResponse.ok(AuthContext.get()); } } ``` - [ ] **Step 5: 新增 PlatformUserResponse** 创建 `backend/mci-server/src/main/java/com/sino/mci/admin/dto/PlatformUserResponse.java`: ```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 roleCodes; private LocalDateTime createTime; } ``` 并调整 `AccountService.listUsers` 返回 `List`,使用 `toUserResponse` 转换。 - [ ] **Step 6: 添加平台管理员角色种子数据** 创建 `backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql`: ```sql INSERT IGNORE INTO mci_platform_role (role_code, role_name, permissions) VALUES ('platform_admin', '平台管理员', '["tenant:manage", "user:manage", "*"]'); ``` - [ ] **Step 7: 运行测试** Run: ```bash 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** ```bash 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`: ```java @GetMapping("/list") public ApiResponse> listSubjects( @RequestHeader("X-Tenant-Code") String tenantCode) { return ApiResponse.ok(subjectService.listSubjects(tenantCode)); } ``` 修改 `ChannelSubjectService.java`: ```java public List listSubjects(String tenantCode) { return subjectMapper.selectList( new LambdaQueryWrapper() .eq(ChannelSubject::getTenantCode, tenantCode) .eq(ChannelSubject::getStatus, 1) .orderByDesc(ChannelSubject::getId)); } ``` - [ ] **Step 2: ChannelAccount 列表** 修改 `ChannelAccountController.java`: ```java @GetMapping("/list") public ApiResponse> listAccounts( @RequestHeader("X-Tenant-Code") String tenantCode) { return ApiResponse.ok(accountService.listAccounts(tenantCode)); } ``` 修改 `ChannelAccountService.java`: ```java public List listAccounts(String tenantCode) { return accountMapper.selectList( new LambdaQueryWrapper() .eq(ChannelAccount::getTenantCode, tenantCode) .eq(ChannelAccount::getStatus, 1) .orderByDesc(ChannelAccount::getId)); } ``` - [ ] **Step 3: Conversation 列表** 修改 `ConversationController.java`: ```java @GetMapping("/list") public ApiResponse> listConversations( @RequestHeader("X-Tenant-Code") String tenantCode) { return ApiResponse.ok(conversationService.listConversations(tenantCode)); } ``` 修改 `ConversationService.java`: ```java public List listConversations(String tenantCode) { return conversationMapper.selectList( new LambdaQueryWrapper() .eq(Conversation::getTenantCode, tenantCode) .eq(Conversation::getStatus, 1) .orderByDesc(Conversation::getId)); } ``` - [ ] **Step 4: Person 列表** 修改 `PersonController.java`: ```java @GetMapping("/list") public ApiResponse> listPersons( @RequestHeader("X-Tenant-Code") String tenantCode) { return ApiResponse.ok(personService.listPersons(tenantCode)); } ``` 修改 `PersonService.java`: ```java public List listPersons(String tenantCode) { return personMapper.selectList( new LambdaQueryWrapper() .eq(Person::getTenantCode, tenantCode) .eq(Person::getStatus, 1) .orderByDesc(Person::getId)); } ``` - [ ] **Step 5: SendRecord 列表** 修改 `SendController.java`: ```java @GetMapping("/list") public ApiResponse> listSendRecords( @RequestHeader("X-Tenant-Code") String tenantCode) { return ApiResponse.ok(sendService.listRecentRecords(tenantCode)); } ``` 修改 `SendService.java`: ```java public List listRecentRecords(String tenantCode) { List records = sendRecordMapper.selectList( new LambdaQueryWrapper() .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`: ```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`: ```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 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() .eq(ChannelSubject::getTenantCode, tenantCode) .eq(ChannelSubject::getStatus, 1)); long accounts = accountMapper.selectCount( new LambdaQueryWrapper() .eq(ChannelAccount::getTenantCode, tenantCode) .eq(ChannelAccount::getStatus, 1)); long conversations = conversationMapper.selectCount( new LambdaQueryWrapper() .eq(Conversation::getTenantCode, tenantCode) .eq(Conversation::getStatus, 1)); long sentToday = sendRecordMapper.selectCount( new LambdaQueryWrapper() .eq(SendRecord::getTenantCode, tenantCode) .between(SendRecord::getSendTime, startOfDay, endOfDay)); return ApiResponse.ok(new DashboardStatsResponse(subjects, accounts, conversations, sentToday)); } } ``` - [ ] **Step 7: 运行测试** Run: ```bash 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** ```bash 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`: ```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`: ```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`: ```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 { } ``` - [ ] **Step 3: FlowEngine 记录轨迹** 修改 `FlowEngine.java`,注入 `FlowExecutionTraceMapper`。 当前 `FlowEngine.executeInboundFlow` 实际执行固定三步:意图识别、Webhook 推送、标记已处理。轨迹按以下固定节点记录: ```java 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`: ```java 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` 节点: ```java 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`: ```java @GetMapping("/{eventId}/trace") public ApiResponse> trace(@PathVariable String eventId) { return ApiResponse.ok(inboundMessageService.listTraces(eventId)); } ``` 在 `InboundMessageService.java` 新增: ```java public List listTraces(String eventId) { return traceMapper.selectList( new LambdaQueryWrapper() .eq(FlowExecutionTrace::getEventId, eventId) .orderByAsc(FlowExecutionTrace::getId)); } ``` - [ ] **Step 5: 运行测试** Run: ```bash 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** ```bash 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`: ```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: ```bash cd /Users/marsal/Projects/sino-project/sino-mci/admin-web npm install ``` - [ ] **Step 2: 改造 client.ts** 修改 `admin-web/src/api/client.ts`: ```typescript 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** ```typescript 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** ```typescript 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** ```typescript 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** ```typescript 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`: ```typescript import client from './client' export function getDashboardStats() { return client.get('/api/v1/dashboard/stats') } ``` `admin-web/src/api/inbound.ts`: ```typescript 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 编辑器调用): ```typescript 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** ```bash 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`: ```typescript 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([]) 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: ```typescript 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`: ```vue ``` - [ ] **Step 4: 改造路由** 修改 `admin-web/src/router/index.ts`: ```typescript 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`(若不存在则创建): ```vue ``` - [ ] **Step 6: 构建验证** Run: ```bash cd /Users/marsal/Projects/sino-project/sino-mci/admin-web npm test ``` Expected: BUILD SUCCESS - [ ] **Step 7: Commit** ```bash 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** ```vue ``` - [ ] **Step 2: 创建 UserManagement.vue** ```vue ``` - [ ] **Step 3: Commit** ```bash 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** ```vue ``` - [ ] **Step 2: ChannelSubject.vue 改造** 补充表单字段并加载列表: ```vue ``` - [ ] **Step 3: ChannelAccount.vue 改造** 补充 subjectId、sessionArchiveEnabled 并加载列表: ```vue ``` - [ ] **Step 4: Conversation.vue 改造** 补充 ownerAccountId 和绑定账号弹窗: ```vue ``` - [ ] **Step 5: Person.vue 改造** 加载人员列表: ```vue ``` - [ ] **Step 6: Tag.vue 改造** 树形展示层级: ```vue ``` - [ ] **Step 7: Commit** ```bash 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** ```vue ``` - [ ] **Step 2: Commit** ```bash 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 实现流程图编辑: ```vue ``` 说明:后端 `FlowDefinition.definitionJson` 为字符串字段,但在 `CreateFlowRequest` 中对应 `Map`,前端按 `{ nodes, edges }` 结构提交,由后端序列化为 JSON 字符串持久化。 - [ ] **Step 3: 创建 FlowTrace.vue** ```vue ``` - [ ] **Step 4: Commit** ```bash 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** ```vue ``` - [ ] **Step 2: Commit** ```bash 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: 启动基础设施** ```bash cd /Users/marsal/Projects/sino-project/sino-mci ./scripts/local-up.sh --with-apps ``` - [ ] **Step 2: 创建初始租户和管理员** ```bash 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: 运行测试** ```bash 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** ```bash 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` 复用现有转换逻辑。