Series Article

Day02 · 供应商管理系统(SRM)

配套面试准备:完成本篇后,可以继续阅读:Day02_SRM核心业务面试准备 Day02_SRM供应商管理系统完整面试指南

课程目标:深度理解供应链最上游的业务逻辑,完整实现供应商全生命周期管理系统,包括信息管理、审核流程、文件上传、绩效评分和供应商协同 Portal。

今日交付物

  • 供应商数据库表设计(5 张表)完整 SQL
  • 供应商 CRUD 接口设计(RESTful 风格)ø
  • 供应商审核工作流(状态机)
  • 文件上传模块(本地 + OSS 双模式)
  • 供应商绩效评分定时计算逻辑
  • 供应商 Portal 账号体系
  • 前端页面结构与交互设计

第一节 供应商管理业务全解析

1.1 为什么供应商管理是供应链的起点

跨境出海的整个供应链,货物的源头来自供应商。没有稳定、可靠的供应商体系,后续所有的采购、仓储、发货都是无源之水。

一个真实的业务场景:

某卖家在亚马逊卖耳机,某天一款产品突然爆单,从日均50单飙升到日均500单。

  • 如果只有1家供应商且供应商产能有限 → 缺货断销,爆款白白浪费
  • 如果有3家备选供应商且评级都在A级以上 → 立即切换到产能更大的供应商,完美承接爆款

这就是供应商管理系统(SRM)的核心价值:

graph TD
    A["没有SRM<br>供应商管理混乱"] --> A1["供应商信息散落在Excel/微信"]
    A --> A2["不知道哪家供应商质量好"]
    A --> A3["出了问题找不到责任方"]
    A --> A4["突发需求无法快速切换供应商"]

    B["有SRM<br>供应商管理规范"] --> B1["所有供应商信息集中管理<br>随时查阅"]
    B --> B2["绩效评分体系<br>数据说话选最优"]
    B --> B3["操作全留记录<br>问题可追溯"]
    B --> B4["多家备选供应商<br>快速响应需求变化"]

1.2 供应商的业务分类

在跨境电商场景中,供应商并非只有”卖货给我们的工厂”这一种,完整的供应商类型包括:

供应商类型代码值业务角色举例
工厂供应商1直接生产商品,价格最低,MOQ较高,交期较长广州电子厂、义乌小商品厂
贸易商2中间商,聚合多家工厂货源,MOQ灵活,价格略高广州综合贸易公司
物流服务商3提供国际物流服务,不提供商品顺丰国际、递四方、云途物流

你可以把工厂供应商理解为”批发市场的厂家直销摊位”,贸易商理解为”中间商贩”,物流服务商理解为”快递公司”。

MOQ = Minimum Order Quantity,最小起订量。 比如某工厂说 MOQ = 1000 件,意思是你每次采购至少要下 1000 件,低于这个数量工厂不接单,或者单价会明显变高。 工厂供应商通常价格低,但 MOQ 高;贸易商 MOQ 灵活,但价格略高。

1.3 供应商全生命周期

供应商在系统中经历的完整生命周期:

flowchart LR
    A(["开始"]) --> B["📝 注册引入<br>采购专员录入供应商信息"]
    B --> C["🔍 资质审核<br>核实营业执照、质检报告"]
    C --> D["✅ 合作上线<br>签订合同、开通Portal账号"]
    D --> E["🛒 日常合作<br>接收采购单、发货、对账"]
    E --> F["📊 绩效评估<br>每月自动计算综合评分"]
    F --> G{"评分结果"}
    G -->|"持续优秀"| H["🏆 晋级优质<br>增加合作份额"]
    G -->|"持续下滑"| I["⚠️ 预警改进<br>限制新采购单"]
    G -->|"严重不达标"| J["🚫 淘汰处理<br>停用账号、转移订单"]
    H --> E
    I --> E
    J --> K(["结束"])

SaaS是服务于卖家的 不直接跟供应商联系 卖家在我们的系统入职之后 他们自己可以去联系供应商 (微信/打电话/线下) 当卖家联系好了供应商 就可以在我们的系统中 录入这个供应商的信息

采购专员 卖家中的一个角色

资质审核 —> 卖家审核

Portal账号 —> 专门给供应商开的一个管理平台

预警改进 / 淘汰处理 都仅仅只是SaaS平台给卖家的一个提醒/通知 具体是否要淘汰 还是取决于卖家自己

1.4 供应商管理涉及的角色

角色在SRM中的操作权限
采购专员新增/编辑供应商信息,查看供应商列表,发起审核申请
采购负责人审核供应商(通过/拒绝),查看绩效报告,停用/启用供应商
财务专员查看供应商结算信息,维护银行账户信息
供应商(外部)登录Portal查看自己的采购单、交货记录、账款信息,上传发货单据

第二节 供应商数据库表设计

2.1 供应商模块表结构总览

SRM 模块共涉及 5 张核心数据表,各表之间的关系如下:

