# 自建物流功能检查与修复报告

**检查时间：** 2026-04-05
**服务模块：** logistics-service
**关联服务：** scheduled-task-service

---

## 一、检查结果汇总

| # | 检查项 | 状态 | 说明 |
|---|--------|------|------|
| 1 | 快递公司适配器轮询拉取 | ⚠️ 框架就绪，API未对接 | 4个适配器已创建，但`queryTraces()`均返回空 |
| 2 | 回调接收接口 | ✅ 已实现 | `POST /api/logistics/callback/{carrier}` |
| 3 | logistics_trace表分区与索引 | ❌ 无分区，有索引 | 表无RANGE LIST分区 |
| 4 | 异常检测定时任务 | ❌ 不存在 | scheduled-task-service中无物流异常检测任务 |
| 5 | 物流时间轴接口 | ⚠️ 接口存在，数据为空 | 依赖轨迹拉取，拉取不到数据则为空 |

---

## 二、详细检查结果

### 2.1 快递公司适配器 — 框架就绪，API待对接

**已实现的适配器（4个）：**
- `SfCarrierAdapter` — 顺丰速运
- `YtoCarrierAdapter` — 圆通速递
- `ZtoCarrierAdapter` — 中通快递
- `YundaCarrierAdapter` — 韵达快递

**公共能力（`AbstractCarrierAdapter`）：**
- `verifyMd5Sign()` — MD5签名验证
- `parseTime()` — 时间解析（支持 yyyy-MM-dd HH:mm:ss 和 yyyy-MM-dd）
- `toJson()` — JSON序列化

**关键问题：`queryTraces()` 均未实现，示例：**

```java
// SfCarrierAdapter.queryTraces()
@Override
public List<LogisticsTrace> queryTraces(String waybillNo) {
    log.info("[顺丰] 查询轨迹, 运单号: {}", waybillNo);
    List<LogisticsTrace> traces = new ArrayList<>();
    try {
        // TODO: 调用顺丰镖局API
        log.warn("[顺丰] API对接待实现，当前返回空轨迹");
    } catch (Exception e) {
        log.error("[顺丰] 查询轨迹异常: {}", waybillNo, e);
    }
    return traces;  // ← 返回空列表
}
```

**轮询拉取调度逻辑（`TraceSyncService`）：**
- ✅ `@Scheduled(fixedDelayString = "${logistics.sync.interval-minutes:30}000")` 每30分钟批量同步
- ✅ `syncPending()` — 查询`need_sync=1`且`sync_status IN (0,3)`的运单
- ✅ `syncSingle()` — 单条同步，支持重试（`@Retryable(maxAttempts=3)`）
- ✅ 去重逻辑：根据`waybill_no + trace_time + location`判断是否重复

---

### 2.2 回调接收接口 — ✅ 已完整实现

**接口路径：** `POST /api/logistics/callback/{carrier}`

```java
@PostMapping("/callback/{carrier}")
public ApiResponse<Void> callback(
        @PathVariable String carrier,
        @RequestBody LogisticsCallbackRequest request) {
    request.setCarrier(carrier);
    boolean success = traceSyncService.processCallback(carrier, request.getData());
    // ...
}
```

**处理流程：**
1. 签名验证（`verifySign()`）— 失败不阻断，仍处理
2. 解析回调（`parseCallback()`）— 各适配器自定义字段映射
3. 保存/更新运单状态（`WaybillStatus`）
4. 去重插入轨迹（`LogisticsTrace`）

---

### 2.3 logistics_trace表 — ❌ 无分区

**现有DDL（`logistics-service/src/main/resources/db/init.sql`）：**

```sql
CREATE TABLE IF NOT EXISTS logistics_trace (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    waybill_no VARCHAR(50) NOT NULL,
    carrier VARCHAR(20) NOT NULL,
    status VARCHAR(30) DEFAULT '',
    status_label VARCHAR(50) DEFAULT '',
    location VARCHAR(200) DEFAULT '',
    description VARCHAR(500) DEFAULT '',
    trace_time DATETIME NOT NULL,
    raw_status_code VARCHAR(50) DEFAULT '',
    raw_data JSON DEFAULT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    deleted TINYINT(1) DEFAULT 0,

    INDEX idx_waybill_no (waybill_no),
    INDEX idx_carrier (carrier),
    INDEX idx_trace_time (trace_time),
    INDEX idx_waybill_trace (waybill_no, trace_time)
);
```

