Series Article

Day08 · 权限安全系统 + SaaS 运营管理 + 项目部署上线

配套面试准备:完成本篇后,可以继续阅读:Day08 · 权限安全 + SaaS 运营核心业务面试准备

课程目标:完成系统最后的两大支撑模块——权限安全体系与 SaaS 运营管理,并将整个项目完整部署上线,同时做好简历包装与面试准备,为求职打下坚实基础。

今日交付物

  • RBAC 权限模型完整实现(用户/角色/菜单/数据权限)
  • Sa-Token + Redis 分布式登录态认证
  • 接口安全防护(限流/SQL注入/XSS/审计日志)
  • SaaS 租户注册与套餐管理系统
  • 系统公告与消息中心
  • Docker 容器化部署(Dockerfile + docker-compose)
  • CI/CD 自动化流水线(GitHub Actions)
  • Nginx 反向代理与 HTTPS 配置
  • 项目简历亮点总结与面试题集

第一节 权限体系业务全解析

1.1 为什么需要权限控制

没有权限控制的系统是灾难性的:

  • 仓库员工可以看到公司财务数据
  • 采购专员可以删除供应商(甚至是竞争对手添加的账号)
  • 实习生可以审批百万采购单
  • 供应商 A 可以查看供应商 B 的报价
graph TD
    A["没有权限控制"] --> B["任意用户<br>可访问任意功能"]
    A --> C["数据泄露<br>操作越权<br>数据损坏"]

    D["有RBAC权限控制"] --> E["每个用户只能访问<br>被授权的功能"]
    D --> F["数据安全<br>操作合规<br>责任清晰"]

权限系统不是后台管理系统的附属功能,而是 SaaS 平台能不能安全交付给企业客户的基础能力。供应链系统里有采购价格、供应商报价、库存成本、财务账单、客户订单、银行账号等敏感数据,不同岗位只能访问自己职责范围内的功能和数据。

权限控制至少要解决四个问题:

  1. 谁能登录系统:通过账号、密码、Token、账号状态控制身份认证。
    1. 账号 注册 SaaS 开通账号 注册临时账号 试用期 AI —> 钱
  2. 谁能使用某个功能:通过菜单权限和按钮权限控制功能访问。
    1. 不同的角色 登录到管理后台之后 显示的菜单和按钮是不一样的
  3. 谁能看到哪些数据:通过租户隔离和数据权限控制数据范围。
    1. 数据权限
  4. 谁做过什么操作:通过操作审计日志保留关键行为证据。
    1. 审计日志 来进行留痕操作 AOP

在 SaaS 系统里,权限还要和租户、套餐绑定。比如基础版租户不能使用高级 BI,供应商 Portal 用户不能看到其他供应商数据,财务专员不能越权调整库存。权限系统的目标不是简单“拦接口”,而是让每个角色只能在被授权的边界内操作。

1.2 权限的三个维度

在跨境供应链 SaaS 系统中,权限需要从三个维度控制:

维度说明举例
菜单权限能看到哪些菜单页面财务专员看不到供应商管理菜单
按钮权限(功能权限)页面内能执行哪些操作采购专员能查看供应商,但不能审核
数据权限能看到哪个范围的数据运营专员只能看自己负责的店铺数据

1.3 系统预置角色设计

graph TD
    A["角色体系"] --> B["超级管理员<br>ROLE_SUPER_ADMIN<br>平台方专用,全部权限"]
    A --> C["租户管理员<br>ROLE_TENANT_ADMIN<br>本租户最高权限"]
    A --> D["采购专员<br>ROLE_PURCHASE<br>供应商+采购模块"]
    A --> E["仓储管理员<br>ROLE_WAREHOUSE<br>WMS全部功能"]
    A --> F["运营专员<br>ROLE_OPERATION<br>商品+订单模块"]
    A --> G["物流专员<br>ROLE_LOGISTICS<br>TMS模块"]
    A --> H["财务专员<br>ROLE_FINANCE<br>FMS+部分BI"]
    A --> I["供应商<br>ROLE_SUPPLIER<br>Portal专属,极限制权限"]

这里要区分两个管理视角:

  • 平台方超级管理员:属于 SaaS 平台运营方,可以管理所有租户、套餐、公告和平台配置,但不应该随意介入租户的日常业务数据。
  • 租户管理员:属于某个企业客户,是该租户内部的最高管理员,可以给本公司员工分配角色、开通模块、查看本租户数据。

业务角色则按照供应链分工拆分。采购专员关注供应商和采购订单,仓储管理员关注入库、出库和盘点,运营专员关注商品和订单,物流专员关注运单和轨迹,财务专员关注账单、利润和应收应付。供应商账号属于 Portal 角色,只能看到与自己相关的采购单、送货单和对账信息。

这种角色划分的好处是:既符合企业岗位分工,也能降低误操作风险。比如库存调整、供应商审核、付款审批这类高风险操作,不能只靠前端隐藏按钮,必须在后端接口层做权限校验。


第二节 RBAC 权限模型设计

2.1 RBAC 核心关系

RBAC(Role-Based Access Control,基于角色的访问控制)是企业权限管理的标准模式:

graph LR
    A["用户 User"] -->|"拥有"| B["角色 Role"]
    B -->|"拥有"| C["权限 Permission"]
    C -->|"控制访问"| D["资源 Resource<br>菜单/按钮/数据"]

    E["张三(采购专员)"] -->|"分配角色"| F["ROLE_PURCHASE"]
    F -->|"允许"| G["srm:supplier:list<br>srm:supplier:add<br>pms:order:list<br>pms:order:add"]
    F -->|"禁止"| H["srm:supplier:audit<br>fms:bill:list<br>wms:inventory:adjust"]

RBAC 的核心思想是不要直接给每个用户分配一堆权限,而是先把权限归到角色上,再给用户分配角色。这样当一个新采购入职时,只需要分配采购专员角色;当采购岗位权限变化时,只需要调整角色权限,不需要逐个修改用户。

在系统中,权限资源通常包含两类:

  • 菜单资源:控制用户能不能看到某个页面,例如供应商管理、库存管理、财务对账。
  • 按钮/接口资源:控制用户能不能执行某个动作,例如新增、编辑、删除、审核、导出。

前端菜单是否显示可以根据权限列表控制,但真正的安全边界必须在后端。即使前端隐藏了“审核供应商”按钮,用户也可能通过浏览器或接口工具直接调用审核接口,所以 Controller 上仍然必须使用权限注解做后端校验。

2.2 权限标识命名规范

Redis的Key的命名规范: 系统:模块:业务:唯一键:唯一键值 Value

权限标识(Permission Code)采用 模块:资源:操作 三段式:

格式:{module}:{resource}:{action}

module(模块):srm / pms / wms / pim / oms / tms / fms / bi / sys
resource(资源):supplier / order / inventory / waybill 等
action(操作):list / add / edit / delete / audit / export 等

完整示例:
  srm:supplier:list    → 查看供应商列表
  srm:supplier:add     → 新增供应商
  srm:supplier:audit   → 审核供应商(仅负责人有此权限)
  pms:order:list       → 查看采购订单
  wms:inventory:adjust → 人工调整库存(高风险操作,需要特殊角色)
  fms:bill:import      → 导入财务账单
  bi:ai:query          → AI数据查询(V2预留)

权限标识建议从项目开始就统一规范,否则后期会出现 supplier:addsrm:addSuppliersupplier:create 混用的问题,导致权限难以维护。

三段式命名的好处是清晰可扩展:

  • 第一段表示模块,方便按 SRM、PMS、WMS、FMS 等模块批量管理。
  • 第二段表示资源,明确是供应商、采购单、库存还是账单。
  • 第三段表示动作,明确是查询、新增、编辑、删除、审核还是导出。

bi:ai:query 可以作为 V2 版本预留权限,当前版本即使不实现 AI 自然语言查询,也可以提前规划权限编码,后续开通时不会破坏已有权限体系。

2.3 角色-权限矩阵

权限标识超级管理员租户管理员采购专员仓储管理员运营专员物流专员财务专员供应商
srm:supplier:list
srm:supplier:add
srm:supplier:audit
pms:order:list
pms:receipt:confirm
wms:inventory:list
wms:inventory:adjust
oms:order:list
tms:waybill:add
fms:bill:import
fms:profit:view
bi:dashboard:view
供应商Portal功能

2.4 完整数据库表设计

第一天已设计了 sys_user / sys_role / sys_menu / sys_user_role / sys_role_menu 五张核心权限表,本节补充以下内容:

-- ============================================================
-- 补充:租户套餐功能开关表
-- 控制每个套餐版本可以使用哪些功能模块
-- ============================================================
CREATE TABLE `sys_plan_feature`
(
    `id`           BIGINT UNSIGNED  NOT NULL,
    `plan_type`    TINYINT          NOT NULL  COMMENT '套餐类型:1=基础版 2=专业版 3=企业版',
    `feature_code` VARCHAR(64)      NOT NULL  COMMENT '功能编码,与权限标识前缀对应,如:bi.ai/tms.waybill',
    `feature_name` VARCHAR(128)     NOT NULL  COMMENT '功能名称,如:高级BI分析',
    `is_enabled`   TINYINT(1)       NOT NULL DEFAULT 0 COMMENT '该套餐是否开启此功能',
    `limit_value`  INT              NULL      COMMENT '数量限制,NULL=无限制。如基础版最多20家供应商',
    `limit_unit`   VARCHAR(32)      NULL      COMMENT '限制单位,如:家/单/个',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_plan_feature` (`plan_type`, `feature_code`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
  COMMENT = '套餐功能开关配置表';

-- 初始化套餐功能配置
INSERT INTO `sys_plan_feature`
    (`id`, `plan_type`, `feature_code`, `feature_name`, `is_enabled`, `limit_value`, `limit_unit`)
VALUES
-- 基础版
(1,  1, 'supplier.max',   '最大供应商数量',    1, 20,   '家'),
(2,  1, 'warehouse.max',  '最大仓库数量',       1, 1,    '个'),
(3,  1, 'order.monthly',  '月最大订单量',       1, 500,  '单'),
(4,  1, 'platform.max',   '最大对接平台数',     1, 1,    '个'),
(5,  1, 'bi.ai',          'AI智能查询(V2预留)', 0, NULL, NULL),
(6,  1, 'supplier.portal','供应商Portal',       0, NULL, NULL),
(7,  1, 'api.open',       '开放API接口',        0, NULL, NULL),
-- 专业版
(8,  2, 'supplier.max',   '最大供应商数量',     1, 100,  '家'),
(9,  2, 'warehouse.max',  '最大仓库数量',       1, 5,    '个'),
(10, 2, 'order.monthly',  '月最大订单量',       1, 5000, '单'),
(11, 2, 'platform.max',   '最大对接平台数',     1, 3,    '个'),
(12, 2, 'bi.ai',          'AI智能查询(V2预留)', 1, NULL, NULL),
(13, 2, 'supplier.portal','供应商Portal',       1, NULL, NULL),
(14, 2, 'api.open',       '开放API接口',        0, NULL, NULL),
-- 企业版
(15, 3, 'supplier.max',   '最大供应商数量',     1, NULL, '无限制'),
(16, 3, 'warehouse.max',  '最大仓库数量',       1, NULL, '无限制'),
(17, 3, 'order.monthly',  '月最大订单量',       1, NULL, '无限制'),
(18, 3, 'platform.max',   '最大对接平台数',     1, NULL, '无限制'),
(19, 3, 'bi.ai',          'AI智能查询(V2预留)', 1, NULL, NULL),
(20, 3, 'supplier.portal','供应商Portal',       1, NULL, NULL),
(21, 3, 'api.open',       '开放API接口',        1, NULL, NULL);

2.5 权限鉴别完整流程

flowchart TD
    A["HTTP 请求到达"] --> B["Sa-Token 拦截器<br>解析 Authorization 头中的随机 Token"]
    B --> C{"Token 是否有效?"}
    C -- "无效/过期" --> D["返回 401<br>前端跳转到登录页"]
    C -- "有效" --> E["从 Token 中提取<br>userId / tenantId / planType"]
    E --> F["ThreadLocal 存储<br>租户上下文"]
    F --> G["注解权限检查<br>@SaCheckPermission('srm:supplier:audit')"]
    G --> H["查询该用户拥有的所有权限标识<br>(通常从 Redis 缓存取,首次从 DB 查)"]
    H --> I{"用户权限列表<br>是否包含所需权限?"}
    I -- "不包含" --> J["返回 403<br>提示:无权限访问"]
    I -- "包含" --> K["套餐功能检查<br>该租户套餐是否开启此功能?"]
    K --> L{"套餐是否支持?"}
    L -- "不支持" --> M["返回 403<br>提示:当前套餐不包含此功能,请升级"]
    L -- "支持" --> N["进入业务逻辑<br>MyBatis-Plus 自动追加 tenant_id"]
    N --> O["业务执行完成<br>返回结果"]

这个流程里有几个容易混淆的点。

401 和 403 不一样。401 表示没有登录或 Token 无效,用户需要重新登录;403 表示用户已经登录,但没有访问这个功能或数据的权限。

401: 认证 登录 403: 授权 登录没有问题的 但是你没有权限

权限校验和套餐校验也不一样。权限校验看的是“这个用户是否被授权使用某功能”,套餐校验看的是“这个租户购买的套餐是否包含某功能”。比如财务专员可能有 bi:dashboard:view 权限,但如果租户是基础版且没有开通高级 BI,仍然应该被套餐拦截。

租户隔离必须贯穿整个请求链路。登录后从 Token 中取出 tenantId,放入 TenantContext,MyBatis-Plus 多租户插件再自动给 SQL 追加 tenant_id 条件。请求结束后必须清理 ThreadLocal,避免 Tomcat 线程复用时把上一个请求的租户 ID 带到下一个请求。

权限缓存需要失效机制。用户权限通常会缓存到 Redis,避免每个接口都查角色表和菜单表。但当管理员修改角色权限、禁用用户、调整套餐时,必须删除对应用户或租户的权限缓存,让新权限及时生效。


第三节 Sa-Token 认证集成

3.1 为什么选择 Sa-Token

对比维度Spring SecuritySa-Token
学习曲线高,配置复杂低,API 简洁直观
代码量多,需要大量配置类少,几行注解搞定
多端登录需要额外配置内置支持,开箱即用
踢人下线复杂一行代码
国内社区文档偏英文中文文档完善
企业场景✅ 适合✅ 适合

Sa-Token 的定位是轻量级 Java 权限认证框架,适合中后台系统快速落地。Spring Security 功能更强,但配置链路较复杂;Sa-Token 用注解和工具类就能完成登录、鉴权、踢人下线、会话管理、多端登录等需求。

在本项目中,Sa-Token 主要负责三件事:

  • 登录后生成 Token,并识别当前登录用户。
  • 通过 @SaCheckPermission@SaCheckRole 做接口级鉴权。
  • 配合 Redis 保存分布式登录态、权限缓存和用户上下文。

需要注意的是,选择 Sa-Token 不代表系统就自动安全。密码加密、Token 存储方式、权限缓存失效、接口限流、数据权限、审计日志仍然需要业务系统自己设计。

3.2 登录认证完整流程

sequenceDiagram
    participant FE as 前端
    participant BE as 后端
    participant Redis as Redis
    participant DB as 数据库

    FE->>BE: POST /api/auth/login<br>{username, password, tenantCode}

    BE->>DB: 查询用户信息<br>SELECT * FROM sys_user WHERE username=?

    DB-->>BE: 返回用户记录

    BE->>BE: BCrypt 验证密码<br>BCrypt.checkpw(inputPwd, storedHash)

    alt 密码正确
        BE->>DB: 更新登录时间和IP
        BE->>Redis: 查询并缓存用户权限列表<br>KEY: perm:{userId}
        BE->>BE: Sa-Token 生成 Token<br>StpUtil.login(userId)
        BE->>Redis: 存储 Token 与用户信息映射
        BE-->>FE: 返回 Token + 用户信息 + 权限列表
    else 密码错误
        BE->>DB: login_fail_count + 1
        BE->>BE: 检查是否达到锁定阈值(5次)
        alt 达到5次
            BE->>DB: status = 2(账号锁定)
            BE-->>FE: 返回错误:账号已锁定,请30分钟后重试
        else 未达到
            BE-->>FE: 返回错误:密码错误(剩余N次机会)
        end
    end

登录流程中最重要的是不要暴露过多错误信息。比如用户不存在和密码错误,最好统一返回“用户名或密码错误”,避免攻击者通过提示判断某个账号是否存在。

密码存储不能使用明文,也不建议使用普通 MD5。BCrypt 会自动加盐,并且计算速度相对较慢,可以增加暴力破解成本。用户输入密码时,系统不会把数据库密码“解密”出来比较,而是用 BCrypt.checkpw 验证输入密码和存储哈希是否匹配。

账号锁定机制用于防暴力破解。比如连续输错 5 次后锁定 30 分钟,或者要求图形验证码、短信验证码二次校验。锁定策略要记录失败次数、失败时间和解锁时间,避免用户永远无法登录。

登录成功后返回权限列表,不只是为了后端校验,也用于前端渲染菜单和按钮。但前端权限只能提升体验,不能作为安全边界,后端接口仍然必须校验权限。

3.3 Token 双令牌机制

graph TD
    A["登录成功"] --> B["生成 AccessToken<br>有效期:2小时<br>用于接口鉴权"]
    A --> C["生成 RefreshToken<br>有效期:7天<br>用于无感刷新"]
    B --> D["前端存储到内存或 SessionStorage<br>不存到 localStorage(安全)"]
    C --> E["前端存储到 HttpOnly Cookie<br>无法被 JS 读取(防XSS窃取)"]
    D --> F{"AccessToken 过期?"}
    F -- 否 --> G["正常携带 AccessToken 请求接口"]
    F -- 是 --> H["自动用 RefreshToken 换取新 AccessToken<br>对用户完全透明"]
    H --> I{"RefreshToken 是否有效?"}
    I -- 有效 --> J["后端签发新 AccessToken<br>前端替换旧 Token 重发请求"]
    I -- 无效(已过期7天未活跃)--> K["强制登出<br>跳转登录页"]

双令牌机制解决的是“安全性”和“用户体验”的平衡。

AccessToken 生命周期短,用于访问业务接口。即使泄露,也只能在较短时间内使用。RefreshToken 生命周期长,只用于换取新的 AccessToken,不直接访问业务接口。

前端存储时也要区分风险。AccessToken 可以放在内存或 SessionStorage 中,减少长期泄露风险;RefreshToken 更适合放在 HttpOnly Cookie 中,让 JavaScript 无法直接读取,降低 XSS 窃取风险。

刷新 Token 时,后端要校验 RefreshToken 是否有效、是否被主动注销、是否和当前设备匹配。用户修改密码、管理员禁用账号、用户主动退出登录时,应让 RefreshToken 失效。

3.4 核心配置文件说明

# application.yml 中 Sa-Token 完整配置
sa-token:
  # Token 名称(前端请求头的 Key)
  token-name: Authorization
  # AccessToken 有效期(秒):2小时 = 7200秒
  timeout: 7200
  # RefreshToken 续签时间(秒):用户活跃则续签,7天内不活跃则失效
  active-timeout: 604800  # 7 × 24 × 3600
  # 是否允许同一账号多端同时登录
  is-concurrent: true
  # 同端最多同时在线设备数(concurrent=true 时生效)
  max-login-count: 5
  # Token 风格:随机字符串,登录态由 Redis 统一保存,便于踢人下线和权限实时失效
  token-style: random-64
  # 是否在初始化配置时打印 banner
  is-print: false

3.5 权限注解使用示例

供应商审核接口的权限控制注解使用说明:

在 Controller 方法上添加:
@SaCheckPermission("srm:supplier:audit")
此注解表示:调用此接口的用户,必须拥有 "srm:supplier:audit" 这个权限标识。
如果没有,Sa-Token 自动拦截并返回 403。

多权限(任一满足):
@SaCheckPermission(value = {"srm:supplier:audit", "sys:super"}, mode = SaMode.OR)

多权限(全部满足):
@SaCheckPermission(value = {"srm:supplier:audit", "pms:order:list"}, mode = SaMode.AND)

角色检查:
@SaCheckRole("ROLE_PURCHASE")

登录检查(不检查权限,仅检查是否登录):
@SaCheckLogin

注解式鉴权的好处是清晰直观。Controller 方法上能直接看到这个接口需要什么权限,代码 Review 时也更容易发现高风险接口是否漏加权限。

实际开发中建议把权限标识定义成常量,避免手写字符串拼错。例如 PermissionConstants.SRM_SUPPLIER_AUDIT。同时,要把权限初始化到菜单表中,让后台可以通过角色管理界面进行授权。


第四节 数据权限设计

4.1 数据权限的业务场景

功能权限控制”能做什么”,数据权限控制”能看哪些数据”。

跨境供应链的典型数据权限需求:

场景需求数据权限设置
多店铺运营运营专员 A 只管亚马逊店,专员 B 只管 TikTok 店按店铺过滤数据
多仓库管理广州仓管理员只能看广州仓数据,不能看上海仓按仓库过滤数据
采购部门普通采购员只能看自己创建的采购单按创建人过滤
财务管控财务经理能看所有数据,财务专员只能看本月按时间范围或按角色级别过滤

4.2 数据权限实现方案

flowchart TD
    A["用户发起数据查询请求"] --> B["数据权限拦截器<br>AOP切面,拦截所有 list 方法"]
    B --> C["获取当前用户的数据权限范围<br>从 sys_role.data_scope 获取"]
    C --> D{"数据权限范围"}
    D -- "data_scope = 1(全部数据)" --> E["不追加额外过滤条件<br>可以看所有数据"]
    D -- "data_scope = 2(本部门数据)" --> F["追加:AND dept_id IN (本部门及子部门)"]
    D -- "data_scope = 3(仅本人数据)" --> G["追加:AND create_by = 当前用户ID"]
    D -- "data_scope = 4(自定义)" --> H["按角色配置的具体规则过滤"]
    E & F & G & H --> I["追加条件后执行查询<br>返回过滤后的数据"]

数据权限比功能权限更容易出问题。两个用户都能进入订单列表页面,但他们能看的订单范围可能完全不同。比如美国站运营只能看美国店铺订单,德国仓管理员只能看德国仓库存,供应商只能看自己的采购单。

数据权限通常分为四类:

  • 全部数据:租户管理员、部门负责人或超级角色。
  • 部门数据:只能看本部门及子部门的数据。
  • 本人数据:只能看自己创建或负责的数据。
  • 自定义数据:按店铺、仓库、供应商、区域等业务维度配置。

实现时可以采用 AOP + ThreadLocal + 查询条件追加。AOP 负责识别当前接口需要哪种数据权限,ThreadLocal 保存本次请求的数据范围,Mapper 查询时追加对应条件。

数据权限不能只依赖前端筛选框。前端只传店铺 ID 是不可信的,因为用户可以篡改请求参数。后端必须根据当前登录用户反查允许访问的店铺、仓库或供应商,再追加过滤条件。

供应商 Portal 的强制数据隔离:

flowchart LR
    A["供应商用户请求采购单列表"] --> B["多租户插件<br>自动追加 tenant_id = 101"]
    B --> C["供应商隔离层<br>识别用户角色为 ROLE_SUPPLIER"]
    C --> D["强制追加 supplier_id = 当前供应商ID<br>不可绕过,代码层面硬编码"]
    D --> E["MySQL 执行:<br>WHERE tenant_id = 101<br>AND supplier_id = 1001<br>供应商A只能看自己的采购单"]

供应商 Portal 是数据权限里最特殊的场景。供应商不是租户内部员工,它只能查看和自己有关的数据。即使它有采购单列表接口的访问权限,也必须强制追加 supplier_id = 当前供应商ID

这类隔离不建议完全交给通用插件自动判断,而应该在供应商 Portal 的业务代码或专用拦截层中强制追加。原因是供应商数据越权风险极高,一旦供应商 A 看到供应商 B 的报价、合同或送货信息,就是严重的数据泄露事故。


第五节 接口安全防护

5.1 安全防护全景

graph TD
    A["接口安全防护体系"] --> B["身份认证<br>Sa-Token 登录态验证"]
    A --> C["权限鉴别<br>功能权限 + 数据权限"]
    A --> D["接口限流<br>防止暴力请求/爬虫"]
    A --> E["输入校验<br>防 SQL 注入 / XSS"]
    A --> F["敏感数据保护<br>脱敏展示 + 加密存储"]
    A --> F2["操作审计<br>全写操作留痕"]
    A --> G["HTTPS 加密<br>传输层安全"]

接口安全不是某一个功能点,而是一套分层防护体系。认证解决“你是谁”,权限解决“你能做什么”,数据权限解决“你能看哪些数据”,限流解决“你能多频繁调用”,输入校验解决“你传进来的内容是否危险”,审计日志解决“出问题后能不能追查”。

后台系统最常见的安全误区是只做登录,不做后续防护。实际上,登录只是第一步。供应链系统里有大量高风险接口,比如付款审批、库存调整、供应商审核、账单导出,这些接口必须同时具备权限校验、参数校验、审计日志和必要的二次确认。

5.2 接口限流设计

flowchart TD
    A["请求到达"] --> B["限流拦截器(AOP)<br>拦截所有接口"]
    B --> C["从 Sa-Token 登录态获取用户ID<br>构建限流 Key:<br>rate_limit:{userId}:{接口路径}"]
    C --> D["Redis 滑动窗口计数:<br>当前1分钟内的请求次数"]
    D --> E{"请求次数 > 阈值?<br>(默认60次/分钟)"}
    E -- 否 --> F["请求次数 +1<br>放行请求"]
    E -- 是 --> G["返回 HTTP 429<br>Too Many Requests<br>提示:操作太频繁,请稍后再试"]

    H["特殊接口单独配置:<br>登录接口:5次/分钟(防暴力破解)<br>文件上传:3次/分钟<br>报表导出:10次/分钟(防止大查询拖垮系统)<br>普通查询:120次/分钟"]

Redis 滑动窗口限流实现原理:

滑动窗口实现(Redis + Lua脚本保证原子性):

Key: rate_limit:501:srm/supplier/list
Value: List 类型,存储每次请求的时间戳

操作步骤(Lua原子执行):
1. 获取当前时间戳 now
2. 移除 now-60秒 之前的所有时间戳(ZREMRANGEBYSCORE)
3. 计算剩余元素数量(ZCARD)
4. 如果 >= 阈值,返回 0(被限流)
5. 否则,添加当前时间戳,设置过期时间,返回 1(放行)

优点:精确的滑动窗口,不会因为固定窗口边界产生突刺

限流要区分接口类型。登录、短信验证码、文件上传、报表导出、批量导入都应该有不同阈值。登录接口阈值要低,用来防暴力破解;普通查询接口阈值可以高一些;导出、上传、批量导入接口消耗资源大,要更严格。

固定窗口限流实现简单,但可能出现窗口边界突刺。例如 10:00:59 请求 60 次,10:01:00 又请求 60 次,短时间内实际打了 120 次。滑动窗口更平滑,更适合登录、短信、导出这类敏感接口。

限流 Key 的设计也很重要。只按 IP 限流可能误伤公司内网用户;只按用户限流可能挡不住未登录攻击;实际可以组合使用 用户ID + 接口路径IP + 接口路径租户ID + 接口路径

5.3 SQL 注入防护

flowchart LR
    A["用户输入:<br>name=' OR 1=1 --"] --> B["MyBatis PreparedStatement<br>参数化查询"]
    B --> C["SQL 执行:<br>SELECT * FROM supplier<br>WHERE name = ?<br>参数值 = ' OR 1=1 --"]
    C --> D["数据库将整个输入<br>作为字符串字面量处理<br>不会解析为 SQL 指令"]
    D --> E["注入失败<br>返回空结果(没有名字叫这个的供应商)"]

    F["危险做法(字符串拼接):<br>String sql = 'SELECT * FROM supplier WHERE name = ' + name<br>执行:SELECT * FROM supplier WHERE name = '' OR 1=1 --<br>返回全部数据!(注入成功)"]

规范:绝对禁止在 MyBatis XML 或代码中使用字符串拼接构建 SQL。所有动态条件必须使用 #{} 参数绑定,而不是 ${} 字符串替换。

#{}${} 的区别必须讲清楚。#{} 会使用 PreparedStatement 参数绑定,用户输入会被当成普通参数;${} 是字符串替换,会直接拼进 SQL,风险很高。

有些场景确实需要动态排序字段,比如 ORDER BY create_time DESC。这种情况下不能直接接收前端传来的字段名,而应该做白名单映射。例如前端传 sortField=created,后端映射为固定字段 create_time,不允许任意字符串进入 SQL。

5.4 XSS 防护

flowchart TD
    A["用户提交数据:<br>商品名称 = <script>alert('xss')</script>"] --> B["输入过滤层<br>XSS Filter(全局拦截器)"]
    B --> C["使用 Hutool 的 HtmlUtil.filter()<br>或 OWASP AntiSamy 过滤危险标签"]
    C --> D["过滤结果:<br>商品名称 = (空,危险内容被移除)<br>或 = &lt;script&gt;alert()&lt;/script&gt;(HTML转义)"]
    D --> E["存储到数据库的是无害内容"]
    E --> F["前端展示时使用 v-text 而非 v-html<br>避免 innerHTML 注入"]

XSS 的本质是用户提交了脚本内容,系统又把它当成 HTML 执行。供应链系统里商品名称、供应商备注、公告内容、消息内容、评论说明都可能成为 XSS 入口。

防护要前后端配合:

  • 后端保存前做过滤或转义,移除危险标签和事件属性。
  • 前端展示普通文本时使用 v-text 或文本插值,不使用 v-html
  • 富文本场景必须使用白名单过滤,只允许安全标签。
  • Cookie 中的 RefreshToken 使用 HttpOnly,降低 XSS 窃取风险。

5.5 敏感数据脱敏

graph LR
    subgraph 接口返回脱敏规则
        A["手机号:138****8888<br>保留前3位和后4位"]
        B["银行卡号:**** **** **** 1234<br>仅显示后4位"]
        C["邮箱:z***@gmail.com<br>用户名保留首字母"]
        D["身份证:110***********1234<br>中间隐藏"]
    end

    subgraph 数据库存储加密
        E["手机号:AES-256-CBC 加密存储<br>查询时解密"]
        F["银行账号:AES-256-CBC 加密存储"]
        G["API Secret:存储时使用不可逆哈希<br>或存储加密后的值"]
    end

敏感数据要区分“展示脱敏”和“存储加密”。

展示脱敏是为了防止普通页面泄露隐私,比如手机号只展示 138****8888。存储加密是为了防止数据库泄露后直接暴露明文,比如银行卡号、API Secret、供应商银行账户等。

不是所有字段都适合不可逆哈希。密码适合不可逆哈希,因为系统只需要校验,不需要还原;银行卡号、手机号、API Token 如果业务还需要调用或展示部分信息,则需要可逆加密并严格控制解密权限。

按照不同的登录用户的角色 展示不同的数据
按照不同的操作 展示不同的数据 比如: 查看列表的时候 显示模糊/脱敏 展开 查询详情的时候 可以看到完整的数据 查询详情 —> 要求对应的权限

5.6 操作审计日志 AOP 实现

flowchart TD
    A["业务方法被调用<br>如:审核供应商"] --> B["@Around AOP 切面拦截<br>@Log(module='供应商管理', action='审核供应商')"]
    B --> C["记录执行前信息:<br>操作人ID+姓名<br>请求IP地址<br>请求参数(序列化为JSON)<br>操作时间"]
    C --> D["执行目标业务方法"]
    D --> E{"执行结果"}
    E -- 成功 --> F["记录:<br>status = 1(成功)<br>执行耗时(ms)"]
    E -- 异常 --> G["记录:<br>status = 0(失败)<br>error_msg = 异常信息<br>执行耗时(ms)"]
    F & G --> H["异步写入 sys_audit_log 表<br>(异步写入不影响主业务响应速度)"]

审计日志注解设计:

@Log 注解的参数设计:

@Log(
  module = "供应商管理",      // 所属业务模块名称
  action = "审核供应商",       // 操作描述
  logType = LogType.UPDATE,   // 操作类型:INSERT/UPDATE/DELETE/QUERY
  saveParam = true,           // 是否保存请求参数(默认true,敏感接口设false)
  sensitiveFields = {"password", "bankAccount"}  // 脱敏字段(不记录明文)
)
public Result approveSupplier(@PathVariable Long id, ...) { }

审计日志重点记录高风险写操作,而不是把所有查询都记录得非常详细。典型场景包括:供应商审核、采购审批、库存调整、付款审批、账单确认、套餐变更、角色授权、用户禁用。

审计日志要记录操作人、租户、IP、操作模块、操作动作、请求参数、执行结果、失败原因和耗时。请求参数中如果包含密码、银行卡号、Token 等敏感字段,必须脱敏或不记录。

写审计日志最好异步执行,避免影响主业务接口响应。但要注意,关键操作的审计日志不能因为异步失败就完全丢失,可以通过本地日志或消息补偿兜底。


第六节 SaaS 租户运营管理

6.1 租户全生命周期

flowchart TD
    A(["访客访问官网"]) --> B["自助注册<br>填写:公司名/负责人/手机/邮箱/密码"]
    B --> C["邮箱验证<br>发送6位验证码,5分钟有效"]
    C --> D["验证成功<br>创建租户记录 + 创建管理员账号"]
    D --> E["选择套餐<br>基础版/专业版/企业版"]
    E --> F["试用期开始<br>免费试用14天(无需绑定信用卡)"]
    F --> G{"试用期内使用情况"}
    G -- "主动续费" --> H["付费订阅<br>进入正式使用期"]
    G -- "试用结束未续费" --> I["账户转为只读<br>数据保留30天<br>系统发送挽留邮件"]
    H --> J["正常使用"]
    J --> K{"套餐到期前7天"}
    K --> L["续费提醒<br>邮件+站内信每日提醒"]
    L --> M{"是否续费?"}
    M -- 是 --> J
    M -- 否 --> N["到期当天切换只读<br>所有写操作被拦截"]
    I & N --> O{"30/60天内是否续费?"}
    O -- 是 --> J
    O -- 否 --> P["账户注销流程<br>数据清理(GDPR合规)"]
    P --> Q(["账户归档"])

SaaS 租户生命周期本质上是平台运营流程,不是单纯的用户注册。一个企业客户从试用、开通、续费、升级、到期、只读、数据保留、注销,每个阶段都要有明确的系统状态和操作限制。

试用期的目标是降低客户体验门槛,所以一般允许使用核心功能,但会限制供应商数量、订单量、仓库数量、平台对接数量等。试用结束未续费时,不建议立即删除数据,而是先转为只读,让客户还能登录查看历史数据,并通过邮件和站内信引导续费。

到期后的“只读状态”非常重要。只读不是不能登录,而是不能新增、修改、删除业务数据。这样既保护平台商业权益,也避免客户因为暂时忘记续费而彻底丢失经营数据。

长期未续费后才进入数据清理或归档流程。清理前应发送多次通知,并保留操作记录。涉及海外客户时,还要考虑 GDPR 等数据删除或导出要求。

6.2 租户初始化数据

新租户注册成功后,系统需要自动为其初始化一套基础数据:

flowchart LR
    A["租户注册成功"] --> B["系统自动初始化"]
    B --> C["创建管理员账号<br>sys_user(user_type=2)"]
    B --> D["分配系统预置角色<br>将8个预置角色复制到该租户"]
    B --> E["初始化默认配置<br>KPI预警阈值/系统字典"]
    B --> F["创建默认仓库<br>'待配置仓库'(引导用户配置)"]
    B --> G["发送欢迎邮件<br>含登录地址和快速入门指南"]
    C & D & E & F & G --> H["租户可以正常登录使用"]

新租户初始化的目标是“注册完成后马上能用”。如果只创建一个租户 ID,不初始化角色、菜单、字典、默认配置,用户登录后会看到空系统,体验很差。

初始化数据通常包括:

  • 创建租户管理员账号,并分配 ROLE_TENANT_ADMIN
  • 复制系统预置角色和菜单权限到当前租户。
  • 初始化业务字典、编号规则、KPI 阈值、消息模板。
  • 创建默认仓库或引导配置记录。
  • 创建套餐功能开关和用量统计记录。

初始化过程必须放在事务里。如果租户创建成功但管理员账号创建失败,就会出现无法登录的半成品租户。失败时应该整体回滚,或记录初始化失败状态,允许平台管理员重新初始化。

6.3 套餐限制拦截机制

flowchart TD
    A["用户发起操作<br>如:新增第21家供应商"] --> B["套餐限制检查拦截器<br>AOP @PlanLimit(feature='supplier.max')"]
    B --> C["查询当前租户的套餐类型<br>plan_type = 1(基础版)"]
    C --> D["查询该功能的套餐限制<br> SELECT limit_value FROM sys_plan_feature<br> WHERE plan_type=1 AND feature_code='supplier.max'<br> → 20家"]
    D --> E["查询当前实际使用量<br> SELECT COUNT(*) FROM supplier<br> WHERE tenant_id=? AND status!=4 AND is_deleted=0<br> → 20家"]
    E --> F{"当前数量 >= 限制数量?"}
    F -- 否 --> G["允许操作,继续执行"]
    F -- 是 --> H["返回错误码 10003<br>'当前套餐最多支持20家供应商<br>升级专业版可增加至100家'"]
    H --> I["前端弹出升级引导弹窗"]

套餐限制是 SaaS 商业化的核心。权限控制的是用户有没有操作资格,套餐限制控制的是租户购买的版本是否允许使用该功能或使用到多少数量。

常见限制包括:

  • 最大供应商数量
  • 最大仓库数量
  • 每月订单量
  • 可对接平台数量
  • 是否允许使用供应商 Portal
  • 是否允许使用开放 API
  • 是否允许使用高级 BI 功能;AI 查询可作为 V2 套餐项预留

实现上可以用 AOP 注解,例如 @PlanLimit(feature = "supplier.max")。业务方法执行前,先查询租户套餐,再查套餐配置中的限制值,然后统计当前使用量。如果已经达到限制,就直接返回套餐升级提示。

用量统计有两种方式。数据量小的时候可以实时 COUNT;数据量大时建议维护租户用量表,例如 tenant_usage,新增供应商、订单、仓库时同步更新用量,避免每次都 COUNT 大表。

6.4 套餐到期处理定时任务

flowchart TD
    A(["定时任务:每天 00:05 执行"]) --> B["查询所有套餐即将到期的租户<br> WHERE plan_end_time BETWEEN NOW() AND DATE_ADD(NOW(),INTERVAL 7 DAY)"]
    B --> C["发送7天到期提醒邮件"]
    C --> D["查询已到期租户<br> WHERE plan_end_time < NOW() AND status = 1(正常)"]
    D --> E["执行到期处理:<br> UPDATE sys_tenant SET status = 3(已到期)"]
    E --> F["对该租户的所有写操作<br>接口返回 403:套餐已到期,请续费"]
    F --> G["发送到期通知邮件<br>提供快速续费链接"]
    G --> H["查询到期超30天的租户<br>发送最终数据删除警告"]
    H --> I(["任务结束"])

套餐到期处理不能只在用户登录时判断,否则长期在线用户可能继续使用已到期功能。更稳妥的方式是定时任务每天扫描租户状态,同时接口拦截器每次写操作也校验租户是否过期。

到期处理一般分几个阶段:

  1. 到期前 7 天开始提醒,降低客户忘记续费的概率。
  2. 到期当天把租户状态改为已到期,写操作被拦截。
  3. 到期后 30 天内保留数据,允许续费恢复。
  4. 超过保留期后进入归档或清理流程。

这里要避免直接删除生产数据。删除前应要求平台管理员确认,并保留数据导出或备份记录。

6.5 超级管理员平台管理功能

功能说明
租户列表查看所有租户的注册信息、套餐状态、使用量统计
手动开通/封禁为企业客户手动开通、或封禁违规租户
套餐变更为租户升级/降级套餐,调整到期时间
数据监控全平台的订单量、库存量、活跃用户数等宏观数据
系统公告发布全平台公告(如维护通知、新功能上线)
操作日志查看全平台的所有操作审计日志

超级管理员是 SaaS 平台方角色,不属于某个租户。它主要管理租户、套餐、公告、平台配置和运营数据。超级管理员权限很高,但仍然需要审计日志,因为它能影响所有客户。

真实系统中,超级管理员访问租户业务数据要非常谨慎。可以设计“代客登录”或“协助排障”功能,并要求填写原因、限制时长、全程审计,避免平台内部人员滥用权限。


第七节 系统公告与消息中心

7.1 消息类型设计

graph TD
    A["消息类型"] --> B["🔔 系统公告<br>平台级,所有租户可见<br>如:新功能上线/维护通知"]
    A --> C["📋 业务通知<br>租户内触发的业务消息<br>如:供应商审核通过/订单即将超期"]
    A --> D["⚠️ 预警告警<br>需要立即处理的紧急通知<br>如:库存零库存/物流异常"]
    A --> E["💰 财务提醒<br>账款到期/VAT申报截止等"]

    B --> F["展示位置:系统公告栏(全局顶部)"]
    C --> G["展示位置:右上角铃铛(未读红点)"]
    D --> H["展示位置:首页Dashboard告警区<br>+ 铃铛红点 + 页面弹窗(紧急级别)"]
    E --> I["展示位置:铃铛 + 邮件"]

消息中心是系统内部的统一通知入口。它和邮件、短信、企业微信的区别是:站内消息一定会留在系统里,用户登录后可以查看历史通知;外部通知更适合重要或紧急场景。

消息可以按优先级分层。普通消息只在铃铛中展示,重要消息实时推送,紧急消息可以弹窗、邮件或企业微信同时通知。比如系统公告可以所有租户可见,业务通知只发给租户内相关人员,财务提醒只发给财务角色。

7.2 站内消息推送机制

flowchart TD
    A["业务事件触发<br>(如:供应商审核通过)"] --> B["消息构建器<br>MessageBuilder.build()"]
    B --> C["填充消息内容:<br>title / content / link_url / priority"]
    C --> D["写入 sys_message 表<br>status = 0(未读)"]
    D --> E{"推送方式"}
    E -- "优先级1普通" --> F["等待前端主动轮询<br>每30秒查询未读消息数"]
    E -- "优先级2重要" --> G["WebSocket 实时推送<br>前端铃铛立即出现红点"]
    E -- "优先级3紧急" --> H["WebSocket 推送 +<br>页面弹窗提醒 + 邮件"]
    F & G & H --> I["前端收到消息<br>更新铃铛红点数字"]

不是所有消息都必须 WebSocket 实时推送。普通消息可以轮询未读数,降低连接压力;紧急告警才需要实时推送和弹窗。消息表里要保存接收人、租户、消息类型、优先级、是否已读、阅读时间、跳转链接等字段。

业务模块不要直接拼消息内容到处发送,建议统一封装 MessageService 或消息构建器。这样不同模块只要发布业务事件,消息中心负责生成站内信、邮件或 WebSocket 推送。

7.3 未读消息数 API

-- 查询当前用户的未读消息数(铃铛显示用)
SELECT COUNT(*) AS unread_count
FROM sys_message
WHERE tenant_id = #{tenantId}
  AND receiver_id = #{userId}
  AND is_read = 0
  AND is_deleted = 0;

-- 查询消息列表(分页)
SELECT
    id, msg_type, title, content, link_url,
    priority, is_read, read_time, send_time
FROM sys_message
WHERE tenant_id = #{tenantId}
  AND receiver_id = #{userId}
  AND is_deleted = 0
ORDER BY
    is_read ASC,        -- 未读排前面
    priority DESC,      -- 优先级高排前面
    send_time DESC      -- 最新的排前面
LIMIT #{offset}, #{size};

第八节 系统监控与告警

8.1 监控体系设计

graph TD
    A["系统监控体系"] --> B["应用层监控<br>SpringBoot Actuator"]
    A --> C["基础设施监控<br>服务器 CPU/内存/磁盘"]
    A --> D["数据库监控<br>慢查询/连接数/QPS"]
    A --> E["业务指标监控<br>订单量/接口成功率/响应时间"]
    B & C & D & E --> F["Prometheus<br>指标数据收集"]
    F --> G["Grafana<br>可视化看板展示"]
    G --> H["AlertManager<br>告警规则 + 发送告警"]
    H --> I["告警渠道:<br>邮件 / 钉钉机器人 / 企业微信"]

监控体系要覆盖四层:应用、基础设施、中间件和业务指标。

应用层关注接口响应时间、错误率、JVM 内存、GC、线程池。基础设施关注 CPU、内存、磁盘、网络。中间件关注 MySQL、Redis、RocketMQ、Nacos。业务指标关注订单处理失败率、库存同步失败、账单解析失败、物流轨迹拉取失败等。

技术监控告诉我们“系统是否健康”,业务监控告诉我们“业务是否正常”。比如 CPU 正常但订单同步失败率升高,仍然是严重问题。

8.2 关键监控指标

指标类别监控项告警阈值
JVM堆内存使用率> 85% 告警
JVMGC 频率Full GC > 1次/分钟 告警
接口P99 响应时间> 2000ms 告警
接口错误率> 1% 告警
数据库连接池使用率> 80% 告警
数据库慢查询(>1s)> 10次/分钟 告警
Redis内存使用率> 80% 告警
磁盘磁盘使用率> 85% 告警
业务订单处理失败率> 0.1% 告警
业务库存同步失败任意失败立即告警

告警阈值不能一开始就设置得过于敏感,否则会产生大量无效告警,最后团队会忽略告警。比较合理的做法是先设置基础阈值,观察一段时间后根据真实运行情况调整。

告警也要分级。磁盘使用率 80% 可以是普通预警,95% 就是紧急告警;接口错误率短时间上升可以通知开发,持续上升则需要通知负责人。每条告警最好带上服务名、实例、时间、指标值、阈值和排查链接。

8.3 健康检查接口

SpringBoot Actuator 内置端点(需要配置保护):

GET /actuator/health
返回:{"status":"UP", "components": {"db":{"status":"UP"}, "redis":{"status":"UP"}}}

GET /actuator/metrics
返回所有指标数据(供 Prometheus 采集)

GET /actuator/info
返回应用基础信息(版本号/构建时间等)

配置说明:
- 这些端点暴露到外网会有安全风险
- 生产环境需要配置 IP 白名单或独立端口(如 8081)
- 或者使用 Spring Security 对这些接口设置访问限制

健康检查接口主要给负载均衡、容器编排和监控系统使用。它不是给普通用户访问的接口。

生产环境中,/actuator/health 可以有限开放给 Nginx、Docker、Kubernetes 或 Prometheus;/actuator/metrics/actuator/env/actuator/beans 等敏感端点必须严格保护,否则会泄露配置、环境变量和内部结构。

如果服务依赖 MySQL、Redis、RocketMQ,健康检查要区分“应用进程活着”和“核心依赖可用”。进程活着但数据库不可用,业务仍然无法正常运行。


第九节 Docker 容器化部署

9.1 为什么要容器化

graph TD
    subgraph 没有Docker时
        A1["开发说:我本地跑得好好的"] --> B1["测试说:我这里跑不起来"]
        B1 --> C1["运维说:线上又崩了"]
        C1 --> D1["原因:环境不一致\nJDK版本/MySQL版本/配置差异"]
    end

    subgraph 有Docker后
        A2["打包成 Docker 镜像"] --> B2["开发/测试/生产<br>运行完全相同的容器"]
        B2 --> C2["环境一致\n不再有'我这里好好的'问题"]
    end

Docker 解决的是运行环境一致性问题。传统部署需要在服务器上手动安装 JDK、配置环境变量、安装 MySQL、Redis、Nginx,任何版本差异都可能导致“本地能跑,线上不能跑”。Docker 把应用和运行环境打包成镜像,开发、测试、生产都运行同一份镜像,部署更可控。

理解 Docker 时要区分几个概念:

  • 镜像:应用运行所需文件和环境的静态模板。
  • 容器:镜像运行起来后的进程实例。
  • 数据卷:保存 MySQL、Redis、日志等持久化数据。
  • 网络:让多个容器之间可以通过服务名通信。

9.2 后端 Dockerfile

微服务架构下,每个服务有独立的 Dockerfile,但结构相同。这里以通用模板展示,通过构建参数 MODULE_NAME 指定服务模块名。

# ============================================================
# FlexChain 微服务通用 Dockerfile(多阶段构建)
# 用法:docker build --build-arg MODULE_NAME=flexchain-oms -t flexchain-oms .
# ============================================================

# 阶段1:构建阶段
FROM maven:3.9-eclipse-temurin-21 AS builder

ARG MODULE_NAME=flexchain-oms    # 默认构建 oms 服务,可通过 --build-arg 覆盖

WORKDIR /app

# 先复制父 pom 和各模块 pom,利用 Docker 层缓存
# 只要 pom 没有变化,依赖层就不需要重新下载
COPY pom.xml .
COPY flexchain-common/pom.xml ./flexchain-common/
COPY flexchain-gateway/pom.xml ./flexchain-gateway/
COPY flexchain-auth/pom.xml ./flexchain-auth/
COPY flexchain-system/pom.xml ./flexchain-system/
COPY flexchain-srm/pom.xml ./flexchain-srm/
COPY flexchain-pms/pom.xml ./flexchain-pms/
COPY flexchain-wms/pom.xml ./flexchain-wms/
COPY flexchain-pim/pom.xml ./flexchain-pim/
COPY flexchain-oms/pom.xml ./flexchain-oms/
COPY flexchain-tms/pom.xml ./flexchain-tms/
COPY flexchain-fms/pom.xml ./flexchain-fms/
COPY flexchain-bi/pom.xml ./flexchain-bi/

# 预下载依赖(依赖不变时此层有缓存)
RUN mvn dependency:go-offline -B

# 复制全部源代码
COPY . .

# 只编译打包指定的服务模块(及其依赖的 common 模块)
RUN mvn clean package -pl flexchain-common,${MODULE_NAME} -am -DskipTests -B

# ============================================================
# 阶段2:运行阶段(只含 JRE,体积更小)
FROM eclipse-temurin:21-jre-alpine

ARG MODULE_NAME=flexchain-oms
ARG SERVER_PORT=9206

RUN addgroup -S flexchain && adduser -S flexchain -G flexchain

WORKDIR /app

# 从构建阶段复制对应服务的 jar
COPY --from=builder /app/${MODULE_NAME}/target/${MODULE_NAME}.jar app.jar

RUN mkdir -p /app/logs && chown -R flexchain:flexchain /app

USER flexchain

EXPOSE ${SERVER_PORT}

ENTRYPOINT ["java", \
    "-Xms256m", \
    "-Xmx512m", \
    "-XX:+UseG1GC", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-Dspring.profiles.active=${SPRING_PROFILE:-prod}", \
    "-jar", "app.jar"]

这个 Dockerfile 使用了多阶段构建。第一阶段用 Maven 镜像编译项目,第二阶段只使用 JRE 运行 jar 包。这样最终镜像里不包含 Maven、本地仓库和源码,体积更小,安全面也更小。

COPY pom.xml 和各模块 pom.xml 放在前面,是为了利用 Docker 层缓存。只要依赖没变,构建时就不需要重复下载 Maven 依赖,可以明显加快镜像构建速度。

运行阶段创建 flexchain 普通用户,是为了避免容器内应用以 root 身份运行。生产环境中,容器最小权限运行是基本安全要求。

9.3 前端 Dockerfile

# ============================================================
# FlexChain 前端 Dockerfile
# ============================================================

# 阶段1:构建阶段
FROM node:20-alpine AS builder

WORKDIR /app

# 先复制 package.json,利用层缓存
COPY package*.json ./

# 使用淘宝镜像源加速,安装依赖
RUN npm install --registry=https://registry.npmmirror.com

# 复制源代码
COPY . .

# 构建生产版本
RUN npm run build

# ============================================================
# 阶段2:Nginx 服务阶段(只需要构建产物,不需要 Node 环境)
FROM nginx:1.25-alpine

# 复制构建产物到 Nginx 默认静态文件目录
COPY --from=builder /app/dist /usr/share/nginx/html

# 复制自定义 Nginx 配置(处理 Vue Router history 模式的 404 问题)
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

前端 Dockerfile 也是多阶段构建。第一阶段用 Node 编译 Vue 或 React 项目,生成静态资源;第二阶段用 Nginx 提供静态文件服务。生产环境不需要 Node 运行时,只需要构建后的 dist 文件。

如果前端使用 history 路由模式,需要 Nginx 配置 try_files,否则用户刷新 /oms/order 这类前端路由时,Nginx 会按真实文件路径查找,导致 404。

9.4 docker-compose.yml 完整编排

微服务架构下,docker-compose 需要编排基础设施 + 所有微服务。生产环境推荐用 Kubernetes,docker-compose 适合单机部署或开发联调。

# ============================================================
# FlexChain 微服务完整编排(docker-compose)
# 包含:Nacos + MySQL + Redis + RocketMQ + 各微服务 + 前端 + Nginx
# ============================================================
version: '3.9'

services:

  # ==================== Nacos 服务注册与配置中心 ====================
  nacos:
    image: nacos/nacos-server:v2.3.2
    container_name: flexchain-nacos
    restart: always
    environment:
      MODE: standalone                           # 单机模式(生产推荐集群模式)
      SPRING_DATASOURCE_PLATFORM: mysql
      MYSQL_SERVICE_HOST: mysql
      MYSQL_SERVICE_DB_NAME: nacos_config
      MYSQL_SERVICE_USER: flexchain
      MYSQL_SERVICE_PASSWORD: ${MYSQL_PASSWORD}
      JVM_XMS: 256m
      JVM_XMX: 512m
    ports:
      - "8848:8848"
      - "9848:9848"                              # gRPC 端口(Nacos 2.x 新增)
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - flexchain-net

  # ==================== MySQL 数据库 ====================
  mysql:
    image: mysql:8.0
    container_name: flexchain-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_USER: flexchain
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      TZ: Asia/Shanghai
    volumes:
      - mysql_data:/var/lib/mysql
      - ./sql/init:/docker-entrypoint-initdb.d   # 初始化多个数据库(各服务独立库)
    ports:
      - "3306:3306"
    networks:
      - flexchain-net
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    command: >
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_unicode_ci
      --max_connections=500

  # ==================== Redis 缓存 ====================
  redis:
    image: redis:7-alpine
    container_name: flexchain-redis
    restart: always
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 512mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - flexchain-net

  # ==================== RocketMQ NameServer ====================
  rocketmq-namesrv:
    image: apache/rocketmq:5.1.4
    container_name: flexchain-rocketmq-namesrv
    restart: always
    command: sh mqnamesrv
    ports:
      - "9876:9876"
    volumes:
      - rocketmq_logs:/home/rocketmq/logs
    networks:
      - flexchain-net

  # ==================== RocketMQ Broker ====================
  rocketmq-broker:
    image: apache/rocketmq:5.1.4
    container_name: flexchain-rocketmq-broker
    restart: always
    command: sh mqbroker -n rocketmq-namesrv:9876 autoCreateTopicEnable=true
    environment:
      ROCKETMQ_PASSWORD: ${ROCKETMQ_PASSWORD}
    ports:
      - "10911:10911"
      - "10909:10909"
    volumes:
      - rocketmq_store:/home/rocketmq/store
      - rocketmq_logs:/home/rocketmq/logs
    depends_on:
      - rocketmq-namesrv
    networks:
      - flexchain-net

  # ==================== RocketMQ Dashboard(可视化控制台) ====================
  rocketmq-dashboard:
    image: apacherocketmq/rocketmq-dashboard:latest
    container_name: flexchain-rocketmq-dashboard
    restart: always
    environment:
      JAVA_OPTS: "-Drocketmq.namesrv.addr=rocketmq-namesrv:9876"
    ports:
      - "8180:8080"                              # 管理控制台(生产可关闭此端口)
    depends_on:
      - rocketmq-namesrv
    networks:
      - flexchain-net

  # ==================== API 网关 ====================
  gateway:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        MODULE_NAME: flexchain-gateway
        SERVER_PORT: 9200
    container_name: flexchain-gateway
    restart: always
    environment:
      SPRING_PROFILE: prod
      NACOS_ADDR: nacos:8848
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    ports:
      - "9200:9200"                              # 对外暴露的唯一后端入口
    depends_on:
      - nacos
      - redis
    networks:
      - flexchain-net

  # ==================== 认证服务 ====================
  auth:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        MODULE_NAME: flexchain-auth
        SERVER_PORT: 9201
    container_name: flexchain-auth
    restart: always
    environment:
      SPRING_PROFILE: prod
      NACOS_ADDR: nacos:8848
      MYSQL_HOST: mysql
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    depends_on:
      - nacos
      - mysql
      - redis
    networks:
      - flexchain-net

  # ==================== 订单服务(高并发,可多实例) ====================
  oms:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        MODULE_NAME: flexchain-oms
        SERVER_PORT: 9206
    container_name: flexchain-oms
    restart: always
    deploy:
      replicas: 2                                # OMS 承接高并发,部署2个实例,Nacos自动负载均衡
    environment:
      SPRING_PROFILE: prod
      NACOS_ADDR: nacos:8848
      MYSQL_HOST: mysql
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      ROCKETMQ_NAMESRV: rocketmq-namesrv:9876
    depends_on:
      - nacos
      - mysql
      - redis
      - rocketmq-broker
    networks:
      - flexchain-net

  # ==================== 其余业务服务(结构同上,略) ====================
  # system / srm / pms / wms / pim / tms / fms / bi
  # 各服务独立配置端口(9202-9209),按需配置 replicas 数量

  # ==================== 前端 ====================
  frontend:
    build:
      context: ./flexchain-web
      dockerfile: Dockerfile
    container_name: flexchain-frontend
    restart: always
    networks:
      - flexchain-net

  # ==================== Nginx 接入层 ====================
  nginx:
    image: nginx:1.25-alpine
    container_name: flexchain-nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./logs/nginx:/var/log/nginx
    depends_on:
      - gateway
      - frontend
    networks:
      - flexchain-net

# ==================== 数据卷 ====================
volumes:
  mysql_data:
  redis_data:
  rocketmq_logs:
  rocketmq_store:

# ==================== 网络 ====================
networks:
  flexchain-net:
    driver: bridge

docker-compose 的核心作用是把多个服务编排在一起。这个项目不是一个单体应用,还依赖 Nacos、MySQL、Redis、RocketMQ、网关、多个业务服务、前端和 Nginx。手动启动这些服务很容易顺序错误或配置错误,compose 可以把服务、网络、环境变量、依赖关系统一描述。

这里有三个重点:

  1. 基础设施先启动:MySQL、Redis、RocketMQ、Nacos 要先于业务服务可用。
  2. 服务之间走内部网络:容器之间通过 mysqlredisnacos 这样的服务名访问,不需要写宿主机 IP。
  3. 数据必须持久化:MySQL、Redis、RocketMQ 的数据要挂载 volume,否则容器删除后数据也会丢失。

生产环境如果规模更大,可以迁移到 Kubernetes;单机部署、演示环境、测试环境用 docker-compose 已经足够。

9.5 .env 环境变量文件

# .env 文件(绝对不能提交到 Git,加入 .gitignore)

# 数据库配置
MYSQL_ROOT_PASSWORD=your_root_password_here
MYSQL_PASSWORD=your_app_password_here

# Redis 配置
REDIS_PASSWORD=your_redis_password_here

# RocketMQ 配置
ROCKETMQ_PASSWORD=your_rocketmq_password_here

# Nacos 命名空间(生产环境)
NACOS_NAMESPACE=prod

# 阿里云 OSS 配置
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_BUCKET=flexchain-files
OSS_ACCESS_KEY=your_oss_access_key
OSS_SECRET_KEY=your_oss_secret_key

# 邮件服务配置
MAIL_HOST=smtp.gmail.com
MAIL_USERNAME=noreply@flexchain.com
MAIL_PASSWORD=your_email_password

.env 文件保存部署时的环境变量,尤其是数据库密码、Redis 密码、OSS 密钥等敏感配置。它不能提交到 Git 仓库,必须加入 .gitignore

配置和镜像要分离。同一个镜像可以部署到测试环境和生产环境,只需要加载不同 .env 文件。这样镜像可复用,敏感配置也不会写死在代码和 Dockerfile 里。


第十节 CI/CD 自动化流水线

10.1 CI/CD 流水线设计

flowchart LR
    A["开发者 git push<br>推送代码到 GitHub"] --> B["GitHub Actions<br>自动触发 Workflow"]
    B --> C["代码检查<br>Checkstyle / SpotBugs"]
    C --> D["单元测试<br>mvn test<br>生成测试报告"]
    D --> E{"测试全部通过?"}
    E -- 否 --> F["流水线失败<br>发送告警邮件给开发者<br>阻止代码合并"]
    E -- 是 --> G["构建 Docker 镜像<br>mvn package + docker build"]
    G --> H["推送镜像到<br>GitHub Container Registry (GHCR)"]
    H --> I{"当前分支是 main?"}
    I -- "否(feature分支)" --> J["CI完成<br>不执行部署"]
    I -- "是(main分支)" --> K["自动部署到测试环境<br>docker-compose up -d"]
    K --> L["冒烟测试<br>调用核心接口验证"]
    L --> M{"测试通过?"}
    M -- 否 --> N["自动回滚<br>docker-compose rollback"]
    M -- 是 --> O["测试环境部署成功<br>发送 Slack/钉钉通知"]
    O --> P["手动审批<br>生产部署需要负责人点击确认"]
    P --> Q["部署到生产环境<br>蓝绿部署/滚动更新"]

CI/CD 解决的是代码交付过程的可靠性问题。没有流水线时,开发人员手动打包、手动上传、手动重启,很容易漏执行测试、传错 jar 包、改错配置。流水线把这些步骤固化下来,每次代码提交都按同一套流程执行。

CI 主要负责持续集成:代码检查、单元测试、集成测试、构建镜像。CD 主要负责持续交付或持续部署:推送镜像、部署测试环境、冒烟测试、生产审批、生产发布。

生产部署不建议完全无审批自动上线。比较稳妥的做法是:main 分支合并后自动部署测试环境,测试通过后由负责人手动审批生产发布。生产发布可以用滚动更新或蓝绿部署,避免一次性停掉旧版本。

10.2 GitHub Actions Workflow 配置

# .github/workflows/ci-cd.yml
name: FlexChain CI/CD Pipeline

# 触发条件:push 到任何分支,或者创建 PR 到 main 分支
on:
  push:
    branches: [ "**" ]
  pull_request:
    branches: [ "main", "develop" ]

# 环境变量(从 GitHub Secrets 读取,不在文件中硬编码)
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}/flexchain-backend

jobs:
  # ==================== 构建和测试 ====================
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      # 1. 检出代码
      - name: Checkout code
        uses: actions/checkout@v4

      # 2. 设置 JDK 环境
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'                             # 缓存 Maven 依赖,加速后续构建

      # 3. 启动测试依赖(MySQL + Redis)
      - name: Start test dependencies
        run: |
          docker compose -f docker-compose.test.yml up -d
          sleep 10  # 等待服务启动

      # 4. 运行单元测试和集成测试
      - name: Run tests
        run: mvn verify -B --no-transfer-progress

      # 5. 上传测试报告(失败时也上传,方便查看)
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: '**/target/surefire-reports/**'

      # 6. 停止测试依赖
      - name: Stop test dependencies
        if: always()
        run: docker compose -f docker-compose.test.yml down

  # ==================== 构建 Docker 镜像 ====================
  build-docker:
    needs: build-and-test                            # 测试通过后才构建镜像
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # 登录到容器注册中心
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 生成镜像标签(使用 git commit sha,方便追溯)
      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=,suffix=,format=short
            type=ref,event=branch
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      # 构建并推送镜像
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha                       # 使用 GitHub Actions 缓存加速构建
          cache-to: type=gha,mode=max

  # ==================== 部署到生产环境 ====================
  deploy-production:
    needs: build-docker
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'              # 只在 main 分支触发
    environment:
      name: production                               # GitHub 环境保护,需要手动审批
      url: https://app.flexchain.com
    steps:
      - name: Deploy to production server
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.PROD_SERVER_HOST }}
          username: ${{ secrets.PROD_SERVER_USER }}
          key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
          script: |
            cd /opt/flexchain
            # 拉取最新镜像
            docker compose pull
            # 滚动更新(先启动新容器,确认健康后停止旧容器)
            docker compose up -d --no-deps backend
            # 等待健康检查
            sleep 30
            # 检查健康状态
            docker compose exec backend curl -f http://localhost:8080/actuator/health
            echo "部署成功"

这段流水线里有几个关键点。

第一,敏感信息全部来自 GitHub Secrets,例如服务器地址、SSH 私钥、镜像仓库密码,不能写在 workflow 文件里。

第二,测试通过后才构建镜像,镜像用 commit sha 打标签。这样线上运行的版本可以追溯到具体代码提交。

第三,生产环境使用 GitHub environment 保护,需要负责人审批后才允许部署。这样可以避免开发误推 main 分支后直接影响生产。

第四,部署后必须做健康检查。仅仅容器启动不代表应用可用,必须调用 /actuator/health 或核心接口确认服务真正可用。


第十一节 Nginx 与 HTTPS 配置

11.1 Nginx 配置文件

Nginx 在系统里承担接入层角色。用户访问域名时,请求先到 Nginx,再由 Nginx 分发到前端静态资源或后端 API 网关。

它主要解决四件事:

  • HTTPS 证书和 TLS 加密。
  • 前端静态资源访问。
  • 后端 API 反向代理。
  • 安全响应头、上传大小、访问日志、接口文档内网限制。
# /etc/nginx/conf.d/flexchain.conf

# ==================== HTTP 重定向到 HTTPS ====================
server {
    listen 80;
    server_name app.flexchain.com;

    # 所有 HTTP 请求强制跳转到 HTTPS(安全最佳实践)
    return 301 https://$server_name$request_uri;
}

# ==================== HTTPS 主配置 ====================
server {
    listen 443 ssl http2;
    server_name app.flexchain.com;

    # SSL 证书配置(使用 Let's Encrypt 免费证书或购买的商业证书)
    ssl_certificate     /etc/nginx/ssl/flexchain.com.crt;
    ssl_certificate_key /etc/nginx/ssl/flexchain.com.key;

    # SSL 安全配置
    ssl_protocols       TLSv1.2 TLSv1.3;     # 只允许 TLS 1.2 和 1.3
    ssl_ciphers         HIGH:!aNULL:!MD5;     # 只使用高强度加密套件
    ssl_prefer_server_ciphers on;
    ssl_session_cache   shared:SSL:10m;       # SSL 会话缓存,减少握手开销

    # HSTS:告诉浏览器今后1年都只用 HTTPS 访问此域名
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # 其他安全响应头
    add_header X-Frame-Options "SAMEORIGIN";           # 防止页面被嵌入 iframe(防点击劫持)
    add_header X-XSS-Protection "1; mode=block";       # 启用浏览器 XSS 过滤
    add_header X-Content-Type-Options "nosniff";       # 禁止浏览器猜测 MIME 类型

    # ==================== 前端静态资源 ====================
    location / {
        proxy_pass http://frontend:80;
        proxy_set_header Host $host;

        # Vue Router history 模式支持:
        # 当访问 /srm/supplier 这样的前端路由时,
        # 如果文件不存在,返回 index.html 让 Vue Router 处理
        try_files $uri $uri/ /index.html;
    }

    # ==================== 后端 API 反向代理 ====================
    location /api/ {
        proxy_pass http://backend:8080;

        # 传递真实客户端 IP
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $host;

        # 超时设置(报表导出、批量导入等接口可能需要较长时间)
        proxy_connect_timeout 10s;
        proxy_read_timeout 120s;
        proxy_send_timeout 30s;

        # 文件上传大小限制(与后端配置保持一致)
        client_max_body_size 20m;
    }

    # ==================== 静态资源缓存 ====================
    location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
        proxy_pass http://frontend:80;
        expires 1y;                                # 静态资源缓存1年
        add_header Cache-Control "public, immutable";
    }

    # ==================== 接口文档(仅内网访问)====================
    location /doc.html {
        # 只允许内网 IP 访问接口文档
        allow 192.168.0.0/16;
        allow 10.0.0.0/8;
        deny all;
        proxy_pass http://backend:8080;
    }

    # 访问日志
    access_log /var/log/nginx/flexchain_access.log;
    error_log  /var/log/nginx/flexchain_error.log;
}

HTTPS 不是可选项。登录 Token、订单数据、财务数据、供应商银行账号都属于敏感信息,如果使用 HTTP 明文传输,中间人可以截获请求内容。

Nginx 还要正确传递 X-Real-IPX-Forwarded-For,否则后端审计日志和限流只能看到 Nginx 容器 IP,无法识别真实客户端。

11.2 Let’s Encrypt 免费 SSL 证书申请

# 在服务器上安装 Certbot 并申请证书
# Let's Encrypt 提供免费的 SSL 证书,每90天自动续期

# 1. 安装 Certbot(Ubuntu)
sudo apt update
sudo apt install certbot python3-certbot-nginx -y

# 2. 申请证书(自动配置 Nginx)
sudo certbot --nginx -d app.flexchain.com

# 3. 验证自动续期(Certbot 默认设置了 cron 任务)
sudo certbot renew --dry-run

# 4. 检查证书状态
sudo certbot certificates

Let’s Encrypt 证书有效期通常较短,需要自动续期。证书续期失败会导致 HTTPS 访问异常,所以生产环境要监控证书到期时间,并定期验证 certbot renew --dry-run 是否正常。

如果系统部署在云厂商负载均衡后面,也可以把证书配置在负载均衡层,由云平台负责证书管理,Nginx 只处理内部 HTTP 转发。


第十二节 生产环境部署实战

12.1 部署前检查清单

graph TD
    A["部署前检查清单"] --> B["代码层面"]
    A --> C["配置层面"]
    A --> D["安全层面"]
    A --> E["性能层面"]

    B --> B1["所有功能测试通过"]
    B --> B2["代码 Review 完成"]
    B --> B3["版本号更新(pom.xml)"]
    B --> B4["CHANGELOG 更新"]

    C --> C1["生产环境配置文件准备"]
    C --> C2[".env 文件配置(不提交 Git)"]
    C --> C3["数据库迁移 SQL 准备"]
    C --> C4["OSS/邮件/短信等第三方配置"]

    D --> D1["默认密码已修改"]
    D --> D2["Actuator 端点已保护"]
    D --> D3["接口文档已关闭或保护"]
    D --> D4["敏感日志输出已关闭"]

    E --> E1["数据库索引已建立"]
    E --> E2["Redis 连接池配置合理"]
    E --> E3["JVM 内存参数已设置"]
    E --> E4["Nginx 已配置 Gzip 压缩"]

部署前检查清单的价值是降低上线事故。很多线上问题不是代码逻辑错误,而是配置遗漏、默认密码没改、数据库脚本没执行、端口没开放、健康检查没保护。

生产环境部署要遵循一个原则:代码、配置、数据、密钥分开管理。代码通过 Git 管理,配置通过环境变量或配置中心管理,数据通过数据库迁移脚本管理,密钥通过 Secrets 管理。

12.2 数据库初始化脚本执行顺序

flowchart LR
    A["01_create_database.sql<br>创建数据库和用户"] --> B["02_sys_tables.sql<br>系统管理模块建表"]
    B --> C["03_srm_tables.sql<br>供应商模块建表"]
    C --> D["04_pms_tables.sql<br>采购模块建表"]
    D --> E["05_wms_tables.sql<br>仓储模块建表"]
    E --> F["06_pim_tables.sql<br>商品模块建表"]
    F --> G["07_oms_tables.sql<br>订单模块建表"]
    G --> H["08_tms_tables.sql<br>物流模块建表"]
    H --> I["09_fms_bi_tables.sql<br>财务和BI模块建表"]
    I --> J["10_init_data.sql<br>初始化基础数据<br>菜单/角色/字典/阈值"]

数据库脚本必须按依赖顺序执行。系统基础表、租户、用户、角色、菜单要先创建,业务模块表再创建,最后执行初始化数据。如果顺序错误,外键、初始化角色、菜单权限、字典数据都可能失败。

生产环境建议使用 Flyway 或 Liquibase 管理数据库版本。每次发版新增一个迁移脚本,记录已执行版本,避免不同环境脚本状态不一致。

12.3 完整部署命令

# ==================== 服务器初始化 ====================
# 1. 安装 Docker 和 Docker Compose
curl -fsSL https://get.docker.com | bash
sudo usermod -aG docker $USER

# 2. 创建项目目录
mkdir -p /opt/flexchain
cd /opt/flexchain

# 3. 克隆或上传项目代码
git clone https://github.com/your-org/flexchain.git .

# 4. 配置环境变量
cp .env.example .env
vim .env  # 填写生产环境的密码和密钥

# ==================== 首次部署 ====================
# 5. 构建并启动所有服务
docker compose up -d --build

# 6. 查看服务启动状态
docker compose ps

# 7. 查看后端启动日志
docker compose logs -f backend --tail=100

# 8. 检查后端健康状态
curl http://localhost:8080/actuator/health

# ==================== 日常更新部署 ====================
# 拉取最新代码(CI/CD 自动完成,手动部署时用)
git pull origin main

# 只重新构建并重启后端(不影响数据库和 Redis)
docker compose up -d --no-deps --build backend

# 查看部署是否成功
docker compose ps
docker compose logs backend --tail=20

# ==================== 常用运维命令 ====================
# 查看所有容器状态
docker compose ps

# 查看某容器日志(实时)
docker compose logs -f backend

# 进入容器内部排查问题
docker compose exec backend sh

# 重启某个服务
docker compose restart backend

# 备份数据库
docker compose exec mysql mysqldump -u flexchain -p flexchain_prod > backup_$(date +%Y%m%d).sql

# 完全停止并删除所有容器(数据不会丢失,存在 volume 中)
docker compose down

第十三节 项目简历包装与面试准备

13.1 简历项目经历写法

错误写法(太泛,看不出技术深度):

项目名称:跨境电商管理系统
项目描述:负责开发了一个跨境电商的后台管理系统,
         包含商品管理、订单管理、库存管理等功能。
技术栈:SpringBoot、MySQL、Vue3

正确写法(有数据、有亮点、有技术深度):

项目名称:FlexChain 跨境出海 SaaS 柔性供应链管理平台

项目描述:
面向中小型跨境出海卖家的 SaaS 供应链管理系统,涵盖供应商管理(SRM)、
采购管理(PMS)、仓储管理(WMS)、商品管理(PIM)、订单管理(OMS)、
物流管理(TMS)、财务结算(FMS)、智能分析(BI)八大核心模块。

核心职责与技术亮点:

1. 多租户 SaaS 架构设计
   采用行级隔离(tenant_id 字段)+ MyBatis-Plus 多租户插件,
   实现一套代码支撑多个企业客户的数据完全隔离;
   设计租户套餐功能开关体系,支持基础/专业/企业三档差异化功能授权。

2. 超卖防护机制(高并发核心难点)
   利用 Redis DECRBY 原子操作实现库存预扣减,
   解决多平台订单并发场景下的库存超卖问题;
   采用 MQ 异步解耦,保证 Redis 与 MySQL 库存最终一致性;
   设计定时对账任务,每小时校验双端数据一致性。

3. 跨境物流智能分配引擎
   设计三层过滤(国家/重量/禁运品) + 三维评分(成本40%+时效35%+可靠性25%)
   的规则引擎,自动为每个包裹推荐最优物流渠道;
   对接顺丰国际/DHL/EMS等多家物流商 API,实现统一轨迹追踪和异常预警。

4. SKU 级精准利润核算
   实现从多平台结算报告(CSV)自动解析、订单匹配到 8 项成本拆解
   (采购/物流/平台费/广告/退款损失/VAT等)的全链路利润核算,
   解决跨境卖家"赚了多少不知道"的核心痛点。

5. 数据驱动的智能分析
   基于 BI 指标体系、KPI 预警、加权移动平均算法实现销售预测和智能补货建议;
   AI 自然语言查询作为后续版本规划,不作为当前版本已完成功能。

6. CI/CD 自动化
   搭建 GitHub Actions 流水线,实现代码提交→自动测试→构建 Docker 镜像→
   部署测试环境的全自动化,代码发布效率提升 80%。

技术栈:
SpringBoot 3.x / MyBatis-Plus / MySQL 8.0 / Redis / RocketMQ /
Sa-Token / Vue3 / Element Plus / ECharts / Docker / GitHub Actions /
Nginx / 阿里云 OSS

13.2 面试高频问题与标准答案

Q1:你们的多租户是怎么实现的?

答:我们采用行级隔离方案(共享数据库、共享表)。

具体实现:
1. 所有业务表都有 tenant_id 字段
2. 使用 MyBatis-Plus 的多租户插件,通过 TenantLineHandler 接口,
   在 SQL 执行前自动拼接 WHERE tenant_id = ? 条件
3. 用户登录后,从 Sa-Token 登录态中提取 tenant_id,
   存入 ThreadLocal(TenantContext)
4. 请求结束后必须清除 ThreadLocal,防止线程复用时数据串用

为什么不用独立数据库方案?
因为我们是 SaaS 产品,客户数量多(几百家),每家单独一个数据库
运维成本极高,而且表结构变更需要同时操作几百个数据库,
行级隔离在性能和运维成本之间取得了最好的平衡。

Q2:如何解决库存超卖问题?

答:我们用 Redis + 原子操作 解决并发超卖问题。

核心思路:
1. 系统启动时,将可售库存从 MySQL 加载到 Redis
   Key 格式:sku:stock:{tenantId}:{skuId}:{warehouseId}

2. 订单到达时,使用 Redis 的 DECRBY 命令原子性扣减
   DECRBY sku:stock:101:1000001:4001 1

3. Redis 是单线程的,DECRBY 是原子操作,多个并发请求
   会被串行处理,天然避免并发问题。

4. 如果 DECRBY 返回值 < 0(即库存被减到负数),
   说明库存不足,立即用 INCRBY 回滚,返回"库存不足"错误。

5. 扣减成功后,通过 MQ 异步通知 WMS 模块更新 MySQL
   的冻结库存(frozen_qty += 数量)。

6. 定时任务每小时对账,校验 Redis 和 MySQL 是否一致,
   不一致则以 MySQL 为准重置 Redis。

面试追问可能问:为什么不直接用 MySQL 的乐观锁?
答:乐观锁在高并发下会有大量重试,性能很差。
Redis 原子操作性能更高,而且 Redis 是内存操作,
即使高并发也能保持毫秒级响应。

Q3:库存流水为什么不能修改?如果写错了怎么办?

答:库存流水是整个仓储管理的"审计底线",
如果允许修改,所有的库存追溯、对账、审计都失去意义。

我们从三个层面保证不可篡改:
1. 应用层:代码中永远不写 UPDATE/DELETE inventory_log 的逻辑
2. 数据库账号:应用使用的 MySQL 账号只有 INSERT 和 SELECT 权限
3. 代码审查:PR 必须经过 Review,防止误写修改代码

如果写错了(如 +50 误写成 +500):
不能修改原记录,而是写一条纠正记录:
- 原记录:change_qty = +500(错误),before_qty=100,after_qty=600
- 纠正记录:change_qty = -450(纠正),before_qty=600,after_qty=150,
  remark = "纠正2025-01-17误操作,原记录ID=6001"

这样历史记录完整保留,同时账面库存也正确了。

Q4:如何保证采购入库时库存和采购单同时更新的一致性?

答:使用数据库事务(@Transactional)。

入库确认时,在同一个事务中执行以下操作:
1. 更新收货单明细(pass_qty / reject_qty)
2. 更新收货单状态
3. 更新采购单明细的已收货数量(received_qty)
4. 判断并更新采购单整体状态(部分到货 or 全部到货)
5. 更新 WMS 库存表(quantity += pass_qty)
6. 写入库存流水记录

只要其中任何一步失败,整个事务全部回滚,
数据库回到操作前的状态,不会出现"库存增加了但采购单状态没更新"
或者"采购单更新了但库存没增加"的半完成状态。

库存更新为什么用 quantity = quantity + N 而不是 quantity = 150?
因为并发场景下,如果两个线程同时读到 quantity=100,
一个写 quantity=150,一个写 quantity=130,后写的会覆盖前写的。
用 quantity = quantity + N 是数据库层面的原子加法,
会自动串行处理,最终结果是 100+50+30=180,不会丢失数据。

Q5:项目中最难的技术点是什么?

答:我认为最难的是两个:

第一:物流渠道智能分配规则引擎
难点在于需求很复杂:要同时考虑目的国覆盖、重量限制、
是否含电池、是否液体、时效要求、价格等多个维度,
而且这些规则都是数据库配置的,不能硬编码。

我们设计了三层结构:
- 过滤层:用 JSON_CONTAINS 查询适用国家,排除不满足的渠道
- 评分层:三维加权评分(成本40%+时效35%+可靠性25%)
- 推荐层:TOP3排序,附上说明理由

第二:多平台订单的幂等处理
亚马逊 Webhook 有时会重复推送同一个订单(网络问题导致重试),
如果不做幂等,会创建重复订单,导致重复出库。

我们的解决方案:
在 order_main 表上建了唯一索引 UNIQUE KEY(tenant_id, platform, platform_order_no)
插入时使用 INSERT IGNORE 或先 SELECT 再 INSERT,
如果已存在则比较状态是否需要更新,不需要则直接返回成功。
这样即使收到100次重复推送,也只会创建一条订单记录。

13.3 项目技术亮点总结

亮点技术实现业务价值
多租户 SaaS 架构MyBatis-Plus 多租户插件 + ThreadLocal一套系统服务多家企业,降低运营成本
高并发超卖防护Redis DECRBY 原子操作 + MQ 异步同步日处理万级订单零超卖
库存流水审计只增不改 + 数据库账号权限控制全程可追溯,满足财务审计要求
物流智能分配多维过滤 + 加权评分规则引擎自动匹配最优物流渠道,降低运营失误
SKU 级利润核算多平台账单解析 + 8 项成本拆解每一分钱来龙去脉清晰
AI 自然语言查询(V2规划)大模型 API + 意图识别 + 安全 SQL 生成作为后续扩展能力,降低非技术人员查数门槛
全链路事务保证@Transactional + 原子操作组合关键操作零数据不一致
CI/CD 自动化GitHub Actions + Docker代码发布效率提升 80%

八天课程总结

完整项目架构回顾

graph TD
    subgraph 第1天 基础架构
        A["SpringBoot项目初始化<br>多租户架构设计<br>数据库规范建立"]
    end

    subgraph 第2天 SRM供应商
        B["供应商全生命周期<br>审核工作流<br>绩效评分体系"]
    end

    subgraph 第3天 PMS采购
        C["三种需求来源<br>询价比价引擎<br>入库事务设计"]
    end

    subgraph 第4天 WMS仓储
        D["库位体系设计<br>五种库存类型<br>流水审计设计"]
    end

    subgraph 第5天 PIM+OMS
        E["SPU/SKU两级体系<br>超卖防护Redis<br>10状态订单机"]
    end

    subgraph 第6天 TMS+合规
        F["物流分配引擎<br>轨迹标准化<br>HS编码/VAT"]
    end

    subgraph 第7天 FMS+BI
        G["利润核算模型<br>BI数据分析<br>KPI预警体系"]
    end

    subgraph 第8天 安全+部署
        H["RBAC权限体系<br>接口安全防护<br>Docker+CI/CD"]
    end

    A --> B --> C --> D --> E --> F --> G --> H

8 天完整数据库表清单

天次模块表数量核心表
Day1系统基础8sys_user / sys_role / sys_menu / sys_tenant
Day2SRM 供应商6supplier / supplier_cert / supplier_score_log
Day3PMS 采购10purchase_order / purchase_receipt / finance_payable
Day4WMS 仓储11inventory / inventory_log / stocktake_task
Day5PIM+OMS13product_spu / product_sku / order_main / order_refund
Day6TMS 物流8logistics_waybill / logistics_track / tax_vat_rate
Day7FMS+BI7finance_profit_snapshot / bi_kpi_threshold
Day8安全运营2sys_plan_feature / (审计日志Day1已建)
合计8 模块65 张表

今日作业

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

按照各天课件的顺序,在 MySQL 中执行全部建表 SQL 和初始化数据 SQL,然后验证:

-- 验证所有表已创建(应该返回 65 条左右)
SELECT TABLE_NAME, TABLE_COMMENT
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'flexchain_dev'
ORDER BY TABLE_NAME;

-- 验证基础数据已初始化
SELECT COUNT(*) AS menu_count FROM sys_menu;        -- 应有15条以上
SELECT COUNT(*) AS role_count FROM sys_role;        -- 应有8条
SELECT COUNT(*) AS dict_count FROM sys_dict_item;   -- 应有30条以上
SELECT COUNT(*) AS threshold_count FROM bi_kpi_threshold; -- 应有8条

-- 验证测试数据完整性
SELECT
    'supplier' AS table_name, COUNT(*) AS record_count FROM supplier WHERE tenant_id=101
UNION ALL
SELECT 'purchase_order', COUNT(*) FROM purchase_order WHERE tenant_id=101
UNION ALL
SELECT 'inventory', COUNT(*) FROM inventory WHERE tenant_id=101
UNION ALL
SELECT 'order_main', COUNT(*) FROM order_main WHERE tenant_id=101
UNION ALL
SELECT 'logistics_waybill', COUNT(*) FROM logistics_waybill WHERE tenant_id=101
UNION ALL
SELECT 'finance_profit_snapshot', COUNT(*) FROM finance_profit_snapshot WHERE tenant_id=101;

作业 2:项目整体回顾(必做)

结合 8 天所有课件内容,用自己的话回答以下问题(每题100-200字):

  1. 请描述一个跨境卖家的完整业务链路——从”决定采购某款商品”到”买家收货并核算出利润”,中间经历了哪些系统模块?每个模块做了什么?

  2. 我们的系统设计了哪几处”事务”(@Transactional)?每处事务保护的是什么业务场景?如果去掉这些事务保护,会出现什么问题?

  3. Redis 在我们系统中有哪几种用途?除了防超卖,还用在哪里?每种用途对应什么业务场景?

  4. 整个项目中,哪些表是”只增不改不删”的设计?为什么要这样设计?如果允许修改会带来什么风险?

参考答案:

  1. 一个跨境卖家的完整链路可以这样描述:运营根据销售预测或库存预警决定采购某款商品,PMS 创建采购申请和采购订单,SRM 提供供应商资料、资质和绩效评分作为供应商选择依据。供应商发货后,仓库在 WMS 中收货、质检、上架并更新库存。商品资料在 PIM 中维护,包括 SPU/SKU、多语言、价格和平台刊登信息。买家在亚马逊、TikTok Shop 等平台下单后,订单同步到 OMS,OMS 做订单幂等、库存占用和发货状态流转。TMS 根据国家、重量、商品属性和时效要求选择物流渠道,创建运单并拉取轨迹。订单完成后,FMS 导入平台账单,核算平台费用、物流费用、采购成本、VAT 和利润。BI 再基于这些数据生成 KPI、利润分析、补货建议和经营预警。

  2. 项目中典型事务包括:供应商审核通过时更新供应商状态、创建 Portal 账号、写审核日志,需要事务保证不会出现供应商已通过但账号没创建;采购入库时更新收货单、采购单、库存和库存流水,需要事务保证库存数量和流水一致;订单创建时写订单主表、明细、地址并冻结库存,需要事务或分布式事务保证订单与库存一致;物流运单创建成功后写运单、轨迹初始记录和订单发货状态,需要保证运单数据完整;财务对账时更新账单状态、明细匹配结果和差异记录,需要保证对账结果可追溯。如果去掉事务,就会出现部分成功、部分失败,导致状态不一致、库存无法审计、财务数据错误。

  3. Redis 在项目中有多种用途。第一是防超卖,使用原子扣减或 Lua 脚本保证高并发下库存不会被重复扣减。第二是分布式锁,比如同一个订单创建运单、同一个供应商审核、同一个任务执行,避免集群环境重复处理。第三是缓存热点数据,比如权限列表、菜单、租户配置、物流规则、汇率、报表结果,降低数据库压力。第四是限流,比如登录接口、验证码接口、物流商 API 调用,防止短时间请求过多。第五是生成业务编码的序列号,例如供应商编码、采购单号、运单号,利用 INCR 的原子性保证并发唯一。

  4. 只增不改不删的表主要包括库存流水表、审核日志表、操作审计日志表、物流轨迹表、财务账单明细表、现金流水表、消息发送记录等。这类表的共同特点是用于审计、追踪和对账,记录的是“发生过的事实”。如果允许随意 UPDATE 或 DELETE,就会破坏证据链:库存为什么变化无法还原,供应商是谁审核的无法追责,物流轨迹是否真实无法判断,财务账单差异无法复盘。正确做法是发现错误时新增一条冲正记录或调整记录,而不是修改历史记录。

作业 3:项目部署实践(选做但强烈推荐)

  1. 安装 Docker Desktop(Windows/Mac 用户)或 Docker Engine(Linux 用户)
  2. 编写 docker-compose.yml,包含 MySQL + Redis + 后端应用三个服务
  3. 配置 .env 文件(使用测试密码即可)
  4. 执行 docker compose up -d 启动服务
  5. 验证后端能正常响应:curl http://localhost:8080/actuator/health

作业 4:简历准备(必做)

  1. 按照第十三节的模板,写出你自己的项目简历描述(要包含具体技术和数字)
  2. 准备并能流利表达以下 5 个问题的答案:
    • 多租户是怎么实现的?
    • 超卖是怎么解决的?
    • 库存流水为什么不能改?
    • 采购入库如何保证一致性?
    • 项目中最难的技术点是什么?

参考答案:

  1. 多租户是怎么实现的?
    本项目采用共享库表 + tenant_id 字段隔离的方案。每张业务表都带 tenant_id,用户登录后从 Sa-Token 会话或 Redis 中获取当前租户 ID,后端查询和写入时必须带上租户条件。为了避免漏加条件,通用查询可以用拦截器或 MyBatis-Plus 租户插件辅助,但核心业务代码仍然要显式校验租户归属,尤其是根据 ID 查询、审核、付款、删除这类敏感操作。这样可以保证 A 租户只能看到自己的供应商、订单、库存和财务数据。

  2. 超卖是怎么解决的?
    超卖的核心问题是多个平台订单同时到达,不能让多个请求同时扣同一份库存。项目中使用 Redis 原子扣减或 Lua 脚本做第一层防护,扣减前先判断库存是否足够,足够才扣,不足直接失败。数据库层面再使用条件更新做第二层保护,例如 UPDATE inventory SET quantity = quantity - ? WHERE sku_id = ? AND quantity - frozen_qty >= ?。同时订单表使用平台订单号唯一索引保证幂等,避免平台重复推送导致重复扣库存。

  3. 库存流水为什么不能改?
    库存流水记录的是库存每一次变化的事实,比如采购入库、销售出库、退货入库、盘点调整。它是排查库存差异、财务核算成本、审计仓库操作的依据。如果允许修改或删除,后续就无法还原某个时间点库存为什么变化,也无法判断是谁操作的。正确做法是流水只增不改,发现错误时新增一条反向调整流水,并关联原流水 ID,把错误和修正过程都保留下来。

  4. 采购入库如何保证一致性?
    采购入库涉及多张表:收货单明细要记录合格数和不合格数,采购单明细要累计已收货数量,采购单主表要更新状态,WMS 库存要增加,库存流水要写入。这些操作必须放在同一个事务中,任何一步失败都要回滚。更新库存时还要使用原子 SQL,避免并发覆盖。对于 PMS 调用 WMS 的跨服务场景,可以根据一致性要求选择本地事务 + MQ 最终一致,或者使用分布式事务方案。

  5. 项目中最难的技术点是什么?
    比较难的不是单个 CRUD,而是跨模块的数据一致性和业务状态流转。例如订单创建会影响 OMS 订单、WMS 库存、TMS 发货、FMS 账务,任何一个模块失败都可能造成数据不一致。所以项目里做了状态机、幂等控制、Redis 原子扣减、数据库唯一索引、事务控制、MQ 异步解耦和审计日志。面试时可以重点讲一个自己最熟悉的场景,比如“订单防超卖”或“采购入库一致性”,把业务问题、技术方案、异常处理和最终效果讲完整。