Skip to content

概述

在租赁业务日常运营中,发货录单与归还质检环节会产生大量的凭证视频。传统方式依赖管理员在订单列表逐单打开弹窗、通过表单上传到应用服务器再转存,存在严重的服务端网络带宽瓶颈、人工选单易错漏、大文件上传超时以及后续常规编辑易被覆盖丢失等问题。

本功能通过浏览器直传云存储 + 快递单号自动二分判定 + 两阶段事务绑定 + 定时维护自愈的完整架构闭环,实现了租赁凭证视频的批量高效处理:

  1. 浏览器直传云存储:管理后台直连云存储驱动(七牛云等),服务端仅做轻量鉴权、验签与元数据管理,零服务端网络带宽消耗;
  2. 多端递归导入与并发池:支持本地文件夹递归扫描(showDirectoryPicker)与多选降级,前端限制最大并发为 3 进行流水线调度;
  3. 单号提取与互斥二分匹配:规范化文件名提取快递单号,在发货集合与归还集合中进行二分判定,单命中自动绑定,0 命中严格不设人工兜底,多单冲突仅在存在待接收归还订单时允许人工消解;
  4. 两阶段绑定与防 TOCTOU:短事务领取 10 分钟租约令牌,事务外进行 MP4 ftyp 盒标识与云端对象正式化移动,第二阶段加锁复核订单谓词并原子写入紧凑 JSON 元数据;
  5. 多端聚合与防覆盖:管理端与移动端同源聚合读取多条凭证材料,旧弹窗保存时主动保护并过滤直传视频,杜绝被普通编辑覆盖或重复落库;
  6. 定时维护五重自愈:通过系统定时任务每分钟自动轮询,覆盖租约恢复、过期临时对象清理、孤儿对象补偿、事件重投等运维场景;
  7. 本地原视频安全清理:全部任务完成后,仅对绑定成功的原视频提供二次确认的批量物理删除能力(基于 File System Access API)。

核心数据结构