**问题：**
- ❌ 无分区字段（建议按`trace_time`做RANGE分区或按`carrier`做LIST分区）
- ⚠️ `logistics_trace` 表名与实体类`@TableName("logistics_trace")`不匹配（实体是`logistics_trace`，而文档中检查的`logistics_traces`不存在）

---

### 2.4 异常检测定时任务 — ❌ 不存在

**检查范围：** `scheduled-task-service` 全部源码

在`scheduled_task`表中无任何物流异常检测任务记录，也无Java代码实现。

---

### 2.5 物流时间轴接口 — ⚠️ 接口存在，数据依赖轨迹拉取

**接口路径：** `GET /api/logistics/trace/{waybillNo}`

```java
public TraceResponse getTraces(String waybillNo) {
    List<LogisticsTrace> traces = logisticsTraceMapper.selectByWaybillNoOrderByTime(waybillNo);
    if (traces.isEmpty()) {
        traceSyncService.syncSingle(waybillNo, null, false);  // 尝试同步
        traces = logisticsTraceMapper.selectByWaybillNoOrderByTime(waybillNo);
    }
    if (traces.isEmpty()) return null;  // ← 无数据时返回null
    // 构建TraceResponse...
}
```

**问题：** 由于`queryTraces()`返回空，同步后仍无数据，时间轴为空。

---

## 三、修复方案

### 3.1 补充DDL：分区 + 索引完善

**文件：** `services/logistics-service/src/main/resources/db/migration/V2__add_partition_and_indexes.sql`

```sql
-- =============================================
-- 物流轨迹表分区与索引补充
-- 适用于 MySQL 8.0+
-- 注意：分区表不支持直接ALTER，需要重建表
-- =============================================

-- Step 1: 创建带分区的影子表
CREATE TABLE IF NOT EXISTS logistics_trace_partitioned (
    id BIGINT AUTO_INCREMENT,
    waybill_no VARCHAR(50) NOT NULL,
    carrier VARCHAR(20) NOT NULL,
    status VARCHAR(30) DEFAULT '' COMMENT '轨迹节点状态',
    status_label VARCHAR(50) DEFAULT '' COMMENT '状态标签',
    location VARCHAR(200) DEFAULT '' COMMENT '轨迹发生地点',
    description VARCHAR(500) DEFAULT '' COMMENT '轨迹描述',
    trace_time DATETIME NOT NULL COMMENT '轨迹发生时间',
    raw_status_code VARCHAR(50) DEFAULT '' COMMENT '物流商原始状态码',
    raw_data JSON DEFAULT NULL COMMENT '原始轨迹数据',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    deleted TINYINT(1) DEFAULT 0 COMMENT '逻辑删除',
    PRIMARY KEY (id, trace_time),  -- 分区字段必须在主键中
    INDEX idx_waybill_no (waybill_no),
    INDEX idx_carrier (carrier),
    INDEX idx_trace_time (trace_time),
    INDEX idx_waybill_trace (waybill_no, trace_time),
    INDEX idx_waybill_status (waybill_no, status),
    INDEX idx_status (status)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
PARTITION BY RANGE (YEAR(trace_time) * 100 + MONTH(trace_time)) (
    PARTITION p202601 VALUES LESS THAN (202602),
    PARTITION p202602 VALUES LESS THAN (202603),
    PARTITION p202603 VALUES LESS THAN (202604),
    PARTITION p202604 VALUES LESS THAN (202605),
    PARTITION p202605 VALUES LESS THAN (202606),
    PARTITION p202606 VALUES LESS THAN (202607),
    PARTITION p202607 VALUES LESS THAN (202608),
    PARTITION p202608 VALUES LESS THAN (202609),
    PARTITION p202609 VALUES LESS THAN (202610),
    PARTITION p202610 VALUES LESS THAN (202611),
    PARTITION p202611 VALUES LESS THAN (202612),
    PARTITION p202612 VALUES LESS THAN (202701),
    PARTITION p_future VALUES LESS THAN MAXVALUE
) COMMENT='物流轨迹记录表（分区版）';

-- Step 2: 迁移数据
INSERT INTO logistics_trace_partitioned
SELECT * FROM logistics_trace WHERE deleted = 0;

-- Step 3: 重命名（需要窗口期，建议在低峰期操作）
-- RENAME TABLE logistics_trace TO logistics_trace_old,
--              logistics_trace_partitioned TO logistics_trace;

-- =============================================
-- logistics_waybill_status 索引补充
-- =============================================
CREATE INDEX IF NOT EXISTS idx_waybill_carrier_status
    ON logistics_waybill_status (waybill_no, carrier, status);

CREATE INDEX IF NOT EXISTS idx_sync_need
    ON logistics_waybill_status (need_sync, sync_status, sync_retry_count);

-- =============================================
-- 异常检测视图（方便异常查询）
-- =============================================
CREATE OR REPLACE VIEW v_logistics_exception AS
SELECT
    w.id,
    w.waybill_no,
    w.carrier,
    CASE w.carrier
        WHEN 'SF' THEN '顺丰速运'
        WHEN 'YTO' THEN '圆通速递'
        WHEN 'ZTO' THEN '中通快递'
        WHEN 'YUNDA' THEN '韵达快递'
        ELSE w.carrier
    END AS carrier_name,
    w.status,
    w.status_label,
    w.location,
    w.description,
    w.last_trace_time,
    w.sync_status,
    w.sync_fail_reason,
    w.order_id,
    w.order_no,
    w.receiver_name,
    w.receiver_phone,
    TIMESTAMPDIFF(HOUR, w.last_trace_time, NOW()) AS stale_hours,
    CASE
        WHEN w.status = 'EXCEPTION' THEN '运单异常'
        WHEN w.sync_status = 3 THEN '同步失败'
        WHEN w.status NOT IN ('SIGNED','RETURNED') AND TIMESTAMPDIFF(HOUR, w.last_trace_time, NOW()) > 48 THEN '停滞预警'
        ELSE '正常'
    END AS exception_type
FROM logistics_waybill_status w
WHERE w.deleted = 0;
```

