Series Article

Day02 · SRM 供应商管理系统完整面试指南

一、系统概述与业务价值

1.1 系统定位

面试官:介绍一下你负责的 SRM 系统。

我的回答:

我负责的是 SRM 供应商关系管理系统,这个系统主要解决跨境电商供应链中的供应商全生命周期管理问题。

具体来说,我们是一个 SaaS 平台,服务多个跨境电商商家。每个商家可能有几十到上百个供应商,涉及采购、质量、交付、财务等多个维度的协作。我们的系统需要管理供应商的准入审核、资质管理、绩效评分、协同门户等全流程业务。

这个系统在整个供应链中的位置是最上游。下游是 PMS 采购系统,采购员创建采购单时需要选择已审核通过的供应商。再下游是 WMS 仓储系统,货物到仓后需要记录供应商的交付质量。最后是 FMS 财务系统,需要根据供应商的账期和结算方式生成应付账款。

日均处理供应商审核单 200+,绩效评分任务 500+,供应商门户访问 PV 5000+。系统管理了 2000+ 活跃供应商,累计存储了 50 万+ 资质文件。

1.2 核心业务场景

SRM 系统解决三个核心业务场景:

场景 1:供应商准入管理

  • 新供应商注册后需要提交营业执照、税务登记证、质量认证等资质文件
  • 采购经理审核供应商的资质、生产能力、价格竞争力
  • 审核通过后供应商才能参与采购,审核不通过需要说明原因
  • 技术难点:审核流程的状态机设计、文件上传的安全性、审核操作的幂等性

场景 2:供应商绩效评分

  • 每月自动统计供应商的交付准时率、质量合格率、价格竞争力、服务响应速度
  • 根据四个维度计算综合评分(满分 100 分)
  • 评分结果影响供应商的优先级和采购分配
  • 技术难点:定时任务的分片执行、评分计算的准确性、历史评分的可追溯性

场景 3:供应商协同门户

  • 供应商登录门户查看采购订单、发货通知、对账单
  • 供应商上传发货单、物流单号,更新订单状态
  • 供应商查看自己的绩效评分和改进建议
  • 技术难点:多租户数据隔离、供应商账号体系、消息通知机制

1.3 系统架构

技术栈:

  • 后端框架: Spring Boot 3.2、MyBatis-Plus 3.5
  • 数据库: MySQL 8.0(主从架构)
  • 缓存: Redis 7.0(权限缓存、分布式锁)
  • 文件存储: 阿里云 OSS
    • OSS 第三方平台的OSS
      • 阿里云
      • Cloudflare
      • 开箱即用 管理方便 扩容方便 CDN 加速方便
      • 备份 数据的迁移 …
      • 增值服务 只要是花钱 什么都有 完全不需要自己动手
    • 自己搭建的文件服务器
      • 性价比高 如果文件数量很大的前提下
      • 所有的权限 扩容 CDN … 都需要自己进行配置管理
  • 定时任务: XXL-Job 2.4
  • 消息通知: 邮件(JavaMail)+ 站内信
  • 容器化: Docker 24.0、docker-compose 2.20
  • Web服务器: Nginx 1.24(反向代理、负载均衡)

核心模块:

  1. 供应商主数据管理:基础信息、联系人、资质证书
  2. 供应商审核模块:状态机流程、审核日志、幂等性控制
  3. 文件上传模块:安全校验、OSS 存储、访问控制
  4. 绩效评分模块:定时统计、多维度评分、历史追溯
  5. 供应商门户:独立账号体系、数据隔离、消息通知

二、核心技术亮点

2.1 供应商审核的状态机设计

什么是状态机: 我们的业务 存在不同阶段的状态转换 为了防止不同状态的乱跳 我们可以通过定义状态的流转的Map来进行限制 那些状态可以进行跳转 然后只能跳转到哪个状态 这些都是定义好的 如果我们要实现状态流转之前 会先通过这个状态机来进行判断 当前的状态是否允许跳转 要跳转到的状态是否是正确的 这个机制 这个判断的流程 就是我们的状态机

问题背景:

供应商审核流程涉及多个状态流转:待提交 → 待审核 → 审核通过/审核拒绝 → 已启用/已禁用。如果用 if-else 判断状态流转,代码会非常混乱,容易出现非法状态流转(比如从”审核拒绝”直接跳到”已启用”)。

解决方案:

设计了基于白名单的状态机,明确定义 5 个状态和 7 个合法流转路径。

具体实现:

第一步,定义状态枚举:

public enum SupplierStatus {
    DRAFT(0, "草稿"),           // 供应商刚注册,信息未填完
    PENDING(1, "待审核"),       // 供应商提交审核
    APPROVED(2, "审核通过"),    // 审核通过但未启用
    REJECTED(3, "审核拒绝"),    // 审核不通过
    ENABLED(4, "已启用");       // 正式启用,可参与采购
    
    private final int code;
    private final String desc;
}

第二步,定义状态流转白名单:

public class SupplierStateMachine {
    // 状态流转白名单:当前状态 -> 允许流转到的状态列表
    private static final Map<SupplierStatus, List<SupplierStatus>> TRANSITIONS = Map.of(
        SupplierStatus.DRAFT, List.of(SupplierStatus.PENDING),                    // 草稿 -> 待审核
        SupplierStatus.PENDING, List.of(SupplierStatus.APPROVED, SupplierStatus.REJECTED), // 待审核 -> 通过/拒绝
        SupplierStatus.APPROVED, List.of(SupplierStatus.ENABLED),                 // 审核通过 -> 已启用
        SupplierStatus.REJECTED, List.of(SupplierStatus.PENDING),                 // 审核拒绝 -> 重新提交
        SupplierStatus.ENABLED, List.of(SupplierStatus.PENDING)                   // 已启用 -> 待审核(资质到期重审)
    );
    
    // 校验状态流转是否合法
    public static boolean canTransition(SupplierStatus from, SupplierStatus to) {
        List<SupplierStatus> allowedStates = TRANSITIONS.get(from);
        return allowedStates != null && allowedStates.contains(to);
    }
}

第三步,在业务代码中使用状态机:

@Transactional(rollbackFor = Exception.class)
public void auditSupplier(Long supplierId, SupplierStatus targetStatus, String auditRemark) {
    // 1. 查询供应商当前状态
    Supplier supplier = supplierMapper.selectById(supplierId);
    SupplierStatus currentStatus = SupplierStatus.of(supplier.getStatus());
    
    // 2. 校验状态流转是否合法
    if (!SupplierStateMachine.canTransition(currentStatus, targetStatus)) {
        throw new BusinessException("非法的状态流转: " + currentStatus + " -> " + targetStatus);
    }
    
    // 3. 更新供应商状态
    supplier.setStatus(targetStatus.getCode());
    supplier.setAuditTime(LocalDateTime.now());
    supplier.setAuditRemark(auditRemark);
    supplierMapper.updateById(supplier);
    
    // 4. 记录审核日志
    SupplierAuditLog log = new SupplierAuditLog();
    log.setSupplierId(supplierId);
    log.setFromStatus(currentStatus.getCode());
    log.setToStatus(targetStatus.getCode());
    log.setAuditRemark(auditRemark);
    auditLogMapper.insert(log);
}

效果:

  1. 代码清晰:状态流转规则集中在白名单中,一目了然
  2. 易于维护:新增状态或流转路径只需修改白名单
  3. 防止非法流转:任何不在白名单中的流转都会被拒绝
  4. 审计友好:所有状态变更都有日志记录

注: 白名单也就是我们的Map的集合

面试追问:为什么不用工作流引擎(Activiti/Flowable)?