erDiagram
    supplier {
        bigint id PK "供应商主键"
        bigint tenant_id "租户ID"
        varchar supplier_code "供应商编码"
        varchar supplier_name "供应商名称"
        tinyint supplier_type "类型1工厂2贸易3物流"
        tinyint status "状态0草稿1待审2通过3拒绝4停用"
        char grade "评级SABC"
        decimal score "综合评分0-100"
    }

    supplier_cert {
        bigint id PK "资质文件主键"
        bigint supplier_id FK "关联供应商"
        tinyint cert_type "资质类型"
        varchar file_name "文件名称"
        varchar file_url "文件存储URL"
        date expire_date "资质有效期"
    }

    supplier_contact {
        bigint id PK "联系人主键"
        bigint supplier_id FK "关联供应商"
        varchar contact_name "联系人姓名"
        varchar position "职位"
        tinyint is_primary "是否主联系人"
    }

    supplier_score_log {
        bigint id PK "评分记录主键"
        bigint supplier_id FK "关联供应商"
        varchar score_month "评分月份YYYYMM"
        decimal delivery_rate "准时到货率"
        decimal quality_rate "质量合格率"
        decimal total_score "综合得分"
    }

    supplier_audit_log {
        bigint id PK "审核记录主键"
        bigint supplier_id FK "关联供应商"
        tinyint from_status "变更前状态"
        tinyint to_status "变更后状态"
        varchar audit_remark "审核意见"
        bigint operator_id "操作人"
    }

    supplier ||--o{ supplier_cert : "拥有多个资质文件"
    supplier ||--o{ supplier_contact : "拥有多个联系人"
    supplier ||--o{ supplier_score_log : "拥有多条评分记录"
    supplier ||--o{ supplier_audit_log : "拥有多条审核记录"

2.2 供应商主表 SQL

-- ============================================================
-- 供应商主表
-- 存储供应商的全部基础信息和当前状态
-- ============================================================
CREATE TABLE `supplier`
(
    -- ==================== 主键与租户 ====================
    `id`               BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID,雪花算法生成',
    `tenant_id`        BIGINT           NOT NULL           COMMENT '租户ID,多租户隔离核心字段',

    -- ==================== 供应商编号 ====================
    `supplier_code`    VARCHAR(32)      NOT NULL           COMMENT '供应商编码,系统自动生成,格式:SUP-YYYYMMDD-XXXX,全局唯一',

    -- ==================== 基础信息 ====================
    `supplier_name`    VARCHAR(128)     NOT NULL           COMMENT '供应商公司名称(全称)',
    `supplier_type`    TINYINT          NOT NULL           COMMENT '供应商类型:1=工厂供应商 2=贸易商 3=物流服务商',
    `category_ids`     JSON             NULL               COMMENT '供货品类ID列表,如[1,5,12],对应商品品类表',
    `province`         VARCHAR(32)      NULL               COMMENT '所在省份,如:广东省',
    `city`             VARCHAR(32)      NULL               COMMENT '所在城市,如:广州市',
    `address`          VARCHAR(256)     NULL               COMMENT '详细地址,含街道门牌号',
    `website`          VARCHAR(256)     NULL               COMMENT '公司官方网站URL',
    `company_size`     TINYINT          NULL               COMMENT '公司规模:1=50人以下 2=50-200人 3=200-500人 4=500人以上',
    `founded_year`     SMALLINT         NULL               COMMENT '成立年份,如:2015',

    -- ==================== 主联系人 ====================
    `contact_name`     VARCHAR(64)      NOT NULL           COMMENT '主联系人姓名',
    `contact_phone`    VARCHAR(20)      NOT NULL           COMMENT '主联系人手机号',
    `contact_email`    VARCHAR(128)     NOT NULL           COMMENT '主联系人邮箱,供应商Portal登录账号',
    `contact_wechat`   VARCHAR(64)      NULL               COMMENT '联系人微信号',
    `contact_whatsapp` VARCHAR(64)      NULL               COMMENT '联系人WhatsApp账号(海外供应商)',

    -- ==================== 财务信息 ====================
    `bank_name`        VARCHAR(64)      NULL               COMMENT '开户银行名称,如:招商银行广州分行',
    `bank_account`     VARCHAR(64)      NULL               COMMENT '银行账号,AES-256加密存储',
    `bank_account_name` VARCHAR(64)     NULL               COMMENT '银行开户名(与营业执照一致)',
    `tax_no`           VARCHAR(32)      NULL               COMMENT '纳税人识别号(统一社会信用代码)',
    `invoice_type`     TINYINT          NULL               COMMENT '开票类型:1=增值税专用发票 2=增值税普通发票 3=收据',

    -- ==================== 供货能力 ====================
    `moq`              INT              NULL               COMMENT 'Minimum Order Quantity,最小起订量,单位:件',
    `lead_time_days`   INT              NULL               COMMENT '交货周期(天),从下单到发货的天数',
    `monthly_capacity` INT              NULL               COMMENT '月最大供货量(件),用于评估是否能承接大单',
    `currency`         CHAR(3)          NOT NULL DEFAULT 'CNY' COMMENT '结算货币:CNY=人民币 USD=美元 EUR=欧元',
    `payment_days`     INT              NOT NULL DEFAULT 0  COMMENT '账期天数:0=现款现货 30=月结30天 60=月结60天',

    -- ==================== 评级与评分 ====================
    `grade`            CHAR(1)          NOT NULL DEFAULT 'C' COMMENT '综合评级:S=优质(90+) A=良好(75-89) B=一般(60-74) C=预警(60以下)',
    `score`            DECIMAL(5, 2)    NOT NULL DEFAULT 0.00 COMMENT '最新综合评分,满分100分,由定时任务每月自动更新',
    `last_score_month` VARCHAR(6)       NULL               COMMENT '最后一次评分的月份,格式YYYYMM,如202501',

    -- ==================== 审核信息 ====================
    `status`           TINYINT          NOT NULL DEFAULT 0  COMMENT '状态:0=草稿 1=待审核 2=已通过 3=已拒绝 4=已停用',
    `audit_user_id`    BIGINT           NULL               COMMENT '最后一次审核操作人的用户ID',
    `audit_time`       DATETIME         NULL               COMMENT '最后一次审核时间',
    `audit_remark`     VARCHAR(512)     NULL               COMMENT '最后一次审核的意见(拒绝时必填)',

    -- ==================== Portal账号 ====================
    `portal_user_id`   BIGINT           NULL               COMMENT '供应商Portal登录账号的用户ID,关联sys_user表',
    `portal_enabled`   TINYINT(1)       NOT NULL DEFAULT 0  COMMENT 'Portal是否已开通:0=未开通 1=已开通',

    -- ==================== 其他 ====================
    `remark`           VARCHAR(512)     NULL               COMMENT '内部备注(供应商看不到)',
    `tags`             JSON             NULL               COMMENT '供应商标签列表,如["重点合作","质量稳定","响应快"]',

    -- ==================== 公共字段 ====================
    `create_time`      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP            COMMENT '创建时间,自动填充',
    `update_time`      DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP
                                                ON UPDATE CURRENT_TIMESTAMP          COMMENT '最后更新时间,自动填充',
    `create_by`        BIGINT           NULL               COMMENT '创建人的用户ID',
    `update_by`        BIGINT           NULL               COMMENT '最后修改人的用户ID',
    `is_deleted`       TINYINT(1)       NOT NULL DEFAULT 0  COMMENT '逻辑删除:0=正常 1=已删除',
    `version`          INT              NOT NULL DEFAULT 0  COMMENT '乐观锁版本号,每次更新自动+1',

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_tenant_supplier_code` (`tenant_id`, `supplier_code`)  COMMENT '同一租户内供应商编码唯一',
    KEY `idx_tenant_status`  (`tenant_id`, `status`)                     COMMENT '按租户+状态筛选',
    KEY `idx_tenant_grade`   (`tenant_id`, `grade`)                      COMMENT '按租户+评级筛选',
    KEY `idx_tenant_type`    (`tenant_id`, `supplier_type`)              COMMENT '按租户+类型筛选',
    KEY `idx_create_time`    (`create_time`)                             COMMENT '按创建时间排序'

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
  COMMENT = '供应商主表';

2.3 供应商资质文件表 SQL

-- ============================================================
-- 供应商资质文件表
-- 每个供应商可上传多种、多份资质文件
-- ============================================================
CREATE TABLE `supplier_cert`
(
    `id`             BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`      BIGINT           NOT NULL           COMMENT '租户ID',
    `supplier_id`    BIGINT           NOT NULL           COMMENT '关联供应商ID,外键关联 supplier.id',
    `cert_type`      TINYINT          NOT NULL           COMMENT '资质类型:1=营业执照 2=质检报告 3=产品认证(CE/FCC/UL) 4=银行账户证明 5=其他',
    `cert_name`      VARCHAR(128)     NOT NULL           COMMENT '资质名称,如:营业执照、CE认证报告',
    `file_name`      VARCHAR(256)     NOT NULL           COMMENT '原始文件名称(上传时的文件名)',
    `file_url`       VARCHAR(512)     NOT NULL           COMMENT '文件存储URL(OSS路径或本地路径)',
    `file_size`      BIGINT           NOT NULL DEFAULT 0  COMMENT '文件大小,单位:字节(Byte)',
    `file_type`      VARCHAR(32)      NOT NULL           COMMENT '文件类型(MIME类型):application/pdf / image/jpeg / image/png',
    `issue_date`     DATE             NULL               COMMENT '资质颁发日期',
    `expire_date`    DATE             NULL               COMMENT '资质有效期截止日期,NULL表示永久有效',
    `is_expired`     TINYINT(1)       NOT NULL DEFAULT 0  COMMENT '是否已过期:0=有效 1=已过期,由定时任务每日更新',
    `cert_no`        VARCHAR(64)      NULL               COMMENT '证书编号(如营业执照注册号)',
    `remark`         VARCHAR(256)     NULL               COMMENT '备注说明',
    `create_time`    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
    `create_by`      BIGINT           NULL               COMMENT '上传人的用户ID',
    `is_deleted`     TINYINT(1)       NOT NULL DEFAULT 0  COMMENT '逻辑删除',

    PRIMARY KEY (`id`),
    KEY `idx_supplier_id`   (`supplier_id`)            COMMENT '按供应商查询其所有资质',
    KEY `idx_tenant_id`     (`tenant_id`),
    KEY `idx_expire_date`   (`expire_date`)            COMMENT '按到期日期查询,用于资质到期预警'

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
  COMMENT = '供应商资质文件表';

2.4 供应商联系人表 SQL

-- ============================================================
-- 供应商联系人表
-- 一个供应商可有多个联系人(主联系人 + 业务联系人 + 财务联系人等)
-- 主联系人冗余存储在 supplier 主表中方便快速查询
-- ============================================================
CREATE TABLE `supplier_contact`
(
    `id`             BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`      BIGINT           NOT NULL           COMMENT '租户ID',
    `supplier_id`    BIGINT           NOT NULL           COMMENT '关联供应商ID',
    `contact_name`   VARCHAR(64)      NOT NULL           COMMENT '联系人姓名',
    `position`       VARCHAR(64)      NULL               COMMENT '职位/职称,如:销售经理、财务总监',
    `phone`          VARCHAR(20)      NULL               COMMENT '手机号码',
    `email`          VARCHAR(128)     NULL               COMMENT '邮箱地址',
    `wechat`         VARCHAR(64)      NULL               COMMENT '微信号',
    `whatsapp`       VARCHAR(64)      NULL               COMMENT 'WhatsApp账号',
    `department`     VARCHAR(64)      NULL               COMMENT '所在部门,如:销售部、财务部',
    `is_primary`     TINYINT(1)       NOT NULL DEFAULT 0  COMMENT '是否主联系人:1=是 0=否,每个供应商只能有1个主联系人',
    `contact_type`   TINYINT          NOT NULL DEFAULT 1  COMMENT '联系人类型:1=业务对接 2=财务对账 3=技术支持 4=紧急联系',
    `remark`         VARCHAR(256)     NULL               COMMENT '备注',
    `create_time`    DATETIME         NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `create_by`      BIGINT           NULL,
    `is_deleted`     TINYINT(1)       NOT NULL DEFAULT 0,

    PRIMARY KEY (`id`),
    KEY `idx_supplier_id` (`supplier_id`),
    KEY `idx_tenant_id`   (`tenant_id`)

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
  COMMENT = '供应商联系人表';

2.5 供应商评分记录表 SQL

-- ============================================================
-- 供应商月度评分记录表
-- 每月由定时任务自动计算并写入一条记录,只增不改
-- ============================================================
CREATE TABLE `supplier_score_log`
(
    `id`                  BIGINT UNSIGNED  NOT NULL      COMMENT '主键ID',
    `tenant_id`           BIGINT           NOT NULL      COMMENT '租户ID',
    `supplier_id`         BIGINT           NOT NULL      COMMENT '关联供应商ID',
    `score_month`         VARCHAR(6)       NOT NULL      COMMENT '评分月份,格式YYYYMM,如202501',

    -- ==================== 各维度原始数据 ====================
    `total_orders`        INT              NOT NULL DEFAULT 0  COMMENT '本月采购订单总数(用于计算各项比率)',
    `delivered_on_time`   INT              NOT NULL DEFAULT 0  COMMENT '本月准时到货的订单数',
    `quality_passed`      INT              NOT NULL DEFAULT 0  COMMENT '本月质检合格的批次数',
    `quality_total`       INT              NOT NULL DEFAULT 0  COMMENT '本月质检总批次数',
    `response_hours_avg`  DECIMAL(8, 2)    NULL               COMMENT '本月平均响应时长(小时),从发询价到回价的平均时间',
    `price_comparison`    DECIMAL(8, 4)    NULL               COMMENT '价格竞争力系数,本供应商均价/市场均价,<1表示低于市场价',

    -- ==================== 各维度评分(满分各25分)====================
    `delivery_score`      DECIMAL(5, 2)    NOT NULL DEFAULT 0.00 COMMENT '准时交货评分(满分25分)= 准时到货率 × 25',
    `quality_score`       DECIMAL(5, 2)    NOT NULL DEFAULT 0.00 COMMENT '质量合格评分(满分25分)= 质量合格率 × 25',
    `response_score`      DECIMAL(5, 2)    NOT NULL DEFAULT 0.00 COMMENT '响应速度评分(满分25分)= f(平均响应时长)',
    `price_score`         DECIMAL(5, 2)    NOT NULL DEFAULT 0.00 COMMENT '价格竞争力评分(满分25分)= f(价格系数)',

    -- ==================== 综合评分与评级 ====================
    `total_score`         DECIMAL(5, 2)    NOT NULL DEFAULT 0.00 COMMENT '综合评分 = 四项评分之和,满分100',
    `grade`               CHAR(1)          NOT NULL             COMMENT '本月评级:S/A/B/C',
    `grade_changed`       TINYINT(1)       NOT NULL DEFAULT 0   COMMENT '评级是否发生变化:0=未变 1=已变(触发通知)',
    `prev_grade`          CHAR(1)          NULL                 COMMENT '上月评级,用于对比展示',

    -- ==================== 计算说明 ====================
    `calc_remark`         VARCHAR(512)     NULL                 COMMENT '评分计算说明,记录本月特殊情况(如无采购单、数据不足)',
    `calc_time`           DATETIME         NOT NULL             COMMENT '定时任务执行计算的时间',

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_supplier_month` (`supplier_id`, `score_month`) COMMENT '同一供应商同一月份只有一条记录',
    KEY `idx_tenant_id`    (`tenant_id`),
    KEY `idx_score_month`  (`score_month`)

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
  COMMENT = '供应商月度评分记录表(只增不改)';

2.6 供应商审核日志表 SQL

-- ============================================================
-- 供应商审核日志表
-- 记录供应商状态的每一次变更,是审核流程的完整追溯链
-- ============================================================
CREATE TABLE `supplier_audit_log`
(
    `id`             BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`      BIGINT           NOT NULL           COMMENT '租户ID',
    `supplier_id`    BIGINT           NOT NULL           COMMENT '关联供应商ID',
    `from_status`    TINYINT          NULL               COMMENT '变更前的状态值(NULL表示首次创建)',
    `to_status`      TINYINT          NOT NULL           COMMENT '变更后的状态值',
    `action`         VARCHAR(64)      NOT NULL           COMMENT '操作动作描述:如 提交审核/审核通过/审核拒绝/停用/重新启用',
    `audit_remark`   VARCHAR(512)     NULL               COMMENT '审核意见或操作备注(拒绝时必填)',
    `operator_id`    BIGINT           NOT NULL           COMMENT '操作人的用户ID',
    `operator_name`  VARCHAR(64)      NOT NULL           COMMENT '操作人姓名(冗余存储,防止用户被删后查不到)',
    `operate_time`   DATETIME         NOT NULL           COMMENT '操作发生时间',

    PRIMARY KEY (`id`),
    KEY `idx_supplier_id`  (`supplier_id`)   COMMENT '查询某供应商的全部审核历史',
    KEY `idx_tenant_id`    (`tenant_id`),
    KEY `idx_operate_time` (`operate_time`)

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
  COMMENT = '供应商审核操作日志表(只增不改不删)';

2.7 数据字典补充

-- 补充供应商相关字典数据
INSERT INTO `sys_dict_type` (`id`, `dict_name`, `dict_code`, `status`, `remark`)
VALUES
(10, '供应商资质类型', 'supplier_cert_type', 1, '供应商可上传的资质文件种类'),
(11, '供应商联系人类型', 'supplier_contact_type', 1, '联系人的业务职能分类'),
(12, '公司规模', 'company_size', 1, '供应商公司的员工人数规模');

INSERT INTO `sys_dict_item` (`id`, `dict_type_id`, `dict_code`, `item_value`, `item_label`, `item_label_en`, `sort`, `css_class`, `status`)
VALUES
-- 供应商资质类型
(1001, 10, 'supplier_cert_type', '1', '营业执照', 'Business License', 1, 'primary', 1),
(1002, 10, 'supplier_cert_type', '2', '质检报告', 'Quality Report', 2, 'success', 1),
(1003, 10, 'supplier_cert_type', '3', '产品认证', 'Product Certification', 3, 'warning', 1),
(1004, 10, 'supplier_cert_type', '4', '银行账户证明', 'Bank Certificate', 4, 'info', 1),
(1005, 10, 'supplier_cert_type', '5', '其他文件', 'Other', 5, '', 1),
-- 联系人类型
(1101, 11, 'supplier_contact_type', '1', '业务对接', 'Business', 1, 'primary', 1),
(1102, 11, 'supplier_contact_type', '2', '财务对账', 'Finance', 2, 'success', 1),
(1103, 11, 'supplier_contact_type', '3', '技术支持', 'Technical', 3, 'info', 1),
(1104, 11, 'supplier_contact_type', '4', '紧急联系', 'Emergency', 4, 'danger', 1),
-- 公司规模
(1201, 12, 'company_size', '1', '50人以下', '<50', 1, '', 1),
(1202, 12, 'company_size', '2', '50-200人', '50-200', 2, '', 1),
(1203, 12, 'company_size', '3', '200-500人', '200-500', 3, '', 1),
(1204, 12, 'company_size', '4', '500人以上', '>500', 4, '', 1);

第三节 供应商信息管理(CRUD)

3.1 CRUD 是什么

CRUD 是数据管理中最基础的四个操作,几乎所有业务功能都建立在 CRUD 之上。

字母操作HTTP 方法业务含义
CCreate(创建)POST新增一个供应商
RRead(读取)GET查询供应商列表/查询某个供应商详情
UUpdate(更新)PUT编辑供应商信息
DDelete(删除)DELETE删除供应商(逻辑删除)

3.2 供应商编码自动生成规则

供应商编码(supplier_code)需要系统自动生成,规则如下:

格式:SUP-{YYYYMMDD}-{4位序号}
示例:SUP-20250117-0001

生成逻辑:
1. 取当前日期,格式化为 YYYYMMDD
2. 查询当天已创建的供应商数量,+1 作为序号,不足4位补零
3. 拼接完整编码:SUP-20250117-0001
4. 检查编码唯一性(并发场景下同一时刻可能生成相同编码)
5. 唯一性冲突时重试,直到生成唯一编码
flowchart TD
    A["调用生成供应商编码方法"] --> B["获取当前日期<br>format: YYYYMMDD"]
    B --> C["查询数据库<br>当天已存在的最大序号"]
    C --> D["序号 = 已有最大序号 + 1<br>如:当天已有3条,则序号=0004"]
    D --> E["拼接编码<br>SUP-20250117-0004"]
    E --> F["检查编码是否已存在<br>SELECT COUNT 验证"]
    F --> G{"是否重复?"}
    G -- "不重复(正常情况)" --> H["返回编码<br>SUP-20250117-0004"]
    G -- "重复(并发冲突)" --> I["序号 +1 重新生成"]
    I --> E

优化做法:

  • Redis INCR 原子自增,天然防并发重复。
  • Key 设置过期时间,比如 2-3 天。
  • 数据库仍然保留唯一索引 uk_tenant_supplier_code 兜底。
  • 如果 Redis 不可用,可以降级到数据库序列表或数据库唯一冲突重试。

我们可以先在Redis中进行查询 当天的Key是否存在 如果存在 直接调用 INCR 自增 获取最新的编号 如果不存在 要先查询数据库 看是否存在当天的供应商的编码 如果不存在说明 当天还没有新增过 先往Redis中 新增一个Key 默认值是 0 然后执行 INCR 1

如果存在说明 当天已经新增过 但是Redis的Key已经丢失了 找到当天最大的那个值 然后存储到Redis中

然后拿到 INCR 后的值 再做拼接

private String nextAvailableCode(Long tenantId, String prefix, long sequence) {  
    long currentSequence = sequence;  
    while (true) {  
        String code = prefix + String.format("%04d", currentSequence);  
        Long count = supplierMapper.selectCount(Wrappers.<Supplier>lambdaQuery()  
                .eq(Supplier::getTenantId, tenantId)  
                .eq(Supplier::getSupplierCode, code));  
        if (count == null || count == 0) {  
            return code;  
        }  
        currentSequence++;  
    }  
}

3.3 供应商列表查询业务规则

每个卖家 只能查询自己引入/签约的供应商 要自动拼接 租户ID

列表查询需要支持多维度组合筛选,这是使用最频繁的功能:

flowchart TD
    A["前端发起列表查询请求"] --> B["传入筛选参数"]
    B --> B1["供应商名称(模糊搜索)"]
    B --> B2["供应商类型(精确匹配)"]
    B --> B3["当前状态(精确匹配)"]
    B --> B4["综合评级(精确匹配)"]
    B --> B5["创建时间范围(区间查询)"]
    B --> B6["页码 page 和每页数 size"]
    B1 & B2 & B3 & B4 & B5 & B6 --> C["构建动态查询条件<br>有值才拼 WHERE 条件<br>没值则忽略该条件"]
    C --> D["多租户插件自动追加<br>AND tenant_id = 当前租户ID"]
    D --> E["分页插件自动处理<br>LIMIT offset, size<br>和 COUNT(*) 查询"]
    E --> F["按创建时间倒序排列<br>ORDER BY create_time DESC"]
    F --> G["返回分页结果<br>total总数 + records列表数据"]

列表接口返回数据示例(前端需要的字段):

{
  "code": 200,
  "msg": "success",
  "data": {
    "total": 156,
    "pages": 8,
    "current": 1,
    "size": 20,
    "records": [
      {
        "id": "1748291234567890",
        "supplierCode": "SUP-20250117-0001",
        "supplierName": "广州市XX电子科技有限公司",
        "supplierTypeName": "工厂供应商",
        "contactName": "张经理",
        "contactPhone": "138****8888",
        "province": "广东省",
        "city": "广州市",
        "grade": "A",
        "score": 88.50,
        "status": 2,
        "statusName": "已通过",
        "moq": 100,
        "leadTimeDays": 7,
        "createTime": "2025-01-17 10:30:00",
        "certExpireWarning": true
      }
    ]
  }
}

注意:手机号在列表中需要脱敏显示(138****8888),完整手机号只在详情页有权限的用户才能查看。certExpireWarning: true 表示该供应商有资质文件即将到期,前端需要用醒目颜色标注。

有的角色只能访问列表 不能访问详情页 有的角色可以访问任何页面 有的角色不能看到完整的手机号码 有的角色是可以看到完整的手机号码

这个有权限来进行控制的 能否进行接口的调用 比如: 当前这个列表查询的接口 只要是有权限调用这个接口的角色 看到的都是 脱敏后的数据 如果想要看到完整的数据 就需要查看详情 但是查看详情的时候 会要求相关权限

3.4 供应商详情查询

详情查询要同时返回主表信息 + 关联的资质文件 + 联系人列表 + 最近评分记录:

flowchart LR
    A["GET /supplier/{id}"] --> B["查询 supplier 主表<br>获取基础信息"]
    B --> C["并行查询关联数据"]
    C --> D["查询 supplier_cert<br>获取全部资质文件"]
    C --> E["查询 supplier_contact<br>获取联系人列表"]
    C --> F["查询 supplier_score_log<br>获取最近12个月评分"]
    C --> G["查询 supplier_audit_log<br>获取审核操作历史"]
    D & E & F & G --> H["组装返回数据<br>VO对象"]
    H --> I["返回完整供应商详情"]

优化操作: 如果串行查询是这样:

查主表 20ms
查资质 30ms
查联系人 20ms
查评分 40ms
查审核日志 30ms
总耗时约 140ms

并行查询是这样:

查主表 20ms
然后同时查资质、联系人、评分、审核日志
总耗时约 20ms + max(30,20,40,30) = 60ms

可以使用CompletableFuture或线程池来实现

@Override  
public SupplierDetailVO detail(Long id) {  
    // 校验 ID  
    if (ObjectUtil.isNull(id) || id <= 0) BusinessException.throwException("参数非法");  
    // 查询主表  
    Supplier supplier = supplierMapper.selectById(id);  
    if (ObjectUtil.isNull(supplier)) {  
        throwSupplierException(SupplierErrorCode.SUPPLIER_NOT_FOUND);  
    }  
    // 在主表查询后 拿到主表的ID 然后开始异步查询每个关联表的数据  
    // certFuture 结果集 可能是一个集合 也有可能是异常    CompletableFuture<List<SupplierCert>> certFuture = asyncQuery("供应商资质", () -> certMapper.selectList(  
            new LambdaQueryWrapper<SupplierCert>().eq(SupplierCert::getSupplierId, id)));  
    CompletableFuture<List<SupplierContact>> contactFuture = asyncQuery("供应商联系人", () -> contactMapper.selectList(  
            new LambdaQueryWrapper<SupplierContact>().eq(SupplierContact::getSupplierId, id)));  
    CompletableFuture<List<SupplierScoreLog>> scoreFuture = asyncQuery("供应商评分", () -> scoreLogMapper.selectList(  
            new LambdaQueryWrapper<SupplierScoreLog>()  
                    .eq(SupplierScoreLog::getSupplierId, id)  
                    .orderByDesc(SupplierScoreLog::getScoreMonth)  
                    .last("LIMIT 12")));  
    CompletableFuture<List<SupplierAuditLog>> auditFuture = asyncQuery("供应商审核日志", () -> auditLogMapper.selectList(  
            new LambdaQueryWrapper<SupplierAuditLog>()  
                    .eq(SupplierAuditLog::getSupplierId, id)  
                    .orderByDesc(SupplierAuditLog::getOperateTime)));  
    try {  
        // join()  阻塞操作  要在这个地方 等待上面的所有的异步都执行完成  
        CompletableFuture.allOf(certFuture, contactFuture, scoreFuture, auditFuture).join();  
    } catch (CompletionException exception) {  
        log.error("供应商详情关联数据查询失败,supplierId={}", id, exception);  
        BusinessException.throwException("供应商关联数据查询失败");  
    }  
      
    // 主表 以及 4个 关联表 都已经查询完成  
    SupplierDetailVO detail = BeanUtil.copyProperties(supplier, SupplierDetailVO.class);  
    detail.setCerts(certFuture.join());  
    detail.setContacts(contactFuture.join());  
    detail.setScoreLogs(scoreFuture.join());  
    detail.setAuditLogs(auditFuture.join());  
    return detail;  
}




private <T> CompletableFuture<List<T>> asyncQuery(String queryName, SupplierRelationQuery<T> query) {  
    return CompletableFuture.supplyAsync(() -> {  
        try {  
            return query.query();  
        } catch (RuntimeException exception) {  
            log.error("{}查询失败", queryName, exception);  
            throw exception;  
        }  
    }, supplierTaskExecutor);  
}


3.5 新增供应商业务校验规则

新增供应商 是怎么触发 ?? 有卖家的采购员 去谈 供应商 采购员就可以在卖家的后台界面录入供应商的基本信息 然后提交保存

所有的前端校验 都可以通过某些手段 进行绕过 也就是说 都不靠谱 所以 不管前端是否有校验 后端都必须要有校验 只要数据库中的数据 一旦出现了问题 就是你的锅

新增供应商时,后端必须做严格的数据校验,不能完全依赖前端:

数据库表 分析 不能完全依赖这个字段约束 还要看 表关系 更主要的是 业务的上下文 业务文档 分析 业务的上下文

字段校验规则错误提示
supplierName必填,长度 2-128 字符”供应商名称不能为空”
supplierType必填,值必须在 [1,2,3] 内”供应商类型不合法”
contactPhone必填,符合手机号格式(11位数字,1开头)“手机号格式不正确”
contactEmail必填,符合邮箱格式”邮箱格式不正确”
moq选填,若填写必须 > 0”最小起订量必须大于0”
leadTimeDays选填,若填写必须在 1-365 之间”交货周期必须在1-365天之间”
supplierName同一租户内供应商名称不允许重复”供应商名称已存在”
contactEmail同一租户内邮箱不允许重复(用于Portal登录)“该邮箱已被使用”

同一个供应商 可以给不同的租户 进行合作 但是每个租户下 都要新增这个供应商的信息 不是按供应商记录的 而是按租户进行记录的

3.6 编辑供应商业务规则

采购员提交供应商信息后 默认的状态是 草稿 如果是草稿 就可以反复修改 一旦提交了审核 在审核完成前 是不可以修改

前端提交编辑请求 这个也是一个接口 根据ID查询一个供应商的信息

flowchart TD
    A["前端提交编辑请求"] --> B["校验供应商ID是否存在"]
    B --> C{"供应商当前状态?"}
    C -- "草稿 / 已拒绝" --> D["允许编辑全部字段"]
    C -- "待审核" --> E["❌ 禁止编辑<br>提示:审核进行中,请等待审核结果"]
    C -- "已通过 / 已停用" --> F["只允许编辑部分字段<br>联系人/备注/标签<br>核心财务信息需要重新审核"]
    D --> G["校验新数据合法性"]
    F --> G
    G --> H["更新数据库<br>version字段+1(乐观锁)"]
    H --> I["记录操作审计日志"]
    I --> J["返回成功"]
    E --> K["返回错误码 10002<br>操作不允许"]

优化操作: 乐观锁 CAS更新

// 构建 新的 实体类  
Supplier update = buildUpdateSupplier(existing, request);  
// CAS 更新  
update.setVersion(request.getVersion() + 1);  
int rows = supplierMapper.update(update, new LambdaUpdateWrapper<Supplier>()  
        .eq(Supplier::getId, id)  
        .eq(Supplier::getVersion, request.getVersion())); // 对比当前的版本号 CAS  
if (rows == 0) {  
    BusinessException.throwException(ResultCode.DATA_VERSION_CONFLICT);  
}

3.7 删除供应商业务规则

删除是风险最高的操作,必须做关联检查:

flowchart TD
    A["前端发起删除请求"] --> B["检查该供应商是否有<br>未完成的采购订单"]
    B --> C{"存在未完成采购单?"}
    C -- 是 --> D["❌ 禁止删除<br>提示:该供应商有X笔进行中的采购单<br>无法删除"]
    C -- 否 --> E["检查供应商当前状态"]
    E --> F{"状态是否为已通过?"}
    F -- 是 --> G["提示确认:正式合作供应商删除后<br>历史采购记录仍保留,确认继续?"]
    G --> H{"用户确认"}
    H -- 确认 --> I["执行逻辑删除<br>is_deleted = 1"]
    H -- 取消 --> J["取消操作"]
    F -- "否(草稿/拒绝/停用)" --> I
    I --> K["Portal账号同步禁用"]
    K --> L["记录审计日志"]
    L --> M["返回成功"]
    D --> N["返回错误信息"]

业务操作: 事务控制 + 逻辑删除(本质是UPDATE操作)

@Override  
@Transactional(rollbackFor = Exception.class)  
public void delete(Long id) {  
    Supplier existing = requireSupplier(id);  
    // 审核中的 供应商 不可以直接删除  
    // 如果有 订货单 还没有结束 不允许删除 当前还未实现    if (SupplierStatus.PENDING_AUDIT.getCode().equals(existing.getStatus())) {  
        throwSupplierStatusException("审核进行中,请等待审核结果");  
    }  
      
    Supplier update = new Supplier();  
    update.setPortalEnabled(0);  
    // 逻辑删除  
    update.setIsDeleted(1);   
    int rows = supplierMapper.update(update, new LambdaUpdateWrapper<Supplier>().eq(Supplier::getId, id));  
    if (rows == 0) {  
        throwSupplierException(SupplierErrorCode.SUPPLIER_NOT_FOUND);  
    }  
    // 记录操作日志  
    insertAuditLog(id, existing.getStatus(), existing.getStatus(), DELETE_ACTION, "逻辑删除供应商");  
}

第四节 供应商审核工作流

4.1 状态机深度解析

**状态机是企业项目中最重要的设计模式之一,几乎所有业务单据(采购单/订单/退款单)都用状态机管理。

什么是状态机?

状态机描述一个对象在其生命周期中:

  • 可能处于哪些状态(State)
  • 每个状态下可以执行哪些事件(Event)
  • 执行事件后会转变到哪个新状态(Transition)

状态机 限定了 只能是哪些状态 状态机非常重要的功能 就是控制 状态之间的流转 我们要进行编码的时候要对当前的状态进行判断 当前属于什么状态 且 只能跳转到什么状态 这些都是状态机里面规定的 setStates(“ABC”);

状态机就是用于 描述 当前业务 允许哪些状态的值 状态之间的流转过程是怎么样 都是状态机来管理的 状态机是一种概念 我们通常会使用 状态机白名单 来进行管理状态之间的流转

什么是状态机白名单 从技术层面上来说 就是一个Map Key Value Key是 运行的状态 Value 从当前Key运行跳转的状态的集合 String: SET<>

状态的修改 要满足/要经过 状态机白名单

供应商状态机定义:

stateDiagram-v2
    [*] --> 草稿 : 采购专员创建供应商
    草稿 --> 待审核 : 提交审核
    待审核 --> 已通过 : 采购负责人审核通过
    待审核 --> 已拒绝 : 采购负责人审核拒绝
    待审核 --> 草稿 : 采购负责人要求补充信息
    已拒绝 --> 草稿 : 采购专员修改后重新提交
    已通过 --> 已停用 : 采购负责人停用
    已停用 --> 已通过 : 采购负责人重新启用
    已通过 --> [*] : 删除(逻辑删除)
    草稿 --> [*] : 删除(逻辑删除)
    已拒绝 --> [*] : 删除(逻辑删除)

状态编码与含义:

状态码状态名称含义可执行操作
0草稿初始创建状态,信息可能不完整编辑、提交审核、删除
1待审核已提交,等待负责人审核无(等待审核)
2已通过正式合作供应商编辑部分字段、停用、向其下采购单
3已拒绝审核未通过编辑后重新提交、删除
4已停用暂停合作重新启用

优化操作: 状态机白名单

public final class SupplierStateMachine {  
  
    private static final Set<Transition> ALLOWED_TRANSITIONS = Set.of(  
            transition(SupplierStatus.DRAFT, SupplierStatus.PENDING_AUDIT),  
            transition(SupplierStatus.PENDING_AUDIT, SupplierStatus.APPROVED),  
            transition(SupplierStatus.PENDING_AUDIT, SupplierStatus.REJECTED),  
            transition(SupplierStatus.PENDING_AUDIT, SupplierStatus.DRAFT),  
            transition(SupplierStatus.REJECTED, SupplierStatus.DRAFT),  
            transition(SupplierStatus.APPROVED, SupplierStatus.DISABLED),  
            transition(SupplierStatus.DISABLED, SupplierStatus.APPROVED)  
    );  
  
    private static final Set<Integer> DELETE_ALLOWED_STATUSES = Set.of(  
            SupplierStatus.DRAFT.getCode(),  
            SupplierStatus.APPROVED.getCode(),  
            SupplierStatus.REJECTED.getCode()  
    );  
  
    private SupplierStateMachine() {  
    }  
  
    /**  
     * 判断状态流转是否在白名单中。     *     * @param fromStatus 原状态  
     * @param toStatus   目标状态  
     * @return 是否允许流转  
     */    public static boolean canTransit(Integer fromStatus, Integer toStatus) {  
        return ALLOWED_TRANSITIONS.contains(new Transition(fromStatus, toStatus));  
    }  
  
    /**  
     * 判断当前状态是否允许逻辑删除。     *     * @param status 当前状态  
     * @return 是否允许逻辑删除  
     */    public static boolean canDelete(Integer status) {  
        return DELETE_ALLOWED_STATUSES.contains(status);  
    }  
  
    private static Transition transition(SupplierStatus fromStatus, SupplierStatus toStatus) {  
        return new Transition(fromStatus.getCode(), toStatus.getCode());  
    }  
  
    private record Transition(Integer fromStatus, Integer toStatus) {  
    }  
}

4.2 审核流程完整实现

flowchart TD
    A(["采购专员填写供应商信息"]) --> B["保存为草稿<br>status = 0"]
    B --> C["继续完善信息<br>上传资质文件"]
    C --> D["点击'提交审核'按钮"]
    D --> E["系统校验必填项<br>是否都已填写"]
    E --> F{"校验结果"}
    F -- "有未填项" --> G["提示缺少字段<br>阻止提交"]
    G --> C
    F -- "校验通过" --> H["校验资质文件<br>营业执照是否已上传"]
    H --> I{"资质是否完整?"}
    I -- "不完整" --> J["提示必须上传营业执照<br>才可提交审核"]
    J --> C
    I -- "完整" --> K["更新状态:草稿→待审核<br>status = 1"]
    K --> L["写入审核日志<br>action=提交审核"]
    L --> M["发送消息通知<br>通知有审核权限的用户"]
    M --> N["采购负责人查看<br>待审核供应商列表"]
    N --> O["核实资质信息<br>(人工审核)"]
    O --> P{"审核决定"}
    P -- "通过" --> Q["更新状态:待审核→已通过<br>status = 2"]
    Q --> R["自动创建Portal账号<br>发送欢迎邮件"]
    R --> S["写入审核日志<br>action=审核通过"]
    P -- "拒绝" --> T["填写拒绝原因(必填)"]
    T --> U["更新状态:待审核→已拒绝<br>status = 3"]
    U --> V["写入审核日志<br>action=审核拒绝<br>记录拒绝原因"]
    V --> W["通知采购专员<br>审核结果+拒绝原因"]
    P -- "要求补充" --> X["填写需补充的内容"]
    X --> Y["更新状态:待审核→草稿<br>status = 0"]
    Y --> Z["通知采购专员<br>需要补充的信息"]
    Z --> C
    S --> AA(["供应商正式上线"])
    W --> AB(["等待重新提交"])

4.3 审核接口的幂等性设计

什么是幂等性? 相同的操作执行多次,结果和执行一次完全相同。

为什么需要幂等? 网络不稳定时,前端可能发送了审核通过的请求,但因为网络超时误以为失败,于是重新发送。如果接口不幂等,同一个供应商可能被审核通过两次,产生两条审核日志,或者产生两个Portal账号。

flowchart TD
    A["前端发起审核通过请求<br>supplier_id = 1001"] --> B["查询供应商当前状态"]
    B --> C{"当前状态是否为<br>待审核(status=1)?"}
    C -- "否(已经是已通过了)" --> D["返回成功<br>提示:该供应商已审核通过<br>无需重复操作"]
    C -- "是" --> E["执行审核通过逻辑<br>更新状态 + 创建账号 + 发通知"]
    E --> F["返回成功"]
    D --> G["前端展示最新状态"]
    F --> G

优化操作: 幂等校验(在修改状态的时候 添加条件 where 状态=待审核) + 状态前置判断(不是幂等性校验) + 唯一约束兜底(数据库的唯一约束) 先判断当前供应商是否仍是“待审核”状态,如果已经通过,就直接返回成功,不重复创建 Portal 账号。 同时 portal_user_id 和用户账号也做唯一性约束,防止并发请求造成脏数据。

// 使用MP进行更新的时候 必须要传递一个Bean  
Supplier update = new Supplier();  
update.setStatus(SupplierStatus.PENDING_AUDIT.getCode());  
int rows = supplierMapper.update(update, new LambdaUpdateWrapper<Supplier>()  
        .eq(Supplier::getId, id) // 根据供应商ID进行更新  
        .eq(Supplier::getStatus, SupplierStatus.DRAFT.getCode())); // CAS 幂等性保障  
if (rows == 0) {  
    throwSupplierStatusException("供应商状态已变化,请刷新后重试");  
}  
// 插入审核日志  
insertAuditLog(id, existing.getStatus(), SupplierStatus.PENDING_AUDIT.getCode(),  
        SupplierAuditAction.SUBMIT.getDescription(), "提交供应商审核");

4.4 审核操作的权限控制

操作所需权限标识角色要求
提交审核srm:supplier:submit采购专员及以上
审核通过srm:supplier:audit采购负责人/租户管理员
审核拒绝srm:supplier:audit采购负责人/租户管理员
停用供应商srm:supplier:disable采购负责人/租户管理员
重新启用srm:supplier:enable采购负责人/租户管理员

业务规则:采购专员不能审核自己提交的供应商(防止利益冲突)。系统需要判断提交人和审核人是否为同一人,若是同一人则拒绝操作。


第五节 资质文件上传模块

5.1 文件上传的业务背景

供应商资质审核需要真实可查的证明文件,常见的资质文件包括:

资质类型文件说明是否必须上传才可提交审核
营业执照证明供应商是合法注册的企业✅ 必须
质检报告证明商品符合质量标准建议(视品类而定)
产品认证CE(欧盟)、FCC(美国)、UL(美国)等认证视目的市场要求
银行账户证明用于付款验证,防止汇款给错误账户建议

5.2 文件上传整体架构

graph TD
    subgraph 前端
        A["用户选择文件<br>点击上传按钮"] --> B["文件格式校验<br>类型/大小/数量"]
        B --> C["调用上传接口<br>multipart/form-data"]
    end

    subgraph 后端上传服务
        D["接收文件流"] --> E["安全检查<br>文件类型白名单验证<br>防止恶意文件上传"]
        E --> F["重命名文件<br>UUID+扩展名<br>防止文件名冲突和路径遍历攻击"]
        F --> G{"存储类型配置"}
        G -- "开发环境local" --> H["存储到本地磁盘<br>/uploads/{tenantId}/{date}/"]
        G -- "生产环境oss" --> I["上传到阿里云OSS<br>返回CDN访问URL"]
        H & I --> J["写入 supplier_cert 表<br>记录文件元数据"]
        J --> K["返回文件URL<br>和文件记录ID"]
    end

    subgraph 文件访问
        L["前端展示图片/预览PDF"] --> M{"文件类型"}
        M -- "图片" --> N["直接img标签展示"]
        M -- "PDF" --> O["iframe或新标签页打开"]
    end

    C --> D
    K --> L

5.3 文件上传接口设计

上传接口:

POST /api/file/upload
Content-Type: multipart/form-data

请求参数:
  file         : 文件二进制数据(必填)
  bizType      : 业务类型,如:supplier_cert(必填,用于文件分类存储)
  bizId        : 关联的业务ID,如:供应商ID(选填)

响应结果:
{
  "code": 200,
  "data": {
    "fileId": "1748291234567891",
    "fileName": "营业执照.pdf",
    "fileUrl": "https://flexchain.oss-cn-hangzhou.aliyuncs.com/supplier/1001/20250117/a3b4c5d6.pdf",
    "fileSizeKb": 256,
    "fileType": "application/pdf"
  }
}

5.4 文件安全校验规则

flowchart TD
    A["文件上传请求到达"] --> B["检查文件是否为空"]
    B --> C{"文件为空?"}
    C -- 是 --> D["返回错误:请选择要上传的文件"]
    C -- 否 --> E["检查文件大小<br>fileSize <= 5MB = 5×1024×1024 Byte"]
    E --> F{"超过5MB?"}
    F -- 是 --> G["返回错误:文件大小不能超过5MB"]
    F -- 否 --> H["检查文件扩展名<br>白名单:pdf/jpg/jpeg/png/doc/docx/xls/xlsx"]
    H --> I{"扩展名合法?"}
    I -- 否 --> J["返回错误:不支持该文件类型"]
    I -- 是 --> K["检查文件真实类型<br>读取文件头部Magic Bytes<br>防止改扩展名绕过"]
    K --> L{"真实类型匹配?"}
    L -- "不匹配(如jpg改为pdf)" --> M["返回错误:文件内容与扩展名不符"]
    L -- 匹配 --> N["生成存储路径<br>/{tenantId}/{bizType}/{YYYYMMDD}/{UUID}.{ext}"]
    N --> O["执行文件存储"]

安全重点:文件类型检查不能只看扩展名!黑客可以把 .php 改成 .jpg 绕过检查。必须读取文件的前几个字节(Magic Bytes)验证真实类型。例如:PDF 文件前4字节一定是 25 50 44 46(%PDF)。

优化操作: 扩展名白名单

  • MIME 类型初筛
  • Magic Bytes 校验
  • 文件大小限制
  • 文件名重命名
  • OSS 私有读写
  • 必要时杀毒扫描(线上)
  • 图片内容的审核(OSS自带 涉政 涉黄 暴力 恐怖) 注意:docx/xlsx 本质上是 zip 包,Magic Bytes 可能都是 zip 文件头,还需要进一步检查内部结构。

同一个模块中的多个业务 需要使用 可以抽取为 公共类/工具类 如果多个系统模块都需要使用 甚至是多个项目也都需要使用 我们可以考虑封装成一个自定义的起步依赖 自定义 starter

5.5 OSS 存储路径规范

OSS Bucket:flexchain-files
存储路径规范:/{tenantId}/{bizType}/{YYYY-MM-DD}/{UUID}.{ext}

示例:
/101/supplier_cert/2025-01-17/a3b4c5d6e7f8.pdf   ← 租户101上传的供应商资质PDF
/102/product_img/2025-01-17/b4c5d6e7f8a9.jpg     ← 租户102上传的商品图片JPG

路径设计理由:
1. 按租户ID隔离:/101/ 和 /102/ 下的文件互不干扰
2. 按业务类型分类:方便管理和统计存储成本
3. 按日期分散:避免单目录文件数量过多影响性能
4. UUID文件名:全局唯一,防止冲突,同时隐藏原始文件名(安全)

5.6 资质文件到期提醒定时任务

flowchart TD
    A(["定时任务:每天 09:00 执行"]) --> B["查询所有未过期的资质文件<br>WHERE is_expired = 0<br>AND expire_date IS NOT NULL<br>AND is_deleted = 0"]
    B --> C["遍历每条资质文件记录"]
    C --> D["计算距到期天数<br>daysLeft = expire_date - today"]
    D --> E{"天数判断"}
    E -- "daysLeft < 0" --> F["标记为已过期<br>UPDATE is_expired = 1"]
    E -- "daysLeft = 30" --> G["发送30天到期提醒<br>通知采购专员更新资质"]
    E -- "daysLeft = 7" --> H["发送7天紧急提醒<br>通知采购专员和采购负责人"]
    E -- "daysLeft = 1" --> I["发送明日到期紧急提醒<br>通知更多层级"]
    F & G & H & I --> J["继续处理下一条"]
    J --> K{"是否还有<br>待处理的记录?"}
    K -- 是 --> C
    K -- 否 --> L["记录任务执行日志<br>处理数量/发送通知数"]
    L --> M(["定时任务结束"])

XXL-JOB 1013

优化操作: 资质到期提醒

每天 09:00 扫描快到期证照
按 tenant_id 或 cert_id 分片
每个分片处理一部分数据
批量分页查询
线程池并发发送站内信/邮件

分片广播的用法

private List<SupplierCert> queryUnexpiredCerts(int shardIndex, int shardTotal) {  
    LambdaQueryWrapper<SupplierCert> wrapper = new LambdaQueryWrapper<SupplierCert>()  
            .eq(SupplierCert::getIsExpired, 0) // 过期状态为0 代表未过期 或者是 已过期但是还没有修改这个状态  
            .isNotNull(SupplierCert::getExpireDate) // 过期时间不为空  
            .eq(SupplierCert::getIsDeleted, 0); // 未被逻辑删除的   
if (shardTotal > 1) { // 判断我们的服务器数量 是否是集群 如果是单机 就不走分片广播  
        // 如果是集群 至少两台服务器 才会走这个条件        // id % shardTotal = shardIndex;        wrapper.apply("MOD(id, {0}) = {1}", shardTotal, shardIndex);  
    }  
    return certMapper.selectList(wrapper);  
}

线程池异步处理

线程池的创建 建议: 把不同业务的线程池 都提前创建好 封装成Bean 当使用的时候 直接注入线程池即可 不建议是任务触发的方法中 每次去创建线程池

/**  
 * 供应商资质到期扫描异步线程池。 * * @return 异步执行器  
 */@Bean("supplierCertExpireExecutor")  
public ThreadPoolTaskExecutor supplierCertExpireExecutor() {  
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();  
    executor.setCorePoolSize(4);  
    executor.setMaxPoolSize(8);  
    executor.setQueueCapacity(500);  
    executor.setThreadNamePrefix("supplier-cert-expire-");  
    executor.initialize();  
    return executor;  
}
/**  
 * 按分片扫描并处理供应商资质到期数据。 * * @param shardIndex 分片序号  
 * @param shardTotal 分片总数  
 * @return 任务执行结果  
 */public SupplierCertExpireJobResult scanCertExpire(int shardIndex, int shardTotal) {  
    // 根据分片数量和当前的分片索引号 查询所有资质  
    // 按照分片的数量 进行查询资质文件    List<SupplierCert> certs = queryUnexpiredCerts(shardIndex, shardTotal);  
    // 扫描的结果封装Bean  
    SupplierCertExpireJobResult result = new SupplierCertExpireJobResult();  
    List<CompletableFuture<Void>> futures = certs.stream()  
            // CompletableFuture.runAsync 异步处理 原子性更新 也就是说多个线程同时处理扫描到的数据 也不会出现 result 的并发问题  
            .map(cert -> CompletableFuture.runAsync(() -> processCert(cert, result), certExpireExecutor)  
                    // 如果出现了异常  
                    .exceptionally(exception -> {  
                        // 记录了异常的失败次数  
                        // 如果失败了 需要重新 processCert 吗 ??                        // 不需要 当天失败了 明天还会再处理一次 1天 处理失败了 明天就过期了                        result.incrementFailed();  
                        log.error("供应商资质到期处理失败,certId={}", cert.getId(), exception);  
                        return null;  
                    }))  
            .toList();  
    // 阻塞操作 等待所有的任务都执行完成  
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();  
    // 如果要处理失败 有两种做法  
    // 第一种是 exception 中直接使用 try catch 在 catch 中进行处理 重试 重新判断    // 第二种是 在所有的线程都执行完成后 判断失败的总的次数 > 0 如果是 就根据失败的资质的ID 针对性的去判断    // 第三种是 不处理  等着下一次 定时任务的触发 到时候再重新处理    // 如果我们要实现增量 或者 需要记录每天的任务结果 就需要创建一个MySQL的表 来存储下面的数据    // log 可以直接写入到文件中 也算是持久化了    log.info("供应商资质到期扫描完成,shardIndex={}, shardTotal={}, scanned={}, expired={}, notice={}, failed={}",  
            shardIndex, shardTotal, result.getScannedCount(), result.getExpiredCount(),  
            result.getNoticeCount(), result.getFailedCount());  
    return result;  
}

传统的线程池 实现方式

/**  
 * 使用传统线程池按分片扫描并处理供应商资质到期数据。 * * @param shardIndex 分片序号  
 * @param shardTotal 分片总数  
 * @return 任务执行结果  
 */public SupplierCertExpireJobResult scanCertExpire(int shardIndex, int shardTotal) {  
    // 根据分片数量和当前的分片索引号 查询所有资质  
    // 按照分片的数量 进行查询资质文件    List<SupplierCert> certs = queryUnexpiredCerts(shardIndex, shardTotal);  
    // 扫描的结果封装Bean  
    SupplierCertExpireJobResult result = new SupplierCertExpireJobResult();  
    List<Future<?>> futures = new ArrayList<>(certs.size());  
    for (SupplierCert cert : certs) {  
        try {  
            // submit 是有返回值的 拿到异常 和 结果  
            // get 阻塞式            futures.add(certExpireExecutor.submit(() -> processCertSafely(cert, result)));  
        } catch (RejectedExecutionException exception) {  
            result.incrementFailed();  
            log.error("供应商资质到期任务提交线程池失败,certId={}", cert.getId(), exception);  
        }  
    }  
    // 当所有的线程都执行完成后   
    // 怎么判断 所有的线程都已经执行完毕了 ??  
    // submit 可以通过 future.get(); 来阻塞主线程的执行 等待子任务执行完成    waitForTraditionalTasks(futures, result);  
    logScanResult(shardIndex, shardTotal, result);  
    return result;  
}


private void waitForTraditionalTasks(List<Future<?>> futures, SupplierCertExpireJobResult result) {  
    for (Future<?> future : futures) {  
        try {  
            future.get();  
        } catch (InterruptedException exception) {  
            Thread.currentThread().interrupt();  
            result.incrementFailed();  
            log.error("供应商资质到期线程等待被中断", exception);  
            return;  
        } catch (ExecutionException exception) {  
            result.incrementFailed();  
            log.error("供应商资质到期线程执行异常", exception);  
        }  
    }  
    // 这个循环走完 也就意味着 所有的子线程都已经执行完毕了  
}

第六节 供应商绩效评分系统

6.1 为什么需要量化评分

纯靠感觉选供应商 → 容易受个人关系影响,无法公平对比 量化评分体系 → 数据说话,客观公平,可追溯

评分维度与权重设计(满分100分):

评分维度权重满分计算数据来源
准时交货率25%25分采购订单的实际到货日期 vs 承诺到货日期
质量合格率25%25分入库质检的合格批次数 / 总批次数
响应速度25%25分收到询价到回复报价的平均小时数
价格竞争力25%25分本供应商均价 / 同品类市场均价

6.2 各维度评分计算细则

① 准时交货率评分(满分25分)

准时交货率 = 当月准时到货的采购单数 / 当月全部到货的采购单数 × 100%

准时定义:实际到货日期 ≤ 采购单中的承诺到货日期

评分公式:准时交货评分 = 准时交货率 × 25

示例:
  本月共10笔采购单到货,其中8笔准时到货
  准时交货率 = 8/10 = 80%
  准时交货评分 = 80% × 25 = 20分

② 质量合格率评分(满分25分)

质量合格率 = 当月质检合格批次数 / 当月质检总批次数 × 100%

评分公式:质量合格评分 = 质量合格率 × 25

示例:
  本月5批货物入库质检,4批全部合格,1批部分不合格
  质量合格率 = 4/5 = 80%
  质量合格评分 = 80% × 25 = 20分

③ 响应速度评分(满分25分)

响应速度使用分段函数计算:

平均响应时长(小时)→ 响应速度评分

≤ 2小时      → 25分(优秀:当天内快速回价)
2-8小时      → 20分(良好:工作日内回价)
8-24小时     → 15分(一般:次日回价)
24-48小时    → 10分(较差:两天内回价)
48-72小时    →  5分(很差:三天内回价)
> 72小时     →  0分(无法接受:三天以上)

④ 价格竞争力评分(满分25分)

价格系数 = 本供应商当月采购均价 / 同品类所有供应商均价

评分公式:
  价格系数 < 0.90  → 25分(比市场均价低10%以上,极具竞争力)
  价格系数 0.90-0.95 → 22分
  价格系数 0.95-1.00 → 18分(接近市场均价)
  价格系数 1.00-1.05 → 12分(略高于市场均价)
  价格系数 1.05-1.10 → 6分(高于市场均价)
  价格系数 > 1.10   → 0分(明显高于市场均价)

6.3 评分计算流程

flowchart TD
    A(["定时任务:每月1日 00:30 执行"]) --> B["获取上月的年月<br>如:执行日期2025-02-01<br>则计算2025-01月的数据"]
    B --> C["查询本租户所有状态为<br>已通过的供应商列表"]
    C --> D["遍历每个供应商"]
    D --> E["查询上月该供应商的<br>采购订单到货数据"]
    E --> F["计算准时交货率<br>→ 准时交货评分"]
    F --> G["查询上月该供应商的<br>入库质检数据"]
    G --> H["计算质量合格率<br>→ 质量合格评分"]
    H --> I["查询上月该供应商的<br>询价响应记录"]
    I --> J["计算平均响应时长<br>→ 响应速度评分"]
    J --> K["查询上月该供应商的<br>采购价格数据"]
    K --> L["计算价格竞争力系数<br>→ 价格竞争力评分"]
    L --> M["汇总四项评分<br>total_score = sum"]
    M --> N{"数据是否充足?<br>(本月是否有采购单)"}
    N -- "无采购单" --> O["写入评分记录<br>remark=本月无采购数据<br>评分沿用上月"]
    N -- "有采购单" --> P["计算综合评级<br>≥90=S ≥75=A ≥60=B <60=C"]
    P --> Q["比较本月评级<br>与上月评级是否变化"]
    Q --> R["写入 supplier_score_log"]
    R --> S["更新 supplier 主表<br>grade 和 score 字段"]
    S --> T{"评级是否变化?"}
    T -- 是 --> U["发送评级变化通知<br>告知采购专员和供应商"]
    T -- 否 --> V["继续下一个供应商"]
    U --> V
    O --> V
    V --> W{"是否还有<br>未处理的供应商?"}
    W -- 是 --> D
    W -- 否 --> X["生成月度供应商<br>绩效汇总报告"]
    X --> Y(["定时任务结束"])

6.4 评分数据的特殊情况处理

特殊情况处理方式
本月没有任何采购单不更新评分,保留上月评分,在 calc_remark 中注明
没有询价记录(不走询价流程直接下单)响应速度评分默认给满分25分
没有质检记录(100%通过无需记录)质量合格评分默认给满分25分
新供应商(历史数据不足1个月)不参与评分,等待下月数据充足后计算
供应商当月被停用仍然计算当月有效数据,但下月开始不再计算

优化操作: 月度评分计算

每月 1 日 00:30 执行
按 tenant_id 或 supplier_id 分片
每个分片计算部分供应商评分
写入 supplier_score_log
更新 supplier 主表 grade/score

任务必须幂等,不能重复发送多次提醒。 可以加一张通知记录表防重复:supplier_cert_remind_log(cert_id, remind_type, remind_date)


第七节 供应商分层分级体系

7.1 分级与差异化合作策略

供应商评级不只是一个数字,背后对应不同的合作策略:

graph TD
    subgraph S级 优质供应商
        S1["评分:90-100分"]
        S2["策略:战略合作<br>优先下单,增加份额<br>给予更优付款条件<br>共同开发新品"]
    end

    subgraph A级 良好供应商
        A1["评分:75-89分"]
        A2["策略:正常合作<br>日常采购首选<br>维持当前合作规模"]
    end

    subgraph B级 待改进供应商
        B1["评分:60-74分"]
        B2["策略:改进警示<br>发送改进通知书<br>列入重点观察名单<br>减少30%采购份额"]
    end

    subgraph C级 预警供应商
        C1["评分:60分以下"]
        C2["策略:预警处置<br>暂停新采购单<br>启动替代供应商<br>连续3个月C级则停用"]
    end

C 级暂停采购、连续 3 个月停用,不应该是 SaaS 平台统一强制。应该是卖家自己的采购策略。 可设计成“系统建议 + 租户可配置”

默认:风险提示,不强制拦截
严格模式:创建新采购单时要求主管审批
强管控模式:禁止普通采购员下单,只允许管理员特批

如果卖家坚持使用 C 级供应商,系统可以允许,但要:

  • 弹出风险提示
  • 要求填写继续采购原因
  • 走采购主管审批
  • 写入审计日志

B级 待改进供应商

系统自动生成改进通知草稿
采购负责人确认后发送

观察名单不只是为了“连续 3 个月 C 级停用”,还可以用于:

  • 采购单审批提醒
  • 供应商列表标红
  • 下单时风险提示
  • 月度复盘
  • 解除观察流程

“减少 30% 采购份额”也不应默认硬限制。 更合理的做法是系统在采购分配、补货建议、供应商推荐时降低推荐权重。是否真的少采购,由卖家采购负责人决定。

之前我们已经实现了一个功能
给每个供应商 进行评分/评级 接下来我们要根据这个评分/评级进行对供应商的归类 如果某个卖家 对供应商的要求比较高 可以自己设定这个供应商的最低的级别 如果供应商没有达到这个级别 我们要帮卖家把这个供应商的信息 保存到 重点观察名单的这个表中 当该卖家再次对这个供应商进行采购的时候 我们要查询这个 重点观察名单表 如果该供应商没有达到卖家的要求 我们就要给卖家相关的提示

哪些供应商需要划到这个表中 哪些供应商不需要划到这个表中 有谁决定 ?? 卖家 所以我们还需要提供一个配置表 我作为卖家 我能允许的最低的等级 B 你作为卖家 你能允许的最低的等级 C

遍历每个租户下的所以的供应商 然后判断当前的供应商 是否低于 卖家自己设定的等级 如果不符合 低于 把这个供应商加入到 重点观察名单表中

7.2 多供应商策略(柔性的核心)

柔性供应链要求对同一品类维护至少 2 家以上供应商,避免单点故障:

graph LR
    A["同一品类:蓝牙耳机"] --> B["主供应商<br>广州S级工厂A<br>份额60%<br>平时稳定供货"]
    A --> C["备用供应商<br>深圳A级工厂B<br>份额30%<br>分散风险备用"]
    A --> D["应急供应商<br>义乌贸易商C<br>份额10%<br>紧急时快速补货"]

    E["正常情况"] --> B
    F["工厂A产能不足<br>或质量问题"] --> C
    G["紧急补货<br>需要3天到货"] --> D

品类-供应商矩阵管理:

在实际业务中,采购负责人需要查看每个品类下有哪些可用供应商及其评级:

品类S级供应商A级供应商B级供应商风险评估
蓝牙耳机1家2家0家低风险 ✅
手机支架0家1家1家中风险 ⚠️
数据线0家0家2家高风险 ❌

系统预警:某品类只有1家供应商,或所有供应商评级低于B时,系统应自动发出风险预警,提醒采购负责人开发新供应商。

某品类供应商不足,如何自动风险预警

可以由定时任务或评分任务后触发。

例如每天凌晨扫描: 按租户 + 品类统计可用供应商数量 如果可用供应商数量 < 2 或者所有供应商评级 < B 生成风险事件 发送通知

通知方式:

  • 站内信:默认必须有
  • 邮件:重要预警建议有
  • 短信/企微/钉钉:紧急级别或企业版可选

接收人: 采购专员 采购负责人 租户管理员

建议可以建一张风险事件表,避免每天重复发同一个预警: supplier_risk_event

每个租户都可以按照自己的要求去配置 风险 比如: 同一个类别的供应商 至少有几个 供应商的等级 最低是什么 我们的系统就可以定时去扫描 判断当前租户的供应商 是否符合 租户的配置 如果符合了 就说明是没有风险的 如果不符合 就说明是有风险的

评分.评级


第八节 供应商 Portal 协同体系

8.1 为什么需要供应商 Portal

没有Portal之前:

  • 采购单通过微信/邮件发给供应商 → 容易丢失,无法追溯
  • 供应商发货信息通过聊天记录传递 → 无法自动进入系统
  • 对账靠人工核对Excel → 容易出错

有了供应商Portal之后:

  • 供应商登录系统直接查看采购单 → 信息准确,有记录
  • 供应商在线填写物流单号 → 自动触发TMS创建运单
  • 在线生成对账单 → 双方实时确认,减少争议

8.2 Portal 账号创建流程

flowchart TD
    A["供应商审核状态变为<br>已通过(status=2)"] --> B["触发Portal账号创建逻辑"]
    B --> C["检查是否已创建过Portal账号<br>portal_user_id IS NULL"]
    C --> D{"是否已有账号?"}
    D -- "已有账号" --> E["跳过创建<br>直接发送通知(补发)"]
    D -- "没有账号" --> F["生成登录账号<br>用户名:供应商邮箱地址<br>如 supplier艾特abc.com"]
    F --> G["生成随机初始密码<br>格式:Flex + 6位随机数字<br>如 Flex123456"]
    G --> H["BCrypt加密密码<br>写入 sys_user 表<br>user_type = 3(供应商用户)"]
    H --> I["分配供应商角色<br>ROLE_SUPPLIER"]
    I --> J["更新 supplier 表<br>portal_user_id = 新创建的user_id<br>portal_enabled = 1"]
    J --> K["发送欢迎邮件<br>内容:登录地址+账号+初始密码"]
    K --> L["发送Portal开通通知<br>(站内信给采购专员)"]
    L --> M(["Portal账号创建完成"])
    E --> K

同一个供应商给多个卖家供货,Portal 账号怎么建

按当前 V1 项目定位:多租户隔离,每个卖家维护自己的供应商档案,因此每个租户下创建独立 Portal 账号。

也就是说,同一个真实工厂:

卖家 A:supplier_id = 1001,portal_user_id = 501
卖家 B:supplier_id = 2001,portal_user_id = 801

哪怕邮箱相同,也建议账号在租户维度隔离。登录时可以用:tenant_code + email

或者要求供应商选择进入哪个卖家的 Portal。

如果做成“一个供应商账号服务多个卖家”,那就进入平台级供应商身份体系了,需要:

  • 平台供应商主数据
  • 供应商和租户关系表
  • 一个账号绑定多个卖家
  • 登录后切换合作卖家
  • 不同卖家的报价、账期、评分隔离

这个功能可以放到 V2 实现

8.3 Portal 权限范围设计

Portal 是供应商视角的系统,权限极其受限,必须严格隔离:

graph TD
    A["供应商Portal用户登录"] --> B{"数据访问范围"}
    B --> C["✅ 可以查看<br>发给本供应商的采购订单"]
    B --> D["✅ 可以填写<br>物流单号和预计发货时间"]
    B --> E["✅ 可以上传<br>发货单据和快递面单照片"]
    B --> F["✅ 可以查看<br>本供应商的应收账款记录"]
    B --> G["✅ 可以修改<br>自己的联系方式和密码"]
    B --> H["❌ 禁止查看<br>其他供应商的任何信息"]
    B --> I["❌ 禁止查看<br>我方的库存和销售数据"]
    B --> J["❌ 禁止查看<br>我方的财务利润数据"]
    B --> K["❌ 禁止操作<br>任何会改变采购单状态的操作"]

Portal 特殊隔离机制:

供应商用户除了 tenant_id 隔离外,还需要额外的 supplier_id 隔离:

-- 供应商查询采购单时,SQL 中必须同时满足以下两个条件
-- 1. 多租户隔离(MyBatis-Plus自动追加)
WHERE tenant_id = 101
-- 2. 供应商隔离(业务代码手动追加,不能依赖自动插件)
AND supplier_id = 当前登录供应商的supplier_id

8.4 Portal 核心功能页面

页面功能操作权限
采购单列表查看发给本供应商的所有采购单,含状态和金额只读
采购单详情查看采购单明细(品名/规格/数量/单价)只读
确认采购单确认接单,填写预计发货时间可写
填写物流信息填写物流公司和运单号可写
上传发货单据上传装箱单、出库单等 PDF/图片可写
应收账款查看未付/已付账款金额和付款状态只读
个人信息修改手机号、邮箱、密码可写

第九节 消息通知机制

9.1 通知场景总览

SRM 模块涉及多个关键节点需要发送通知,确保相关人员及时知晓:

触发事件通知对象通知方式优先级
供应商提交审核所有有审核权限的用户站内信普通
审核通过提交人 + 供应商(邮件)站内信 + 邮件普通
审核拒绝提交人站内信 + 邮件普通
需要补充资料提交人站内信普通
资质文件30天到期采购专员站内信 + 邮件普通
资质文件7天到期采购专员 + 采购负责人站内信 + 邮件紧急
供应商评级下降(如A→B)采购专员 + 采购负责人站内信普通
供应商评级进入C级预警采购专员 + 采购负责人 + 租户管理员站内信 + 邮件紧急
Portal账号开通供应商(邮件)邮件普通

9.2 站内信设计

flowchart LR
    A["业务事件触发<br>如:审核通过"] --> B["构建通知消息体<br>标题/内容/跳转链接/接收人"]
    B --> C["写入消息表<br>sys_message"]
    C --> D["前端轮询或WebSocket推送<br>用户看到消息红点"]
    D --> E["用户点击消息<br>标记为已读"]
    E --> F["跳转到相关页面<br>如:供应商详情页"]

站内信数据表:

-- ============================================================
-- 站内消息通知表
-- 系统内部的消息通知,显示在右上角消息铃铛
-- ============================================================
CREATE TABLE `sys_message`
(
    `id`              BIGINT UNSIGNED  NOT NULL           COMMENT '主键ID',
    `tenant_id`       BIGINT           NOT NULL           COMMENT '租户ID',
    `receiver_id`     BIGINT           NOT NULL           COMMENT '接收人的用户ID',
    `sender_id`       BIGINT           NULL               COMMENT '发送人的用户ID,NULL表示系统自动发送',
    `msg_type`        VARCHAR(64)      NOT NULL           COMMENT '消息类型:SUPPLIER_AUDIT/CERT_EXPIRE/SCORE_CHANGE等',
    `title`           VARCHAR(128)     NOT NULL           COMMENT '消息标题,如:【供应商审核】广州工厂A已通过审核',
    `content`         TEXT             NOT NULL           COMMENT '消息详细内容,支持HTML格式',
    `link_url`        VARCHAR(256)     NULL               COMMENT '点击消息后跳转的页面路径,如:/srm/supplier/1001',
    `ref_id`          BIGINT           NULL               COMMENT '关联的业务ID,如供应商ID(用于跳转和去重)',
    `ref_type`        VARCHAR(32)      NULL               COMMENT '关联业务类型,如:SUPPLIER',
    `priority`        TINYINT          NOT NULL DEFAULT 1  COMMENT '优先级:1=普通 2=重要 3=紧急(紧急消息弹窗提醒)',
    `is_read`         TINYINT(1)       NOT NULL DEFAULT 0  COMMENT '是否已读:0=未读 1=已读',
    `read_time`       DATETIME         NULL               COMMENT '用户读取时间',
    `send_time`       DATETIME         NOT NULL           COMMENT '消息发送时间',
    `is_deleted`      TINYINT(1)       NOT NULL DEFAULT 0  COMMENT '逻辑删除(用户手动删除消息)',

    PRIMARY KEY (`id`),
    KEY `idx_receiver_read`  (`receiver_id`, `is_read`)  COMMENT '查询用户未读消息数',
    KEY `idx_tenant_id`      (`tenant_id`),
    KEY `idx_send_time`      (`send_time`)

) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci
  COMMENT = '站内消息通知表';

9.3 邮件通知设计

邮件发送流程:

flowchart TD
    A["业务触发邮件发送需求"] --> B["构建邮件内容<br>读取邮件模板<br>替换模板变量"]
    B --> C["写入邮件发送队列表<br>email_send_queue<br>status=待发送"]
    C --> D["定时任务每分钟扫描队列<br>取出待发送的邮件"]
    D --> E["调用JavaMail<br>通过SMTP发送邮件"]
    E --> F{"发送结果"}
    F -- 成功 --> G["更新队列记录<br>status=已发送<br>记录发送时间"]
    F -- "失败(如网络超时)" --> H["更新重试次数<br>retry_count + 1"]
    H --> I{"重试次数<br>是否超过3次?"}
    I -- 否 --> J["等待下次任务<br>重新发送"]
    I -- 是 --> K["标记为发送失败<br>status=失败<br>记录失败原因<br>告警通知管理员"]
    J --> D

邮件模板示例(供应商审核通过):

主题:【FlexChain】您的供应商申请已审核通过

邮件正文:
尊敬的 {供应商联系人姓名},

您好!

您在 FlexChain 平台的供应商注册申请已于 {审核时间} 审核通过。

您的供应商信息如下:
- 供应商名称:{供应商名称}
- 供应商编码:{供应商编码}
- 评级初始值:C级(将根据月度绩效动态调整)

您的 Portal 登录信息如下:
- 登录地址:https://portal.flexchain.com
- 登录账号:{联系人邮箱}
- 初始密码:{初始密码}

请在首次登录后及时修改密码。

如有任何问题,请联系您的采购对接人:{采购专员姓名}({采购专员手机号})

FlexChain 平台团队

第十节 接口设计规范

10.1 供应商模块完整接口清单

按照 RESTful 规范设计接口,接口路径用名词复数,HTTP 方法表达操作语义。

基础路径: /api/srm/suppliers

接口名称HTTP方法路径权限标识说明
供应商分页列表GET/api/srm/supplierssrm:supplier:list支持多条件筛选
供应商详情GET/api/srm/suppliers/{id}srm:supplier:list含资质/联系人/评分
新增供应商POST/api/srm/supplierssrm:supplier:add创建草稿状态
编辑供应商PUT/api/srm/suppliers/{id}srm:supplier:edit含乐观锁version字段
删除供应商DELETE/api/srm/suppliers/{id}srm:supplier:delete软删除,需关联检查
提交审核PUT/api/srm/suppliers/{id}/submitsrm:supplier:submit草稿→待审核
审核通过PUT/api/srm/suppliers/{id}/approvesrm:supplier:audit待审核→已通过
审核拒绝PUT/api/srm/suppliers/{id}/rejectsrm:supplier:audit待审核→已拒绝,需填原因
停用供应商PUT/api/srm/suppliers/{id}/disablesrm:supplier:disable已通过→已停用
重新启用PUT/api/srm/suppliers/{id}/enablesrm:supplier:enable已停用→已通过
审核日志GET/api/srm/suppliers/{id}/audit-logssrm:supplier:list查询审核历史
评分历史GET/api/srm/suppliers/{id}/scoressrm:supplier:list查询月度评分趋势
上传资质文件POST/api/srm/suppliers/{id}/certssrm:supplier:edit上传资质文件
删除资质文件DELETE/api/srm/suppliers/{id}/certs/{certId}srm:supplier:edit软删除资质记录
新增联系人POST/api/srm/suppliers/{id}/contactssrm:supplier:edit新增联系人
删除联系人DELETE/api/srm/suppliers/{id}/contacts/{contactId}srm:supplier:edit删除联系人
供应商下拉选项GET/api/srm/suppliers/optionssrm:supplier:list已通过的供应商简要信息

10.2 关键接口请求/响应示例

① 新增供应商 - 请求体(Request Body):

POST /api/srm/suppliers
Content-Type: application/json
Authorization: Bearer {token}

{
  "supplierName": "广州市XX电子科技有限公司",
  "supplierType": 1,
  "categoryIds": [3, 7, 12],
  "province": "广东省",
  "city": "广州市",
  "address": "天河区科技园A栋5楼",
  "contactName": "张经理",
  "contactPhone": "13812345678",
  "contactEmail": "zhang@guangzhou-xx.com",
  "contactWechat": "zhangmanager",
  "bankName": "招商银行广州天河支行",
  "bankAccount": "6225880012345678",
  "bankAccountName": "广州市XX电子科技有限公司",
  "taxNo": "91440101MA5XXXXX4G",
  "moq": 100,
  "leadTimeDays": 7,
  "monthlyCapacity": 5000,
  "currency": "CNY",
  "paymentDays": 30,
  "remark": "重点开发的工厂,产品质量稳定"
}

② 审核拒绝 - 请求体:

PUT /api/srm/suppliers/1748291234567890/reject
Content-Type: application/json

{
  "auditRemark": "营业执照已过期(有效期至2024-12-31),请更新后重新提交。质检报告缺失,需上传最新质检报告。"
}

③ 供应商下拉选项 - 响应(用于采购单选择供应商时):

GET /api/srm/suppliers/options?categoryId=3

{
  "code": 200,
  "data": [
    {
      "value": "1748291234567890",
      "label": "广州市XX电子科技有限公司",
      "supplierCode": "SUP-20250117-0001",
      "grade": "A",
      "score": 88.50,
      "moq": 100,
      "leadTimeDays": 7,
      "contactName": "张经理",
      "contactPhone": "138****5678"
    }
  ]
}

10.3 接口错误码规范

供应商模块专用错误码范围:11000 - 11099

错误码含义场景
11001供应商不存在根据ID查不到数据
11002供应商状态不允许此操作状态流转非法
11003供应商名称已存在同一租户内重名
11004供应商邮箱已被使用邮箱重复
11005该供应商有未完成的采购单,不能删除删除时关联检查
11006审核拒绝原因不能为空拒绝时必填原因
11007提交人不能审核自己的供应商利益冲突防护
11008必须上传营业执照才能提交审核资质完整性检查
11009资质文件类型不合法文件校验失败
11010资质文件超过大小限制(10MB)文件校验失败

第十一节 前端页面设计

11.1 供应商管理模块页面地图

graph TD
    A["供应商管理模块<br>/srm/supplier"] --> B["供应商列表页<br>index.vue<br>主页面"]
    B --> C["新增供应商抽屉<br>SupplierForm.vue<br>右侧滑出抽屉"]
    B --> D["供应商详情页<br>detail.vue<br>点击名称跳转"]
    D --> E["基本信息Tab<br>BasicInfo.vue"]
    D --> F["资质文件Tab<br>CertFiles.vue<br>含上传功能"]
    D --> G["联系人Tab<br>Contacts.vue"]
    D --> H["评分记录Tab<br>ScoreHistory.vue<br>含折线图"]
    D --> I["审核日志Tab<br>AuditLogs.vue"]
    B --> J["审核操作弹窗<br>AuditDialog.vue<br>通过/拒绝"]

11.2 供应商列表页面结构

┌─────────────────────────────────────────────────────┐
│  供应商管理                          [+ 新增供应商]    │
├─────────────────────────────────────────────────────┤
│  🔍 搜索区域(折叠展开)                                │
│  供应商名称 [___________]  类型 [全部 ▼]               │
│  状态 [全部 ▼]  评级 [全部 ▼]  [搜索] [重置]       │
├───────┬────────┬────────┬─────┬──────┬──────┬──────┤
│ 编码   │ 名称   │ 类型    │ 评级 │ 评分 │ 状态  │  操作 │
├───────┼────────┼────────┼─────┼──────┼──────┼──────┤
│SUP-.. │广州XX.  │ 工厂 🏭 │  A  │ 88.5 │ ✅通过│ 查看 │
│       │        │        │     │      │      │ 编辑  │
│       │        │        │     │      │      │ 审核  │
├───────┼────────┼────────┼─────┼──────┼──────┼──────┤
│SUP-.. │深圳YY. │贸易 🏪   │ B   │72.3  │⏳待审 │查看  │
│  ⚠️资质│       │         │     │      │      │撤回  │
│  即将过│       │         │     │      │      │      │
│  期   │       │         │     │      │      │      │
├───────┴────────┴────────┴─────┴──────┴──────┴──────┤
│                           共 156 条  每页 20 ▼  < 1 >│
└─────────────────────────────────────────────────────┘

11.3 新增/编辑供应商表单设计

表单采用分步骤(Steps)设计,降低信息填写的压力感:

步骤一:基础信息         步骤二:财务信息         步骤三:供货能力
━━━━━━━━━━━━━━━━         ─────────────────         ─────────────────

供应商名称 *             开户银行                  最小起订量(MOQ)
[___________________]    [___________________]      [_______] 件

供应商类型 *             银行账号                  交货周期(Lead Time)
[○工厂 ○贸易商 ○物流]   [___________________]      [_______] 天

供货品类 *               开户名称                  月最大产能
[品类多选框]             [___________________]      [_______] 件

所在地区                 税号                      结算货币
[省份 ▼] [城市 ▼]       [___________________]      [CNY ▼]

主联系人姓名 *           开票类型                  账期
[___________________]    [○专票 ○普票 ○收据]       [0天/30天/60天 ▼]

联系电话 *
[___________________]

邮箱 *(Portal登录账号)
[___________________]

备注
[多行文本框]

                         [上一步]  [下一步]         [上一步]  [保存草稿] [提交审核]

11.4 供应商详情页面结构

┌──────────────────────────────────────────────────────────────┐
│  ← 返回列表                                                   │
│  广州市XX电子科技有限公司    SUP-20250117-0001               │
│  [工厂供应商] [A级] [88.5分] [已通过 ✅]                     │
│  [编辑] [停用] [Portal: 已开通]                              │
├──────┬────────┬──────────┬──────────┬──────────────────────┤
│基本信│资质文件│  联系人  │  评分记录│      审核日志         │
├──────┴────────┴──────────┴──────────┴──────────────────────┤
│                                                              │
│  基本信息                                                    │
│  ┌─────────────┬──────────────────────────────────────┐    │
│  │ 供应商编码  │ SUP-20250117-0001                    │    │
│  │ 公司类型    │ 工厂供应商                           │    │
│  │ 所在地区    │ 广东省 广州市                        │    │
│  │ 主联系人    │ 张经理   138****5678  查看完整号码   │    │
│  │ 联系邮箱    │ zhang@guangzhou-xx.com               │    │
│  │ 创建时间    │ 2025-01-17 10:30:00                  │    │
│  └─────────────┴──────────────────────────────────────┘    │
│                                                              │
│  供货能力                                                    │
│  ┌─────────────┬──────────────────────────────────────┐    │
│  │ 最小起订量  │ 100 件                               │    │
│  │ 交货周期    │ 7 天                                 │    │
│  │ 月最大产能  │ 5,000 件                             │    │
│  │ 结算货币    │ CNY(人民币)                        │    │
│  │ 账期        │ 月结30天                             │    │
│  └─────────────┴──────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────┘

11.5 评分趋势图设计

评分历史 Tab 中展示折线图,直观呈现供应商绩效走势:

评分趋势(近12个月)                     当前评级:A级

  100 │
   90 │         ●─────●─────●─────●
   80 │    ●───●                   ●─────●
   70 │───●                               ●
   60 │────────────────────────────────────── 60分警戒线(虚线)
   50 │
      └─────────────────────────────────────
       8月  9月  10月 11月 12月  1月  2月

  ● 综合评分趋势    --- 60分警戒线

  各维度本月详情:
  准时交货率: 90% → 22.5分
  质量合格率: 95% → 23.75分
  响应速度评分:      22分(平均4.5小时响应)
  价格竞争力:        20分(略高于市场均价)
  综合得分: 88.25分

11.6 前端关键交互逻辑

状态按钮的显示/隐藏控制逻辑:

flowchart TD
    A["供应商列表每行<br>渲染操作按钮"] --> B{"供应商当前状态"}
    B -- "草稿(0)" --> C["显示:编辑 | 提交审核 | 删除"]
    B -- "待审核(1)" --> D{"当前用户角色"}
    D -- "采购专员" --> E["显示:查看(只读)"]
    D -- "采购负责人" --> F["显示:查看 | 审核通过 | 审核拒绝 | 要求补充"]
    B -- "已通过(2)" --> G["显示:查看 | 编辑(部分字段)| 停用"]
    B -- "已拒绝(3)" --> H["显示:查看 | 编辑 | 重新提交 | 删除"]
    B -- "已停用(4)" --> I["显示:查看 | 重新启用"]

文件上传组件核心交互:

flowchart TD
    A["用户选择文件"] --> B["前端校验<br>大小/格式/数量"]
    B --> C{"校验通过?"}
    C -- 否 --> D["显示错误提示<br>阻止上传"]
    C -- 是 --> E["显示进度条<br>开始上传"]
    E --> F["调用后端上传接口<br>/api/file/upload"]
    F --> G{"上传成功?"}
    G -- 成功 --> H["显示文件预览卡片<br>文件名 + 大小 + 上传时间"]
    G -- 失败 --> I["显示失败原因<br>提供重试按钮"]
    H --> J["用户可以在线预览<br>点击文件名打开预览"]
    H --> K["用户可以删除<br>点击删除图标移除"]

今日总结与作业

今日知识点回顾

业务层面:

  • 供应商的三种类型(工厂/贸易商/物流服务商)及其业务区别
  • 供应商全生命周期:注册(采购员提交审核)→审核(采购负责人)→合作→评估→淘汰
  • 状态机设计:5个状态、8种状态流转、每种状态的可执行操作
  • 供应商分层分级:S/A/B/C四级评分体系和差异化合作策略
  • 多供应商策略:同品类至少2家以上,实现柔性响应
  • Portal协同体系:供应商外部登录,严格权限隔离
  • 消息通知机制:站内信(WebSocket) + 邮件双通道,关键节点必触发

技术层面:

  • 5张数据库表设计:主表/资质文件/联系人/评分记录/审核日志 (供应商的基础操作表)
  • 供应商编码自动生成:SUP-YYYYMMDD-XXXX 格式,防并发冲突
  • 文件上传安全:Magic Bytes 文件真实类型检测,OSS 路径规范 (大小 格式 路径 目录)
  • 评分计算四维度:准时交货率/质量合格率/响应速度/价格竞争力,各25分
  • 幂等性设计:审核操作重复执行不会产生副作用
  • 接口设计规范:RESTful风格,完整的错误码体系(11000-11099)
  • 定时任务:每月1日计算评分,每天检查资质到期

今日作业

作业 1:数据库执行(必做)

  1. 执行本课所有建表 SQL:

    • supplier(主表)
    • supplier_cert(资质文件)
    • supplier_contact(联系人)
    • supplier_score_log(评分记录)
    • supplier_audit_log(审核日志)
    • sys_message(站内消息)
  2. 执行字典数据 SQL(供应商资质类型/联系人类型/公司规模)

  3. 插入测试数据并验证:

-- 插入3条测试供应商数据(不同状态)
INSERT INTO `supplier` (
    `id`, `tenant_id`, `supplier_code`, `supplier_name`, `supplier_type`,
    `province`, `city`, `contact_name`, `contact_phone`, `contact_email`,
    `moq`, `lead_time_days`, `currency`, `payment_days`,
    `grade`, `score`, `status`, `create_by`, `create_time`, `update_time`
) VALUES
-- 已通过的A级供应商
(1001, 101, 'SUP-20250117-0001', '广州市鑫源电子科技有限公司', 1,
 '广东省', '广州市', '李总监', '13812345678', 'li@xinyuan.com',
 200, 7, 'CNY', 30, 'A', 88.50, 2, 501, NOW(), NOW()),
-- 待审核的供应商
(1002, 101, 'SUP-20250117-0002', '深圳市博通贸易有限公司', 2,
 '广东省', '深圳市', '王经理', '13987654321', 'wang@botong.com',
 50, 3, 'CNY', 0, 'C', 0.00, 1, 502, NOW(), NOW()),
-- 草稿状态的供应商
(1003, 101, 'SUP-20250117-0003', '义乌市精品小商品店', 2,
 '浙江省', '义乌市', '陈老板', '13611112222', 'chen@yiwu.com',
 10, 5, 'CNY', 0, 'C', 0.00, 0, 501, NOW(), NOW());

-- 为已通过的供应商插入审核日志
INSERT INTO `supplier_audit_log` (
    `id`, `tenant_id`, `supplier_id`, `from_status`, `to_status`,
    `action`, `audit_remark`, `operator_id`, `operator_name`, `operate_time`
) VALUES
(2001, 101, 1001, 0, 1, '提交审核', NULL, 501, '张采购员', '2025-01-17 09:00:00'),
(2002, 101, 1001, 1, 2, '审核通过', '资质完整,信息真实,批准合作', 502, '李采购负责人', '2025-01-17 10:30:00');

-- 验证查询:查看各状态供应商数量
SELECT
    status,
    CASE status
        WHEN 0 THEN '草稿'
        WHEN 1 THEN '待审核'
        WHEN 2 THEN '已通过'
        WHEN 3 THEN '已拒绝'
        WHEN 4 THEN '已停用'
    END AS status_name,
    COUNT(*) AS count
FROM supplier
WHERE tenant_id = 101 AND is_deleted = 0
GROUP BY status
ORDER BY status;

作业 2:接口设计练习(必做)

按照本节课的接口规范,手动设计以下接口的请求和响应:

  1. 查询供应商列表接口

    • 请求参数有哪些?数据类型是什么?
    • 响应数据结构是什么样的?
    • 如果 keyword 参数为空,SQL 中该如何处理?
  2. 审核通过接口

    • 接口路径是什么?
    • 需要做哪些业务校验(写出至少5条)?
      • 供应商必须存在,且未被逻辑删除。
      • 供应商必须属于当前租户,不能跨租户审核。
      • 当前用户必须有 srm:supplier:audit 权限。
      • 供应商当前状态必须是 待审核 status=1。
      • 必填信息必须完整,比如供应商名称、联系人、联系电话、主营品类等。
      • 必须已上传必要资质,比如营业执照。
      • 如果已存在 Portal 账号,不能重复创建。
      • 审核备注长度、格式需要校验。
    • 审核通过后需要做哪些后续操作(写出完整步骤)?
      • 查询供应商当前状态。
      • 判断是否为待审核状态。
      • 更新供应商状态为已通过 status=2。
      • 初始化供应商评级,例如默认 C 级或初始分。
      • 创建供应商 Portal 账号,写入 sys_user。
      • 给账号分配供应商角色 ROLE_SUPPLIER。
      • 回写 supplier.portal_user_id 和 portal_enabled=1。
      • 写入供应商审核日志 supplier_audit_log。
      • 发送站内信通知采购专员。
      • 发送邮件给供应商联系人,告知 Portal 登录地址和初始账号。
      • 返回审核成功结果。

项目中的每个业务 不用全部都看 只看你自己想要准备的接口/业务 也就是说 你对哪个接口感兴趣 回头要写到简历上的 那你就必须要把相关业务流程(文字版)和实现逻辑(代码版) 都必须搞懂

每个同学 都必须至少要掌握 4到5个核心的业务 最好是不要跨太多的模块/系统 一个系统中 搞3个 一个系统中搞2个

作业 3:业务分析题(必做)

请回答以下业务问题:

  1. 状态机安全:系统如何防止有人通过篡改请求参数,把”待审核”直接跳变为”已完成”(绕过审核)?在哪一层做校验最合适,为什么? 不能允许前端直接传一个 status=2 或 status=99 就修改供应商状态。状态流转必须由后端控制,前端只能调用明确的业务动作接口 最合适的校验层是 Service 业务层。 原因是 Controller 只负责接收请求,数据库约束只能保证字段合法,真正的业务规则在 Service 层最清楚。Service 层需要判断“当前状态 + 当前操作 + 用户权限”是否合法。比如只有待审核状态才能审核通过,已停用供应商不能直接变成草稿。

    在状态机类中已经限定了 固定的状态值 固定的调整的流程

  2. 并发问题:两个采购负责人同时打开了同一个供应商的审核页面,几乎同时点击了”审核通过”。请描述如果没有幂等处理会发生什么?有了幂等处理后系统如何保证正确性? 如果没有幂等处理,两个采购负责人同时点击审核通过,可能出现:

    • 两次更新供应商状态;
    • 创建两个 Portal 账号;
    • 发送两封开通邮件;
    • 写入两条重复审核日志;
    • 数据状态不一致,后续排查困难。

    有幂等处理后,系统应该这样保证正确性:

    • 审核前查询当前状态,只有 status=1 才允许审核。
    • 更新时带状态条件 CAS 原子性更新
    • 如果影响行数是 1,说明本次审核成功。
    • 如果影响行数是 0,说明已经被别人审核过,直接返回“已审核成功”,不重复创建账号。
    • Portal 账号创建也要有唯一约束,防止并发重复创建。
      • 在供应商表中有一个字段 portal_user_id 标记当前的供应商 是否已经完成了用户的注册
      • 这个字段的更新 和 对供应商用户账号的注册 要放到同一个事务中
  3. 评分数据缺失:某供应商本月没有下过任何采购单,请描述评分定时任务应该如何处理这种情况?直接给0分合理吗?为什么? 如果某供应商本月没有任何采购单,不应该直接给 0 分。 因为 0 分代表供应商表现极差,但本月没有采购数据并不等于它交货差、质量差、响应慢。直接给 0 分会导致评分失真,甚至错误地把正常供应商降为 C 级。 更合理的处理方式:

    • 本月不重新计算评分。
    • 沿用上月评分和评级。
    • 在评分记录中写入说明:本月无采购数据,评分沿用上月
    • 如果连续多个月没有合作,可以单独标记为“长期未合作”或“待复核供应商”,而不是用低分惩罚。

作业 4:数据库设计题(选做)

根据以下新需求,补充设计数据库表:

新需求:支持供应商自己上传发货单据(装箱单/出库单/物流面单图片),这些文件与资质文件不同,是针对每一笔采购单的,需要关联到具体的采购单。

请完成:

  1. 设计 supplier_delivery_doc(发货单据表)的完整字段
  2. 说明该表与 supplier 表、purchase_order(采购单)表的关联关系
  3. 写出建表 SQL

今日技术亮点

1. 供应商审核状态机

业务点:供应商从草稿、待审核、已通过、已拒绝、已停用之间流转,不能乱跳状态。

技术点:状态机设计 + 状态流转校验 + 审核日志

可以面试这样讲:

我负责供应商审核流程,使用状态机约束供应商生命周期。比如只有“待审核”的供应商才能审核通过,已停用供应商不能直接下采购单。每次状态变化都会写入审核日志,方便后续追溯是谁在什么时间做了什么操作。

这是很自然的亮点,因为供应商审核本来就需要状态控制。

2. 审核接口幂等设计

业务点:审核通过时要更新状态、创建 Portal 账号、发送通知。如果用户重复点击或网络重试,不能创建多个账号。

技术点:幂等校验 + 状态前置判断 + 唯一约束兜底

可以面试这样讲:

审核通过接口做了幂等处理。接口执行前会判断当前供应商是否仍是“待审核”状态,如果已经通过,就直接返回成功,不重复创建 Portal 账号。同时 portal_user_id 和用户账号也做唯一性约束,防止并发请求造成脏数据。在进行更新的时候 要使用CAS 更新 确保状态是 待审核 才能改成 审核通过

3. 供应商编码生成的并发优化

业务点:供应商编码要唯一,例如 SUP-20250117-0001。

技术点:Redis INCR 原子自增 + 数据库唯一索引兜底

可以面试这样讲:

供应商编码没有采用每次查询数据库最大值再加一的方式,因为高并发下容易重复,也会增加数据库压力。我使用 Redis INCR 按租户和日期生成自增序号,例如 supplier:code:seq:{tenantId}:{yyyyMMdd},再用数据库唯一索引兜底,保证编码唯一。

这个亮点能自然引出 Redis 原子性、并发冲突、降级方案。

4. 文件上传安全校验

业务点:供应商需要上传营业执照、认证证书、授权文件等资质。

技术点:扩展名白名单 + MIME 初筛 + Magic Bytes 校验 + OSS/MinIO 存储

可以面试这样讲:

供应商资质上传不能只看文件后缀,因为攻击者可能把脚本文件改成 jpg 或 pdf。我做了多层校验:限制文件大小、校验扩展名、读取 Magic Bytes 判断真实文件类型,然后统一重命名后上传到 OSS,路径按租户和业务类型隔离。

5. 资质文件到期提醒任务

业务点:营业执照、授权证书、质量认证都有有效期,到期后不能继续作为有效供应商资质。

技术点:XXL-JOB 定时任务 + 分片处理 + 通知去重

可以面试这样讲:

我实现了供应商资质到期提醒任务,每天扫描 30 天、7 天、1 天内即将到期的资质文件,并通知采购专员和负责人。数据量大时可以用 XXL-JOB 分片,按 tenant_id 或证书 ID 分片处理,同时通过提醒记录表避免重复发送。

分片广播 和 线程池异步处理

6. 供应商绩效评分模型

业务点:卖家不能只凭感觉选供应商,需要根据交期、质量、响应、价格综合评估。

技术点:定时评分任务 + 多维度加权模型 + 评分快照/历史记录

可以面试这样讲:

我实现了供应商绩效评分模型,从采购单、入库质检、询价响应和采购价格中提取数据,按准时交货率、质量合格率、响应速度、价格竞争力四个维度加权计算总分,并按月写入评分记录表,保留历史趋势。

重业务逻辑 能够讲清楚 评分的过程 四个维度

7. 供应商风险预警

业务点:某个品类只有一家供应商,或者供应商连续低评级,会导致断供风险。

技术点:风险规则引擎雏形 + 站内信/邮件通知 + 风险事件记录

可以面试这样讲:

我做了供应商风险预警规则,比如某品类可用供应商少于 2 家,或者所有供应商评级低于 B,就自动生成风险事件,并通过站内信和邮件提醒采购负责人开发备选供应商。这样可以提前发现断供风险,而不是等缺货后再补救。

风险表 数据 怎么进来的 什么时候标记为 已解除

8. 供应商 Portal 数据隔离

业务点:供应商登录后只能看自己的采购单和对账信息,不能看到其他供应商、库存、利润数据。

技术点:tenant_id 多租户隔离 + supplier_id 业务隔离 + RBAC 权限控制

可以面试这样讲:

Portal 端除了多租户隔离,还要做供应商维度的数据隔离。tenant_id 只能保证不同卖家之间数据隔离,但同一个卖家下面可能有多个供应商,所以 Portal 查询采购单时还必须追加 supplier_id = 当前登录供应商ID,防止供应商之间互相看到数据。

这是非常好的面试点,因为它能体现你真的理解 SaaS 和业务权限。

9. 消息通知机制

业务点:审核、资质到期、评级变化、风险预警都需要通知不同角色。

技术点:业务事件 + 站内信 + 邮件通知 + 异步发送

可以面试这样讲:

我把供应商审核、评级变化、证照到期等场景抽象成业务事件,系统根据事件类型找到接收人,生成站内信,重要事件再发送邮件。通知发送可以异步处理,避免影响主业务接口响应。

如果要在简历上写 SRM 供应商系统 可以围绕着下面这些点来写

负责 SRM 供应商管理模块,完成供应商档案、审核状态机、资质文件管理、供应商 Portal 协同和绩效评分功能。

使用状态机控制供应商审核流程,结合幂等校验和审核日志保证审核操作可追溯、可重复提交不产生脏数据。

使用 Redis INCR 生成供应商业务编码,并通过数据库唯一索引兜底,解决并发新增供应商时编码冲突问题。

实现供应商资质文件上传与到期提醒,上传过程结合文件大小、扩展名、Magic Bytes 校验和 OSS 存储,到期提醒通过 XXL-JOB 定时扫描并发送站内信/邮件。

设计供应商绩效评分模型,基于准时交货率、质量合格率、响应速度、价格竞争力按月计算供应商评分,并触发低评级风险预警。

明日预告

第三天:采购管理系统(PMS)完整实现

明天将完整实现从采购需求产生到入库付款的完整采购全链路,包括:

  • 采购需求来源:库存预警触发 / 销售预测 / 手动申请 三种模式
  • 询价比价模块:多供应商报价对比和自动评分
  • 采购订单状态机:8个状态,完整状态流转
  • 采购入库与库存联动(WMS联动核心)
  • 应付账款管理和账期到期提醒
  • 采购数据统计报表

预习建议:

  • 回顾状态机设计概念(今天已学,明天有更复杂的采购状态机)
  • 理解”主从表”的数据库设计模式(采购订单主表 + 订单明细表)
  • 了解什么是”业务幂等”和”事务”(采购入库需要保证库存更新的原子性)

学习提示:今天的内容涉及大量业务流程细节,这些细节在面试中非常有价值。面试官往往会问”你们的供应商审核流程是怎么设计的?”、“如何防止状态流转被绕过?“等问题。建议把今天的状态机图和审核流程图打印出来贴在桌上,多看几遍。