---

### 3.2 创建物流异常检测定时任务

**文件：** `services/logistics-service/src/main/java/com/erp/logistics/job/LogisticsExceptionDetectJob.java`

```java
package com.erp.logistics.job;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.erp.logistics.entity.WaybillStatus;
import com.erp.logistics.mapper.WaybillStatusMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 物流异常检测定时任务
 *
 * 检测以下异常：
 * 1. 运单状态为 EXCEPTION（物流商报告异常）
 * 2. 同步失败次数超过阈值
 * 3. 运单停滞超过48小时无新轨迹（未签收且未退回）
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class LogisticsExceptionDetectJob {

    private final WaybillStatusMapper waybillStatusMapper;

    @Value("${logistics.exception.stale-hours:48}")
    private Integer staleHours;

    @Value("${logistics.exception.max-retry:5}")
    private Integer maxRetry;

    /**
     * 每小时检测一次
     * 触发时间：每小时的第5分钟
     */
    @Scheduled(cron = "0 5 * * * ?")
    public void detectExceptions() {
        log.info("[异常检测] 开始物流异常检测");
        long startTime = System.currentTimeMillis();

        try {
            detectAbnormalStatus();    // 检测异常状态运单
            detectSyncFailures();       // 检测同步失败运单
            detectStaleWaybills();     // 检测停滞运单
        } finally {
            log.info("[异常检测] 完成，耗时: {}ms", System.currentTimeMillis() - startTime);
        }
    }

    /**
     * 检测异常状态运单（status = EXCEPTION）
     */
    private void detectAbnormalStatus() {
        LambdaQueryWrapper<WaybillStatus> wrapper = new LambdaQueryWrapper<WaybillStatus>()
                .eq(WaybillStatus::getStatus, "EXCEPTION")
                .eq(WaybillStatus::getDeleted, 0);

        List<WaybillStatus> exceptions = waybillStatusMapper.selectList(wrapper);

        for (WaybillStatus waybill : exceptions) {
            log.warn("[异常检测] 运单异常: waybillNo={}, carrier={}, status={}, location={}, description={}",
                    waybill.getWaybillNo(), waybill.getCarrier(),
                    waybill.getStatusLabel(), waybill.getLocation(),
                    waybill.getDescription());
            // TODO: 触发告警通知（发送邮件/短信/站内消息）
        }

        if (!exceptions.isEmpty()) {
            log.info("[异常检测] 发现 {} 条异常状态运单", exceptions.size());
        }
    }

    /**
     * 检测同步失败运单（sync_status = 3 且重试次数超限）
     */
    private void detectSyncFailures() {
        LambdaQueryWrapper<WaybillStatus> wrapper = new LambdaQueryWrapper<WaybillStatus>()
                .eq(WaybillStatus::getSyncStatus, 3)
                .ge(WaybillStatus::getSyncRetryCount, maxRetry)
                .eq(WaybillStatus::getDeleted, 0);

        List<WaybillStatus> failures = waybillStatusMapper.selectList(wrapper);

        for (WaybillStatus waybill : failures) {
            log.error("[异常检测] 同步失败: waybillNo={}, carrier={}, retryCount={}, reason={}",
                    waybill.getWaybillNo(), waybill.getCarrier(),
                    waybill.getSyncRetryCount(), waybill.getSyncFailReason());
            // TODO: 触发告警，通知运维或客服
        }

        if (!failures.isEmpty()) {
            log.info("[异常检测] 发现 {} 条同步失败运单", failures.size());
        }
    }

    /**
     * 检测停滞运单（48小时无新轨迹且未签收/退回）
     */
    private void detectStaleWaybills() {
        LocalDateTime threshold = LocalDateTime.now().minusHours(staleHours);

        LambdaQueryWrapper<WaybillStatus> wrapper = new LambdaQueryWrapper<WaybillStatus>()
                .notIn(WaybillStatus::getStatus, "SIGNED", "RETURNED", "RETURNING")
                .eq(WaybillStatus::getDeleted, 0)
                .and(w -> w
                        .isNull(WaybillStatus::getLastTraceTime)
                        .or()
                        .lt(WaybillStatus::getLastTraceTime, threshold)
                );

        List<WaybillStatus> stale = waybillStatusMapper.selectList(wrapper);

        for (WaybillStatus waybill : stale) {
            long hours = waybill.getLastTraceTime() == null
                    ? -1
                    : java.time.Duration.between(waybill.getLastTraceTime(), LocalDateTime.now()).toHours();
            log.warn("[异常检测] 运单停滞: waybillNo={}, carrier={}, lastTraceTime={}, 停滞={}小时",
                    waybill.getWaybillNo(), waybill.getCarrier(),
                    waybill.getLastTraceTime(), hours);
            // TODO: 触发告警或自动重新拉取
        }

        if (!stale.isEmpty()) {
            log.info("[异常检测] 发现 {} 条停滞运单（超过{}小时无更新）", stale.size(), staleHours);
        }
    }
}
```

