Files
sino-mci/docs/superpowers/plans/2026-07-07-admin-auth-flow-testing-plan.md

78 KiB
Raw Blame History

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<dependencies> 节点,添加:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
  • Step 2: 创建 JwtTokenProvider

创建 backend/mci-server/src/main/java/com/sino/mci/admin/security/JwtTokenProvider.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<String> 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

package com.sino.mci.admin.security;

import lombok.Data;

public class AuthContext {

    private static final ThreadLocal<AuthUser> CURRENT = new ThreadLocal<>();

    @Data
    public static class AuthUser {
        private Long userId;
        private String tenantCode;
        private String username;
        private java.util.List<String> 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

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

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:

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

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

import com.sino.mci.admin.security.JwtTokenProvider;

private final JwtTokenProvider jwtTokenProvider;

构造器已使用 @RequiredArgsConstructor,无需改动构造器。

改造 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()));

    String token = jwtTokenProvider.generateToken(
            user.getTenantCode(), user.getId(), user.getUsername(), userInfo.getRoleCodes());
    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 复用现有转换逻辑。