后端 (book_mcp_server.py, 335 行, 15 个 MCP 工具):
- create_book / add_document / mark_html_ready / mark_json_ready / mark_failed
- update_book_status / soft_delete_book / retry_failed
- get_book / list_books / get_document / list_documents / get_document_url
- upload_to_rustfs / health
- 走 JSON-RPC + stateless_http + json_response(前端可直接调)
- launchd 守护 / 6 秒拉起(macOS)
- systemd 守护脚本(Linux/codelab)
存储:
- MongoDB (mongo:8.0 容器) - book/book_document/document_file 三表
- RustFS 对象存储(fs.code-lab.cn/scan/)- book/<id>/docs/<doc_id>/{docx,html,json}
- 幂等设计: zip_sha256 唯一 + (book_id+original_path) 唯一
前端 (Vue 3 + TS + Arco Design):
- 列表页 /book/list - 搜索 + 状态过滤 + 进度
- 详情页 /book/:id - 文档折叠 + 文件下载
- MCP 客户端 src/api/mcp.ts (JSON-RPC over fetch)
部署:
- deploy/install.sh (codelab 一键装 backend + systemd)
- deploy/install-frontend.sh (vite dev / 生产构建)
- docs/deploy.md (运维手册)
测试:
- tests/test_basic.py (sanity 7 项)
- tests/test_e2e.py (完整 e2e: docx→HTML→入库→上传→校验)
95 lines
2.9 KiB
Python
95 lines
2.9 KiB
Python
"""RustFS 对象存储封装(基于 boto3)"""
|
||
import os
|
||
import hashlib
|
||
import urllib.parse
|
||
from pathlib import Path
|
||
import boto3
|
||
import botocore
|
||
|
||
from db.database import load_config
|
||
|
||
|
||
def _s3():
|
||
cfg = load_config()['rustfs']
|
||
return boto3.client(
|
||
's3',
|
||
endpoint_url=cfg['endpoint'],
|
||
aws_access_key_id=cfg['access_key'],
|
||
aws_secret_access_key=cfg['secret_key'],
|
||
region_name='us-east-1',
|
||
config=botocore.config.Config(
|
||
signature_version='s3v4',
|
||
s3={'addressing_style': 'path'},
|
||
),
|
||
verify=cfg.get('verify_ssl', False),
|
||
)
|
||
|
||
|
||
def _content_type(path: str) -> str:
|
||
p = path.lower()
|
||
if p.endswith('.html'):
|
||
return 'text/html; charset=utf-8'
|
||
if p.endswith('.js'):
|
||
return 'application/javascript'
|
||
if p.endswith('.json'):
|
||
return 'application/json'
|
||
if p.endswith('.docx'):
|
||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||
if p.endswith('.png'):
|
||
return 'image/png'
|
||
if p.endswith('.log'):
|
||
return 'text/plain; charset=utf-8'
|
||
if p.endswith('.zip'):
|
||
return 'application/zip'
|
||
return 'application/octet-stream'
|
||
|
||
|
||
def upload(local_path: str, key: str, bucket: str | None = None) -> dict:
|
||
"""上传文件 → 返回 {url, key, bucket, size, checksum}"""
|
||
cfg = load_config()['rustfs']
|
||
bucket = bucket or cfg['default_bucket']
|
||
|
||
size = os.path.getsize(local_path)
|
||
# sha256
|
||
h = hashlib.sha256()
|
||
with open(local_path, 'rb') as f:
|
||
for chunk in iter(lambda: f.read(65536), b''):
|
||
h.update(chunk)
|
||
checksum = h.hexdigest()
|
||
|
||
_s3().upload_file(
|
||
local_path, bucket, key,
|
||
ExtraArgs={'ContentType': _content_type(local_path)},
|
||
)
|
||
url = f"{cfg['public_base']}/{bucket}/" + urllib.parse.quote(key, safe='/')
|
||
return {
|
||
'url': url,
|
||
'key': key,
|
||
'bucket': bucket,
|
||
'size': size,
|
||
'checksum': checksum,
|
||
}
|
||
|
||
|
||
def delete_prefix(prefix: str, bucket: str | None = None) -> int:
|
||
"""批量删一个前缀下的所有对象,返回删除条数"""
|
||
cfg = load_config()['rustfs']
|
||
bucket = bucket or cfg['default_bucket']
|
||
s3 = _s3()
|
||
n = 0
|
||
paginator = s3.get_paginator('list_objects_v2')
|
||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||
keys = [{'Key': o['Key']} for o in page.get('Contents', [])]
|
||
if keys:
|
||
s3.delete_objects(Bucket=bucket, Delete={'Objects': keys})
|
||
n += len(keys)
|
||
return n
|
||
|
||
|
||
def build_key(book_id: int, doc_id: str, file_type: str) -> str:
|
||
"""根据 book_id / doc_id / file_type 生成对象存储 key"""
|
||
cfg = load_config()['storage']
|
||
file_naming = cfg['file_naming']
|
||
filename = file_naming.get(file_type, f'{file_type}.bin')
|
||
return f"{cfg['book_prefix']}/{book_id}/{cfg['docs_subdir']}/{doc_id}/{filename}"
|