**配置项（追加到 `application.yml`）：**

```yaml
logistics:
  exception:
    stale-hours: 48      # 停滞阈值（小时）
    max-retry: 5         # 同步失败重试上限
    detect-cron: "0 5 * * * ?"  # 异常检测Cron表达式（每小时第5分钟）
```

---

### 3.3 在 scheduled-task-service 中注册异常检测任务

**文件：** `services/logistics-service/src/main/resources/db/migration/V3__register_logistics_tasks.sql`

```sql
-- =============================================
-- 注册物流异常检测定时任务到 scheduled_task 表
-- =============================================

INSERT INTO scheduled_task (
    task_name,
    description,
    task_group,
    cron_expression,
    task_class,
    method_name,
    task_params,
    status,
    concurrent,
    sync,
    task_type,
    max_retries,
    retry_interval,
    timeout,
    alert_enabled,
    misfire_policy,
    owner,
    created_at,
    updated_at
) VALUES (
    'logistics_exception_detect',
    '物流异常检测任务：检测EXCEPTION状态、同步失败、停滞运单，每小时执行一次',
    'LOGISTICS',
    '0 5 * * * ?',
    'com.erp.logistics.job.LogisticsExceptionDetectJob',
    'detectExceptions',
    '{"staleHours":48,"maxRetry":5}',
    'RUNNING',
    TRUE,
    FALSE,
    'BEAN',
    3,
    60,
    300,
    TRUE,
    'DO_NOTHING',
    'system',
    NOW(),
    NOW()
);

-- 轨迹批量同步任务（已有@Scheduled，这里补充DB记录）
INSERT INTO scheduled_task (
    task_name,
    description,
    task_group,
    cron_expression,
    task_class,
    method_name,
    task_params,
    status,
    concurrent,
    sync,
    task_type,
    max_retries,
    retry_interval,
    timeout,
    alert_enabled,
    misfire_policy,
    owner,
    created_at,
    updated_at
) VALUES (
    'logistics_trace_sync',
    '物流轨迹批量同步：每30分钟同步一次待同步运单轨迹',
    'LOGISTICS',
    '0 */30 * * * ?',
    'com.erp.logistics.service.TraceSyncService',
    'syncPending',
    '{"batchSize":100,"maxRetry":3}',
    'RUNNING',
    TRUE,
    FALSE,
    'BEAN',
    3,
    60,
    600,
    TRUE,
    'DO_NOTHING',
    'system',
    NOW(),
    NOW()
) ON DUPLICATE KEY UPDATE
    description = VALUES(description),
    cron_expression = VALUES(cron_expression),
    task_params = VALUES(task_params),
    status = 'RUNNING';
```

