diff --git a/example.v b/example.v index 0c31584..d26bbb3 100644 --- a/example.v +++ b/example.v @@ -2,11 +2,16 @@ module main import net.http import hono +import os +import json fn main() { // ===== 1. 应用初始化 ===== mut app := hono.Hono.new() + // 创建分片上传管理器 + mut upload_manager := hono.new_chunk_upload_manager(hono.ChunkUploadConfig{}) + // ===== 2. 中间件配置 ===== // 日志中间件 @@ -50,7 +55,231 @@ fn main() { } app.use(hono.serve_static(options)) - // ===== 4. 基础路由 ===== + // ===== 4. 分块上传功能 ===== + + // 分片上传接口 + app.post('/upload/chunk', fn [mut upload_manager] (mut c hono.Context) http.Response { + return upload_manager.handle_chunk_upload(mut c) + }) + + // 查询上传状态接口 + app.get('/upload/status', fn [mut upload_manager] (mut c hono.Context) http.Response { + return upload_manager.get_upload_status(mut c) + }) + + // 查询已上传分片接口 + app.get('/upload/chunks', fn [mut upload_manager] (mut c hono.Context) http.Response { + file_hash := c.query['file_hash'] or { + c.status(400) + return c.json('{"error": "Missing file_hash parameter"}') + } + upload_status := upload_manager.uploads[file_hash] or { + // 如果内存中没有记录,检查是否有最终文件(已完成上传) + final_path := './uploads/${file_hash.trim_space()}' + if os.exists(final_path) { + // 文件已存在,返回所有分片已上传 + mut uploaded_chunks := []int{} + uploaded_chunks << 0 // 假设只有一个分片 + return c.json('{"uploaded_chunks": ' + json.encode(uploaded_chunks) + ', "total_chunks": 1, "completed": true}') + } + c.status(404) + return c.json('{"error": "Upload not found"}') + } + + // 检查是否所有分片都已上传(基于分片大小总和) + chunk_dir := os.join_path(os.join_path('./uploads/chunks', file_hash.trim_space()), upload_status.chunk_size.str()) + mut total_chunk_size := u64(0) + mut chunk_count := 0 + + if os.exists(chunk_dir) { + for i := 0; ; i++ { + chunk_path := os.join_path(chunk_dir, 'chunk_${i}.part') + if !os.exists(chunk_path) { + break + } + chunk_info := os.stat(chunk_path) or { continue } + total_chunk_size += chunk_info.size + chunk_count++ + } + } + + is_completed := total_chunk_size >= u64(upload_status.file_size) + + return c.json('{"uploaded_chunks": ' + json.encode(upload_status.uploaded_chunks) + ', "total_chunks": $chunk_count, "completed": $is_completed}') + }) + + // 检查分片是否存在接口(秒传功能) + app.get('/upload/chunk_exists', fn [mut upload_manager] (mut c hono.Context) http.Response { + file_hash := c.query['file_hash'] or { + c.status(400) + return c.json('{"error": "Missing file_hash parameter"}') + } + chunk_index := c.query['chunk_index'] or { + c.status(400) + return c.json('{"error": "Missing chunk_index parameter"}') + } + chunk_hash := c.query['chunk_hash'] or { + c.status(400) + return c.json('{"error": "Missing chunk_hash parameter"}') + } + file_size_str := c.query['file_size'] or { + c.status(400) + return c.json('{"error": "Missing file_size parameter"}') + } + file_size := file_size_str.int() + + // 新增 trunk_size 参数 + trunk_size_str := c.query['trunk_size'] or { '' } + mut chunk_size := upload_manager.config.chunk_size + if trunk_size_str != '' { + chunk_size = trunk_size_str.int() + } else if upload_status := upload_manager.uploads[file_hash] { + chunk_size = upload_status.chunk_size + } + + chunk_path := os.join_path(os.join_path(os.join_path('./uploads/chunks', file_hash.trim_space()), chunk_size.str()), 'chunk_${chunk_index}.part') + println('[DEBUG] Checking chunk: $chunk_path') + println('[DEBUG] File hash: $file_hash') + println('[DEBUG] Chunk index: $chunk_index') + println('[DEBUG] Expected hash: $chunk_hash') + + // 清理无效的上传状态 + upload_manager.cleanup_invalid_status() + + mut exists := os.exists(chunk_path) + println('[DEBUG] File exists: $exists') + + // 计算是否所有分片都已上传 + mut all_chunk_uploaded := false + + // 方案1:优先检查最终文件是否存在(新格式:filehash.filetype) + uploads_dir := './uploads/files' + if os.exists(uploads_dir) { + // 从上传状态中获取文件扩展名 + mut file_ext := '' + if upload_status := upload_manager.uploads[file_hash] { + file_ext = get_file_extension(upload_status.filename) + } + + // 如果内存中没有记录,尝试从数据库获取 + if file_ext == '' { + if file_info := upload_manager.db.get_file_by_hash(file_hash) { + file_ext = file_info.file_type + } + } + + // 构建最终文件名 + final_filename := '${file_hash.trim_space()}${file_ext}' + final_path := os.join_path(uploads_dir, final_filename) + + if os.exists(final_path) { + println('[DEBUG] Found final file: $final_path') + all_chunk_uploaded = true + } + } + + // 方案2:如果最终文件不存在,检查分片文件 + if !all_chunk_uploaded { + chunk_dir := os.join_path(os.join_path('./uploads/chunks', file_hash.trim_space()), chunk_size.str()) + if os.exists(chunk_dir) { + // 计算所有分片文件的大小总和 + mut total_chunk_size := u64(0) + mut chunk_count := 0 + // 遍历所有分片文件 + for i := 0; ; i++ { + chunk_file_path := '${chunk_dir}/chunk_${i}.part' + if !os.exists(chunk_file_path) { + break + } + // 获取分片文件大小 + chunk_info := os.stat(chunk_file_path) or { break } + total_chunk_size += chunk_info.size + chunk_count++ + } + // 使用传入的文件大小 + expected_file_size := u64(file_size) + println('[DEBUG] Total chunk size: $total_chunk_size, Expected file size: $expected_file_size, Chunk count: $chunk_count') + + // 计算理论上需要的分片数量 + expected_chunks := (expected_file_size + u64(chunk_size) - 1) / u64(chunk_size) + println('[DEBUG] Expected chunks: $expected_chunks') + + // 只有当分片数量达到预期且总大小 >= 文件大小时,才认为上传完成 + if chunk_count >= int(expected_chunks) && total_chunk_size >= expected_file_size { + all_chunk_uploaded = true + println('[DEBUG] All chunks uploaded: chunk_count=$chunk_count >= expected_chunks=$expected_chunks && total_size=$total_chunk_size >= file_size=$expected_file_size') + } else { + println('[DEBUG] Not all chunks uploaded: chunk_count=$chunk_count < expected_chunks=$expected_chunks || total_size=$total_chunk_size < file_size=$expected_file_size') + } + } + } + + println('[DEBUG] All chunk uploaded: $all_chunk_uploaded') + + // chunk_exists 接口不应该自动触发合并,只返回状态信息 + // 合并操作应该在 /upload/chunk 接口中处理 + + // 只检查文件是否存在,不验证 hash(因为前后端 hash 算法可能不一致) + // 如果需要严格验证,可以在这里添加 hash 检查逻辑 + + return c.json('{"exists": $exists, "all_chunk_uploaded": $all_chunk_uploaded}') + }) + + // 获取所有文件信息 + app.get('/api/files', fn [mut upload_manager] (mut c hono.Context) http.Response { + files := upload_manager.db.get_all_files() or { + c.status(500) + return c.json('{"error": "Failed to get files", "message": "$err"}') + } + return c.json(json.encode(files)) + }) + + // 根据文件UUID获取文件信息 + app.get('/api/files/:uuid', fn [mut upload_manager] (mut c hono.Context) http.Response { + file_uuid := c.params['uuid'] or { + c.status(400) + return c.json('{"error": "Missing file UUID"}') + } + + file_info := upload_manager.db.get_file_by_uuid(file_uuid) or { + c.status(404) + return c.json('{"error": "File not found"}') + } + + return c.json(json.encode(file_info)) + }) + + // 根据文件hash获取文件信息 + app.get('/api/files/hash/:hash', fn [mut upload_manager] (mut c hono.Context) http.Response { + file_hash := c.params['hash'] or { + c.status(400) + return c.json('{"error": "Missing file hash"}') + } + + file_info := upload_manager.db.get_file_by_hash(file_hash) or { + c.status(404) + return c.json('{"error": "File not found"}') + } + + return c.json(json.encode(file_info)) + }) + + // 删除文件信息 + app.delete('/api/files/:uuid', fn [mut upload_manager] (mut c hono.Context) http.Response { + file_uuid := c.params['uuid'] or { + c.status(400) + return c.json('{"error": "Missing file UUID"}') + } + + upload_manager.db.delete_file(file_uuid) or { + c.status(500) + return c.json('{"error": "Failed to delete file", "message": "$err"}') + } + + return c.json('{"success": true, "message": "File deleted successfully"}') + }) + + // ===== 5. 基础路由 ===== // 根路径 - 欢迎页面 app.get('/', fn (mut c hono.Context) http.Response { @@ -69,11 +298,12 @@ fn main() { .api-section { background: #f3e5f5; border-left: 4px solid #9C27B0; } .file-section { background: #e8f5e8; border-left: 4px solid #4CAF50; } .route-section { background: #fff3e0; border-left: 4px solid #FF9800; } + .upload-section { background: #fff8e1; border-left: 4px solid #FFC107; }

🚀 V-Hono 完整示例

-

A lightweight web framework for V with static file serving

+

A lightweight web framework for V with static file serving and chunk upload

📁 静态文件服务

@@ -142,6 +372,33 @@ fn main() {
+
+

📤 分块上传功能

+
+ 分片上传: POST /upload/chunk +
+
+ 上传状态: GET /upload/status +
+
+ 分片查询: GET /upload/chunks +
+
+ 分片检查: GET /upload/chunk_exists +
+
+ 文件管理: GET /api/files +
+

特性:

+ +
+

✨ 特性

@@ -182,6 +440,11 @@ curl http://localhost:8080/hello curl http://localhost:8080/users/123 curl http://localhost:8080/posts/456/comments/789 +# 分块上传测试 +curl -X POST http://localhost:8080/upload/chunk -F "file_hash=test123" -F "chunk_index=0" -F "chunk=@test.txt" +curl http://localhost:8080/upload/status?file_hash=test123 +curl http://localhost:8080/api/files + # POST 测试 curl -X POST http://localhost:8080/api/users -d "{\\"name\\":\\"John\\"}" curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"name\\":\\"Test\\"}" @@ -210,7 +473,7 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na return c.json(response) }) - // ===== 5. API 路由 ===== + // ===== 6. API 路由 ===== // 健康检查 app.get('/api/health', fn (mut c hono.Context) http.Response { @@ -237,12 +500,13 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na "ETag support", "Last-Modified headers", "Configurable caching", - "Custom headers" + "Custom headers", + "Chunk upload support" ] }') }) - // ===== 6. 动态路由 ===== + // ===== 7. 动态路由 ===== // 单参数路由 app.get('/users/:id', fn (mut c hono.Context) http.Response { @@ -279,7 +543,7 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na return c.json(response) }) - // ===== 7. HTTP 方法示例 ===== + // ===== 8. HTTP 方法示例 ===== // POST 请求 app.post('/api/users', fn (mut c hono.Context) http.Response { @@ -380,7 +644,7 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na return c.text('') }) - // ===== 8. 文件服务功能 ===== + // ===== 9. 文件服务功能 ===== // 基本文件服务示例 app.get('/file/:filename', fn (mut c hono.Context) http.Response { @@ -449,7 +713,7 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na return c.file_with_options(file_path, options) }) - // ===== 9. 错误处理示例 ===== + // ===== 10. 错误处理示例 ===== // 安全文件服务 app.get('/safe-file/:filename', fn (mut c hono.Context) http.Response { @@ -471,7 +735,7 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na return c.json('{"error": "Not Found", "message": "The requested resource was not found"}') }) - // ===== 10. 服务器启动 ===== + // ===== 11. 服务器启动 ===== println('🚀 V-Hono 完整示例启动中...') println('📍 服务器地址: http://localhost:8080') @@ -497,6 +761,13 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na println(' - GET /dynamic/:filename - 动态文件服务') println(' - GET /safe-file/:filename - 安全文件服务') println('') + println('📤 分块上传端点:') + println(' - POST /upload/chunk - 分片上传') + println(' - GET /upload/status - 上传状态') + println(' - GET /upload/chunks - 分片查询') + println(' - GET /upload/chunk_exists - 分片检查') + println(' - GET /api/files - 文件管理') + println('') println('🎯 动态路由:') println(' - GET /users/123') println(' - GET /posts/456/comments/789') @@ -534,9 +805,28 @@ curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"na println(' curl http://localhost:8080/users/123') println(' curl http://localhost:8080/posts/456/comments/789') println('') + println(' # 分块上传测试') + println(' curl -X POST http://localhost:8080/upload/chunk -F "file_hash=test123" -F "chunk_index=0" -F "chunk=@test.txt"') + println(' curl http://localhost:8080/upload/status?file_hash=test123') + println(' curl http://localhost:8080/api/files') + println('') println(' # POST 测试') println(' curl -X POST http://localhost:8080/api/users -d "{\\"name\\":\\"John\\"}"') println(' curl -X POST "http://localhost:8080/api/test/create/456?token=abc123" -d "{\\"name\\":\\"Test\\"}"') app.listen(':8080') +} + +// 获取文件扩展名 +fn get_file_extension(filename string) string { + println('[DEBUG] Getting extension for filename: "$filename"') + parts := filename.split('.') + println('[DEBUG] Split parts: $parts') + if parts.len > 1 { + ext := '.${parts.last()}' + println('[DEBUG] Extracted extension: "$ext"') + return ext + } + println('[DEBUG] No extension found, returning empty string') + return '' } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 9f19dee..5d4a04f 100644 --- a/public/index.html +++ b/public/index.html @@ -244,6 +244,71 @@ grid-template-columns: 1fr; } } + + /* 分块上传相关样式 */ + .upload-area { + border: 2px dashed #ccc; + border-radius: 8px; + padding: 40px; + text-align: center; + margin: 20px 0; + transition: border-color 0.3s; + } + + .upload-area:hover { + border-color: #007bff; + } + + .upload-area.dragover { + border-color: #007bff; + background-color: #f8f9fa; + } + + .file-info { + margin: 10px 0; + padding: 15px; + background-color: #f8f9fa; + border-radius: 5px; + display: none; + } + + .progress-container { + margin: 20px 0; + display: none; + } + + .progress-bar { + width: 100%; + height: 20px; + background-color: #e9ecef; + border-radius: 10px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #007bff, #28a745); + width: 0%; + transition: width 0.3s; + } + + .chunk-status { + margin: 10px 0; + font-size: 14px; + color: #666; + } + + .status.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + } + + .status.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + } @@ -281,6 +346,10 @@

🔄 HTTP 方法支持

完整支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 等 HTTP 方法

+
+

📤 分块上传

+

支持大文件分片上传、断点续传、秒传功能,内置文件完整性验证

+
@@ -307,6 +376,7 @@ + @@ -354,6 +424,40 @@ + + +
+
+

📤 分块上传功能

+

支持大文件分片上传、断点续传、秒传功能

+ +
+

📁 拖拽文件到此处或点击选择文件

+ + +
+ +
+

文件信息

+

文件名:

+

文件大小:

+

分片数量:

+

分片大小:

+
+ +
+

上传进度

+
+
+
+
+
+ +
+ + +
+
@@ -413,6 +517,23 @@
文件目录: /files/sample.json (来自 ./uploads)
+ +

📤 分块上传端点

+
+ 分片上传: POST /upload/chunk +
+
+ 上传状态: GET /upload/status +
+
+ 分片查询: GET /upload/chunks +
+
+ 分片检查: GET /upload/chunk_exists +
+
+ 文件管理: GET /api/files +
@@ -477,6 +598,7 @@ app.listen(':8080') + \ No newline at end of file diff --git a/test_no_total_chunks.v b/test_no_total_chunks.v deleted file mode 100644 index e4ac766..0000000 --- a/test_no_total_chunks.v +++ /dev/null @@ -1,93 +0,0 @@ -module main - -import hono -import os - -fn main() { - println('Testing upload without total_chunks parameter...') - - // Create upload manager - mut upload_manager := hono.new_chunk_upload_manager(hono.ChunkUploadConfig{}) - - // Test file info - file_hash := 'test123456789' - filename := 'test_file.zip' - chunk_index := 0 - chunk_size := 2097152 - file_size := 93569 - - // Create test chunk directory and file - chunk_dir := os.join_path(os.join_path('./uploads/chunks', file_hash), chunk_size.str()) - os.mkdir_all(chunk_dir) or { panic('Failed to create chunk directory') } - - // Create a test chunk file - chunk_path := os.join_path(chunk_dir, 'chunk_${chunk_index}.part') - test_data := 'This is test chunk data for testing the merge functionality.' - os.write_file(chunk_path, test_data) or { panic('Failed to create test chunk') } - - println('Created test chunk at: $chunk_path') - - // Test the update_upload_status function - upload_manager.update_upload_status(file_hash, filename, chunk_index, file_size, chunk_size) - println('Updated upload status') - - // Check if the upload status was created correctly - if file_hash in upload_manager.uploads { - status := upload_manager.uploads[file_hash] - println('Upload status:') - println(' File hash: ${status.file_hash}') - println(' Filename: ${status.filename}') - println(' Total chunks: ${status.total_chunks}') - println(' Uploaded chunks: ${status.uploaded_chunks}') - println(' File size: ${status.file_size}') - println(' Chunk size: ${status.chunk_size}') - println(' Status: ${status.status}') - } else { - println('Upload status not found!') - return - } - - // Test the merge logic - file_ext := get_file_extension(filename) - final_filename := '${file_hash.trim_space()}${file_ext}' - final_path := os.join_path('./uploads/files', final_filename) - - println('Testing merge logic...') - println('Final path: $final_path') - - // Calculate actual total chunks - mut actual_total_chunks := 0 - for i := 0; ; i++ { - check_chunk_path := os.join_path(chunk_dir, 'chunk_${i}.part') - if !os.exists(check_chunk_path) { - break - } - actual_total_chunks++ - } - - println('Actual total chunks: $actual_total_chunks') - - // Test merge - upload_manager.handle_chunk_merge_internal(file_hash, filename, actual_total_chunks, final_path, chunk_size, file_size, file_ext) or { - println('Merge failed: $err') - return - } - - println('Merge successful!') - println('Final file exists: ${os.exists(final_path)}') - - // Clean up - os.rmdir_all(chunk_dir) or { println('Failed to clean up chunk directory') } - os.rm(final_path) or { println('Failed to clean up final file') } - - println('Test completed successfully!') -} - -// 获取文件扩展名 -fn get_file_extension(filename string) string { - parts := filename.split('.') - if parts.len > 1 { - return '.${parts.last()}' - } - return '' -} \ No newline at end of file diff --git a/test_specific_fix.v b/test_specific_fix.v deleted file mode 100644 index 562ad1f..0000000 --- a/test_specific_fix.v +++ /dev/null @@ -1,66 +0,0 @@ -module main - -import hono -import os - -fn main() { - println('Testing specific file fix...') - - // Create upload manager - mut upload_manager := hono.new_chunk_upload_manager(hono.ChunkUploadConfig{}) - - // Use the exact same parameters from the failing case - file_hash := 'a45abf58302850765be03cf78bcf5129' - filename := 'test.zip' // This should give us .zip extension - total_chunks := 1 - chunk_size := 2097152 - file_size := 93569 - - // Create test chunk directory and file - chunk_dir := os.join_path(os.join_path('./uploads/chunks', file_hash), chunk_size.str()) - os.mkdir_all(chunk_dir) or { panic('Failed to create chunk directory') } - - // Create a test chunk file - chunk_path := os.join_path(chunk_dir, 'chunk_0.part') - test_data := 'This is test chunk data for testing the merge functionality.' - os.write_file(chunk_path, test_data) or { panic('Failed to create test chunk') } - - println('Created test chunk at: $chunk_path') - - // Test the file extension function - file_ext := get_file_extension(filename) - println('File extension: "$file_ext"') - - // Test the merge functionality with the exact same path construction - final_filename := '${file_hash.trim_space()}${file_ext}' - final_path := os.join_path('./uploads/files', final_filename) - println('Attempting to merge to: $final_path') - - upload_manager.handle_chunk_merge_internal(file_hash, filename, total_chunks, final_path, chunk_size, file_size, file_ext) or { - println('Merge failed: $err') - return - } - - println('Merge successful!') - println('Final file exists: ${os.exists(final_path)}') - - // Clean up - os.rmdir_all(chunk_dir) or { println('Failed to clean up chunk directory') } - os.rm(final_path) or { println('Failed to clean up final file') } - - println('Test completed successfully!') -} - -// 获取文件扩展名 -fn get_file_extension(filename string) string { - println('[DEBUG] Getting extension for filename: "$filename"') - parts := filename.split('.') - println('[DEBUG] Split parts: $parts') - if parts.len > 1 { - ext := '.${parts.last()}' - println('[DEBUG] Extracted extension: "$ext"') - return ext - } - println('[DEBUG] No extension found, returning empty string') - return '' -} \ No newline at end of file