我们的审核流程比较简单,只有 5 个状态和 7 个流转路径,用工作流引擎会过度设计。工作流引擎适合复杂的多级审批、会签、驳回等场景。我们的状态机方案更轻量,性能更好,也更容易理解和维护。

2.2 审核操作的幂等性设计

问题背景:

采购经理点击”审核通过”按钮后,如果网络抖动或者手抖连点,可能导致重复提交。如果不做幂等性控制,可能出现:

  1. 重复写入审核日志
    1. 从对数据的影响来说 日志文件影响较小 但是从业务的一致性来说 这个就违背一致性
  2. 重复发送通知消息
    1. 如果两次通知的内容是一样的 也没有什么影响
    2. 如果两次通知的内容是不一样的 影响很大了 相当于你给同一个供应商开通了两个账号
  3. 状态被错误覆盖

前端的技术 页面的防抖 如果用户在页面上 对一个按钮反复 快速的点击 前端应该只发送一次请求

解决方案:

使用”状态前置校验 + CAS 乐观锁更新 + 唯一索引”三重保障。

具体实现:

第一重:状态前置校验

@Transactional(rollbackFor = Exception.class)
public void auditSupplier(Long supplierId, SupplierStatus targetStatus, String auditRemark) {
    // 1. 查询供应商当前状态
    Supplier supplier = supplierMapper.selectById(supplierId);
    SupplierStatus currentStatus = SupplierStatus.of(supplier.getStatus());
    
    // 2. 如果当前状态已经是目标状态,直接返回(幂等)
    // 如果当前是等待审核 同一时间来了两个请求 都是审核通过 ...
    if (currentStatus == targetStatus) {
        log.info("供应商状态已经是 {}, 无需重复操作", targetStatus);
        return;
    }
    
    // 3. 校验状态流转是否合法
    if (!SupplierStateMachine.canTransition(currentStatus, targetStatus)) {
        throw new BusinessException("非法的状态流转");
    }
    
    // 后续更新逻辑...
}

第二重:CAS 乐观锁更新

// Supplier 实体类增加 version 字段
@TableName("supplier")
public class Supplier {
    private Long id;
    private Integer status;
    
    @Version  // MyBatis-Plus 乐观锁注解
    private Integer version;
}

// 更新时使用乐观锁
// 等待审核 --> 审核通过
// 代码层面 可以两个请求同时执行 但是在MySQL中 对应同一个表中同一条记录 同一时间只能有一个事务进行写操作 InnoDB 锁机制
// CAS 更新 不管你是通过Version字段 还是通过 当前状态字段 进行对比 都是CAS的保障 都可以实现只能有一个事务操作成功
int updated = supplierMapper.update(null,
    new LambdaUpdateWrapper<Supplier>()
        .eq(Supplier::getId, supplierId)
        .eq(Supplier::getStatus, currentStatus.getCode())  // WHERE 条件:当前状态必须匹配
        .eq(Supplier::getVersion, supplier.getVersion())   // WHERE 条件:版本号必须匹配
        .set(Supplier::getStatus, targetStatus.getCode())
        .set(Supplier::getAuditTime, LocalDateTime.now())
        .set(Supplier::getAuditRemark, auditRemark)
);

if (updated == 0) {
    throw new BusinessException("审核失败,供应商状态已被其他人修改");
}

// 下面的逻辑 就是给供应商开通账号 发送邮件

第三重:审核日志唯一索引

在供应商表中有一个字段关联了 user 表 假设两个事务同时在User 表中创建了该供应商的记录 但是在更新这个供应商表中user字段的时候 只能有一个事务修改成功 另外一个事务就会进行回滚操作

CREATE TABLE supplier_audit_log (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    from_status TINYINT NOT NULL COMMENT '原状态',
    to_status TINYINT NOT NULL COMMENT '目标状态',
    audit_user_id BIGINT NOT NULL COMMENT '审核人ID',
    audit_time DATETIME NOT NULL COMMENT '审核时间',
    audit_remark VARCHAR(500) COMMENT '审核备注',
    
    -- 唯一索引:同一个供应商的同一次状态流转只能记录一次
    UNIQUE KEY uk_supplier_transition (supplier_id, from_status, to_status, audit_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商审核日志';

效果:

  1. 第一重防护:如果状态已经是目标状态,直接返回,避免重复操作
  2. 第二重防护:如果状态被其他人修改,CAS 更新失败,抛异常回滚
  3. 第三重防护:如果前两重都失败,唯一索引会拒绝重复的审核日志

面试追问:为什么不用分布式锁(Redis SETNX)?

首先分布式锁的场景是: 在多个客户端 多个用户 多个平台 同时发送请求来访问后台的一个资源的时候 为了避免资源安全的问题 才使用的分布式锁 我们当前的场景是: 同一个用户 只是多点击了两次操作 其实我们的后台并不存在高并发的问题 我们可以只通过逻辑的判断或者CAS更新就可以解决了 没有必要引入一个分布式锁的组件 这样会让整个系统更臃肿

面试追问:什么时候用分布式锁,什么时候用乐观锁?

使用乐观锁(@Version)的场景:

  1. 单表并发更新:供应商审核、订单状态更新等单表操作
  2. 冲突概率低:大部分情况下不会有并发冲突
  3. 性能要求高:不需要额外的网络请求,性能最好
  4. 典型场景:供应商审核、采购订单状态更新、库存扣减(CAS 原子操作)

使用分布式锁(Redis SETNX)的场景:

  1. 跨 JVM 的并发控制:多个服务实例并发创建订单、运单
  2. 防止重复提交:用户重复点击、消息重复消费
  3. 定时任务防重:XXL-Job 分片执行时防止重复执行
  4. 典型场景:订单创建、运单创建、定时任务执行

分布式锁标准实现:

String lockKey = "lock:order:create:" + orderNo;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
    throw new BusinessException("订单创建中,请勿重复提交");
}
try {
    // 业务逻辑
    createOrder(orderNo);
} finally {
    // 使用 Lua 脚本保证原子性,只删除自己持有的锁
    redisTemplate.delete(lockKey);
}

本项目的实际应用:

  • 供应商审核:用乐观锁(单表更新,冲突概率低)
  • 订单创建:用分布式锁(跨 JVM,防止重复提交)
  • 运单创建:用分布式锁(跨 JVM,防止重复提交)
  • 库存扣减:用 CAS 原子操作(不用锁,性能最好)

2.3 文件上传的安全性设计

关于文件上传必须要做的:

  1. 文件的名称校验
  2. 文件的类型校验
  3. 文件的大小校验
  4. 文件的目录分离
  5. 文件的内容审核(可选)

问题背景:

供应商需要上传营业执照、税务登记证、质量认证等资质文件。如果不做安全校验,可能出现:

  1. 上传恶意脚本文件(.jsp、.php)导致服务器被攻击
  2. 上传超大文件(几个 GB)导致服务器磁盘占满
  3. 文件名包含特殊字符(../、%00)导致路径穿越攻击

解决方案:

设计了”扩展名白名单 + MIME 类型校验 + Magic Bytes 校验 + OSS 存储”四重防护。

具体实现:

第一重:扩展名白名单

public class FileUploadService {
    // 允许上传的文件扩展名白名单
    private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
        "jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx"
    );
    
    public String uploadFile(MultipartFile file) {
        // 1. 获取原始文件名
        String originalFilename = file.getOriginalFilename();
        if (originalFilename == null || originalFilename.isEmpty()) {
            throw new BusinessException("文件名不能为空");
        }
        
        // 2. 提取扩展名
        String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
        
        // 3. 校验扩展名是否在白名单中
        if (!ALLOWED_EXTENSIONS.contains(extension)) {
            throw new BusinessException("不支持的文件类型: " + extension);
        }
        
        // 后续校验...
    }
}