---

### 3.4 补充物流时间轴增强接口

**文件：** `services/logistics-service/src/main/java/com/erp/logistics/dto/response/TraceResponse.java`（需补充字段）

```java
// 追加以下字段到 TraceResponse
private Integer totalTraces;          // 轨迹总节点数
private String estimatedDelivery;     // 预计送达时间
private Boolean isOnTime;             // 是否准时
private Integer transitDays;          // 在途天数
private List<String> transitCities;   // 途经城市列表
```

**增强接口：`LogisticsController` 追加**

```java
@GetMapping("/trace/timeline/{waybillNo}")
@Operation(summary = "物流时间轴（增强版）", description = "返回物流轨迹时间轴，包含预计送达、分段统计等")
public ApiResponse<TraceResponse> getTraceTimeline(
        @Parameter(description = "运单号") @PathVariable String waybillNo) {
    TraceResponse trace = logisticsService.getTracesEnhanced(waybillNo);
    if (trace == null) {
        return ApiResponse.notFound("未找到物流轨迹");
    }
    return ApiResponse.success(trace);
}
```

---

## 四、修复优先级

| 优先级 | 修复项 | 影响 | 工作量 |
|--------|--------|------|--------|
| P0 | 3.1 DDL分区补充 | 数据库性能、长期运维 | 中 |
| P0 | 3.2 异常检测Job | 运单异常无法发现 | 低 |
| P1 | 3.3 定时任务注册 | 异常检测Job无法自动触发 | 低 |
| P2 | 3.4 时间轴增强 | 接口体验 | 低 |
| P2 | 快递公司API对接 | 轨迹数据拉取（需第三方API密钥） | 高（需商务对接） |
| P0 | 缺失依赖修复（hutool + spring-retry）| pom.xml缺少依赖导致编译失败 | 低 |

---

## 五、风险说明

1. **分区表重建风险**：MySQL分区表不支持在线变更主键和分区字段，需要在低峰期用`RENAME TABLE`切换。建议提前在测试环境验证。
2. **API对接依赖**：当前4个快递公司适配器的`queryTraces()`均未对接真实API，轨迹主动拉取功能暂时无效，**只能依赖回调推送**。建议尽快与顺丰镖局/圆通开放平台完成商务对接。
3. **告警渠道未配置**：`LogisticsExceptionDetectJob`发现异常后，`// TODO: 触发告警`部分需要接入邮件/钉钉/飞书等通知渠道。

---

## 六、文件清单

| 操作 | 文件路径 |
|------|----------|
| 新增 | `services/logistics-service/src/main/resources/db/migration/V2__add_partition_and_indexes.sql` |
| 新增 | `services/logistics-service/src/main/java/com/erp/logistics/job/LogisticsExceptionDetectJob.java` |
| 新增 | `services/logistics-service/src/main/resources/db/migration/V3__register_logistics_tasks.sql` |
| 修改 | `services/logistics-service/src/main/resources/application.yml`（追加exception配置节） |
| 修改 | `services/logistics-service/src/main/java/com/erp/logistics/dto/response/TraceResponse.java`（追加增强字段） |
| 修改 | `services/logistics-service/src/main/java/com/erp/logistics/controller/LogisticsController.java`（追加timeline接口） |
| 修改 | `services/logistics-service/src/main/java/com/erp/logistics/service/LogisticsService.java`（新增getTracesEnhanced方法） |
| 修改 | `services/logistics-service/pom.xml`（追加hutool、spring-retry依赖） |

## 七、验证结果

```bash
$ mvn clean compile -pl services/logistics-service -am -DskipTests
...
[INFO] Compiling 27 source files with javac [debug target 17] to target/classes
[INFO] BUILD SUCCESS
```

**编译通过** — 27个源文件全部编译成功（含新增的`LogisticsExceptionDetectJob`）。
