HCData 视频缓存管理系统 - 项目开发文档
一、项目概述
1.1 项目名称
HCData — 局域网视频缓存管理与点播系统
1.2 项目定位
HCData 是一个运行在 Windows 平台上的轻量级 Web 服务端系统,用于管理和缓存网络视频资源(主要是 m3u8/HLS 流)。系统通过 Web 管理后台提供可视化操作界面,支持视频下载、缓存管理、在线播放、磁盘管理等功能,并可通过局域网供多设备访问。
1.3 核心场景
- 将网络 m3u8 视频流下载并缓存为本地 MP4 文件
- 通过局域网内任意设备浏览器访问并播放已缓存视频
- 首次播放未缓存视频时,自动触发后台下载,下次访问可"秒播"
- 管理下载任务队列,监控下载进度和系统状态
1.4 项目特点
- 零构建部署:无需 webpack/vite 等前端构建工具,所有 HTML/CSS/JS 内联在单文件中
- 单文件后端:整个服务端逻辑集中在
server.js(1463 行),无路由拆分文件 - 嵌入式数据库:SQLite 单文件数据库,无需安装 MySQL/Redis 等服务
- 局域网优先:自动检测局域网 IP,视频文件通过局域网 URL 直接访问
- 智能缓存:首次播放走网络流,后台自动缓存,二次播放秒播
- 实时推送:基于 SSE 的下载进度实时推送,无需轮询
二、技术架构
2.1 技术栈
| 层级 | 技术 | 说明 |
|---|---|---|
| 后端运行时 | Node.js | JavaScript 运行时环境 |
| Web 框架 | Express 5.2.1 | HTTP 服务与 RESTful API |
| 数据库 | SQLite3 (better-sqlite3 12.10.0) | 本地嵌入式数据库 |
| 下载引擎 | N_m3u8DL-RE (20251027) | 高性能 m3u8 下载工具 |
| 视频处理 | FFmpeg | 视频合并/转码(N_m3u8DL-RE 依赖) |
| 前端播放器 | DPlayer + MuiPlayer + hls.js | 视频播放(HLS/MP4) |
| 实时通信 | SSE (Server-Sent Events) | 服务端向客户端实时推送下载进度 |
| 构建方式 | 无构建 | 纯原生 HTML/CSS/JS,无前端打包工具 |
2.2 系统架构图
┌─────────────────────────────────────────────────────────┐
│ 浏览器(客户端) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 当前任务 │ │ 已缓存 │ │ 日志 │ │ 设置 │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
│ SSE 实时事件流 / RESTful API 调用 │
└──────────────────────┬──────────────────────────────────┘
│ HTTP
┌──────────────────────┴──────────────────────────────────┐
│ Express 服务端 (port 3000) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ RESTful API 层 │ │
│ │ /api/tasks /api/cached /api/settings /api/logs │ │
│ │ /api/download-video /api/check-video /api/video │ │
│ │ /api/stats /api/disk-space /api/drives │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SSE 推送模块 │ │ 下载队列引擎 │ │ 文件管理模块 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ SQLite3 数据库 (hcdata.db) │ │
│ │ config | download_tasks | video_downloads | logs │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────────────┘
│ 调用外部工具
┌────────────┴────────────┐
│ N_m3u8DL-RE.exe │
│ FFmpeg (合并/转码) │
└─────────────────────────┘2.3 server.js 模块拆解
server.js 是整个系统的核心文件(1463 行),内部按功能划分为以下逻辑模块:
| 模块 | 行号范围 | 职责 |
|---|---|---|
| SSE 推送模块 | 11-39 | 管理 SSE 连接池,实现 broadcastSSE() 广播函数 |
| 数据目录初始化 | 41-45 | 确保 data/ 目录存在 |
| 数据库初始化 | 47-103 | 创建 4 张数据表(config/download_tasks/video_downloads/system_logs) |
| 日志工具 | 106-117 | writeLog() 函数,自动保留最近 500 条 |
| 数据库迁移 | 119-125 | 为旧版 video_downloads 表补充 play_count 字段 |
| 静态资源 & 中间件 | 127-128 | express.json() + express.static('public') |
| 系统信息 API | 130-202 | 获取局域网 IP、磁盘空间、盘符列表 |
| 配置管理 API | 204-334 | 通用 Key-Value 配置读写(单个/批量/全部) |
| 日志管理 API | 336-375 | 日志列表查询(分页)和清空 |
| 下载任务 API | 377-455 | 任务列表、添加、更新进度 |
| 缓存管理 API | 457-552 | 单个删除、一键清空已缓存视频 |
| 视频播放核心 | 554-635 | 存储路径管理、文件名生成、缓存检查 |
| 下载队列引擎 | 637-699 | 临时目录、队列管理、并发控制、processQueue() |
| 启动恢复 | 701-734 | initDownloadQueue() 服务启动时恢复未完成任务 |
| 下载执行引擎 | 736-1192 | startDownload() 核心函数:spawn 进程、进度解析、文件转移、超时处理 |
| 下载入口 API | 1194-1242 | POST /api/download-video 去重、入队 |
| 状态查询 API | 1244-1318 | 下载状态、已缓存列表(分页+文件大小) |
| 视频流服务 | 1320-1430 | 播放地址生成、Range 请求支持、CORS 头 |
| 服务启动 & 优雅关闭 | 1432-1463 | 监听端口、初始化队列、SIGINT/SIGTERM/SIGBREAK 处理 |
2.4 目录结构
e:\071058s-260801\
├── hcdata-web/ # 项目主目录
│ ├── server.js # 后端服务主文件(核心,1463行)
│ ├── check-db.js # 数据库检查工具(辅助脚本)
│ ├── package.json # Node.js 依赖配置
│ ├── data/ # 数据目录(运行时生成)
│ │ └── hcdata.db # SQLite 数据库文件
│ ├── public/ # 前端静态资源
│ │ ├── index.html # 管理后台主页面(1346行)
│ │ ├── player.html # 视频播放页面(256行)
│ │ └── assets/ # 第三方前端库
│ │ ├── DPlayer.min.js # DPlayer 播放器
│ │ ├── DPlayer.min.css
│ │ ├── mui-player.min.js # MuiPlayer 播放器
│ │ ├── mui-player.min.css
│ │ ├── mui-player-desktop-plugin.min.js
│ │ └── hls.min.js # HLS 流媒体解析库
│ └── node_modules/ # Node.js 依赖包
├── tools/ # 外部工具目录
│ ├── N_m3u8DL-RE.exe # m3u8 下载器
│ ├── N_m3u8DL-RE参数说明.txt # 下载器参数文档
│ ├── ffmpeg.exe # 视频处理工具
│ ├── ffplay.exe # 视频播放工具
│ ├── ffprobe.exe # 视频探测工具
│ ├── avcodec-62.dll # FFmpeg 依赖库
│ ├── avdevice-62.dll
│ ├── avfilter-11.dll
│ ├── avformat-62.dll
│ ├── avutil-60.dll
│ ├── swresample-6.dll
│ └── swscale-9.dll
├── datatmp/tmp/ # 下载临时文件目录(运行时使用)
├── 启动服务端.bat # Windows 一键启动脚本
└── 更新日志.md # 版本更新记录三、数据库设计
3.1 数据库文件
- 路径:
hcdata-web/data/hcdata.db - 引擎: SQLite3(通过 better-sqlite3 同步 API 操作)
3.2 数据表结构
3.2.1 config — 通用配置表
存储系统所有可配置项,采用 Key-Value 模式。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER (PK, AUTO) | 主键 |
| key | TEXT (UNIQUE, NOT NULL) | 配置键名 |
| value | TEXT (NOT NULL) | 配置值 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
已知配置项:
| key | 说明 | 默认值 |
|---|---|---|
storage_path | 视频文件存储根路径 | 未设置(需用户在设置页选择盘符) |
max_concurrent | 最大并发下载任务数 | 4 |
thread_count | 单任务下载线程数 | 4 |
nm_user_agent | N_m3u8DL-RE 请求 User-Agent | 空 |
3.2.2 download_tasks — 下载任务表
记录当前正在进行的下载任务(完成后删除)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER (PK, AUTO) | 主键 |
| name | TEXT (NOT NULL) | 任务名称 |
| url | TEXT (NOT NULL) | 视频 URL |
| status | TEXT | 状态:waiting / downloading / merging / error |
| progress | INTEGER | 进度百分比 (0-100) |
| speed | TEXT | 下载速度显示文本 |
| size | TEXT | 文件总大小 |
| downloaded | TEXT | 已下载大小 |
| created_at | DATETIME | 创建时间 |
设计说明:此表仅保存"进行中"的任务,下载完成后记录会被删除,转移到 video_downloads 表。3.2.3 video_downloads — 视频下载记录表
保存所有视频的下载记录(包括历史)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER (PK, AUTO) | 主键 |
| url | TEXT (UNIQUE, NOT NULL) | 视频 URL(唯一约束,用于去重) |
| name | TEXT | 视频名称 |
| local_path | TEXT | 本地文件绝对路径 |
| status | TEXT | 状态:pending / downloading / completed / error |
| progress | INTEGER | 进度百分比 |
| message | TEXT | 状态消息 |
| format | TEXT | 输出格式(默认 mp4) |
| play_count | INTEGER | 播放次数(用于统计) |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
3.2.4 system_logs — 系统日志表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER (PK, AUTO) | 主键 |
| level | TEXT | 日志级别:info / warn / error |
| message | TEXT (NOT NULL) | 日志内容 |
| created_at | DATETIME | 创建时间 |
自动清理:日志超过 500 条时自动删除最早的记录。
四、API 接口文档
4.1 系统信息类
GET /api/local-ip
获取服务器局域网 IP 地址。
响应:
{ "success": true, "ip": "192.168.1.100" }GET /api/disk-space
获取存储盘符的磁盘空间信息。
响应:
{
"success": true,
"drive": "D:",
"freeSpace": "120.50 GB",
"totalSize": "500.00 GB",
"usedSpace": "379.50 GB",
"usedPercent": "75.9"
}GET /api/drives
获取电脑所有可用盘符列表。
响应:
{ "success": true, "drives": ["C:", "D:", "E:"] }GET /api/stats
获取仪表盘统计数据(已完成数、点播总次数、命中率)。
响应:
{
"success": true,
"stats": {
"completed": 42,
"playCountTotal": 128,
"hitRate": "304.8"
}
}4.2 配置管理类
GET /api/config
获取存储路径配置。
GET /api/settings
获取所有配置项。
GET /api/settings/:key
获取指定配置项。
POST /api/settings/:key
保存/更新指定配置项。
请求体:
{ "value": "4" }POST /api/settings
批量保存配置项。
请求体:
{ "max_concurrent": "4", "thread_count": "8" }POST /api/save-drive
设置存储盘符并创建 hcdata 文件夹。
请求体:
{ "drive": "D:" }4.3 下载任务类
GET /api/tasks
获取当前进行中的下载任务列表(状态为 waiting/downloading/merging)。
POST /api/tasks
添加下载任务。
请求体:
{ "name": "视频名称", "url": "https://example.com/video.m3u8", "size": "500MB" }PUT /api/tasks/:id
更新下载任务进度。
POST /api/download-video
启动视频下载(核心接口)。自动去重,加入下载队列。
请求体:
{ "url": "https://example.com/video.m3u8", "name": "视频名称" }响应:
{ "success": true, "message": "下载任务已添加到队列", "taskId": 5 }GET /api/download-status?url=xxx
查询指定 URL 的下载状态。
GET /api/check-video?url=xxx
检查本地是否已有指定 URL 的视频缓存。
响应(已缓存):
{
"success": true,
"exists": true,
"format": "mp4",
"path": "D:\\hcdata\\abc123\\143025.mp4",
"relativePath": "abc123/143025.mp4"
}4.4 缓存管理类
GET /api/cached?page=1&limit=99
获取已缓存视频列表(分页)。
DELETE /api/cached/:id
删除指定缓存记录及其本地文件。
DELETE /api/cached
一键清空所有已缓存视频。
4.5 视频播放类
GET /api/video?url=xxx
获取视频播放地址(返回局域网可访问的 URL),同时增加播放计数。
GET /hcdata/*filepath
视频文件静态服务路由,支持 HTTP Range 请求(断点续播/拖拽进度条)。
4.6 日志管理类
GET /api/logs?page=1&limit=100
获取系统日志(分页,按时间倒序)。
DELETE /api/logs
清空所有日志。
4.7 SSE 实时推送
GET /api/events
SSE 事件流端点,推送以下事件:
| 事件名 | 触发时机 | 数据 |
|---|---|---|
connected | 客户端连接成功 | {"status":"ok"} |
task-update | 下载进度更新 | {taskId, status, progress, speed, ...} |
task-completed | 下载任务完成 | {taskId, url, localPath} |
五、核心业务流程
5.1 视频下载流程
用户提交URL
│
▼
检查去重(download_tasks + video_downloads)
│ 已存在 → 返回"已在队列"或"已下载"
│ 不存在 ↓
▼
插入 download_tasks 表(status=waiting)
│
▼
加入内存下载队列 downloadQueue
│
▼
processQueue() 检查并发数
│ 未达上限 → 立即开始 startDownload()
│ 已达上限 → 等待队列
▼
调用 N_m3u8DL-RE.exe(spawn 子进程)
│ 参数:URL、保存目录、线程数、UA 等
│
▼
实时解析 stdout/stderr 输出
│ 提取进度百分比、速度、TS块号
│ 更新 download_tasks 表
│ SSE 推送 task-update 事件
│
▼
进程退出(exit code 0 = 成功)
│
▼
在临时目录递归查找视频文件
│
▼
safeMoveFile() 转移到最终存储路径
│ 路径格式:{storagePath}/{URL的MD5}/{时分秒}.mp4
│
▼
更新 video_downloads 表(status=completed)
删除 download_tasks 记录
SSE 推送 task-completed 事件
│
▼
延迟 8 秒后清理临时目录5.2 视频播放流程
用户点击"播放"按钮
│
▼
打开 player.html?url={encodedURL}
│
▼
调用 /api/check-video 检查本地缓存
│
├── 已缓存(MP4)──→ "秒播模式"
│ │
│ ▼
│ 调用 /api/video 获取局域网播放URL
│ │
│ ▼
│ 使用 MuiPlayer 播放本地 MP4
│ (播放次数 +1)
│
└── 未缓存 ──→ "后台加速中..."
│
▼
使用 DPlayer + hls.js 直接播放 m3u8 流
│
▼
同时调用 /api/download-video 提交后台下载
(下次访问即可秒播)5.3 下载队列管理
┌─────────────────────┐
│ downloadQueue[] │ ← 新任务入队
└─────────┬───────────┘
│ shift()
▼
┌─────────────────────────┐
│ activeDownloads 计数器 │
│ 上限 = max_concurrent │
└─────────┬───────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 任务 A │ │ 任务 B │ │ 任务 C │ ... 最多 N 个并发
│ 线程: T │ │ 线程: T │ │ 线程: T │
└─────────┘ └─────────┘ └─────────┘
│ │ │
▼ ▼ ▼
完成后 activeDownloads--
调用 processQueue() 取下一个5.4 文件存储策略
存储根路径(用户设置)
D:\hcdata\
│
├── {MD5_hash_1}\ # 每个URL对应一个哈希目录
│ ├── 143025.mp4 # 文件名 = 下载时间(时分秒)
│ └── 151200.mp4
│
├── {MD5_hash_2}\
│ └── 092530.mp4
│
└── ...
临时下载目录:
datatmp\tmp\
├── {MD5_hash}\ # N_m3u8DL-RE 下载时的临时目录
│ └── {MD5_hash}.mp4 # 下载完成后的临时文件
└── tmp\ # 分片临时目录
└── ... # 下载完成后延迟 8 秒清理关键设计:
- URL 通过 MD5 哈希生成唯一目录名,避免文件名冲突
- 文件名为下载时间(HHmmss 格式),同一 URL 多次下载不会覆盖
- 跨盘符文件移动使用
safeMoveFile()兼容 EXDEV 错误(先复制后删除) - 临时文件延迟清理,避免影响其他正在进行的任务
5.5 服务启动流程
服务启动时按以下顺序初始化:
1. 创建 Express 应用,设置端口 3000
2. 初始化 SSE 客户端集合 (sseClients = new Set())
3. 创建 data/ 目录(如不存在)
4. 打开 SQLite 数据库 (hcdata.db)
5. 执行 CREATE TABLE IF NOT EXISTS 创建 4 张表
6. 执行数据库迁移(为旧表添加 play_count 字段)
7. 注册中间件(JSON 解析、静态资源服务)
8. 注册所有 API 路由
9. 调用 app.listen() 开始监听
10. 写入启动日志
11. 调用 initDownloadQueue() 恢复未完成任务initDownloadQueue() 恢复逻辑:
- 查询
download_tasks表中状态为waiting或downloading的所有记录 - 将这些任务的状态重置为
waiting,进度归零 - 依次加入内存队列
downloadQueue[] - 调用
processQueue()开始处理
设计意图:服务异常退出后重启,未完成的下载任务不会丢失,会自动重新排队。
5.6 优雅关闭流程
收到 SIGINT / SIGTERM / SIGBREAK 信号
│
▼
gracefulShutdown(signal)
│
├── 打印关闭提示
├── 关闭数据库连接 (db.close())
└── process.exit(0)信号处理:
SIGINT:Ctrl+C 触发SIGTERM:系统终止信号SIGBREAK:Windows 平台特有,Ctrl+Break 触发
注意:当前优雅关闭未等待进行中的下载任务完成,直接退出。进行中的 N_m3u8DL-RE 子进程会随父进程退出而终止。
5.7 合并超时处理机制
N_m3u8DL-RE 下载完成后需要合并 TS 分片,这一步可能卡住。程序实现了超时兑底:
进度 >= 99%
│
▼
记录 mergeStartTime,启动 30 秒间隔检查器
│
▼
每 30 秒检查:Date.now() - mergeStartTime > 5 分钟?
│
├── 未超时 → 继续等待
│
└── 已超时 →
│
▼
递归查找临时目录中的视频文件
│
├── 找到文件 → 强制转移到最终路径,标记完成
└── 未找到 → 标记为 error,终止进程超时处理中的文件查找策略:
- 先查
tempSubDir(任务专属临时目录) - 再查
tempPath(整个临时下载目录) - 最后查
tempOutputPath(固定预期路径) - 支持的文件扩展名:
.mp4,.mkv,.ts,.flv,.m4s
六、核心函数详解
6.1 startDownload(task) — 下载执行引擎
这是系统最核心的函数,负责从启动下载到文件转移的完整流程。
参数:
{
url: string, // 视频 m3u8 URL
name: string, // 视频名称
taskId: number // download_tasks 表中的 ID
}执行步骤:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 计算路径 | 生成 URL 的 MD5 哈希、最终存储路径、临时下载路径 |
| 2 | 更新任务状态 | download_tasks.status = 'downloading' |
| 3 | 写入视频记录 | video_downloads 表插入/更新记录 |
| 4 | 构建命令行参数 | URL、保存目录、线程数、UA 等 |
| 5 | spawn 子进程 | 启动 N_m3u8DL-RE.exe |
| 6 | 注册进程映射 | activeDownloadProcesses.set(taskId, {child, ...}) |
| 7 | 监听 stdout/stderr | 实时解析进度 |
| 8 | 监听 close 事件 | 处理成功/失败 |
| 9 | 成功时转移文件 | safeMoveFile() 到最终路径 |
| 10 | 更新数据库 | video_downloads.status = 'completed' |
| 11 | 清理临时文件 | 延迟 8 秒后清理 |
| 12 | 推送 SSE | task-completed 事件 |
| 13 | 触发下一任务 | activeDownloads--; processQueue() |
路径计算逻辑:
// 最终存储路径
const finalDir = path.join(storagePath, urlHash); // D:\hcdata\{md5}\
const timeStr = formatTime(new Date()); // 如 "143025"
const finalPath = path.join(finalDir, timeStr + '.mp4'); // D:\hcdata\{md5}\143025.mp4
// 临时下载路径
const tempSubDir = path.join(tempPath, urlHash); // datatmp\tmp\{md5}\
const tempOutputPath = path.join(tempSubDir, urlHash + '.mp4');6.2 parseProgress(output) — 进度解析函数
从 N_m3u8DL-RE 的原始输出中提取结构化进度信息。
处理流程:
原始输出(含 ANSI 转义码)
│
▼
去除 ANSI 转义码:output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
替换 \r 为 \n
│
▼
正则提取进度百分比:/(\d+\.?\d*)%/
正则提取 TS 块进度:/(\d+)\/(\d+)\s+\d+\.?\d*%/
正则提取下载速度:/([\d.]+\s*[KMGT]?Bps)/
│
▼
根据进度判断状态:
- progress >= 99 → status = 'merging', speed = '合并中...'
- progress >= 95 → speed = '即将完成...'
- 其他 → status = 'downloading'
│
▼
更新 download_tasks 表
更新 video_downloads 表
SSE 广播 task-update 事件为什么同时监听 stdout 和 stderr:
N_m3u8DL-RE 在某些情况下会将进度信息输出到 stderr 而非 stdout。为确保不丢失进度更新,两个流都调用 parseProgress() 解析。
6.3 processQueue() — 队列调度器
async function processQueue() {
while (activeDownloads < getMaxConcurrent() && downloadQueue.length > 0) {
const task = downloadQueue.shift(); // 从队头取出
activeDownloads++;
startDownload(task)
.catch(err => console.error('下载任务失败:', err))
.finally(() => {
activeDownloads--; // 无论成功失败都释放槽位
processQueue(); // 递归检查队列
});
}
}关键特性:
- 使用
while循环一次性填充所有可用槽位 getMaxConcurrent()每次从数据库读取,支持运行时动态调整finally块确保异常时也能释放槽位并触发下一轮调度- 递归调用实现链式调度
6.4 safeMoveFile(src, dest) — 跨盘符文件移动
function safeMoveFile(src, dest) {
try {
fs.renameSync(src, dest); // 同盘符:直接重命名(零拷贝)
return true;
} catch (err) {
if (err.code === 'EXDEV') { // 跨盘符:EXDEV 错误
fs.copyFileSync(src, dest); // 先复制
fs.unlinkSync(src); // 再删除源
return true;
}
throw err; // 其他错误向上抛出
}
}为什么需要这个函数:
临时下载目录在 datatmp\tmp\(通常在 C 盘),而最终存储路径可能在 D/E 等任意盘符。fs.renameSync() 在跨盘符时会抛出 EXDEV 错误,需要回退到复制+删除方案。
6.5 getVideoFileName(url) — URL 哈希生成
function getVideoFileName(url) {
const hash = crypto.createHash('md5').update(url).digest('hex');
return hash; // 32 位十六进制字符串
}用途: 将任意长度的 URL 映射为固定长度的唯一目录名,避免 URL 中的特殊字符导致文件系统问题。
七、SSE 实时推送机制
7.1 服务端实现
const sseClients = new Set(); // 存储所有 SSE 连接的 response 对象
// 客户端连接
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // 禁止 Nginx 缓冲
});
res.write('event: connected\ndata: {"status":"ok"}\n\n');
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
});
// 广播函数
function broadcastSSE(event, data) {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
sseClients.forEach(client => {
try { client.write(message); }
catch (e) { sseClients.delete(client); }
});
}设计要点:
- 使用
Set存储连接,自动去重 - 写入失败时自动移除断开的连接
X-Accel-Buffering: no防止反向代理缓冲 SSE 数据- 每个 SSE 消息以
\n\n结尾(SSE 协议规范)
7.2 客户端实现
const eventSource = new EventSource('/api/events');
eventSource.addEventListener('connected', () => {
console.log('SSE 已连接');
loadTasks(); // 连接成功后立即刷新任务列表
});
eventSource.addEventListener('task-update', (e) => {
loadTasks(); // 每次进度更新都刷新任务列表
if (data.status === 'merging' || data.progress >= 99) {
loadStats(); // 合并阶段同时刷新统计
}
});
eventSource.addEventListener('task-completed', (e) => {
loadTasks();
loadStats();
loadCached(); // 刷新已缓存列表
loadDiskSpace(); // 刷新磁盘空间
});
eventSource.onerror = () => {
eventSource.close();
setTimeout(() => location.reload(), 3000); // 3 秒后整页刷新重连
};7.3 SSE 事件触发点
| 触发位置 | 事件名 | 触发时机 |
|---|---|---|
parseProgress() | task-update | 每次解析到进度更新 |
startDownload() 成功 | task-completed | 文件转移完成,数据库更新后 |
7.4 备用轮询机制
为防止 SSE 失效,前端同时使用定时轮询作为备用:
| 轮询目标 | 间隔 | 说明 |
|---|---|---|
loadTasks() | 5 秒 | 任务列表(主刷新渠道) |
loadDiskSpace() | 10 秒 | 磁盘空间 |
loadLogs() | 15 秒 | 系统日志 |
loadCached() | 60 秒 | 已缓存列表 |
八、安全机制
8.1 路径遍历防护
视频文件服务路由 /hcdata/*filepath 实现了路径安全检查:
app.get('/hcdata/*filepath', (req, res) => {
const filePath = decodeURIComponent(req.params.filepath.join('/'));
const fullPath = path.join(storagePath, filePath);
// 安全检查:确保解析后的文件路径在存储目录内
const resolvedPath = path.resolve(fullPath);
const resolvedStorage = path.resolve(storagePath);
if (!resolvedPath.startsWith(resolvedStorage)) {
return res.status(403).json({ success: false, error: '拒绝访问' });
}
// ...
});防护原理: 攻击者可能构造 ../../etc/passwd 这样的路径来访问系统文件。通过 path.resolve() 将路径解析为绝对路径后,检查是否以存储根路径开头,防止目录遍历攻击。
8.2 XSS 防护
前端渲染用户输入(视频名称、URL)时使用 escapeHtml() 函数:
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text; // 浏览器自动转义 HTML 实体
return div.innerHTML;
}所有动态内容在插入 DOM 前都经过此函数处理,防止跨站脚本攻击。
8.3 输入验证
所有 API 接口都进行基础输入验证:
- 必填字段检查:
if (!url) return res.status(400).json(...) - 类型检查:
if (!drive) ...、if (value === undefined || value === null) ... - 数值范围限制:并发数 1-10、线程数 1-32
- URL 参数解码:
decodeURIComponent()
九、前端页面详细说明
9.1 管理后台 (index.html) — CSS 设计体系
CSS 变量主题系统:
:root {
--bg-base: #0d1117; /* 页面底色(最深) */
--bg-surface: #161b22; /* 卡片/面板背景 */
--bg-elevated: #1c2128; /* 悬浮/高亮背景 */
--border: #30363d; /* 边框颜色 */
--text-primary: #e6edf3; /* 主文本 */
--text-secondary: #8b949e;/* 次要文本 */
--accent: #58a6ff; /* 强调色(蓝) */
--success: #3fb950; /* 成功色(绿) */
--warning: #d29922; /* 警告色(黄) */
--error: #f85149; /* 错误色(红) */
}设计原则:
- 三层背景深度:
base → surface → elevated,营造层次感 - 所有
border-radius: 0,方正风格 - 无阴影效果,依赖边框分割
- 颜色与 GitHub Dark 主题相近
9.2 管理后台 — 布局结构
┌────────────────────────────────────────────────────┐
│ body (flex) │
│ ┌──────────┐ ┌────────────────────────────┐ │
│ │ sidebar │ │ main-content │ │
│ │ (220px) │ │ (flex: 1) │ │
│ │ │ │ │ │
│ │ ┌─────┐ │ │ ┌─────────────────────┐ │ │
│ │ │ Logo │ │ │ │ page-title │ │ │
│ │ └─────┘ │ │ └─────────────────────┘ │ │
│ │ ┌─────┐ │ │ ┌─────────────────────┐ │ │
│ │ │统计区│ │ │ │ │ │ │
│ │ │ IP │ │ │ │ 当前任务/已缓存 │ │ │
│ │ │已完成│ │ │ │ /日志/设置 │ │ │
│ │ │存储路径│ │ │ │ │ │ │
│ │ │磁盘剩余│ │ │ │ │ │ │
│ │ │点播次数│ │ │ │ │ │ │
│ │ │命中率│ │ │ │ │ │ │
│ │ └─────┘ │ │ └─────────────────────┘ │ │
│ │ ┌─────┐ │ │ │ │
│ │ │导航菜单│ │ │ │
│ │ │当前任务│ │ │ │
│ │ │已缓存 │ │ │ │
│ │ │日志 │ │ │ │
│ │ │设置 │ │ │ │
│ │ └─────┘ │ │ │ │
│ └──────────┘ └────────────────────────────┘ │
└────────────────────────────────────────────────────┘四个功能页面:
| 页面 | 导航项 | 功能说明 |
|---|---|---|
| 当前任务 | 📊 当前任务 | 显示正在下载/等待/合并的任务列表,含进度条、速度、TS块进度 |
| 已缓存 | 💾 已缓存 | 已下载完成的视频列表,支持播放、删除、分页浏览、一键清空 |
| 日志 | 📋 日志 | 系统运行日志查看,支持分页、清空 |
| 设置 | ⚙️ 设置 | 存储盘符选择、并发下载数设置、单任务线程数设置 |
页面切换机制:
- 4 个
.pagediv 同时存在,通过.active类控制显示/隐藏 - 导航点击时移除所有
.active,只给目标页添加 - 无动画过渡,瞬间切换
9.3 管理后台 — 前端状态管理
全局变量:
let tasks = []; // 当前下载任务列表(内存缓存)
let cachedCurrentPage = 1; // 已缓存列表当前页码
let logCurrentPage = 1; // 日志列表当前页码
const cachedPageSize = 24; // 已缓存每页条数
const logPageSize = 100; // 日志每页条数数据加载函数一览:
| 函数 | 调用时机 | 请求 API |
|---|---|---|
loadSettingsDrives() | 页面初始化 | GET /api/drives |
loadConfig() | 页面初始化 | GET /api/config |
loadMaxConcurrent() | 页面初始化 | GET /api/settings/max_concurrent |
loadThreadCount() | 页面初始化 | GET /api/settings/thread_count |
loadDiskSpace() | 初始化 + 每 10 秒 | GET /api/disk-space |
loadTasks() | 初始化 + 每 5 秒 + SSE | GET /api/tasks |
loadCached(page) | 初始化 + 每 60 秒 + SSE | GET /api/cached |
loadLogs(page) | 初始化 + 每 15 秒 | GET /api/logs |
loadLocalIP() | 页面初始化 | GET /api/local-ip |
loadStats() | 初始化 + SSE | GET /api/stats |
9.4 管理后台 — 各页面详细功能
当前任务页
表格列定义(CSS Grid):
grid-template-columns: 2fr 100px 130px 100px;| 列 | 宽度 | 内容 |
|---|---|---|
| 下载地址 | 自适应 | 视频名称 + URL(溢出截断) |
| 进度 | 100px | 状态点 + 百分比 |
| 速度/TS块 | 130px | 实时速度 + TS块进度 |
| 状态 | 100px | 下载中/等待中/合并中 |
状态点颜色:
downloading→ 绿色 (--success)waiting→ 黄色 (--warning)merging→ 蓝色 (--accent)
已缓存页
表格列定义:
grid-template-columns: 2fr 1fr 100px 1fr 120px;| 列 | 内容 |
|---|---|
| 文件名 | 视频名称(title 显示完整 URL) |
| 大小 | 文件大小(MB) |
| 播放次数 | N 次 |
| 下载时间 | MM-DD HH:mm 格式 |
| 操作 | 播放按钮 + 删除按钮 |
分页控件:
- 显示总记录数
- 页码按钮(最多显示当前后各 2 页)
- 超出范围用省略号表示
- 上/下一页按钮
日志页
表格列定义:
grid-template-columns: 120px 80px 1fr;| 列 | 内容 |
|---|---|
| 时间 | MM-DD HH:mm:ss |
| 级别 | INFO/WARN/ERROR(带颜色) |
| 内容 | 日志消息(自动换行) |
级别颜色映射:
const levelColors = {
info: 'var(--accent)', // 蓝色
warn: 'var(--warning)', // 黄色
error: 'var(--error)' // 红色
};设置页
三个设置卡片:
| 卡片 | 控件 | 保存 API | 范围 |
|---|---|---|---|
| 默认保存路径 | 盘符下拉框 + 保存按钮 | POST /api/save-drive | 系统盘符列表 |
| 同时下载任务数 | 数字输入框 + 保存按钮 | POST /api/settings/max_concurrent | 1-10 |
| 单任务下载线程数 | 数字输入框 + 保存按钮 | POST /api/settings/thread_count | 1-32 |
9.5 播放页面 (player.html) — 详细流程
初始化流程:
页面加载
│
▼
从 URL 参数获取视频源地址
const m3u8Url = getQueryParam('url');
│
├── 无 URL → 显示错误信息 "错误: 未提供视频地址"
│
└── 有 URL →
│
▼
调用 /api/check-video?url=xxx
│
├── 已缓存 (format=mp4) →
│ │
│ ▼
│ updateSourceTag('cached') // 显示"秒播模式"
│ │
│ ▼
│ 调用 /api/video?url=xxx 获取局域网播放 URL
│ │
│ ▼
│ createMuiPlayer(videoUrl) // MuiPlayer 播放
│
└── 未缓存 / 请求失败 →
│
▼
updateSourceTag('network') // 显示"后台加速中..."
│
▼
createDPlayer(m3u8Url, true) // DPlayer+hls.js 播放网络流
│
▼
submitDownloadTask(m3u8Url) // 后台提交下载播放器创建对比:
| 特性 | DPlayer | MuiPlayer |
|---|---|---|
| 使用场景 | 网络 m3u8 流 | 本地 MP4 文件 |
| HLS 支持 | 通过 hls.js customType | 原生支持 |
| 自动播放 | autoplay: true | autoplay: true |
| 桌面插件 | 无 | MuiPlayerDesktopPlugin |
| 控件 | 默认控件 | 可自定义 headControls/footerControls |
9.6 视频流服务 — Range 请求支持
GET /hcdata/*filepath 路由支持 HTTP Range 请求,实现视频拖拽进度条和断点续播:
客户端请求:
GET /hcdata/abc123/143025.mp4
Range: bytes=1048576-2097151
服务端响应:
HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-2097151/5242880
Accept-Ranges: bytes
Content-Length: 1048576
Content-Type: video/mp4CORS 头设置:
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Range, Content-Type');
res.header('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');十、API 接口详细参考
10.1 统一响应格式
成功响应:
{ "success": true, "data_field": "...", "message": "操作成功" }失败响应:
{ "success": false, "error": "错误描述信息" }HTTP 状态码使用:
| 状态码 | 场景 |
|---|---|
| 200 | 成功 |
| 400 | 参数缺失/无效 |
| 403 | 路径遍历拒绝 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
10.2 下载相关 API 详细逻辑
POST /api/download-video — 完整去重逻辑
收到请求 { url, name }
│
▼
检查 download_tasks 表是否有相同 URL
│
├── status=completed → 返回 { exists: true, message: '该视频已下载完成' }
├── status=downloading/waiting → 返回 { exists: true, message: '该视频已在下载队列中' }
│
└── 不存在 ↓
│
▼
检查 video_downloads 表
│
├── status=completed → 返回 { exists: true, message: '该视频已下载完成' }
│
└── 不存在/其他状态 ↓
│
▼
插入 download_tasks(status=waiting)
加入 downloadQueue[]
调用 processQueue()
返回 { success: true, taskId }GET /api/check-video?url=xxx — 三级缓存检查
检查顺序:
1. 数据库记录 → video_downloads 表中 url 对应记录的 local_path 是否存在
2. 哈希目录 → {storagePath}/{md5}/ 目录下是否有 .mp4 文件
3. 兼容旧路径 → {storagePath}/{md5}.mp4 或 {md5}.m3u8 是否存在GET /api/cached — 响应结构
{
"success": true,
"videos": [
{
"id": 1,
"url": "https://example.com/video.m3u8",
"name": "视频名称",
"local_path": "D:\\hcdata\\abc123\\143025.mp4",
"status": "completed",
"progress": 100,
"message": "下载完成",
"format": "mp4",
"play_count": 5,
"created_at": "2026-07-31 14:30:25",
"updated_at": "2026-07-31 14:35:10",
"file_size": "256.78 MB"
}
],
"pagination": { "page": 1, "limit": 99, "total": 42, "totalPages": 1 }
}file_size 字段是服务端实时读取文件系统获取的,非数据库存储。
10.3 缓存管理 API 详细逻辑
DELETE /api/cached/:id — 删除流程
1. 查询 video_downloads 获取记录
2. 删除本地文件 (fs.unlinkSync)
3. 尝试删除空目录 (fs.rmSync)
4. 删除 video_downloads 记录
5. 删除关联的 download_tasks 记录(按 URL 匹配)
6. 写入日志DELETE /api/cached — 一键清空流程
1. 查询所有 status='completed' 的记录
2. 逐个删除本地文件和空目录
3. 删除所有 completed 的 video_downloads 记录
4. 删除所有 completed 的 download_tasks 记录
5. 返回删除统计 { deletedCount, errorCount }十一、临时文件清理策略
11.1 临时文件产生来源
N_m3u8DL-RE 下载过程中会产生以下临时文件:
| 文件类型 | 位置 | 说明 |
|---|---|---|
| 下载输出文件 | datatmp\tmp\{md5}\{md5}.mp4 | 下载完成后的合并文件 |
| TS 分片 | datatmp\tmp\tmp\ 下以 md5 前 8 位命名的子目录 | 下载的原始分片 |
| 元数据 JSON | datatmp\tmp\{md5}\{md5}.json | 解析信息(如果开启了 write-meta-json) |
11.2 清理时机
程序采用“延迟清理”策略,避免影响其他并发任务:
任务完成
│
▼
立即转移视频文件到最终存储路径
│
▼
启动 8 秒延迟定时器
│
▼
8 秒后执行清理:
├── 清理任务专属临时目录 (tempSubDir)
├── 清理 tmp 目录中匹配 urlHash 前 8 位的子目录
└── 清理 tmp 目录根下匹配 urlHash 前 8 位的文件11.3 清理函数详解
function cleanupTempDir(dir) {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
cleanupTempDir(itemPath); // 递归清理子目录
fs.rmdirSync(itemPath);
} else {
fs.unlinkSync(itemPath); // 删除文件
}
}
}匹配规则: 通过 tmpItem.includes(urlHash.substring(0, 8)) 匹配当前任务的分片目录,避免误删其他任务的临时文件。
11.4 异常残留处理
如果服务异常退出(如断电、强制关闭),临时文件可能残留。当前处理方式:
- 无自动清理机制:残留文件需手动删除
- 不影响功能:新下载任务会创建新的临时目录,不会读取旧残留
十二、系统数据流总览
12.1 完整数据流图
用户浏览器
│
│ ① 访问管理后台
▼
Express 静态服务 (public/index.html)
│
│ ② 请求下载任务列表
▼
GET /api/tasks → SQLite download_tasks 表 → JSON 响应
│
│ ③ SSE 连接
▼
GET /api/events → 保持连接 → 实时推送进度
│
│ ④ 提交下载任务(通过播放器页面或 API)
▼
POST /api/download-video
│
├── 去重检查 (download_tasks + video_downloads)
├── 插入 download_tasks 表
├── 加入 downloadQueue[]
└── processQueue() → startDownload()
│
│ ⑤ 调用外部工具
▼
spawn N_m3u8DL-RE.exe
│
│ ⑥ 实时解析进度
▼
stdout/stderr → parseProgress()
│
├── 更新 download_tasks 表
├── 更新 video_downloads 表
└── SSE 广播 task-update
│
│ ⑦ 下载完成
▼
safeMoveFile() 转移文件
│
├── 更新 video_downloads (status=completed)
├── 删除 download_tasks 记录
├── SSE 广播 task-completed
└── 8 秒后清理临时文件
│
│ ⑧ 用户点击播放
▼
GET /api/check-video → 检查缓存
│
├── 已缓存 → GET /api/video → 返回局域网 URL
│ │
│ ▼
│ GET /hcdata/*filepath → Range 请求支持 → 视频流
│
└── 未缓存 → 返回 m3u8 原始 URL + 后台下载12.2 数据库表关系
config ──────────────────┐
(key-value) │
storage_path ────────┐ │
max_concurrent │ │
thread_count │ │
nm_user_agent │ │
│ │
download_tasks ──────┐ │ │
(进行中的任务) │ │ │
id, url, name │ │ │
status, progress │ │ │
完成后删除 ───────┤ │ │
│ │
video_downloads ────┤ │ │
(全部历史记录) │ │ │
url (UNIQUE) ─────┘ │ │
local_path ──────────┘ │
status, play_count │
完成后保留 ───────────┘
system_logs
(运行日志)
自动保留 500 条十三、外部工具集成
13.1 N_m3u8DL-RE
版本: 20251027 (Beta)
调用方式: 通过 Node.js child_process.spawn() 启动子进程
核心参数:
| 参数 | 值 | 说明 |
|---|---|---|
<input> | 视频 URL | m3u8 地址 |
--save-dir | datatmp\tmp | 输出目录 |
--save-name | URL 的 MD5 哈希 | 保存文件名 |
--tmp-dir | datatmp\tmp\tmp | 临时分片目录 |
--thread-count | 配置值(默认 4) | 下载线程数 |
--del-after-done | false | 不自动删除临时文件(由程序管理清理) |
--header | User-Agent: xxx | 可选,自定义请求头 |
进度解析:
- 同时监听 stdout 和 stderr
- 先去除 ANSI 转义码
- 正则提取进度百分比 (
\d+\.?\d*%) - 正则提取 TS 块进度 (
\d+/\d+) - 正则提取下载速度 (
[KMGT]?Bps)
13.2 FFmpeg
N_m3u8DL-RE 内部依赖 FFmpeg 进行视频合并。项目 tools 目录包含完整的 FFmpeg 可执行文件及依赖 DLL。
十四、启动与部署
14.1 环境要求
- 操作系统: Windows(使用了 wmic、Windows 信号等)
- 运行时: Node.js(需支持 ES5+ 语法)
- 浏览器: 任意现代浏览器(Chrome/Edge/Firefox/Safari)
14.2 安装步骤
cd hcdata-web
npm install14.3 启动方式
方式一:批处理启动
双击 启动服务端.bat该脚本会:
- 设置 UTF-8 编码
- 切换到 hcdata-web 目录
- 自动打开浏览器访问 http://localhost:3000
- 启动 Node.js 服务
方式二:命令行启动
cd hcdata-web
node server.js14.4 访问地址
- 本机:http://localhost:3000
- 局域网:http://{服务器IP}:3000
14.5 首次使用配置
- 进入"设置"页面
- 选择视频文件存储盘符
- 点击"保存"(会自动创建
{盘符}:\hcdata目录) - 可选:调整并发下载数和线程数
十五、关键设计决策
15.1 为什么用 SQLite 而非文件存储配置
- 支持并发读写(better-sqlite3 同步 API 无竞态问题)
- 事务支持(批量保存配置)
- 结构化查询(分页、条件过滤)
- 单文件部署,无需额外数据库服务
15.2 为什么用 SSE 而非 WebSocket
- 单向推送即可满足需求(服务端 → 客户端)
- 基于 HTTP,无需额外协议支持
- 浏览器原生 EventSource API,代码更简洁
- 断线自动重连
15.3 为什么双播放器方案
- DPlayer + hls.js:对 m3u8 流兼容性最好
- MuiPlayer:MP4 本地文件播放体验更好(支持更多控件)
- 根据缓存状态自动切换,用户无感知
15.4 文件命名策略
- URL → MD5 哈希作为目录名:保证唯一性,避免特殊字符
- 时间戳作为文件名:同一 URL 可多次下载不覆盖
- 延迟清理临时文件:避免并发任务间的文件冲突
15.5 下载队列设计
- 内存队列 + 数据库双写:保证重启后可恢复
- 服务启动时扫描未完成的任务,重新加入队列
- 支持配置并发数(1-10)和单任务线程数(1-32)
十六、辅助工具
16.1 check-db.js
数据库检查脚本,用于调试查看数据库状态:
node check-db.js输出所有表名和 download_tasks 表的全部记录。
16.2 启动服务端.bat
Windows 一键启动脚本,自动打开浏览器。
十七、已知限制与注意事项
17.1 平台限制
- 仅支持 Windows:使用了
wmic获取磁盘信息、Windows 信号处理(SIGBREAK) - 无跨平台支持:
wmic logicaldisk是 Windows 特有命令
17.2 安全限制
- 无用户认证:局域网内任何人可访问管理后台,无登录/权限机制
- 无 HTTPS:仅支持 HTTP 协议,数据传输未加密
- CORS 全开:
Access-Control-Allow-Origin: *,任何来源可调用 API
17.3 性能限制
- 数据库单文件:高并发场景下可能有锁竞争(当前场景可忽略)
- 同步数据库 API:better-sqlite3 同步操作会阻塞 Node.js 事件循环(但 SQLite 操作通常很快)
- 无连接数限制:SSE 连接数无上限,大量客户端可能导致内存问题
17.4 可靠性限制
- 临时文件清理:依赖延迟定时器(8 秒),异常退出可能残留临时文件
- 合并超时:N_m3u8DL-RE 合并阶段可能卡住,程序设了 5 分钟超时兑底
- 磁盘空间:大量下载可能导致磁盘满,侧边栏有实时显示但无预警机制
- 优雅关闭不完善:关闭服务时不等待进行中的下载任务完成,子进程直接终止
17.5 功能限制
- 无搜索功能:已缓存列表不支持搜索/过滤
- 无批量导入:只能逐个提交下载任务,不支持批量导入 URL 列表
- 无下载重试:下载失败后不会自动重试,需手动重新提交
- 无速度限制:单任务下载无限速配置(N_m3u8DL-RE 支持但程序未使用)
十八、依赖清单
Node.js 依赖 (package.json)
| 包名 | 版本 | 用途 |
|---|---|---|
| express | ^5.2.1 | Web 服务框架 |
| better-sqlite3 | ^12.10.0 | SQLite 数据库驱动 |
| node-gyp | ^12.3.0 | better-sqlite3 编译依赖 |
外部工具
| 工具 | 版本 | 用途 |
|---|---|---|
| N_m3u8DL-RE | 20251027 | m3u8/HLS/DASH 视频下载 |
| FFmpeg | 最新 | 视频合并/转码(被 N_m3u8DL-RE 调用) |
前端库
| 库名 | 用途 |
|---|---|
| DPlayer | 视频播放器(网络流播放) |
| MuiPlayer | 视频播放器(本地文件播放) |
| MuiPlayerDesktopPlugin | MuiPlayer 桌面端插件 |
| hls.js | HLS 流媒体解析 |
十九、开发约定
19.1 代码风格
- 后端:CommonJS 模块 (
require/module.exports) - 前端:原生 JavaScript,无框架,内联
<script>标签 - CSS:内联
<style>标签,CSS 变量管理主题色 - 无代码构建步骤
19.2 API 响应格式
所有 API 统一返回 JSON:
{ "success": true/false, "data...": "...", "error": "错误信息" }19.3 错误处理
- API 层统一 try-catch,返回 500 状态码
- 下载任务错误记录到数据库和日志
- 前端 fetch 调用统一 catch 处理
文档编写日期:2026年8月1日
基于项目源码版本:hcdata-web v1.0.0
评论