把一套企业级船运物流系统从0到1跑通后,我想把这些核心设计说清楚
做船运物流系统这类项目,和普通管理系统的差别其实很大。业务链条长、单据种类多、金额计算复杂,加上港口、船公司、货代、报关行多个角色之间的协作,这些特点决定了它跟电商、ERP管理系统在业务设计上完全是两码事。最近我把一套基于SpringBoot + Vue + MyBatis + MySQL的企业级船运物流管理系统源码完整梳理了一遍,从业务模块到数据库设计,从前端权限到后端接口,整体跑通后感受挺深的,这里把我整理的设计思路、核心实现和踩坑经验写出来,给准备做类似项目的同行一个参考。
这个系统适合谁呢?一个是给船运公司、货代公司做内部系统开发的团队;另一个是自己想练手完整企业级项目、了解物流系统业务闭环的开发者。前者可以直接参考模块划分和表结构设计,后者可以顺着业务流转把整条技术链路串起来。
1. 系统整体设计与思路拆解
1.1 先看懂船运物流的业务链路
做这类系统,上来就写代码是大忌。你得先把业务链路画出来。船运物流的完整链路大概是这样的:
订舱(客户下单) → 配载(安排船次和舱位) → 报关/单证(准备提单、箱单发票) → 装船(码头实操作业) → 离港 → 到港 → 卸船 → 换单/提货 → 结算(运费、港杂费、代垫费用)
每一步都会产生对应的单据:订舱单(Booking Note)、提单(Bill of Lading)、装箱单、舱单、费用单……这些单据之间又是互相咬合的,提单要关联订舱单,订舱单要关联船次,船次要关联船舶和港口。
所以系统的核心不是"录入数据",而是管理这些单据之间的流转关系。我设计时把整个系统拆成了四层:基础数据层(船舶、港口、航线、客户、费率)、业务操作层(订舱、配载、单证、装箱、跟踪)、财务结算层(应收应付、开票、对账)、系统管理层(用户、角色、菜单、权限)。
这里有个容易犯的错误:很多新手会把"基础数据"和"业务数据"混在一张表里。比如在订舱单里顺手存了客户名称、船名、港口名,后面要统计"某条航线一季度走了多少货",就得去订舱单表里模糊查字符串,不仅慢还容易错。正确的做法是外键关联到基础数据表,业务表只存ID,展示的时候再关联查询。
1.2 技术选型为什么是这套组合
选型的时候也权衡过。船运物流系统是典型的"重业务、重单据、重报表"的系统,对事务一致性要求高,对并发要求中等(不像电商秒杀那么极端),对报表统计有明确需求。
SpringBoot + MyBatis 的组合,优势在两点:
- SpringBoot 把配置简化了一大截,尤其是多环境配置(dev/prod)、数据源管理、定时任务,基本上注解一挂就能用,省去大量 XML 配置。
- MyBatis 的 SQL 是手写的,对"多表关联 + 条件动态拼接"的查询支持非常舒服。物流系统的列表页往往有大量筛选条件(船次、时间范围、客户、状态),MyBatis 的动态 SQL(if/where/foreach)正好能灵活应对。
用一句话概括我的选型态度:如果项目是强事务的业务管理系统,MyBatis 比 JPA/Hibernate 更可控。JPA 在复杂关联查询上虽然能写 JPQL,但出了问题调试成本更高,不如 MyBatis 直接面对 SQL 踏实。
数据库选 MySQL 8.x,是因为它兼顾稳定和功能。InnoDB 的事务支持、行级锁、MVCC 对于订单类业务是刚需;8.x 的窗口函数(ROW_NUMBER、SUM() OVER())在做报表统计时写起来特别顺手。比如计算"某航线月度累计货量",窗口函数一条 SQL 就能搞定,不用在应用层做二次聚合。
前端用 Vue 2.x + Element UI,核心是开发效率。物流系统的页面大多是表单 + 列表 + 弹窗的组合,Element UI 的表单校验、表格组件、日期选择器这些现成能力能省掉很多重复工作。Vue 的组件化让每个业务模块(订舱、单证、费用)成为独立组件,互不干扰。如果用原生 JS 或者 jQuery,这种规模的前端代码维护成本会高得让人崩溃。
1.3 模块划分与人效估算
按我实际开发的经验,这套系统如果一个人全职开发,大概 3~4 周能把核心业务闭环跑通(不包含美工和复杂报表),如果是一个 3 人小团队(1 后端 + 1 前端 + 1 测试兼业务),2 周左右可以上线第一版。时间主要花在业务梳理和联调上,单纯写 CRUD 反而是最不耗时的部分。
模块规划上,我的建议是"先打通主链路,再做辅助模块"。主链路是:登录 → 基础数据(船、港、客户)→ 订舱 → 配载 → 提单 → 费用 → 列表查询。辅助模块是:操作日志、消息通知、报表导出、数据字典。把辅助模块放在主链路跑通之后再做,能避免开发阶段被细节拖住。
2. 核心细节解析与实操要点
2.1 数据库设计:每张表为什么这么建
先看最核心的几个表:
船舶表(ss_ship)
CREATE TABLE ss_ship ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ship_name VARCHAR(100) NOT NULL COMMENT '船名', mmsi VARCHAR(20) COMMENT 'MMSI编号', imo VARCHAR(20) COMMENT 'IMO编号', tonnage DECIMAL(12,2) COMMENT '总吨位', max_containers INT COMMENT '最大箱量(TEU)', ship_status TINYINT DEFAULT 1 COMMENT '1可用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;航次表(ss_voyage)
CREATE TABLE ss_voyage ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voyage_no VARCHAR(30) NOT NULL COMMENT '航次号', ship_id BIGINT NOT NULL COMMENT '船舶ID', route_id BIGINT COMMENT '航线ID', depart_port VARCHAR(20) NOT NULL COMMENT '出发港代码', arrive_port VARCHAR(20) NOT NULL COMMENT '目的港代码', depart_time DATETIME COMMENT '离港时间', arrive_time DATETIME COMMENT '预计到港时间', max_containers INT COMMENT '本航次最大箱量', booked_num INT DEFAULT 0 COMMENT '已订箱量', status TINYINT DEFAULT 0 COMMENT '0待开航 1在途 2已完成 3已取消', UNIQUE KEY uk_voyage_no (voyage_no), KEY idx_ship (ship_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订舱单表(ss_booking)
CREATE TABLE ss_booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, booking_no VARCHAR(30) NOT NULL COMMENT '订舱单号', customer_id BIGINT NOT NULL COMMENT '客户ID', voyage_id BIGINT NOT NULL COMMENT '航次ID', cargo_name VARCHAR(200) COMMENT '货物品名', cargo_weight DECIMAL(12,2) COMMENT '货物重量KG', cargo_volume DECIMAL(12,2) COMMENT '体积CBM', container_num INT DEFAULT 1 COMMENT '箱量', container_type VARCHAR(10) COMMENT '箱型 20GP/40GP/40HQ', total_amount DECIMAL(14,2) COMMENT '预估总费用', status TINYINT DEFAULT 0 COMMENT '状态:0草稿 1已确认 2已配载 3已装船 4已离港 5已到港 6已换单 7已完成 8已取消', remark VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, create_by VARCHAR(50), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_booking_no (booking_no), KEY idx_voyage_id (voyage_id), KEY idx_customer_id (customer_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;提单表(ss_bill_of_lading)
CREATE TABLE ss_bill_of_lading ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bl_no VARCHAR(30) NOT NULL COMMENT '提单号', booking_id BIGINT NOT NULL COMMENT '订舱单ID', shipper VARCHAR(200) COMMENT '发货人', consignee VARCHAR(200) COMMENT '收货人', notify_party VARCHAR(200) COMMENT '通知方', cargo_desc TEXT COMMENT '货物描述', on_board_date DATETIME COMMENT '装船日期', issue_date DATETIME COMMENT '签发日期', status TINYINT DEFAULT 0 COMMENT '0待签发 1已签发 2已换单 3已作废', UNIQUE KEY uk_bl_no (bl_no), KEY idx_booking_id (booking_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;费用表(ss_fee)
CREATE TABLE ss_fee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, fee_no VARCHAR(30) NOT NULL COMMENT '费用单号', booking_id BIGINT NOT NULL COMMENT '订舱单ID', fee_type VARCHAR(50) COMMENT '费用类型: 海运费/港杂费/文件费/报关费', direction TINYINT COMMENT '1应收 2应付', amount DECIMAL(14,2) COMMENT '外币金额', currency VARCHAR(10) DEFAULT 'USD' COMMENT '币种', rate DECIMAL(10,4) COMMENT '汇率快照', exchange_amount DECIMAL(14,2) COMMENT '本币金额', status TINYINT DEFAULT 0 COMMENT '0未确认 1已确认 2已开票 3已核销', KEY idx_booking_id (booking_id), KEY idx_fee_type (fee_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里几个关键设计说明:
- 字符串编号(booking_no、voyage_no、bl_no)一定要唯一索引。业务上这些编号要打印在单据上,还要跨系统引用,不能只看主键 id。
- 关键外键列必须有索引。比如 booking 表的 voyage_id、customer_id,费用表的 booking_id。这些是高频查询条件,没有索引,数据量到几十万行以后,关联查询会明显变慢。
- 状态字段用 tinyint 而不是 varchar,代码里用枚举或常量类维护,不直接写数字。这样排序、统计、索引都更高效。
- 金额字段统一 decimal(14,2),运费、杂费、代垫费都用同一精度,避免运算时精度不一致。涉及外币时用 currency + rate + exchange_amount 三个字段,本币金额 = 外币金额 * 汇率,汇率在生成费用时快照下来,防止后续汇率变动影响历史单。
2.2 状态机设计:货物的生命周期怎么管
船运货物从客户下订到最终交付,经历的状态不能乱跳。我用一个状态字段统一管理,配合操作日志表记录每一步。
订舱单状态流转:
草稿 → 已确认 → 已配载 → 已装船 → 已离港 → 已到港 → 已换单 → 已提货 → 已完成 (任意环节可 → 已取消)这个状态机设计有两个要点:
- "已确认"必须由业务人员主动执行,不能在保存时默认置为确认。原因很实际:订舱单可能录到一半,不完整的单子不能进入后续流程。
- 状态只能单向流转,不能回退。要是操作员录错,正确的做法是"取消当前单 + 复制新建",而不是修改原单状态。这是物流行业的业务规范,做系统时要顺应它,不要为了操作方便设计回退按钮——回退按钮会破坏单据的可追溯性。
每张核心表都配一张操作日志表(ss_booking_log),记录谁在什么时间把状态从 A 改到了 B,附上备注。这样一旦出现货损、延误纠纷,可以完整还原操作链路。日志表结构很简单:id、业务单ID、操作前状态、操作后状态、操作人、操作时间、备注。写入日志的时机放在状态更新的同一个事务里,保证日志和数据永远一致。
2.3 权限和账户体系的设计
物流系统使用者角色很多:销售、操作(订舱员)、单证员、财务、管理员、老板。不同角色看到的菜单和按钮都不同。我用的是经典 RBAC(基于角色的访问控制),五张表:
ss_user 用户表 ss_role 角色表 ss_menu 菜单表 ss_user_role 用户-角色关联表 ss_role_menu 角色-菜单关联表登录后把当前用户的菜单列表和权限标识(permission code)缓存到前端 store,配合 Vue Router 的导航守卫做页面级控制,按钮级控制用自定义指令 v-permission 判断。
有个细节:按钮级权限不能只在前端做,后端接口同样要校验。前端隐藏了按钮只能防君子,拦截器里校验当前用户是否拥有该接口的权限标识才是正途。我见过不少项目只在菜单上做控制、后端所有接口都裸奔,被内部人员绕过前端直接调接口,数据就乱了。
3. 实操过程与核心环节实现
3.1 环境准备与工程初始化
我以 JDK 1.8 + Maven 3.6 + Node.js 14.x + MySQL 8.0 为基准,这套组合在实际项目里兼容性最稳。
后端工程结构:
ss-shipping/ ├── pom.xml ├── src/main/java/com/ss/shipping/ │ ├── common/ // 通用返回体、异常处理、常量 │ ├── config/ // 配置类 │ ├── controller/ // 接口层 │ ├── service/ // 业务层 │ ├── mapper/ // MyBatis Mapper接口 │ ├── entity/ // 实体类 │ └── utils/ // 工具类 └── src/main/resources/ ├── mapper/ // XML文件 └── application.yml前端工程结构:
ss-shipping-web/ ├── src/ │ ├── api/ // 接口封装 │ ├── components/ // 通用组件 │ ├── views/ // 页面组件 │ ├── router/ // 路由 │ ├── store/ // Vuex │ ├── permission.js // 权限指令 │ ├── main.js │ └── App.vue初始化时要注意的点:
- MySQL 字符集用 utf8mb4,不能省。物流单据里会录入各国港口名、收货人名称,夹杂特殊字符概率不低,utf8mb4 支持完整的 Unicode,还兼容 emoji(万一有人备注里贴个图标也不至于报错)。
- 建库时统一时间字段类型为 datetime,代码里统一用 java.time.LocalDateTime 映射,不要用 java.util.Date。LocalDateTime 没有时区概念,配合 MySQL datetime 类型,前后端传参用字符串(如 "2025-06-12 14:30:00"),能绕开一大堆时区偏移的坑。
- application.yml 里分 dev/prod 两套 profile,数据库连接池用 HikariCP(SpringBoot 2.x 默认),最大连接数按业务情况配置。物流系统在线操作员通常几十人,连接池 max-pool-size 配 20 就够,配太大反而浪费内存。
3.2 后端核心接口实现
我挑订舱、费用结算两个典型场景讲。
订舱接口设计
POST /api/v1/booking // 创建订舱单(草稿) PUT /api/v1/booking/{id} // 修改 POST /api/v1/booking/confirm // 确认订舱 GET /api/v1/booking/page // 分页查询创建订舱单时,业务逻辑集中在"生成单号 + 校验可用舱位 + 写入货物明细"。
生成单号我用的是:前缀 + 日期 + 序列号,比如 BK202506120001。序列号用 Redis INCR 或者查当天最大单号 + 1 都可以。小规模系统直接查库:
SELECT COUNT(*) FROM ss_booking WHERE booking_no LIKE 'BK20250612%'然后 +1 补零。这种方式在并发量不高的情况下够用,一天几千单以内不会有问题。如果后续业务量增长,再换成 Redis INCR 也不难。
校验舱位是订舱的核心逻辑。简单做法是:航次记录里维护一个已订舱量与最大舱位量,每次确认订舱时校验:
SELECT version, booked_num, max_containers FROM ss_voyage WHERE id = ?查询出来后判断 booked_num + 当前单的箱量 <= max_containers,不满足就直接报错。这里要注意并发问题:两个操作员同时给同一个航次订最后几个舱位,会同时读到旧值然后都通过校验。解决办法有两个:
- 乐观锁:
UPDATE ss_voyage SET booked_num = booked_num + #{num}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion}- 悲观锁(更简单但并发性能下降):
SELECT ... FOR UPDATE实际项目里我推荐乐观锁,对性能和并发友好,update 影响行数为 0 时说明被别人抢先了,返回友好提示"当前航次舱位已满"。
费用结算接口
费用单的生成不能手动敲金额,要通过费率模板自动带出。费率表(ss_freight_rate)维护"航线 + 箱型 + 费用类型 → 单价",生成费用单时根据订舱单的航线、箱量自动计算:
应付海运费 = 箱量 * 单价(美元) 本币金额 = 应付海运费 * 汇率财务这块的核心心得是:应收和应付要分开建费用单,不能混在一张单里加减。因为两者的审核流程、开票流程、账期都不同。应收是向客户收钱,应付是付给船公司或车队,混在一起到对账时会乱套。
3.3 前端核心页面与权限控制
前端最核心的两个部分:订舱操作台的实现和路由权限。
订舱操作台
订舱页面结构:
- 顶部:客户信息、航线选择、船次选择(带舱位剩余提示)
- 中间:货物明细表格(品名、件数、重量、体积,可增删行)
- 底部:费用预估区(实时根据费率模板计算并展示)
这里有个很实用的技巧:输入货物明细时,重量和体积的单位要统一,前端做单位换算,后端一律以千克和立方米存储。因为业务人员在录入时习惯不一致,有人填吨,有人填千克,如果不做统一,后续配载和计费会差出几个数量级。前端可以加一个单位切换的下拉框,选了吨就在提交时自动乘以 1000 转成千克。
路由守卫与权限指令
路由守卫写法:
router.beforeEach((to, from, next) => { if (to.path === '/login') return next() const token = store.state.user.token if (!token) return next('/login') if (!store.state.user.menus.length) { store.dispatch('user/getUserInfo').then(() => { next({ ...to, replace: true }) }) } else { next() } })按钮权限指令 v-permission:
Vue.directive('permission', { inserted(el, binding) { const required = binding.value const has = store.state.user.permissions.includes(required) if (!has) el.parentNode && el.parentNode.removeChild(el) } })用法:
<el-button v-permission="'booking:confirm'">确认订舱</el-button>后端保存菜单时就把每个菜单的权限标识存成字符串,比如"booking:confirm"。前后端校验的是同一套标识,后端接口注解里校验:
@RequirePermission("booking:confirm")这个注解配合拦截器,判断当前登录用户权限列表里有没有对应标识,没有就直接抛 403。注意权限标识的命名规范,我用的是"模块:操作"格式,全局唯一。
3.4 联调和部署要点
前后端联调时,最大的坑是跨域和鉴权。我用的方案:
- 后端在 SecurityConfig 里统一配置 CORS,允许前端地址。
- 登录成功发放 JWT,前端存储在 localStorage,每次请求由 axios 拦截器自动携带 Authorization 头。
- 后端拦截器放行 /api/auth/login 和 swagger 相关路径,其余校验 token,无效返回 401,前端全局拦截 401 跳转登录页。
localStorage 存 token 有个安全隐患:XSS 攻击可以拿到 token。实际项目里可以用 httpOnly Cookie 替代,同时后端开启 CSRF 防护。但如果系统只在内网跑,localStorage 方案也能接受,权衡开发成本和安全等级后自行选择。
部署时我用的是经典组合:Nginx 托管 Vue 打包产物 + 反向代理 /api 到后端服务。Nginx 配置片段:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有个隐藏坑:location 路径和 proxy_pass 的 URI 拼接规则。我习惯写成 location /api/ + proxy_pass http://127.0.0.1:8080/,不带末尾 URI,这样请求 /api/v1/booking 会原样转发到后端,后端 context-path 配 /api,两边不冲突。如果 proxy_pass 写了 http://127.0.0.1:8080/(带斜杠),则 /api 前缀会被吃掉了,后端的 Controller 映射里就不需要再带 /api,两种方式都行但必须统一,不然接口路径会多一段导致 404。
4. 常见问题与排查技巧实录
4.1 订舱并发超卖问题
前面提过乐观锁方案。我实际测过,两个线程同时提交,第一个成功第二个影响行数为 0,接口返回"舱位不足"提示,这个现象是符合预期的。但要注意:乐观锁的前提是事务边界要清晰。Update 语句和后续业务操作必须在一个事务里,否则会出现"更新成功了,但后续生成单证失败"导致数据不一致。我的做法是在 Service 层方法上加 @Transactional,并且把 UPDATE 语句写在业务操作的前面,先抢锁再干活。
另外,事务里不要做耗时的外部调用,例如发送短信通知、调用外部接口。锁持有时间越长,并发冲突概率越高。把外部调用放到事务提交之后(可以用 Spring 的 TransactionSynchronizationManager 注册 afterCommit 回调),既保证数据一致性,又不拖长事务时间。
4.2 MyBatis 分页查询性能问题
列表页带上关联查询后,分页可能出现 count 慢的问题。我踩过:订舱单列表关联 5 张表,数据到 30 万行时 count 查询要 2 秒。排查后发现是 MySQL 对多表 left join + count 的优化不给力。解决办法:
- count 查询时去掉不需要的 join,只保留过滤条件和主表;
- 分页 SQL 用子查询先取 id,再根据 id 反向 join 明细:
SELECT b.*, s.ship_name, p.port_name FROM ss_booking b LEFT JOIN ss_voyage v ON b.voyage_id = v.id LEFT JOIN ss_ship s ON v.ship_id = s.id WHERE b.id IN ( SELECT id FROM ss_booking WHERE status = #{status} ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} )- 确认必要字段的索引都加上了,尤其是 booking_no、status、create_time 这几个条件字段。如果 create_time 是排序字段,单列索引要建好,联合索引 (status, create_time) 更好,覆盖了筛选加排序的场景。
4.3 前端接口请求报错的排查思路
联调时经常遇到"前端 401"或者"前端拿不到数据"。我的排查顺序:
先看浏览器 Network 面板:
- 状态 404:大概率是路径问题,对比后端 @RequestMapping 和前端 api 里的 URL 是否一致。
- 状态 500:看后端控制台日志,优先查 MyBatis 的 SQL 语句。在 application.yml 里开启 SQL 日志:
logging: level: com.ss.shipping.mapper: debug直接复制日志里的 SQL 到 Navicat 执行,十有八九是 join 条件写错或者字段名对不上。
- 状态 401:检查 token 是否过期,或者 Authorization 头有没有带上。axios 拦截器需要全局设置:
service.interceptors.request.use(config => { const token = store.state.user.token if (token) config.headers['Authorization'] = 'Bearer ' + token return config })4.4 部署上线后中文乱码
新系统要避免这个问题,前后端编码统一用 UTF-8,MySQL 连接串加 characterEncoding=utf8,配置文件里加 server.servlet.encoding.force=true,基本不会出现。如果数据库里的数据已经是乱码,那就只能改字符集后重导数据,没有捷径。
还有一个容易忽略的点:Linux 服务器的系统字符集。如果服务器 locale 不是 UTF-8,日志文件里的中文也可能显示乱码,但不影响接口返回。ssh 客户端设置 UTF-8 编码查看即可。生产环境一般建议启动脚本里显式加上 -Dfile.encoding=UTF-8,防一手服务器默认字符集不是 UTF-8 的极端情况。
4.5 前后端接口字段命名不一致
前后端联调最消磨耐心的就是字段名对不上。后端的 camelCase(createTime)和前端习惯不一致时,要么后端统一返回 camelCase,前端也用 camelCase 接收,要么用 JSON 序列化注解做映射。我推荐后端统一返回 camelCase(SpringBoot 默认就是),前端 axios 不做 transformResponse 处理,保持默认,这样最简单直接。
另外建议用接口文档工具自动生成前端 API 代码,减少手写导致的大小写错误。Swagger 的 OpenAPI 规范 + 前端代码生成插件,或者类似 postman 的自动生成代码功能,都能降低这类低级错误。手写了几百个接口之后,你会感谢这个习惯。
5. 部署上线后的几个自动化加分项
5.1 定时任务:自动抓船期和自动催款
SpringBoot 的 @Scheduled 做定时任务很简单。船运系统里我加过两个有用的定时任务:
- 每天早上 8 点抓取船司公布的最新船期,更新 ss_voyage 的预计离港/到港时间,操作员不用手动改。
- 每周一给账期内未核销费用的客户发送催款提醒邮件或站内消息,减少财务人工催款。
定时任务要注意幂等性,避免重复执行导致的数据问题。我的做法是在任务方法上先查一次状态,确认需要处理再执行,并且在关键操作上控制并发(ShedLock 或者数据库分布式锁)。
5.2 报表导出:用 EasyExcel 搞定
财务月底要对账,操作要导出订舱明细,老板要看月度货量统计。导出功能用 EasyExcel(阿里开源),比 POI 原生 API 省心得多。核心代码:
List<BookingReportVO> list = bookingService.getReportData(params); String fileName = "月度订舱统计_" + LocalDate.now() + ".xlsx"; EasyExcel.write(response.getOutputStream(), BookingReportVO.class) .sheet("订舱统计") .doWrite(list);注意大列表导出要分批查询,一次查全量 10 万行,内存直接就爆了。我一般在导出方法里限制最大导出行数(比如 5 万行),超出提示用户缩小日期范围。
5.3 操作日志自动记录
前面提到日志表要和业务操作同一事务写入。具体做法是写一个注解 @OpLog("确认订舱"),通过 AOP 切面在方法执行后记录操作人和操作内容。这个切面把"谁在什么时候改了什么状态"自动记下来,省掉在每个 Service 方法里手写日志代码的重复工作。
操作人信息从当前登录用户的上下文里取,不要从前端传参数——前端可以伪造,这是安全底线。日志内容尽量说清楚业务动作,比如"操作人:张三,订舱单 BK202506120001 状态从'已确认'改为'已配载'",而不是简单记一句"状态变更"。
6. 这个系统后续还能怎么扩展
实际船运物流系统远不止这些模块。做完了这套核心框架,后续扩展可以考虑:
- 对接船司 EDI:通过接口自动导入舱单、船期数据,减少人工录入。船司的 EDI 格式五花八门(有 XML、有 EDIFACT),建议抽象一个适配器层,每种格式写一个实现类,后续新增船司只要加一个适配器。
- 集装箱跟踪:接入箱管数据源,客户可在线查询箱子的实时位置和状态。这块涉及外部数据源的轮询或 webhook 接收,数据量大时要考虑消息队列做削峰。
- 报表分析:用定时任务生成月度货量统计、航次成本分析、客户贡献度报表,直接导出 Excel。数据量大了以后,可以把统计查询下沉到 MySQL 的汇总表,或者单独建一个报表库,避免影响核心业务库的查询性能。
- 移动端:给码头现场和司机配一个 App/H5,处理到港确认、提货预约这些现场操作。移动端可以直接复用现有后端 API,前端单独做一套适配移动端的页面。
这些扩展都是在现有技术栈上的增量开发,SpringBoot 的定时任务、MyBatis 的 SQL 能力、Vue 的组件化都能无缝支撑。基础打好了,后面加功能就是往框架里填代码的事。
关于这套系统的经验就这么多。我个人在实际操作中的体会是:船运物流系统这类项目,难点从来不在某个单一技术上,而在业务建模的完整性和数据流转的准确性。把订舱、配载、单证、费用这些环节用清晰的数据模型串起来,系统自然稳定;如果上来就堆功能、表结构想一出是一出,后面每次加需求都是一次重构。做之前把状态机、单据流、权限模型理清楚,这套源码跑通之后你会发现,同样的思路迁移到其他物流领域(空运、海运拼箱、拖车运输)都很快。
在跑这套源码时,建议你多动手改一改:试着给订舱单加一个"预计离港时间"字段,或者给费用模块加一个"汇率批量更新"功能,改一次比读十遍文档都涨经验。真正吃透一个项目的方式,就是往里面加你自己的需求。