应急物资供应管理系统作为救灾响应体系的核心支撑平台,需要兼顾日常物资管理和突发应急响应双重需求。我们采用前后端分离架构,后端基于Spring Boot 2.7.x构建微服务,前端使用Vue 3组合式API开发,形成松耦合的技术栈组合。
后端选择Spring Boot框架主要基于以下考量:
前端选用Vue 3.x主要优势在于:
数据库采用MySQL 8.0而非PostgreSQL的考虑点:
整体采用经典三层架构:
code复制表现层:Vue SPA + Element Plus
↑
业务逻辑层:Spring Boot + Spring Security
↑
数据访问层:Spring Data JPA + QueryDSL
↑
存储层:MySQL Cluster(主从复制)
关键设计要点:
提示:生产环境建议使用Nginx配置动静分离,将前端构建产物与后端服务分开部署,通过反向代理统一访问入口。
主要实体关系图:
code复制物资(Inventory) ←→ 库存记录(Stock)
↑ ↑
供应商(Supplier) 仓库(Warehouse)
↑ ↑
供应记录(Supply) ← 调拨记录(Transfer)
sql复制CREATE TABLE `inventory` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`code` VARCHAR(32) UNIQUE COMMENT '物资编码',
`name` VARCHAR(64) NOT NULL,
`category` ENUM('医疗','食品','工具','防护') NOT NULL,
`spec` JSON COMMENT '规格参数',
`unit` VARCHAR(16) NOT NULL,
`safety_stock` INT DEFAULT 0,
`shelf_life` INT COMMENT '保质期(天)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制CREATE TABLE `stock` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`inventory_id` BIGINT NOT NULL,
`warehouse_id` BIGINT NOT NULL,
`quantity` INT NOT NULL DEFAULT 0,
`locked` INT DEFAULT 0 COMMENT '预占数量',
`version` INT DEFAULT 0 COMMENT '乐观锁版本',
UNIQUE KEY `uk_warehouse_inventory` (`warehouse_id`,`inventory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意:库存表采用version字段实现乐观锁,避免超卖问题。预占字段用于处理未完成的调拨申请。
应急申请单表包含状态机设计:
java复制public enum ApplicationStatus {
DRAFT, // 草稿
PENDING, // 待审批
APPROVED, // 已批准
REJECTED, // 已拒绝
PART_SHIPPED, // 部分出库
COMPLETED, // 已完成
CANCELLED // 已取消
}
供应商评估采用星型模型:
sql复制CREATE TABLE `supplier_evaluation` (
`id` BIGINT PRIMARY KEY,
`supplier_id` BIGINT NOT NULL,
`delivery_score` DECIMAL(3,1),
`quality_score` DECIMAL(3,1),
`response_score` DECIMAL(3,1),
`avg_score` DECIMAL(3,1) GENERATED ALWAYS AS
((delivery_score + quality_score + response_score)/3) STORED
);
java复制@Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long>,
JpaSpecificationExecutor<Inventory> {
@Query("SELECT i FROM Inventory i WHERE " +
"(:category IS NULL OR i.category = :category) AND " +
"(:keyword IS NULL OR i.name LIKE %:keyword%)")
Page<Inventory> search(
@Param("category") String category,
@Param("keyword") String keyword,
Pageable pageable);
}
定时任务配置示例:
java复制@Scheduled(cron = "0 0 9 * * ?") // 每天9点执行
public void checkLowStock() {
inventoryRepository.findLowStockItems(safetyStockThreshold)
.forEach(item -> {
String message = String.format(
"物资[%s]库存低于安全线,当前:%d,安全:%d",
item.getName(), item.getQuantity(), item.getSafetyStock());
alertService.sendAlert(message);
});
}
状态转换逻辑:
java复制public class ApplicationService {
@Transactional
public void approve(Long applicationId) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException("申请单不存在"));
if (app.getStatus() != ApplicationStatus.PENDING) {
throw new BusinessException("当前状态不可审批");
}
// 库存预占
app.getItems().forEach(item -> {
int affected = stockRepository.lockStock(
item.getInventoryId(),
item.getWarehouseId(),
item.getQuantity());
if (affected == 0) {
throw new StockLockException("库存不足");
}
});
app.setStatus(ApplicationStatus.APPROVED);
applicationRepository.save(app);
}
}
使用Pinia管理全局状态:
javascript复制// stores/application.js
export const useApplicationStore = defineStore('application', {
state: () => ({
draftItems: [],
currentApplication: null
}),
actions: {
async submitApplication(payload) {
const { data } = await api.post('/applications', payload);
this.currentApplication = data;
}
},
persist: true // 持久化配置
});
动态加载实现:
vue复制<template>
<el-select
v-model="selectedItems"
multiple
filterable
remote
:remote-method="loadOptions"
:loading="loading">
<el-option
v-for="item in options"
:key="item.id"
:label="`${item.name} (${item.code})`"
:value="item.id">
<span>{{ item.name }}</span>
<span class="float-right text-gray-500">{{ item.category }}</span>
</el-option>
</el-select>
</template>
<script setup>
const loading = ref(false);
const options = ref([]);
const loadOptions = async (query) => {
if (!query) return;
loading.value = true;
const { data } = await api.get('/inventories', { params: { q: query } });
options.value = data.content;
loading.value = false;
};
</script>
使用ECharts实现可视化:
javascript复制const initChart = () => {
const chart = echarts.init(document.getElementById('chart'));
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['当前库存', '安全库存'] },
xAxis: { type: 'category', data: categories },
yAxis: { type: 'value' },
series: [
{
name: '当前库存',
type: 'bar',
data: currentData,
itemStyle: {
color: (params) =>
currentData[params.dataIndex] < safetyData[params.dataIndex]
? '#f56c6c' : '#67c23a'
}
},
{
name: '安全库存',
type: 'line',
data: safetyData
}
]
});
};
JWT认证流程:
权限控制注解示例:
java复制@PreAuthorize("hasRole('WAREHOUSE_KEEPER') or hasRole('ADMIN')")
@PostMapping("/inventories/{id}/stock-in")
public ResponseEntity<?> stockIn(@PathVariable Long id, @Valid @RequestBody StockDTO dto) {
// 入库操作
}
多级缓存方案:
java复制@Cacheable(value = "inventory", key = "#id", unless = "#result == null")
public Inventory getById(Long id) {
return inventoryRepository.findById(id).orElse(null);
}
库存操作采用分布式锁:
java复制public boolean deductStock(Long inventoryId, int quantity) {
String lockKey = "stock:" + inventoryId;
try {
// 尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "LOCK", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 实际扣减逻辑
return stockRepository.deduct(inventoryId, quantity) > 0;
}
return false;
} finally {
redisTemplate.delete(lockKey);
}
}
Docker Compose配置示例:
yaml复制version: '3.8'
services:
backend:
image: emergency-backend:1.0.0
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
depends_on:
- redis
- mysql
frontend:
image: nginx:1.21
ports:
- "80:80"
volumes:
- ./dist:/usr/share/nginx/html
mysql:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=secret
- MYSQL_DATABASE=emergency
redis:
image: redis:6.2
Spring Boot Actuator配置:
yaml复制management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
tags:
application: emergency-system
Grafana监控面板应包含:
现象:系统显示库存余量与实际物理库存不符
排查步骤:
解决方案:
现象:前端频繁收到401未授权响应
可能原因:
处理方案:
javascript复制// axios响应拦截器
instance.interceptors.response.use(null, (error) => {
if (error.response.status === 401 &&
!error.config.url.includes('/auth/refresh')) {
return refreshToken().then(() => {
return instance(error.config);
});
}
return Promise.reject(error);
});
解决方案:
java复制@Async
public void exportInventory(OutputStream output) {
try (ExcelWriter writer = ExcelUtil.getWriter(true)) {
int page = 0;
while (true) {
Page<Inventory> data = inventoryRepository.findAll(
PageRequest.of(page, 1000));
if (!data.hasContent()) break;
writer.write(data.getContent(), true);
page++;
}
writer.flush(output, true);
}
}
RFID集成技术栈:
基于历史数据的预测流程:
关键特征工程:
数据库层面方案:
Spring Boot实现要点:
java复制public class TenantContext {
private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setTenant(String tenant) {
currentTenant.set(tenant);
}
public static String getTenant() {
return currentTenant.get();
}
}
@Configuration
public class TenantConfig {
@Bean
public FilterRegistrationBean<TenantFilter> tenantFilter() {
FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TenantFilter());
registration.addUrlPatterns("/*");
return registration;
}
}
在开发过程中,我们发现物资分类的枚举值需要预留扩展空间,最终采用字典表替代硬编码枚举。对于审批流程,初期设计的固定状态机无法满足部分机构的特殊流程需求,后续改造为可配置的工作流引擎(如Flowable)。这些经验表明,应急管理系统在设计初期就需要考虑足够的灵活性,以应对不同应用场景的特殊需求。