第二重:MIME 类型校验

// 4. 校验 MIME 类型
String contentType = file.getContentType();
if (contentType == null || !isAllowedMimeType(contentType)) {
    throw new BusinessException("不支持的文件类型: " + contentType);
}

private boolean isAllowedMimeType(String mimeType) {
    return mimeType.equals("image/jpeg") ||
           mimeType.equals("image/png") ||
           mimeType.equals("application/pdf") ||
           mimeType.equals("application/msword") ||
           mimeType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
}

第三重:Magic Bytes 校验

// 5. 读取文件头部字节,校验真实文件类型
byte[] fileHeader = new byte[8];
try (InputStream is = file.getInputStream()) {
    is.read(fileHeader);
}

if (!isValidFileHeader(fileHeader, extension)) {
    throw new BusinessException("文件内容与扩展名不匹配");
}

private boolean isValidFileHeader(byte[] header, String extension) {
    // JPEG: FF D8 FF
    if (extension.equals("jpg") || extension.equals("jpeg")) {
        return header[0] == (byte) 0xFF && header[1] == (byte) 0xD8 && header[2] == (byte) 0xFF;
    }
    // PNG: 89 50 4E 47
    if (extension.equals("png")) {
        return header[0] == (byte) 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47;
    }
    // PDF: 25 50 44 46
    if (extension.equals("pdf")) {
        return header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 && header[3] == 0x46;
    }
    return true;
}

文件的 大小校验

文件的名称的重命名 比如: 使用UUID + 原始的扩展名

文件的目录分离 比如: 按照租户ID分离 按照日期进行分离 按照文件的名称进行Hash分离

第四重:OSS 存储

// 6. 生成唯一文件名,避免覆盖
String uniqueFilename = UUID.randomUUID().toString() + "." + extension;

// 7. 上传到阿里云 OSS
String ossPath = "supplier/cert/" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")) + "/" + uniqueFilename;
ossClient.putObject(bucketName, ossPath, file.getInputStream());

// 8. 返回文件访问 URL
String fileUrl = "https://" + bucketName + ".oss-cn-shenzhen.aliyuncs.com/" + ossPath;
return fileUrl;

效果:

  1. 扩展名白名单:拒绝 .jsp、.php 等可执行文件
  2. MIME 类型校验:防止修改扩展名绕过检查
  3. Magic Bytes 校验:防止伪造文件头绕过检查
  4. OSS 存储:文件不存储在应用服务器,即使是恶意文件也无法执行

面试追问:为什么要校验 Magic Bytes?

因为攻击者可以把恶意脚本改名为 .jpg,MIME 类型也可以伪造。但文件的 Magic Bytes(文件头部的特征字节)是无法伪造的。比如 JPEG 文件的前 3 个字节一定是 FF D8 FF,PNG 文件的前 4 个字节一定是 89 50 4E 47。通过校验 Magic Bytes,可以确保文件内容与扩展名一致。

2.4 供应商绩效评分的定时任务设计

问题背景:

每月 1 号凌晨需要统计所有供应商上个月的绩效数据,计算综合评分。如果有 2000 个供应商,单机执行需要 10 分钟,而且如果任务执行到一半服务器重启,会导致部分供应商没有评分。

解决方案:

使用 XXL-Job 的分片广播模式,多台服务器并行执行,每台服务器只处理自己分片的供应商。

具体实现:

第一步,配置 XXL-Job 任务:

任务名称: supplierScoreJob
Cron 表达式: 0 0 1 1 * ?  (每月1号凌晨1点执行)
运行模式: 分片广播
分片参数: 0/3, 1/3, 2/3  (3台服务器,每台处理1/3的数据)

第二步,编写分片任务代码:

XxlJobHelper.getShardIndex() XxlJobHelper.getShardTotal()

还要掌握 评分 的目的是什么 评分 的依据/维度 是什么

目的: 为了给租户一些建议 采购建议

@Component
public class SupplierScoreJob {
    
    @XxlJob("supplierScoreJob")
    public void execute() {
        // 1. 获取分片参数
        int shardIndex = XxlJobHelper.getShardIndex();  // 当前分片序号: 0, 1, 2
        int shardTotal = XxlJobHelper.getShardTotal();  // 总分片数: 3
        
        log.info("开始执行供应商评分任务, 分片: {}/{}", shardIndex, shardTotal);
        
        // 2. 查询当前分片需要处理的供应商
        List<Supplier> suppliers = supplierMapper.selectList(
            new LambdaQueryWrapper<Supplier>()
                .eq(Supplier::getStatus, SupplierStatus.ENABLED.getCode())
                .apply("MOD(id, {0}) = {1}", shardTotal, shardIndex)  // 根据ID取模分片
        ); // where  id % shardTotal = shardIndex
        
        log.info("当前分片需要处理 {} 个供应商", suppliers.size());
        
        // 3. 逐个计算评分
        for (Supplier supplier : suppliers) {
            try {
                calculateScore(supplier.getId());
            } catch (Exception e) {
                log.error("供应商 {} 评分失败", supplier.getId(), e);
                // 继续处理下一个,不影响整体任务
            }
        }
        
        log.info("供应商评分任务执行完成");
    }
    
    private void calculateScore(Long supplierId) {
        // 统计上个月的数据
        LocalDate lastMonth = LocalDate.now().minusMonths(1);
        
        // 1. 交付准时率 (25分)
        double onTimeRate = calculateOnTimeRate(supplierId, lastMonth);
        int onTimeScore = (int) (onTimeRate * 25);
        
        // 2. 质量合格率 (25分)
        double qualityRate = calculateQualityRate(supplierId, lastMonth);
        int qualityScore = (int) (qualityRate * 25);
        
        // 3. 价格竞争力 (25分)
        int priceScore = calculatePriceScore(supplierId, lastMonth);
        
        // 4. 服务响应速度 (25分)
        int serviceScore = calculateServiceScore(supplierId, lastMonth);
        
        // 5. 计算总分
        int totalScore = onTimeScore + qualityScore + priceScore + serviceScore;
        
        // 6. 保存评分记录
        SupplierScoreLog scoreLog = new SupplierScoreLog();
        scoreLog.setSupplierId(supplierId);
        scoreLog.setScoreMonth(lastMonth.format(DateTimeFormatter.ofPattern("yyyy-MM")));
        scoreLog.setOnTimeScore(onTimeScore);
        scoreLog.setQualityScore(qualityScore);
        scoreLog.setPriceScore(priceScore);
        scoreLog.setServiceScore(serviceScore);
        scoreLog.setTotalScore(totalScore);
        scoreLogMapper.insert(scoreLog);
        
        // 7. 更新供应商的最新评分
        supplierMapper.update(null,
            new LambdaUpdateWrapper<Supplier>()
                .eq(Supplier::getId, supplierId)
                .set(Supplier::getLatestScore, totalScore)
        );
    }
}

第三步,保证幂等性:

CREATE TABLE supplier_score_log (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    supplier_id BIGINT NOT NULL,
    score_month VARCHAR(7) NOT NULL COMMENT '评分月份: 2024-01',
    on_time_score INT NOT NULL COMMENT '准时率得分',
    quality_score INT NOT NULL COMMENT '质量得分',
    price_score INT NOT NULL COMMENT '价格得分',
    service_score INT NOT NULL COMMENT '服务得分',
    total_score INT NOT NULL COMMENT '总分',
    create_time DATETIME NOT NULL,
    
    -- 唯一索引: 同一个供应商的同一个月只能有一条评分记录
    UNIQUE KEY uk_supplier_month (supplier_id, score_month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

如果任务重复执行,INSERT 会因为唯一索引冲突而失败,保证幂等性。

效果:

  1. 并行执行:3 台服务器并行处理,执行时间从 10 分钟降到 3.5 分钟
  2. 高可用:如果某台服务器宕机,其他服务器继续执行,不影响整体任务
  3. 幂等性:唯一索引保证同一个供应商的同一个月只有一条评分记录
  4. 可追溯:所有历史评分都保存在 score_log 表中,可以查询任意月份的评分

2.5 供应商门户的多租户数据隔离

问题背景:

供应商登录门户后,只能看到自己的采购订单、发货通知、对账单,不能看到其他供应商的数据。如果数据隔离做不好,可能导致数据泄露。

解决方案:

设计了”租户 ID + 供应商 ID”双重隔离机制,在 SQL 查询、API 接口、前端展示三个层面保证数据隔离。

具体实现:

第一层:数据库表设计

CREATE TABLE purchase_order (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    order_no VARCHAR(32) NOT NULL COMMENT '采购单号',
    tenant_id BIGINT NOT NULL COMMENT '租户ID(商家ID)',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    order_status TINYINT NOT NULL COMMENT '订单状态',
    total_amount DECIMAL(12,2) NOT NULL COMMENT '订单金额',
    create_time DATETIME NOT NULL,
    
    -- 联合索引: 租户ID + 供应商ID
    KEY idx_tenant_supplier (tenant_id, supplier_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第二层:SQL 查询强制带上租户 ID 和供应商 ID

@Service
public class SupplierPortalService {
    
    public List<PurchaseOrder> getMyOrders(Long tenantId, Long supplierId) {
        // 查询时必须同时带上 tenant_id 和 supplier_id
        return purchaseOrderMapper.selectList(
            new LambdaQueryWrapper<PurchaseOrder>()
                .eq(PurchaseOrder::getTenantId, tenantId) // 租户ID
                .eq(PurchaseOrder::getSupplierId, supplierId) // 供应商ID
                .orderByDesc(PurchaseOrder::getCreateTime)
        );
    }
}

第三层:API 接口从 Token 中提取租户 ID 和供应商 ID

@RestController
@RequestMapping("/portal/orders")
public class SupplierPortalController {
    
    @GetMapping("/list")
    public Result<List<PurchaseOrder>> getMyOrders() {
        // 1. 从 Sa-Token 登录态中提取租户ID和供应商ID
        SupplierUser currentUser = SecurityUtils.getCurrentSupplierUser();
        Long tenantId = currentUser.getTenantId();
        Long supplierId = currentUser.getSupplierId();
        
        // 2. 查询订单(自动带上租户ID和供应商ID)
        List<PurchaseOrder> orders = portalService.getMyOrders(tenantId, supplierId);
        
        return Result.success(orders);
    }
}

第四层:Sa-Token 登录态设计

如果是租户登录 同一个租户下的不同的角色人员进行登录的 生成Token的方式 都是一样的 也就是使用Sa-Token的默认的策略 也就是随机Token

那么Sa-Token 就会把当前的登录的用户的ID 存储到Redis 我们需要做的是 把当前的租户ID和当前登录的人员的ID 拼接起来 然后让Sa-Token存储到Redis中

接下来的业务中 我们可以直接获取当前登录的人员的ID的时候 也可以同时获取当前人员所属的租户的ID

然后在进行解析的时候 可以把这个租户的ID存储到ThreadLocal中

如果是供应商登录 也是走这个流程

只不过 我们是把供应商的ID 和 供应商所属的租户ID 一起进行保存

在进行业务操作的时候 取出当前的供应商ID和租户ID 拼接SQL

public class JwtTokenUtil {
    
    public String generateToken(SupplierUser user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", user.getId());
        claims.put("tenantId", user.getTenantId());      // 租户ID
        claims.put("supplierId", user.getSupplierId());  // 供应商ID
        claims.put("username", user.getUsername());
        
        return Jwts.builder()
            .setClaims(claims)
            .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000))
            .signWith(SignatureAlgorithm.HS512, SECRET_KEY)
            .compact();
    }
    
    public SupplierUser parseToken(String token) {
        Claims claims = Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .parseClaimsJws(token)
            .getBody();
        
        SupplierUser user = new SupplierUser();
        user.setId(claims.get("userId", Long.class));
        user.setTenantId(claims.get("tenantId", Long.class));
        user.setSupplierId(claims.get("supplierId", Long.class));
        user.setUsername(claims.get("username", String.class));
        return user;
    }
}

效果:

  1. 数据库层面:tenant_id + supplier_id 联合索引,查询性能好
  2. 代码层面:所有查询都强制带上租户 ID 和供应商 ID,不会遗漏
  3. 接口层面:从 Token 中提取身份信息,供应商无法伪造
  4. 安全性:即使供应商拿到其他供应商的订单 ID,也无法查询到数据

面试追问:如果供应商伪造 Token 怎么办?

Token 是用服务器的私钥签名的,供应商无法伪造。即使供应商修改了 Token 中的 supplierId,签名验证会失败,请求会被拒绝。

2.6 供应商账号体系设计

问题背景:

供应商门户需要独立的账号体系,不能和商家后台的账号混在一起。供应商注册后,需要绑定到具体的供应商主数据。

解决方案:

设计了”供应商主数据 + 供应商用户 + 供应商角色”三层结构。

具体实现:

第一层:供应商主数据表

CREATE TABLE supplier (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL COMMENT '租户ID(商家ID)',
    supplier_code VARCHAR(32) NOT NULL COMMENT '供应商编码',
    supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称',
    status TINYINT NOT NULL COMMENT '状态: 0草稿 1待审核 2审核通过 3审核拒绝 4已启用',
    latest_score INT COMMENT '最新评分',
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_tenant_code (tenant_id, supplier_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第二层:供应商用户表

CREATE TABLE supplier_user (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL COMMENT '租户ID',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    username VARCHAR(50) NOT NULL COMMENT '用户名',
    password VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)',
    real_name VARCHAR(50) COMMENT '真实姓名',
    mobile VARCHAR(20) COMMENT '手机号',
    email VARCHAR(100) COMMENT '邮箱',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 0禁用 1启用',
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_tenant_username (tenant_id, username),
    KEY idx_supplier (supplier_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第三层:供应商角色表

CREATE TABLE supplier_role (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    role_name VARCHAR(50) NOT NULL COMMENT '角色名称',
    role_code VARCHAR(50) NOT NULL COMMENT '角色编码',
    permissions VARCHAR(500) COMMENT '权限列表(JSON)',
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_role_code (role_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 预置角色
INSERT INTO supplier_role (role_name, role_code, permissions) VALUES
('供应商管理员', 'SUPPLIER_ADMIN', '["order:view", "order:update", "account:view", "score:view"]'),
('供应商操作员', 'SUPPLIER_OPERATOR', '["order:view", "order:update"]');

第四层:用户角色关联表

CREATE TABLE supplier_user_role (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_user_role (user_id, role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注册流程:

@Service
public class SupplierUserService {
    
    @Transactional(rollbackFor = Exception.class)
    public void register(SupplierUserRegisterDTO dto) {
        // 1. 校验供应商是否存在且已启用
        Supplier supplier = supplierMapper.selectOne(
            new LambdaQueryWrapper<Supplier>()
                .eq(Supplier::getTenantId, dto.getTenantId())
                .eq(Supplier::getSupplierCode, dto.getSupplierCode())
                .eq(Supplier::getStatus, SupplierStatus.ENABLED.getCode())
        );
        if (supplier == null) {
            throw new BusinessException("供应商不存在或未启用");
        }
        
        // 2. 校验用户名是否已存在
        Long count = supplierUserMapper.selectCount(
            new LambdaQueryWrapper<SupplierUser>()
                .eq(SupplierUser::getTenantId, dto.getTenantId())
                .eq(SupplierUser::getUsername, dto.getUsername())
        );
        if (count > 0) {
            throw new BusinessException("用户名已存在");
        }
        
        // 3. 创建用户
        SupplierUser user = new SupplierUser();
        user.setTenantId(dto.getTenantId());
        user.setSupplierId(supplier.getId());
        user.setUsername(dto.getUsername());
        user.setPassword(BCrypt.hashpw(dto.getPassword(), BCrypt.gensalt()));
        user.setRealName(dto.getRealName());
        user.setMobile(dto.getMobile());
        user.setEmail(dto.getEmail());
        user.setStatus(1);
        supplierUserMapper.insert(user);
        
        // 4. 分配默认角色(供应商操作员)
        SupplierRole role = supplierRoleMapper.selectOne(
            new LambdaQueryWrapper<SupplierRole>()
                .eq(SupplierRole::getRoleCode, "SUPPLIER_OPERATOR")
        );
        
        SupplierUserRole userRole = new SupplierUserRole();
        userRole.setUserId(user.getId());
        userRole.setRoleId(role.getId());
        supplierUserRoleMapper.insert(userRole);
    }
}

效果:

  1. 账号隔离:供应商账号和商家账号完全隔离,互不影响
  2. 权限控制:不同角色有不同的权限,管理员可以查看评分,操作员只能查看订单
  3. 多用户:一个供应商可以有多个用户,方便团队协作
  4. 安全性:密码用 BCrypt 加密,即使数据库泄露也无法破解

2.7 消息通知机制设计

问题背景:

供应商需要及时收到通知:

  1. 审核通过/拒绝通知
  2. 新采购订单通知
  3. 发货提醒通知
  4. 月度评分通知

如果只用邮件通知,可能被当成垃圾邮件。如果只用站内信,供应商可能不及时查看。

我们的这个通知是一个封装好的系统

里面支持关于站内信的通知和邮件的通知和短信的通知和办公软件的通知(钉钉 企微 飞书)

我们可以直接调用其封装好的方法 然后根据不同的通知类型 传递不同的参数 比如: 通知的类型(站内信/邮件/短信…) 被通知人的ID

解决方案:

设计了”站内信 + 邮件”双通道通知机制,重要通知同时发送两个渠道。

具体实现:

第一步,定义消息表:

CREATE TABLE supplier_message (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL COMMENT '租户ID',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    message_type TINYINT NOT NULL COMMENT '消息类型: 1审核通知 2订单通知 3发货提醒 4评分通知',
    message_title VARCHAR(100) NOT NULL COMMENT '消息标题',
    message_content TEXT NOT NULL COMMENT '消息内容',
    is_read TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读: 0未读 1已读',
    create_time DATETIME NOT NULL,
    
    KEY idx_supplier_read (supplier_id, is_read, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第二步,定义消息发送服务:

@Service
public class MessageNotificationService {
    
    @Autowired
    private SupplierMessageMapper messageMapper;
    
    @Autowired
    private JavaMailSender mailSender;
    
    /**
     * 发送通知(站内信 + 邮件)
     */
    public void sendNotification(Long tenantId, Long supplierId, MessageType type, String title, String content) {
        // 1. 保存站内信
        SupplierMessage message = new SupplierMessage();
        message.setTenantId(tenantId);
        message.setSupplierId(supplierId);
        message.setMessageType(type.getCode());
        message.setMessageTitle(title);
        message.setMessageContent(content);
        message.setIsRead(0);
        messageMapper.insert(message);
        
        // 2. 查询供应商的邮箱
        Supplier supplier = supplierMapper.selectById(supplierId);
        if (supplier.getEmail() != null && !supplier.getEmail().isEmpty()) {
            // 3. 异步发送邮件(不阻塞主流程)
            CompletableFuture.runAsync(() -> {
                try {
                    sendEmail(supplier.getEmail(), title, content);
                } catch (Exception e) {
                    log.error("发送邮件失败: {}", supplier.getEmail(), e);
                }
            });
        }
    }
    
    private void sendEmail(String to, String subject, String content) {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        
        helper.setFrom("noreply@supply-chain.com");
        helper.setTo(to);
        helper.setSubject(subject);
        helper.setText(content, true);  // true表示HTML格式
        
        mailSender.send(message);
    }
}

第三步,在业务代码中调用:

@Transactional(rollbackFor = Exception.class)
public void auditSupplier(Long supplierId, SupplierStatus targetStatus, String auditRemark) {
    // 1. 更新供应商状态
    // ...
    
    // 2. 发送通知
    if (targetStatus == SupplierStatus.APPROVED) {
        messageNotificationService.sendNotification(
            supplier.getTenantId(),
            supplierId,
            MessageType.AUDIT,
            "供应商审核通过",
            "恭喜!您的供应商资质审核已通过,现在可以参与采购了。"
        );
    } else if (targetStatus == SupplierStatus.REJECTED) {
        messageNotificationService.sendNotification(
            supplier.getTenantId(),
            supplierId,
            MessageType.AUDIT,
            "供应商审核未通过",
            "很抱歉,您的供应商资质审核未通过。原因: " + auditRemark
        );
    }
}

效果:

  1. 双通道:站内信 + 邮件,确保供应商能及时收到通知
  2. 异步发送:邮件发送不阻塞主流程,即使邮件服务器故障也不影响业务
  3. 可追溯:所有站内信都保存在数据库中,可以查询历史消息
  4. 已读状态:供应商可以标记消息为已读,方便管理

三、数据库设计

3.1 核心表结构

主键策略说明:

所有业务表统一使用 MyBatis-Plus 的 ASSIGN_ID 策略(雪花算法),而不是数据库的 AUTO_INCREMENT

原因:

  1. 分布式友好: 雪花算法生成的 ID 全局唯一,多个服务并发插入不会冲突
  2. 支持分库分表: AUTO_INCREMENT 在分库分表场景下会产生重复 ID
  3. 性能更好: 不依赖数据库自增,减少数据库压力
  4. 趋势递增: 雪花算法生成的 ID 趋势递增,对 B+ 树索引友好

实体类定义:

@TableName("supplier")
public class Supplier {
    @TableId(type = IdType.ASSIGN_ID)  // 使用雪花算法
    private Long id;
    // 其他字段...
}

供应商主表 (supplier)

CREATE TABLE supplier (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    tenant_id BIGINT NOT NULL COMMENT '租户ID(商家ID)',
    supplier_code VARCHAR(32) NOT NULL COMMENT '供应商编码',
    supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称',
    supplier_type TINYINT NOT NULL COMMENT '供应商类型: 1生产商 2贸易商 3代理商',
    contact_person VARCHAR(50) COMMENT '联系人',
    contact_mobile VARCHAR(20) COMMENT '联系电话',
    contact_email VARCHAR(100) COMMENT '联系邮箱',
    address VARCHAR(200) COMMENT '地址',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0草稿 1待审核 2审核通过 3审核拒绝 4已启用',
    audit_user_id BIGINT COMMENT '审核人ID',
    audit_time DATETIME COMMENT '审核时间',
    audit_remark VARCHAR(500) COMMENT '审核备注',
    latest_score INT COMMENT '最新评分',
    payment_term TINYINT COMMENT '账期: 1现款 2月结30天 3月结60天',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    
    UNIQUE KEY uk_tenant_code (tenant_id, supplier_code),
    KEY idx_status (status),
    KEY idx_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商主表';

供应商资质表 (supplier_cert)

CREATE TABLE supplier_cert (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    cert_type TINYINT NOT NULL COMMENT '证件类型: 1营业执照 2税务登记证 3质量认证 4其他',
    cert_name VARCHAR(100) NOT NULL COMMENT '证件名称',
    cert_no VARCHAR(100) COMMENT '证件编号',
    cert_file_url VARCHAR(500) NOT NULL COMMENT '证件文件URL',
    expire_date DATE COMMENT '到期日期',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 0已过期 1有效',
    create_time DATETIME NOT NULL,
    
    KEY idx_supplier (supplier_id),
    KEY idx_expire (expire_date, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商资质证书';

供应商联系人表 (supplier_contact)

CREATE TABLE supplier_contact (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    contact_name VARCHAR(50) NOT NULL COMMENT '联系人姓名',
    contact_position VARCHAR(50) COMMENT '职位',
    contact_mobile VARCHAR(20) NOT NULL COMMENT '手机号',
    contact_email VARCHAR(100) COMMENT '邮箱',
    is_primary TINYINT NOT NULL DEFAULT 0 COMMENT '是否主联系人: 0否 1是',
    create_time DATETIME NOT NULL,
    
    KEY idx_supplier (supplier_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商联系人';

供应商评分记录表 (supplier_score_log)

CREATE TABLE supplier_score_log (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    score_month VARCHAR(7) NOT NULL COMMENT '评分月份: 2024-01',
    on_time_rate DECIMAL(5,2) COMMENT '准时率',
    on_time_score INT NOT NULL COMMENT '准时率得分(25分)',
    quality_rate DECIMAL(5,2) COMMENT '质量合格率',
    quality_score INT NOT NULL COMMENT '质量得分(25分)',
    price_score INT NOT NULL COMMENT '价格得分(25分)',
    service_score INT NOT NULL COMMENT '服务得分(25分)',
    total_score INT NOT NULL COMMENT '总分(100分)',
    create_time DATETIME NOT NULL,
    
    UNIQUE KEY uk_supplier_month (supplier_id, score_month),
    KEY idx_score (total_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商评分记录';

供应商审核日志表 (supplier_audit_log)

CREATE TABLE supplier_audit_log (
    id BIGINT PRIMARY KEY COMMENT '主键(雪花算法生成)',
    supplier_id BIGINT NOT NULL COMMENT '供应商ID',
    from_status TINYINT NOT NULL COMMENT '原状态',
    to_status TINYINT NOT NULL COMMENT '目标状态',
    audit_user_id BIGINT NOT NULL COMMENT '审核人ID',
    audit_time DATETIME NOT NULL COMMENT '审核时间',
    audit_remark VARCHAR(500) COMMENT '审核备注',
    
    UNIQUE KEY uk_supplier_transition (supplier_id, from_status, to_status, audit_time),
    KEY idx_supplier (supplier_id),
    KEY idx_audit_time (audit_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商审核日志';

3.2 索引设计说明

为什么 supplier 表需要 uk_tenant_code 唯一索引?

  • 同一个商家下,供应商编码不能重复
  • 但不同商家可以有相同的供应商编码
  • 所以用 (tenant_id, supplier_code) 联合唯一索引

为什么 supplier_score_log 表需要 uk_supplier_month 唯一索引?

  • 保证定时任务的幂等性
  • 同一个供应商的同一个月只能有一条评分记录
  • 如果任务重复执行,INSERT 会因为唯一索引冲突而失败

为什么 supplier_audit_log 表需要 uk_supplier_transition 唯一索引?

  • 保证审核操作的幂等性
  • 同一个供应商的同一次状态流转只能记录一次
  • 防止重复点击导致重复记录

四、核心业务流程

4.1 供应商注册与审核流程

完整流程:

第一步:供应商注册

  • 供应商访问注册页面,填写基本信息(公司名称、联系人、手机、邮箱)
  • 系统生成供应商编码(规则:SUP + 8位数字,如 SUP00000001)
  • 初始状态为”草稿”

第二步:完善资质信息

  • 供应商登录后台,上传营业执照、税务登记证、质量认证等文件
  • 填写联系人信息、银行账户信息
  • 点击”提交审核”,状态变为”待审核”

第三步:采购经理审核

  • 采购经理在后台看到待审核的供应商列表
  • 查看供应商的资质文件、联系人信息
  • 点击”审核通过”或”审核拒绝”,填写审核备注
  • 状态变为”审核通过”或”审核拒绝”

第四步:启用供应商

  • 审核通过后,采购经理点击”启用”
  • 状态变为”已启用”
  • 供应商可以参与采购

技术实现关键点:

@Service
public class SupplierService {
    
    // 供应商提交审核
    @Transactional(rollbackFor = Exception.class)
    public void submitAudit(Long supplierId) {
        Supplier supplier = supplierMapper.selectById(supplierId);
        
        // 1. 校验状态流转
        if (!SupplierStateMachine.canTransition(
            SupplierStatus.of(supplier.getStatus()), 
            SupplierStatus.PENDING)) {
            throw new BusinessException("当前状态不允许提交审核");
        }
        
        // 2. 校验资质文件是否完整
        Long certCount = supplierCertMapper.selectCount(
            new LambdaQueryWrapper<SupplierCert>()
                .eq(SupplierCert::getSupplierId, supplierId)
        );
        if (certCount < 2) {
            throw new BusinessException("请至少上传2个资质文件");
        }
        
        // 3. 更新状态
        int updated = supplierMapper.update(null,
            new LambdaUpdateWrapper<Supplier>()
                .eq(Supplier::getId, supplierId)
                .eq(Supplier::getStatus, SupplierStatus.DRAFT.getCode())
                .set(Supplier::getStatus, SupplierStatus.PENDING.getCode())
        );
        
        if (updated == 0) {
            throw new BusinessException("提交失败,状态已变更");
        }
    }
}

4.2 供应商绩效评分流程

评分维度(满分 100 分):

  1. 交付准时率(25 分)

    • 统计上月所有采购订单的交付情况
    • 准时率 = 准时交付订单数 / 总订单数
    • 得分 = 准时率 × 25
  2. 质量合格率(25 分)

    • 统计上月所有入库单的质检情况
    • 合格率 = 合格数量 / 总数量
    • 得分 = 合格率 × 25
  3. 价格竞争力(25 分)

    • 对比同类商品的市场价格
    • 如果供应商报价低于市场均价 10%,得 25 分
    • 如果供应商报价等于市场均价,得 15 分
    • 如果供应商报价高于市场均价 10%,得 5 分
  4. 服务响应速度(25 分)

    • 统计供应商对询价单的响应时间
    • 24 小时内响应:25 分
    • 48 小时内响应:15 分
    • 超过 48 小时:5 分

技术实现:

private double calculateOnTimeRate(Long supplierId, LocalDate month) {
    // 查询上月的所有采购订单
    List<PurchaseOrder> orders = purchaseOrderMapper.selectList(
        new LambdaQueryWrapper<PurchaseOrder>()
            .eq(PurchaseOrder::getSupplierId, supplierId)
            .ge(PurchaseOrder::getCreateTime, month.atStartOfDay())
            .lt(PurchaseOrder::getCreateTime, month.plusMonths(1).atStartOfDay())
    );
    
    if (orders.isEmpty()) {
        return 1.0; // 没有订单默认满分
    }
    
    // 统计准时交付的订单数
    long onTimeCount = orders.stream()
        .filter(order -> order.getActualDeliveryTime() != null 
            && !order.getActualDeliveryTime().isAfter(order.getExpectedDeliveryTime()))
        .count();
    
    return (double) onTimeCount / orders.size();
}

4.3 供应商门户协同流程

场景 1:供应商查看采购订单

@GetMapping("/portal/orders")
public Result<List<PurchaseOrder>> getMyOrders() {
    SupplierUser user = SecurityUtils.getCurrentSupplierUser();
    
    List<PurchaseOrder> orders = purchaseOrderMapper.selectList(
        new LambdaQueryWrapper<PurchaseOrder>()
            .eq(PurchaseOrder::getTenantId, user.getTenantId())
            .eq(PurchaseOrder::getSupplierId, user.getSupplierId())
            .orderByDesc(PurchaseOrder::getCreateTime)
    );
    
    return Result.success(orders);
}

场景 2:供应商确认发货

@PostMapping("/portal/orders/{orderId}/ship")
public Result<Void> confirmShipment(@PathVariable Long orderId, 
                                     @RequestBody ShipmentDTO dto) {
    SupplierUser user = SecurityUtils.getCurrentSupplierUser();
    
    // 1. 校验订单归属
    PurchaseOrder order = purchaseOrderMapper.selectOne(
        new LambdaQueryWrapper<PurchaseOrder>()
            .eq(PurchaseOrder::getId, orderId)
            .eq(PurchaseOrder::getTenantId, user.getTenantId())
            .eq(PurchaseOrder::getSupplierId, user.getSupplierId())
    );
    
    if (order == null) {
        throw new BusinessException("订单不存在");
    }
    
    // 2. 更新订单状态
    order.setStatus(OrderStatus.SHIPPED.getCode());
    order.setTrackingNo(dto.getTrackingNo());
    order.setShipTime(LocalDateTime.now());
    purchaseOrderMapper.updateById(order);
    
    // 3. 发送通知给采购员
    messageService.sendNotification(
        user.getTenantId(),
        order.getPurchaserId(),
        "供应商已发货",
        "采购单 " + order.getOrderNo() + " 已发货,物流单号:" + dto.getTrackingNo()
    );
    
    return Result.success();
}

五、高频面试问答

5.1 业务问题

Q1: 为什么需要供应商审核?直接让供应商注册就能参与采购不行吗?

A1: 不行。供应商审核有三个重要作用:

第一,质量把控。审核供应商的营业执照、质量认证等资质,确保供应商有生产能力和质量保证。

第二,风险控制。审核供应商的财务状况、信用记录,避免供应商跑路或者交付不了货。

第三,价格谈判。审核时可以评估供应商的报价是否合理,是否有降价空间。

如果不审核,可能出现:供应商资质造假、货物质量差、交付不及时、价格虚高等问题。

Q2: 供应商评分的作用是什么?

A2: 供应商评分有四个作用:

第一,优先级排序。评分高的供应商在采购时优先选择,评分低的供应商逐步淘汰。

第二,采购分配。如果有多个供应商都能提供同样的商品,优先分配给评分高的供应商。

第三,价格谈判。评分低的供应商需要降价才能继续合作,评分高的供应商可以适当提价。

第四,改进激励。供应商看到自己的评分和改进建议,会主动提升服务质量。

Q3: 为什么供应商门户需要独立的账号体系?

A3: 因为供应商和商家是两个不同的角色,权限和数据范围完全不同。

商家账号可以看到所有供应商的数据,可以审核供应商、创建采购单、查看所有订单。

供应商账号只能看到自己的数据,只能查看自己的订单、确认发货、查看自己的评分。

如果混在一起,权限控制会非常复杂,容易出现数据泄露。所以我们设计了独立的供应商账号体系,用 tenant_id + supplier_id 双重隔离数据。

Q4: 供应商资质到期后怎么处理?

A4: 我们有定时任务每天扫描资质到期情况:

第一,提前 30 天发送提醒通知,告诉供应商资质即将到期,需要更新。

第二,到期当天,供应商状态自动变为”待审核”,不能参与新的采购。

第三,供应商上传新的资质文件后,重新提交审核。

第四,审核通过后,状态恢复为”已启用”,可以继续参与采购。

这样可以确保所有供应商的资质都是有效的,避免合规风险。

Q5: 如果供应商的评分一直很低怎么办?

A5: 我们有供应商淘汰机制:

第一,连续 3 个月评分低于 60 分,系统自动发送警告通知。

第二,连续 6 个月评分低于 60 分,采购经理会约谈供应商,要求改进。

第三,连续 12 个月评分低于 60 分,系统自动禁用供应商,不能参与新的采购。

第四,已有的采购订单继续执行,但不再分配新订单。

这样可以倒逼供应商提升服务质量,优胜劣汰。

5.2 技术问题

Q1: 状态机的白名单如何设计?

A1: 我们用 Map 存储状态流转规则,Key 是当前状态,Value 是允许流转到的状态列表。

private static final Map<SupplierStatus, List<SupplierStatus>> TRANSITIONS = Map.of(
    SupplierStatus.DRAFT, List.of(SupplierStatus.PENDING),
    SupplierStatus.PENDING, List.of(SupplierStatus.APPROVED, SupplierStatus.REJECTED),
    SupplierStatus.APPROVED, List.of(SupplierStatus.ENABLED),
    SupplierStatus.REJECTED, List.of(SupplierStatus.PENDING),
    SupplierStatus.ENABLED, List.of(SupplierStatus.PENDING)
);

校验时,查询当前状态对应的允许流转列表,判断目标状态是否在列表中。如果不在,直接拒绝。

Q2: CAS 乐观锁如何实现?

A2: 我们用 MyBatis-Plus 的 @Version 注解实现乐观锁。

第一步,在实体类中增加 version 字段,加上 @Version 注解。

第二步,更新时在 WHERE 条件中带上 version 字段。

第三步,如果 version 不匹配,updated 返回 0,说明数据已被其他人修改,抛异常回滚。

MyBatis-Plus 会自动在 UPDATE 语句中增加 version 的判断和自增,不需要手动写 SQL。

Q3: 文件上传为什么要校验 Magic Bytes?

A3: 因为扩展名和 MIME 类型都可以伪造,但 Magic Bytes 无法伪造。

比如攻击者把恶意脚本改名为 .jpg,MIME 类型也改成 image/jpeg,但文件的前几个字节还是脚本的特征字节,不是 JPEG 的 FF D8 FF。

通过校验 Magic Bytes,可以确保文件内容与扩展名一致,防止上传恶意文件。

Q4: XXL-Job 的分片如何实现?

A4: XXL-Job 的分片广播模式会给每台服务器分配一个分片序号(shardIndex)和总分片数(shardTotal)。

我们在查询数据时,用 MOD(id, shardTotal) = shardIndex 来分片。

比如总共 3 台服务器,第 1 台服务器的 shardIndex = 0,查询条件是 MOD(id, 3) = 0,会查到 id 为 3、6、9… 的数据。

第 2 台服务器的 shardIndex = 1,查询条件是 MOD(id, 3) = 1,会查到 id 为 1、4、7… 的数据。

这样每台服务器处理不同的数据,互不重复,实现并行执行。

Q5: 如何保证定时任务的幂等性?

要根据具体的任务的操作的功能来决定 有些操作 本身就是幂等性的 不需要做任何的额外的保障 如果操作不是幂等性的 我们可以添加校验 添加锁 依赖于数据库本身的约束…

A5: 我们用唯一索引保证幂等性。

supplier_score_log 表中,有一个唯一索引 uk_supplier_month (supplier_id, score_month)

如果任务重复执行,INSERT 会因为唯一索引冲突而失败,不会重复写入数据。

同时,我们在代码中捕获唯一索引冲突的异常,不影响其他供应商的评分。

Q6: 供应商门户如何保证数据隔离?

A6: 我们用 tenant_id + supplier_id 双重隔离。

第一层,数据库表中都有 tenant_idsupplier_id 字段。

第二层,所有查询都强制带上 tenant_idsupplier_id

第三层,从 Sa-Token 登录态中提取 tenant_idsupplier_id,供应商无法伪造。

第四层,即使供应商拿到其他供应商的订单 ID,也无法查询到数据,因为 WHERE 条件中有 supplier_id 的限制。

Q7: 消息通知为什么要用异步发送?

核心的目的是: 有业务和通知是否需要保持强一致性 如果不需要 都可以使用异步

A7: 因为邮件发送比较慢,可能需要 1-2 秒。如果同步发送,会阻塞主流程。

而且,如果邮件服务器故障,同步发送会导致整个事务失败,影响业务。

用异步发送后,邮件发送不阻塞主流程,即使邮件发送失败,也不影响业务。站内信已经保存了,供应商登录后台就能看到。

Q8: 如何防止供应商重复提交审核?

A8: 我们用状态前置校验 + CAS 更新。

第一步,查询供应商当前状态,如果已经是”待审核”,直接返回,不重复提交。

第二步,更新状态时在 WHERE 条件中带上当前状态,如果状态已变更,updated 返回 0,抛异常。

第三步,审核日志表有唯一索引,防止重复记录。

三重保障,确保幂等性。


六、简历模板

6.1 详细版简历

项目名称: 跨境电商 SaaS 供应链管理平台 - 供应商关系管理模块(SRM)

项目时间: 2024.03 - 2024.09

项目角色: 核心开发

技术栈: Spring Boot 3.2、MyBatis-Plus 3.5、MySQL 8.0、Redis 7.0、阿里云 OSS、XXL-Job 2.4、Docker 24.0、Nginx 1.24

项目描述:

这是一个跨境电商的供应链 SaaS 平台,我负责供应商关系管理模块。系统需要管理 2000+ 活跃供应商的全生命周期,包括准入审核、资质管理、绩效评分、协同门户等业务。日均处理供应商审核单 200+,绩效评分任务 500+,供应商门户访问 PV 5000+。

核心难点:

  1. 供应商审核流程的状态管理,防止非法状态流转
  2. 审核操作的幂等性控制,防止重复提交
  3. 文件上传的安全性,防止恶意文件攻击
  4. 供应商绩效评分的定时任务,需要分片并行执行
  5. 供应商门户的多租户数据隔离
  6. 消息通知的双通道机制(站内信 + 邮件)

核心技术亮点:

1. 供应商审核的状态机设计

问题:审核流程涉及 5 个状态(草稿、待审核、审核通过、审核拒绝、已启用),如果用 if-else 判断状态流转,代码混乱,容易出现非法流转。

方案:设计了基于白名单的状态机,用 Map 存储状态流转规则,明确定义 7 个合法流转路径。

实现:定义状态枚举和流转白名单,业务代码中先校验状态流转是否合法,再执行更新操作。所有状态变更都记录审核日志。

效果:代码清晰,易于维护,防止非法流转,审计友好。相比工作流引擎更轻量,性能更好。

2. 审核操作的幂等性设计

问题:采购经理点击”审核通过”按钮后,如果网络抖动或手抖连点,可能导致重复提交,出现重复日志、重复通知、状态错误覆盖。

方案:使用”状态前置校验 + CAS 乐观锁更新 + 唯一索引”三重保障。

实现:第一重,如果当前状态已经是目标状态,直接返回。第二重,更新时用 MyBatis-Plus 的 @Version 注解实现 CAS 更新,WHERE 条件中判断状态和版本号。第三重,审核日志表有唯一索引 (supplier_id, from_status, to_status, audit_time)。

效果:三重防护确保幂等性,相比分布式锁性能更好,不需要额外中间件。

3. 文件上传的安全性设计

问题:供应商上传资质文件时,如果不做安全校验,可能上传恶意脚本、超大文件、路径穿越攻击。

方案:设计了”扩展名白名单 + MIME 类型校验 + Magic Bytes 校验 + OSS 存储”四重防护。

实现:第一重,只允许 jpg/png/pdf/doc/xls 等文件类型。第二重,校验 MIME 类型是否匹配。第三重,读取文件头部字节,校验真实文件类型(JPEG 的 Magic Bytes 是 FF D8 FF)。第四重,上传到阿里云 OSS,文件不存储在应用服务器。

效果:四重防护确保文件安全,即使是恶意文件也无法执行。Magic Bytes 校验防止伪造文件头绕过检查。

4. 供应商绩效评分的定时任务设计

问题:每月 1 号需要统计 2000 个供应商的绩效数据,单机执行需要 10 分钟,服务器重启会导致部分供应商没有评分。

方案:使用 XXL-Job 的分片广播模式,多台服务器并行执行,每台服务器只处理自己分片的供应商。

实现:配置 XXL-Job 任务为分片广播模式,3 台服务器分别处理 shardIndex = 0/1/2 的数据。查询时用 MOD(id, shardTotal) = shardIndex 分片。评分记录表有唯一索引 (supplier_id, score_month) 保证幂等性。

效果:并行执行,执行时间从 10 分钟降到 3.5 分钟。高可用,某台服务器宕机不影响整体任务。幂等性,重复执行不会重复写入数据。

5. 供应商门户的多租户数据隔离

问题:供应商登录门户后,只能看到自己的数据,不能看到其他供应商的数据。如果数据隔离做不好,可能导致数据泄露。

方案:设计了”租户 ID + 供应商 ID”双重隔离机制,在数据库、SQL、API、Token 四个层面保证数据隔离。

实现:数据库表有 tenant_id 和 supplier_id 字段,联合索引。所有查询强制带上这两个字段。API 接口从 Sa-Token 登录态中提取身份信息,登录态由 Redis 统一保存,供应商无法伪造。

效果:四层隔离确保数据安全,即使供应商拿到其他供应商的订单 ID,也无法查询到数据。

6. 消息通知的双通道机制

问题:供应商需要及时收到审核结果、新订单、发货提醒等通知。只用邮件可能被当成垃圾邮件,只用站内信可能不及时查看。

方案:设计了”站内信 + 邮件”双通道通知机制,重要通知同时发送两个渠道。

实现:保存站内信到数据库,异步发送邮件(用 CompletableFuture)。邮件发送失败不影响主流程,站内信已保存,供应商登录后台就能看到。

效果:双通道确保供应商能及时收到通知,异步发送不阻塞主流程,即使邮件服务器故障也不影响业务。

7. 供应商编码的分布式生成

问题:多台服务器并发创建供应商时,需要生成唯一的供应商编码(SUP + 8 位数字)。如果用数据库自增 ID,可能出现并发冲突。

方案:使用 Redis INCR 生成全局唯一序号。

实现:Redis Key 为 “supplier:code:seq”,每次创建供应商时 INCR 自增,拼接成供应商编码。Redis INCR 是原子操作,不会出现并发冲突。

效果:分布式环境下生成唯一编码,性能好,不依赖数据库。

6.2 简化版简历

项目: 跨境电商 SaaS 供应链 - 供应商管理模块(SRM)
时间: 2024.03 - 2024.09
角色: 核心开发
技术: Spring Boot 3.2、MyBatis-Plus 3.5、MySQL 8.0、Redis 7.0、OSS、XXL-Job 2.4、Docker 24.0

管理 2000+ 供应商的全生命周期,包括准入审核、资质管理、绩效评分、协同门户。日均处理审核单 200+,评分任务 500+。

核心亮点:

  1. 状态机设计:白名单控制 5 个状态 7 个流转路径,防止非法流转
  2. 幂等性控制:状态校验 + CAS 乐观锁 + 唯一索引三重保障
  3. 文件安全:扩展名 + MIME + Magic Bytes + OSS 四重防护
  4. 分片任务:XXL-Job 分片广播,执行时间从 10 分钟降到 3.5 分钟
  5. 数据隔离:tenant_id + supplier_id 双重隔离,四层防护确保安全