1. 批量上传任务表(eb_lease_voucher_upload_task

记录上传授权、匹配状态、云端对象 Key、去重锁以及两阶段租约生命周期:

字段名类型说明
idBIGINT UNSIGNED任务主键 ID
admin_idINT UNSIGNED后台管理员 ID
oidBIGINT UNSIGNED关联租赁订单 ID(0 命中或未确定前为 NULL)
sceneVARCHAR(10)凭证场景:send(发货) / back(归还)
original_nameVARCHAR(255)客户端原始文件名
normalized_original_nameVARCHAR(255)规范化后的文件名(UTF-8,去首尾空格)
express_noVARCHAR(64)解析出的快递物流单号
file_sizeBIGINT UNSIGNED视频文件大小(字节,最大限制 1GB)
mime_typeVARCHAR(128)MIME 类型(固定 video/mp4
match_modeVARCHAR(10)匹配模式:auto(自动) / manual(人工归还消解)
dedupe_keyCHAR(64)业务去重键 sha256(oid|scene|normalized_original_name),唯一索引 uk_dedupe_key
dedupe_owner_idBIGINT UNSIGNED冲突时占有该业务键的母任务 ID
match_snapshotTEXT匹配当时命中的订单上下文快照(JSON)
match_grant_hashCHAR(64)匹配授权 Token 的 SHA-256 哈希,唯一索引 uk_match_grant_hash
match_grant_expires_atINT UNSIGNED匹配授权过期时间戳(默认 15 分钟)
match_grant_statusVARCHAR(16)匹配授权状态:issued / consumed / expired
upload_grant_hashCHAR(64)上传授权 Token 的 SHA-256 哈希
upload_grant_expires_atINT UNSIGNED上传授权过期时间戳(默认 30 分钟)
upload_grant_statusVARCHAR(16)上传授权状态:none / issued / binding / consumed / expired
upload_grant_attemptsTINYINT UNSIGNED直传凭据申请/替换重试次数(上限 3 次)
temp_object_keyVARCHAR(512)云端临时对象 Key:lease-voucher/tmp/{task_id}/{random}.mp4
final_object_keyVARCHAR(512)云端正式对象 Key:lease-voucher/final/{task_id}.mp4
object_urlVARCHAR(1024)云端可信访问 URL
object_statusVARCHAR(24)对象状态:none / temp_uploaded / promoted / failed
binding_lease_untilINT UNSIGNED绑定租约到期时间戳(默认 10 分钟)
binding_lease_tokenCHAR(32)绑定租约持有者令牌(防止并发接管冲突)
binding_attemptsTINYINT UNSIGNED绑定重试尝试次数
event_keyVARCHAR(96)订单动态事件唯一键 lease_voucher_video_bind:{task_id},唯一索引 uk_event_key
event_statusVARCHAR(16)订单动态事件状态:none / pending / done / failed
event_attemptsTINYINT UNSIGNED订单动态事件执行尝试次数(上限 5 次)
event_errorVARCHAR(500)动态事件执行失败原因
task_statusVARCHAR(24)任务主状态枚举(见下文)
bind_errorVARCHAR(500)绑定失败或核验失败原因
cleanup_statusVARCHAR(24)资源清理状态:none / pending / cleanup_done / cleanup_failed
last_cleanup_atINT UNSIGNED上次清理维护时间戳

任务状态(task_status)枚举流转

text
               ┌───────────────► parse_error / invalid(前端拦截,不落库)

[用户选文件] ───┴─► matched ────► upload_issued ────► uploaded ────► binding ────► bound(终态成功)
                      │                 │               │               │
                      ├─► unmatched     ├─► deduplicated└─► upload_failed├─► bind_failed
                      └─► conflict      └─► expired (超时未传)           └─► expired (超时未绑定)

2. 订单补充凭证表(eb_addition_media)的集成形式

任务绑定成功后,向 eb_addition_media 写入一条正式记录:

  • oid:订单 ID
  • scenesendback
  • type:固定为 video
  • url:七牛正式访问路径(https://domain/lease-voucher/final/{task_id}.mp4
  • mem:紧凑元数据 JSON 字符串,严格控制在 VARCHAR(300) 容量内:
    json
    {
      "source": "lease_voucher_video_upload",
      "original_name": "SF5156050594926_20260917.mp4",
      "express_no": "SF5156050594926",
      "match_mode": "auto"
    }

业务规则与匹配算法

1. 文件命名与安全规范

  • 命名格式:必须符合 {快递单号}_{自定义内容}.mp4,例如 SF5156050594926_20260917103000.mp4
  • 单号解析:取首个下划线 _ 之前的子串作为快递单号,单号长度在 1~64 字符内;
  • 安全过滤:严格校验 UTF-8 编码,文件名长度不得超过 128 字节;严禁包含路径分隔符(/\)、双引号(")或控制字符(\x00-\x1F\x7F),杜绝路径穿越与 JSON 转义膨胀;
  • 文件规格:单文件大小必须在 0 ~ 1,000,000,000 字节(1GB)之间,MIME 必须为 video/mp4

2. 互斥二分场景判定算法

根据解析出的快递单号,服务端同时在发货集合归还集合中进行等值检索:

  1. 发货订单集合检索条件(send

    • o.is_del = 0o.paid = 1
    • o.status IN (1, 5)(已发货 / 租用中)
    • o.refund_status IN (0, 3)(无退款或退款已拒绝)
    • l.audit_status = 1(审核通过)
    • l.contract_status = 2(合同已签署)
    • l.return_delivery_id = ''(尚未进入归还流程)
    • o.delivery_id = :expressNo(发货物流单号精确匹配)
  2. 归还订单集合检索条件(back

    • o.is_del = 0o.paid = 1
    • o.status IN (2, 3)(待归还 / 已完成)
    • o.refund_status IN (0, 3)
    • l.audit_status = 1l.contract_status = 2l.judicial_status = 0
    • l.lease_status = 6(待接收质检状态)
    • l.return_delivery_id = :expressNo(归还物流单号精确匹配)
  3. 二分判定策略

    • 发货命中数 + 归还命中数 = 1自动匹配(auto_matched)。单号归属唯一,自动确定 scene,签发匹配授权 match_grant
    • 发货命中数 + 归还命中数 = 00 命中(unmatched)。系统不设任何模糊或人工兜底(防止错绑影响资产追偿),生成终态已过期任务,引导用户改走通用素材中心;
    • 发货命中数 + 归还命中数 > 1冲突(conflict)
      • 若归还命中数 > 0:允许人工消解,前端弹出待接收归还订单选择列表,选定后以 manual 模式按归还场景继续直传;
      • 若归还命中数 = 0(如发货集合命中多笔):不可人工消解,禁止绑定。

3. 并发安全与去重业务键

  • 业务键dedupe_key = sha256(oid|scene|normalized_original_name)
  • 冲突消解
    • 在初次申请直传 Token 时加锁检查 findActiveByDedupeKeyForUpdate
    • 若已被其他任务占用,当前任务平滑降级为 deduplicated 状态,直接复用已有任务进度,跳过二次直传;
    • 若发生高并发抢占触发 MySQL 1062 唯一约束异常,服务端平滑捕获并置为 deduplicated
    • 当任务最终失败或过期时,系统自动将 dedupe_key 置空(NULL),彻底避免后续同名文件被唯一索引死锁。

核心调用链路

mermaid
sequenceDiagram
    autonumber
    participant Admin as 管理员浏览器
    participant Vue as voucherVideoUpload.vue
    participant API as OrderAddition 控制器
    participant Svc as AdditionMediaServices
    participant TaskDao as LeaseVoucherUploadTaskDao
    participant Qiniu as 七牛云存储 Kodo
    participant DB as MySQL (eb_addition_media)
    participant Queue as ThinkQueue 队列

    Admin->>Vue: 导入文件夹 / 选择视频
    Vue->>Vue: 本地前置校验(扩展名/大小/下划线单号)
    Vue->>API: POST /order/addition/video_match (批量单号/文件名)
    API->>Svc: videoMatch()
    Svc->>Svc: 发货/归还集合精确二分判定
    Svc->>TaskDao: 创建任务记录(生成 match_grant)
    Svc-->>Vue: 返回匹配结果列表 (auto_matched / conflict / unmatched)

    opt 人工消解多单冲突
        Vue->>API: POST /order/addition/video_match (带 match_grant)
        API->>Svc: 查询归还候选列表 (lease_status=6)
        Svc-->>Vue: 返回候选订单列表
        Admin->>Vue: 人工选定归还订单
    end

    Admin->>Vue: 点击「开始批量上传与绑定」
    loop 并发池调度 (最大并发 3)
        Vue->>API: POST /order/addition/video_upload_token (match_grant)
        API->>Svc: videoUploadToken()
        Svc->>TaskDao: 行锁核验 + 去重键占用 + 并发软上限(3)
        Svc->>Qiniu: issueUploadGrant() 生成指定 Key 上传凭证
        Svc-->>Vue: 返回 upload_grant + temp_object_key + 七牛 Uptoken
        Vue->>Qiniu: videoCloud.directUploadVoucherVideo 直传临时对象
        Qiniu-->>Vue: 上传完成
        Vue->>API: POST /order/addition/bind_video (upload_grant)
        API->>Svc: bindVideo()
        Note over Svc,Qiniu: 【Phase 1】事务外调用 verifyObject<br/>Range 请求校验前 16 字节 MP4 ftyp 盒
        Svc->>TaskDao: 短事务领取 10 分钟 binding 租约与持有令牌
        Note over Svc,Qiniu: 【事务外】调用 promoteObject<br/>七牛临时对象移动至正式 Key
        Note over Svc,DB: 【Phase 2】原子提交事务<br/>锁任务行 + 锁订单行 + 复核租约令牌 + 复核订单谓词
        Svc->>DB: 写入 eb_addition_media 凭证记录
        Svc->>TaskDao: 更新任务状态为 bound
        Svc->>Queue: 事务后派发 LeaseVoucherVideoBindEventJob
        Svc-->>Vue: 绑定成功通知
    end

    Queue->>DB: 异步写入订单流转日志 (StoreOrderStatus)
    opt 本地原视频清理
        Admin->>Vue: 点击「批量删除已绑定的本地原视频」
        Vue->>Admin: 弹窗二次确认
        Vue->>Vue: File System Access API 验证权限并物理删除
    end

接口清单

所有接口位于 /adminapi/ 路由组下,依赖后台管理员鉴权中间件。

1. 凭证视频批量匹配 / 人工候选查询

  • 路由POST /adminapi/order/addition/video_match
  • 控制器方法\app\controller\admin\v1\addition\OrderAddition@videoMatch
  • 权限标识admin-leaseOrder-voucherVideoUpload
  • 频控:每管理员每分钟最多 10 次(基于 Redis 缓存限流)

请求形态 A(批量文件匹配提交):

参数名类型必填说明
filesArray文件数组,单批次最多 100 个
files[].original_nameString文件名(如 SF123_01.mp4
files[].sizeInteger文件大小(字节)
files[].mimeStringMIME 类型(默认 video/mp4

请求形态 B(冲突任务候选归还订单分页查询):

参数名类型必填说明
match_grantString匹配授权 Token(长度 32~64)
pageInteger分页页码,默认 1
limitInteger分页大小,默认 20(1~100)

响应结构(形态 A 核心字段):

json
{
  "status": 200,
  "msg": "ok",
  "data": [
    {
      "index": 0,
      "valid": true,
      "original_name": "SF5156050594926_20260917.mp4",
      "express_no": "SF5156050594926",
      "status": "auto_matched",
      "scene": "send",
      "task_id": 1024,
      "match_grant": "a1b2c3d4...",
      "order": {
        "oid": 5892,
        "order_id": "WX2026091712000001",
        "express_no": "SF5156050594926"
      },
      "message": "匹配成功(发货)"
    }
  ]
}

2. 凭证视频获取直传凭据

  • 路由POST /adminapi/order/addition/video_upload_token
  • 控制器方法\app\controller\admin\v1\addition\OrderAddition@videoUploadToken

请求形态 A(初次签发凭据):

参数名类型必填说明
match_grantString匹配授权 Token
match_modeString匹配模式:automanual(默认 auto
oidInteger人工模式下选定的归还订单 ID

请求形态 B(重试/替换凭据):

参数名类型必填说明
task_idInteger任务主键 ID
upload_grantString原上传授权 Token(用于旧凭证失效校验)

响应结构:

json
{
  "status": 200,
  "msg": "ok",
  "data": {
    "status": "issued",
    "task_id": 1024,
    "upload_grant": "e5f6g7h8...",
    "object_key": "lease-voucher/tmp/1024/7a9b0c1d2e3f4a5b.mp4",
    "attempts": 1,
    "config": {
      "type": "QINIU",
      "token": "uptoken_xxx...",
      "domain": "https://img.ebaozu.cn",
      "key": "lease-voucher/tmp/1024/7a9b0c1d2e3f4a5b.mp4",
      "bucket": "ebaozu-lease",
      "region": "z0",
      "max_size": 1000000000
    }
  }
}

3. 凭证视频确认绑定

  • 路由POST /adminapi/order/addition/bind_video
  • 控制器方法\app\controller\admin\v1\addition\OrderAddition@bindVideo

请求参数:

参数名类型必填说明
upload_grantString上传授权 Token

响应结构:

json
{
  "status": 200,
  "msg": "ok",
  "data": {
    "task_id": 1024,
    "status": "bound",
    "url": "https://img.ebaozu.cn/lease-voucher/final/1024.mp4",
    "message": "凭证视频绑定成功"
  }
}

存储驱动与安全防护

1. 存储适配器抽象(LeaseVoucherStorageInterface

系统定义了独立于基础版的直传适配器接口:

  • issueUploadGrant(string $tempObjectKey, int $expires): array:签发指定 Key 限制的直传签名;
  • verifyObject(string $tempObjectKey, int $expectedSize): array:核验对象大小并读取文件头魔数;
  • promoteObject(string $tempObjectKey, string $finalObjectKey): bool:将临时对象原子移动为正式持久化对象;
  • deleteObject(string $objectKey): bool:删除云端废弃或临时对象;
  • getPermanentUrl(string $finalObjectKey): string:获取永久无时效访问 URL。

2. 七牛云直传适配(QiniuStorageAdapter

  • 限制上传策略(Policy)
    • insertOnly = 1:七牛服务端禁止同名 Key 覆盖上传;
    • fsizeLimit = 1,000,000,000:强行限制单文件不可超 1GB;
  • 防伪装验证(Range 读取 MP4 ftyp 盒): 服务端使用私有签名链接发起 HTTP Range 请求(仅拉取第 0~15 字节分片),读取文件头部,严格核验字节 4~7 必须等于 ASCII 字符 ftyp。如果被篡改或者非 MP4 文件,立即阻断并标记上传失败;
  • Key 隔离: 临时对象统一使用 lease-voucher/tmp/{task_id}/{random}.mp4,正式对象统一使用 lease-voucher/final/{task_id}.mp4。严格禁用客户端提交的原始文件名作为云端 Key,彻底切断 S3/Kodo 路径穿越风险。

历史兼容与多端展示

1. 凭证聚合读取逻辑(getAggregatedAdditionMedia

针对 eb_addition_media 历史记录,旧逻辑采用 foreach 逐行赋值,导致同场景同类型多条记录时后写入记录覆盖前写入记录。 现重构为聚合读取方案,并在移动端(/api/order/addition/info)与后台管理端(/adminapi/order/addition/info)同源复用:

  • URL 数组聚合:合并展示历史所有有效直传及手工上传视频 URL,并自动去重;
  • media_items 结构明细:返回逐条记录的 idurlis_auto_upload 标识、提取的单号与原始文件名;
  • 兼容 mem 字段:若存在历史人工录入的备注,始终保留最后一条非直传记录的备注原文,绝不将 JSON 元数据暴露给传统前端文本框。

2. 旧窗口保存二次过滤保护

在原有发货/归还图片弹窗(sendReceiveImage.vue)保存时,系统实施双重保护:

  • 前端保护:识别为自动上传的直传视频隐藏删除按钮,并在提交的 video.list 中将已直传的视频 URL 剔除;
  • 后端保护(insertAdditionMedia:在事务内检索所有 is_auto_upload 记录,自动直传记录绝对不执行软删除;同时对传入的新视频列表进行二次过滤,已存在于直传记录中的 URL 不再重复插入。

定时维护与自愈机制

系统通过 eb_system_timer 表注册了常驻定时任务:

  • 任务标识(Mark)lease_voucher_task_maintain
  • 执行周期:每 1 分钟执行一次
  • 核心处理服务\app\services\addition\LeaseVoucherTaskMaintainServices@maintain
text
                                 [每1分钟定时触发]

        ┌────────────────────────────────┼────────────────────────────────┬────────────────────────┐
        ▼                                ▼                                ▼                        ▼
1. 恢复超时 Binding 租约        2. 清理过期临时对象              3. 补偿删除失败对象        4. 回收过期匹配任务
  (binding_lease_until超时)        (upload_grant已过期且未Bound)    (CLEANUP_PENDING/FAILED)  (match_grant已过期且未传)
        │                                │                                │                        │
  - 正式对象在: 接管完成绑定       - 调用 Qiniu::deleteObject       - 补偿删除临时/正式对象   - 标记为 expired
  - 临时对象在: 回退 uploaded      - 标记 expired + 释放去重键      - 清理完成置 CLEANUP_DONE - 释放资源
  - 都不在: 置 bind_failed         - 失败转 CLEANUP_FAILED 补偿

此外,维护服务每次循环均会扫描状态为 bound 但事件状态处于 pendingfailed 且重试次数 < 5 的任务,自动重投 LeaseVoucherVideoBindEventJob,保证订单动态日志 100% 最终一致。


前端交互与体验

1. 文件系统访问与降级

  • 首选:使用 Chrome / Edge 的 window.showDirectoryPicker(),递归遍历文件夹及其子目录下的所有 .mp4 文件;
  • 次选:使用 window.showOpenFilePicker({ multiple: true }) 进行批量多选;
  • 兜底:若浏览器不支持现代 File System API,自动无缝降级触发隐藏的 <input type="file" multiple accept=".mp4">

2. 流水线并发池控制

  • 前端通过 Worker 循环将上传任务限制为 3 个并发通道CONCURRENCY_LIMIT = 3),避免短时间内由于上百个大视频直传耗尽浏览器 HTTP 连接池与上传带宽;
  • 每个任务提供实时上传进度(10% ~ 95%),支持单任务网络错误后 3 次原位重试(调用替换凭据接口,更新临时 Key 并重新上传)。

3. 本地原文件批量物理删除

全部任务处理完毕后,针对绑定成功的任务提供「批量删除已绑定的本地原视频」功能:

  • 严格安全性限制:仅允许删除处于 bound 状态且具备本地文件句柄(Handle)的文件;未匹配、冲突或上传失败的原文件绝对保留;
  • 权限确认与降级:通过 handle.requestPermission({ mode: 'readwrite' }) 索取授权,优先调用 handle.remove(),不支持时降级通过父目录 parentDirHandle.removeEntry(name) 执行删除,操作结果逐项实时回显。

承信租多门店租赁商城系统官方文档