Skip to content

Instantly share code, notes, and snippets.

@jussker
Created July 28, 2025 07:09
Show Gist options
  • Save jussker/b361d4c8fefdd02543133474f6d37f4b to your computer and use it in GitHub Desktop.
Save jussker/b361d4c8fefdd02543133474f6d37f4b to your computer and use it in GitHub Desktop.
OSS智能图片管理器 - 支持去重、链接续期、多云存储的图片上传工具

OSS 图片管理器 (Smart Image Manager)

一个智能的图片上传管理工具,专为 LLM 集成设计,支持去重、链接续期、多平台云存储。

✨ 主要特性

  • 🔄 智能去重: 基于 MD5 哈希值自动检测重复图片
  • 🔗 链接续期: 自动检测并续期已上传的图片链接
  • 🌩️ 多云支持: 通过 rclone 支持 Azure Blob、AWS S3、Google Cloud 等
  • 🗂️ 本地索引: JSON 格式的本地索引,快速查找历史记录
  • 🧹 自动清理: 支持按时间清理过期文件
  • 📥 批量下载: 支持通过哈希值或 URL 下载图片
  • 🎛️ 多种模式: 支持详细输出、试运行、强制上传等模式
  • 🛡️ 异常处理: 完善的错误处理和信号处理机制

🚀 快速开始

依赖安装

# macOS
brew install rclone jq

配置 rclone

# 配置您的云存储
rclone config

# 示例:配置 Azure Blob Storage
# 1. 选择 "n" 新建配置
# 2. 输入名称,如 "azure-storage"
# 3. 选择存储类型 "azureblob"
# 4. 按提示配置访问密钥等信息

从旧版本升级

如果您之前使用过本工具的旧版本,配置文件结构已改变。使用以下命令自动迁移:

# 自动迁移旧配置到新目录结构
./oss_image_manager.sh migrate

# 或者在首次运行时会自动检测并迁移
./oss_image_manager.sh list

迁移完成后,您可以手动删除旧文件:

rm ~/.image_manager.conf ~/.image_index.json

初始化配置

# 下载脚本
chmod +x oss_image_manager.sh

# 创建默认配置文件
./oss_image_manager.sh init-config

# 编辑配置文件
vi ~/.image-manager/config

配置文件示例:

# Image Manager Configuration
STORAGE_PROVIDER="azure"
CONTAINER="your-container-name"
LINK_EXPIRY="3600"
MAX_FILE_SIZE="10485760"
ALLOWED_FORMATS="jpg,jpeg,png,gif,webp"

# rclone 配置名称 (修改为您的实际配置名)
RCLONE_REMOTE="your-remote-name"

# 高级选项
ENABLE_COMPRESSION=true
COMPRESSION_QUALITY=85
AUTO_CLEANUP_DAYS=7

📖 使用方法

基本用法

# 上传图片
./oss_image_manager.sh image.jpg

# 详细模式上传
./oss_image_manager.sh --verbose image.png

# 强制重新上传(跳过去重检测)
./oss_image_manager.sh --force image.jpg

# 试运行模式(不实际上传)
./oss_image_manager.sh --dry-run image.jpg

管理功能

# 列出所有记录
./oss_image_manager.sh list

# 详细列表
./oss_image_manager.sh --verbose list

# 清理过期文件(默认7天)
./oss_image_manager.sh cleanup

# 清理3天前的文件
./oss_image_manager.sh cleanup 3

# 试运行清理(查看会删除哪些文件)
./oss_image_manager.sh --dry-run cleanup 0

下载功能

# 通过哈希下载(支持部分匹配)
./oss_image_manager.sh download 0858f70b

# 通过 URL 下载
./oss_image_manager.sh download "https://example.com/image.jpg"

# 下载到指定目录
./oss_image_manager.sh download 0858f70b ./downloads/

# 强制覆盖已存在的文件
./oss_image_manager.sh --force download 0858f70b

命令行选项

选项:
    -h, --help              显示帮助信息
    -v, --verbose           详细输出模式
    -n, --dry-run           试运行模式(不执行实际操作)
    -f, --force             强制重新上传/覆盖下载
    -c, --config FILE       指定配置文件
    -i, --index FILE        指定索引文件
    --cleanup [DAYS]        清理过期文件
    --list                  列出所有记录
    --download HASH|URL [DIR]  下载图片
    --init-config           创建默认配置文件

🏗️ 工作原理

  1. 去重机制: 使用 MD5 哈希值识别相同文件
  2. 索引管理: 本地 JSON 文件记录上传历史
  3. 智能续期: 检测远程文件状态,自动续期有效链接
  4. 原子操作: 关键操作支持备份和恢复机制
  5. 信号处理: 优雅处理中断信号,自动清理临时文件

📁 文件结构

~/.image-manager/         # 配置目录
├── config               # 配置文件
└── index.json           # 本地索引文件

索引文件格式:

{
  "文件哈希值": {
    "remote_path": "远程文件路径",
    "url": "访问链接",
    "local_path": "本地文件路径", 
    "uploaded_at": "上传时间戳"
  }
}

🔧 高级配置

支持的存储后端

通过 rclone 支持 40+ 种云存储服务:

  • 对象存储: AWS S3, Azure Blob, Google Cloud Storage
  • 网盘服务: Google Drive, OneDrive, Dropbox
  • 专业服务: Backblaze B2, Wasabi, DigitalOcean Spaces
  • 国内服务: 阿里云 OSS, 腾讯云 COS, 火山引擎 TOS

自定义配置

# 使用自定义配置文件
./oss_image_manager.sh --config /path/to/config --index /path/to/index.json

# 调整文件大小限制 (配置文件中)
MAX_FILE_SIZE="52428800"  # 50MB

# 支持的格式 (配置文件中)
ALLOWED_FORMATS="jpg,jpeg,png,gif,webp,bmp,tiff"

🛡️ 安全特性

  • ✅ 严格的错误处理模式 (set -euo pipefail)
  • ✅ 信号处理和优雅退出
  • ✅ 临时文件自动清理
  • ✅ 原子操作和数据完整性保护
  • ✅ 参数验证和输入检查
  • ✅ 文件哈希验证

🐛 故障排除

常见问题

  1. rclone 配置错误

    # 检查配置
    rclone config show your-remote-name
    
    # 测试连接
    rclone lsd your-remote-name:
  2. 权限问题

    # 检查文件权限
    ls -la ~/.image-manager/config
    ls -la ~/.image-manager/index.json
  3. 依赖缺失

    # 检查依赖
    which rclone jq curl

调试模式

# 启用详细输出
./oss_image_manager.sh --verbose [command]

# 使用试运行模式测试
./oss_image_manager.sh --dry-run [command]

# 手动调试
bash -x ./oss_image_manager.sh [command]

📝 许可证

MIT License - 可自由使用、修改和分发。

🤝 贡献

欢迎提交 Issue 和 Pull Request!


注意: 使用前请确保已正确配置 rclone 和相应的云存储访问凭证。本工具不存储任何访问密钥,所有凭证管理由 rclone 负责。

#!/bin/bash
#
# Smart Image Manager for LLM Integration
# Version: 1.0.0
# Author: GitHub Copilot
# Description: 智能图片上传管理工具,支持去重、链接续期、多平台存储
#
set -euo pipefail
# ============================================================================
# 配置区域
# ============================================================================
# 默认配置
DEFAULT_CONFIG_DIR="$HOME/.image-manager"
DEFAULT_CONFIG_FILE="$DEFAULT_CONFIG_DIR/config"
DEFAULT_INDEX_FILE="$DEFAULT_CONFIG_DIR/index.json"
DEFAULT_STORAGE_PROVIDER="azure"
DEFAULT_CONTAINER="temp-images"
DEFAULT_LINK_EXPIRY="3600" # 1小时
DEFAULT_MAX_FILE_SIZE="10485760" # 10MB
DEFAULT_ALLOWED_FORMATS="jpg,jpeg,png,gif,webp"
DEFAULT_AUTO_CLEANUP_DAYS="7"
# 运行时变量
SCRIPT_NAME=$(basename "$0")
SCRIPT_VERSION="1.0.0"
VERBOSE=false
DRY_RUN=false
FORCE_UPLOAD=false
CONFIG_FILE="$DEFAULT_CONFIG_FILE"
INDEX_FILE="$DEFAULT_INDEX_FILE"
AUTO_CLEANUP_DAYS="$DEFAULT_AUTO_CLEANUP_DAYS"
# ============================================================================
# 工具函数
# ============================================================================
# 日志函数
log() {
local level="$1"
shift
local message="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
case "$level" in
"INFO") if [ "$VERBOSE" = true ]; then echo "[$timestamp] ℹ️ $message"; fi ;;
"WARN") if [ "$VERBOSE" = true ]; then echo "[$timestamp] ⚠️ $message" >&2; fi ;;
"ERROR") echo "[$timestamp] ❌ $message" >&2 ;;
"DEBUG") if [ "$VERBOSE" = true ]; then echo "[$timestamp] 🔍 $message"; fi ;;
"SUCCESS") if [ "$VERBOSE" = true ]; then echo "[$timestamp] ✅ $message"; fi ;;
esac
}
# 错误处理
error_exit() {
local error_code="${2:-1}"
log "ERROR" "$1"
# 清理临时文件
cleanup_temp_files
exit "$error_code"
}
# 清理临时文件
cleanup_temp_files() {
if [ -f "${INDEX_FILE}.tmp" ]; then
rm -f "${INDEX_FILE}.tmp"
log "DEBUG" "已清理临时索引文件"
fi
}
# 信号处理函数
handle_interrupt() {
log "WARN" "收到中断信号,正在清理..."
cleanup_temp_files
exit 130 # SIGINT 的标准退出码
}
# 设置信号处理
trap handle_interrupt SIGINT SIGTERM
# 依赖检查
check_dependencies() {
local deps=("rclone" "jq")
local missing=()
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
missing+=("$dep")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
error_exit "缺少依赖: ${missing[*]}. 请安装后重试."
fi
log "DEBUG" "Dependencies check passed"
}
# 检查下载依赖
check_download_dependencies() {
if ! command -v curl &> /dev/null && ! command -v wget &> /dev/null; then
error_exit "下载功能需要 curl 或 wget"
fi
}
# 配置文件管理
migrate_old_config() {
local old_config="$HOME/.image_manager.conf"
local old_index="$HOME/.image_index.json"
local migrated=false
# 迁移配置文件
if [ -f "$old_config" ] && [ ! -f "$CONFIG_FILE" ]; then
log "INFO" "检测到旧配置文件,正在迁移..."
# 确保新目录存在
mkdir -p "$(dirname "$CONFIG_FILE")"
# 复制配置文件
cp "$old_config" "$CONFIG_FILE"
log "SUCCESS" "配置文件已迁移: $old_config -> $CONFIG_FILE"
migrated=true
fi
# 迁移索引文件
if [ -f "$old_index" ] && [ ! -f "$INDEX_FILE" ]; then
log "INFO" "检测到旧索引文件,正在迁移..."
# 确保新目录存在
mkdir -p "$(dirname "$INDEX_FILE")"
# 复制索引文件
cp "$old_index" "$INDEX_FILE"
log "SUCCESS" "索引文件已迁移: $old_index -> $INDEX_FILE"
migrated=true
fi
if [ "$migrated" = true ]; then
log "INFO" "迁移完成!旧文件已保留,您可以手动删除:"
[ -f "$old_config" ] && log "INFO" " rm $old_config"
[ -f "$old_index" ] && log "INFO" " rm $old_index"
fi
}
create_default_config() {
# 确保配置目录存在
if [ ! -d "$(dirname "$CONFIG_FILE")" ]; then
mkdir -p "$(dirname "$CONFIG_FILE")"
log "INFO" "已创建配置目录: $(dirname "$CONFIG_FILE")"
fi
cat > "$CONFIG_FILE" << EOF
# Image Manager Configuration
STORAGE_PROVIDER="$DEFAULT_STORAGE_PROVIDER"
CONTAINER="$DEFAULT_CONTAINER"
LINK_EXPIRY="$DEFAULT_LINK_EXPIRY"
MAX_FILE_SIZE="$DEFAULT_MAX_FILE_SIZE"
ALLOWED_FORMATS="$DEFAULT_ALLOWED_FORMATS"
# rclone 配置名称 (请修改为您的配置)
RCLONE_REMOTE="your-remote-name"
# 高级选项
ENABLE_COMPRESSION=true
COMPRESSION_QUALITY=85
AUTO_CLEANUP_DAYS=7
EOF
log "INFO" "已创建默认配置文件: $CONFIG_FILE"
}
load_config() {
# 尝试迁移旧配置
migrate_old_config
if [ ! -f "$CONFIG_FILE" ]; then
log "WARN" "配置文件不存在,创建默认配置"
create_default_config
fi
# shellcheck source=/dev/null
source "$CONFIG_FILE"
log "DEBUG" "配置文件加载完成"
}
# 索引文件管理
init_index_file() {
# 确保索引文件目录存在
if [ ! -d "$(dirname "$INDEX_FILE")" ]; then
mkdir -p "$(dirname "$INDEX_FILE")"
log "INFO" "已创建索引目录: $(dirname "$INDEX_FILE")"
fi
if [ ! -f "$INDEX_FILE" ]; then
echo '{}' > "$INDEX_FILE"
log "INFO" "已初始化索引文件: $INDEX_FILE"
fi
}
# 文件哈希计算
calculate_hash() {
local file="$1"
if command -v md5 &> /dev/null; then
md5 -q "$file"
elif command -v md5sum &> /dev/null; then
md5sum "$file" | cut -d' ' -f1
else
error_exit "无法找到 md5 或 md5sum 命令"
fi
}
# 文件验证
validate_file() {
local file="$1"
# 检查文件是否存在
if [ ! -f "$file" ]; then
error_exit "文件不存在: $file"
fi
# 检查文件大小
local file_size
if [[ "$OSTYPE" == "darwin"* ]]; then
file_size=$(stat -f%z "$file")
else
file_size=$(stat -c%s "$file")
fi
if [ "$file_size" -gt "$MAX_FILE_SIZE" ]; then
error_exit "文件过大: ${file_size} bytes (最大: ${MAX_FILE_SIZE} bytes)"
fi
# 检查文件格式
local extension="${file##*.}"
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
if [[ ",$ALLOWED_FORMATS," != *",$extension,"* ]]; then
error_exit "不支持的文件格式: $extension (支持: $ALLOWED_FORMATS)"
fi
log "DEBUG" "文件验证通过: $file"
}
# 索引操作
get_record() {
local hash="$1"
jq -r ".\"$hash\" // empty" "$INDEX_FILE"
}
save_record() {
local hash="$1"
local remote_path="$2"
local url="$3"
local local_path="$4"
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# 验证必要参数
if [ -z "$hash" ] || [ -z "$remote_path" ] || [ -z "$url" ]; then
error_exit "保存记录时缺少必要参数"
fi
# DRY_RUN 模式下不保存记录
if [ "$DRY_RUN" = true ]; then
log "DEBUG" "[DRY RUN] 模拟保存记录: $hash"
return 0
fi
# 备份原索引文件
if [ -f "$INDEX_FILE" ]; then
if ! cp "$INDEX_FILE" "${INDEX_FILE}.backup"; then
error_exit "无法创建索引文件备份"
fi
fi
local record
if ! record=$(jq -n \
--arg remote "$remote_path" \
--arg url "$url" \
--arg local "$local_path" \
--arg time "$timestamp" \
'{remote_path: $remote, url: $url, local_path: $local, uploaded_at: $time}' 2>/dev/null); then
error_exit "创建记录对象失败"
fi
# 原子性写入
if ! jq ".\"$hash\" = $record" "$INDEX_FILE" > "${INDEX_FILE}.tmp" 2>/dev/null; then
# 恢复备份
if [ -f "${INDEX_FILE}.backup" ]; then
mv "${INDEX_FILE}.backup" "$INDEX_FILE"
fi
error_exit "更新索引文件失败"
fi
if ! mv "${INDEX_FILE}.tmp" "$INDEX_FILE"; then
# 恢复备份
if [ -f "${INDEX_FILE}.backup" ]; then
mv "${INDEX_FILE}.backup" "$INDEX_FILE"
fi
error_exit "保存索引文件失败"
fi
# 清理备份文件
rm -f "${INDEX_FILE}.backup"
log "DEBUG" "记录已保存: $hash"
}
# 存储操作
upload_to_storage() {
local file="$1"
local remote_name="$2"
local remote_path="${RCLONE_REMOTE}:${CONTAINER}/${remote_name}"
# 验证输入参数
if [ -z "$file" ] || [ -z "$remote_name" ]; then
error_exit "上传函数缺少必要参数"
fi
# 验证文件存在性
if [ ! -f "$file" ]; then
error_exit "要上传的文件不存在: $file"
fi
if [ "$DRY_RUN" = true ]; then
echo "https://example.com/dry-run-url"
return 0
fi
# 检查rclone配置
if ! rclone config show "$RCLONE_REMOTE" >/dev/null 2>&1; then
error_exit "rclone 远程配置 '$RCLONE_REMOTE' 不存在或无效"
fi
# 使用 copyto 来指定具体的目标文件名
# 将所有rclone输出重定向到stderr,避免污染函数返回值
local upload_result=0
if [ "$VERBOSE" = true ]; then
rclone copyto "$file" "$remote_path" --progress >&2
upload_result=$?
else
rclone copyto "$file" "$remote_path" --progress >/dev/null 2>&1
upload_result=$?
fi
# 检查上传是否成功
if [ $upload_result -ne 0 ]; then
case $upload_result in
1) error_exit "rclone 语法或使用错误" ;;
2) error_exit "rclone 找不到文件或目录" ;;
3) error_exit "rclone 目标目录不存在或无权限" ;;
4) error_exit "rclone 网络连接失败" ;;
*) error_exit "rclone 上传失败 (退出码: $upload_result)" ;;
esac
fi
# 生成临时链接
local url
local link_attempts=3
local attempt=1
while [ $attempt -le $link_attempts ]; do
if url=$(rclone link "$remote_path" 2>/dev/null); then
break
fi
log "DEBUG" "链接生成失败,重试 $attempt/$link_attempts"
sleep 1
((attempt++))
done
if [ $attempt -gt $link_attempts ]; then
log "WARN" "无法生成临时链接,返回直接路径" >&2
url="$remote_path"
fi
echo "$url"
}
check_remote_exists() {
local remote_name="$1"
# DRY_RUN 模式下模拟检查结果
if [ "$DRY_RUN" = true ]; then
# 模拟:假设文件存在(这样可以测试续期链接逻辑)
log "DEBUG" "[DRY RUN] 模拟检查远程文件存在: $remote_name"
return 0
fi
rclone lsf "${RCLONE_REMOTE}:${CONTAINER}/" | grep -q "^${remote_name}$"
}
renew_link() {
local remote_name="$1"
local remote_path="${RCLONE_REMOTE}:${CONTAINER}/${remote_name}"
if [ "$DRY_RUN" = true ]; then
echo "https://example.com/dry-run-renewed-url"
return 0
fi
local url
if ! url=$(rclone link "$remote_path" 2>/dev/null); then
log "WARN" "无法生成新链接,返回直接路径" >&2
url="$remote_path"
fi
echo "$url"
}
# ============================================================================
# 主要功能
# ============================================================================
# 下载图片
download_image() {
local hash_or_url="$1"
local output_dir="${2:-.}" # 默认下载到当前目录
log "INFO" "查找图片: $hash_or_url"
local hash=""
local record=""
local url=""
local filename=""
# 判断输入是哈希还是URL
if [[ "$hash_or_url" =~ ^https?:// ]]; then
# 输入是URL,查找对应的记录
hash=$(jq -r "to_entries[] | select(.value.url == \"$hash_or_url\") | .key" "$INDEX_FILE" | head -1)
if [ -z "$hash" ]; then
error_exit "未找到对应的记录: $hash_or_url"
fi
else
# 输入可能是哈希(支持部分匹配)
if [ ${#hash_or_url} -ge 8 ]; then
hash=$(jq -r "keys[]" "$INDEX_FILE" | grep "^$hash_or_url" | head -1)
else
error_exit "哈希长度至少需要8位字符"
fi
if [ -z "$hash" ]; then
error_exit "未找到匹配的记录: $hash_or_url"
fi
fi
# 获取记录信息
record=$(get_record "$hash")
url=$(echo "$record" | jq -r '.url')
local remote_path=$(echo "$record" | jq -r '.remote_path')
local original_local_path=$(echo "$record" | jq -r '.local_path // empty')
if [ -z "$url" ] || [ "$url" = "null" ]; then
error_exit "记录中没有有效的URL"
fi
# 确定输出文件名
if [ -n "$original_local_path" ] && [ "$original_local_path" != "null" ]; then
filename=$(basename "$original_local_path")
else
filename="$remote_path"
fi
# 规范化输出路径,避免双斜杠
if [[ "$output_dir" == */ ]]; then
local output_path="${output_dir}${filename}"
else
local output_path="$output_dir/$filename"
fi
if [ "$DRY_RUN" = true ]; then
log "INFO" "[DRY RUN] 将下载: $url -> $output_path"
echo "$output_path"
return 0
fi
# 检查输出目录是否存在
if [ ! -d "$output_dir" ]; then
log "INFO" "创建输出目录: $output_dir"
mkdir -p "$output_dir"
fi
# 检查文件是否已存在
if [ -f "$output_path" ] && [ "$FORCE_UPLOAD" = false ]; then
if [ "$VERBOSE" = true ]; then
log "WARN" "文件已存在: $output_path (使用 --force 覆盖)"
fi
echo "$output_path"
return 0
fi
log "INFO" "下载图片: $filename"
log "DEBUG" "从 $url 下载到 $output_path"
# 尝试使用curl下载
local download_result=0
if command -v curl &> /dev/null; then
if [ "$VERBOSE" = true ]; then
curl -L -o "$output_path" "$url" --progress-bar --max-time 300 --retry 3
download_result=$?
else
curl -L -o "$output_path" "$url" -s --max-time 300 --retry 3
download_result=$?
fi
# 备用wget
elif command -v wget &> /dev/null; then
if [ "$VERBOSE" = true ]; then
wget -O "$output_path" "$url" --progress=bar --timeout=300 --tries=3
download_result=$?
else
wget -O "$output_path" "$url" -q --timeout=300 --tries=3
download_result=$?
fi
else
error_exit "需要 curl 或 wget 来下载文件"
fi
# 检查下载是否成功
if [ $download_result -ne 0 ]; then
# 清理可能的损坏文件
[ -f "$output_path" ] && rm -f "$output_path"
case $download_result in
6|7) error_exit "下载失败: 无法连接到服务器" ;;
22) error_exit "下载失败: HTTP 错误 (可能是链接已过期)" ;;
28) error_exit "下载失败: 连接超时" ;;
*) error_exit "下载失败 (退出码: $download_result)" ;;
esac
fi
# 验证下载的文件
if [ ! -f "$output_path" ]; then
error_exit "下载完成但文件不存在: $output_path"
fi
# 检查文件大小
local file_size=0
if [[ "$OSTYPE" == "darwin"* ]]; then
file_size=$(stat -f%z "$output_path" 2>/dev/null || echo "0")
else
file_size=$(stat -c%s "$output_path" 2>/dev/null || echo "0")
fi
if [ "$file_size" -eq 0 ]; then
rm -f "$output_path"
error_exit "下载的文件为空"
fi
# 检查下载是否成功并验证文件完整性
if [ "$VERBOSE" = true ]; then
log "SUCCESS" "下载完成: $output_path"
# 验证下载的文件
local downloaded_hash
if ! downloaded_hash=$(calculate_hash "$output_path"); then
rm -f "$output_path"
error_exit "无法计算下载文件的哈希值"
fi
if [ "$downloaded_hash" = "$hash" ]; then
log "SUCCESS" "文件完整性验证通过"
else
rm -f "$output_path"
error_exit "文件哈希不匹配,可能文件已损坏或被修改"
fi
else
# 非verbose模式,静默验证文件完整性
local downloaded_hash
if ! downloaded_hash=$(calculate_hash "$output_path"); then
rm -f "$output_path"
error_exit "无法计算下载文件的哈希值"
fi
if [ "$downloaded_hash" != "$hash" ]; then
rm -f "$output_path"
error_exit "文件哈希不匹配,下载失败"
fi
fi
echo "$output_path"
}
process_image() {
local file="$1"
# 验证输入参数
if [ -z "$file" ]; then
error_exit "缺少文件路径参数"
fi
log "INFO" "处理图片: $file"
# 验证文件
if ! validate_file "$file"; then
error_exit "文件验证失败: $file"
fi
# 计算哈希
local hash
if ! hash=$(calculate_hash "$file"); then
error_exit "计算文件哈希失败: $file"
fi
log "DEBUG" "文件哈希: $hash"
# 生成远程文件名
local extension="${file##*.}"
if [ -z "$extension" ]; then
error_exit "无法确定文件扩展名: $file"
fi
local remote_name="img_${hash}.${extension}"
# 检查本地记录
local existing_record
if ! existing_record=$(get_record "$hash"); then
log "DEBUG" "获取记录失败,将作为新文件处理"
existing_record=""
fi
if [ -n "$existing_record" ] && [ "$FORCE_UPLOAD" = false ]; then
log "INFO" "🔄 发现历史图片,检查远程状态..."
local remote_path
if ! remote_path=$(echo "$existing_record" | jq -r '.remote_path' 2>/dev/null); then
log "WARN" "解析远程路径失败,重新上传"
elif check_remote_exists "$remote_path"; then
log "SUCCESS" "远程文件存在,续期链接"
log "INFO" "续期链接: ${RCLONE_REMOTE}:${CONTAINER}/${remote_path}"
local new_url
if ! new_url=$(renew_link "$remote_path"); then
log "WARN" "续期链接失败,重新上传"
else
# 更新记录
if save_record "$hash" "$remote_path" "$new_url" "$file"; then
echo "$new_url"
return 0
else
log "WARN" "保存记录失败,但返回新URL"
echo "$new_url"
return 0
fi
fi
else
log "WARN" "远程文件丢失,重新上传"
fi
fi
# 上传新文件
log "INFO" "📤 上传新图片..."
log "INFO" "上传文件: $file -> ${RCLONE_REMOTE}:${CONTAINER}/${remote_name}"
local new_url
if ! new_url=$(upload_to_storage "$file" "$remote_name"); then
error_exit "上传文件失败: $file"
fi
# 保存记录
if ! save_record "$hash" "$remote_name" "$new_url" "$file"; then
log "WARN" "保存记录失败,但上传成功"
fi
log "SUCCESS" "上传完成"
echo "$new_url"
}
# 清理过期文件
cleanup_expired() {
local days="${1:-$AUTO_CLEANUP_DAYS}"
# 验证天数参数
if ! [[ "$days" =~ ^[0-9]+$ ]]; then
error_exit "无效的天数参数: $days"
fi
log "INFO" "清理 $days 天前的文件..."
if [ "$DRY_RUN" = true ]; then
# 验证索引文件
if [ ! -f "$INDEX_FILE" ]; then
log "INFO" "[DRY RUN] 索引文件不存在,无文件需要清理"
echo "0"
return 0
fi
# 计算过期时间戳
local cutoff_date
if [[ "$OSTYPE" == "darwin"* ]]; then
if ! cutoff_date=$(date -v-"${days}d" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then
error_exit "无法计算过期时间戳"
fi
else
if ! cutoff_date=$(date -d "$days days ago" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then
error_exit "无法计算过期时间戳"
fi
fi
# 查找过期记录
local expired_hashes
if ! expired_hashes=$(jq -r "to_entries[] | select(.value.uploaded_at < \"$cutoff_date\") | .key" "$INDEX_FILE" 2>/dev/null); then
error_exit "查找过期记录失败"
fi
local count=0
log "INFO" "[DRY RUN] 将清理以下过期文件:"
while IFS= read -r hash; do
[ -z "$hash" ] && continue
local record
if record=$(get_record "$hash" 2>/dev/null); then
local remote_path
if remote_path=$(echo "$record" | jq -r '.remote_path' 2>/dev/null); then
log "INFO" " - $remote_path (${hash:0:8}...)"
((count++))
fi
fi
done <<< "$expired_hashes"
if [ $count -eq 0 ]; then
log "INFO" "[DRY RUN] 没有过期文件需要清理"
else
log "INFO" "[DRY RUN] 共找到 $count 个过期文件"
fi
echo "$count"
return 0
fi
# 验证索引文件
if [ ! -f "$INDEX_FILE" ]; then
log "WARN" "索引文件不存在,无需清理"
echo "0"
return 0
fi
# 获取过期时间戳
local cutoff_date
if [[ "$OSTYPE" == "darwin"* ]]; then
if ! cutoff_date=$(date -v-"${days}d" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then
error_exit "无法计算过期时间戳"
fi
else
if ! cutoff_date=$(date -d "$days days ago" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then
error_exit "无法计算过期时间戳"
fi
fi
# 查找过期记录
local expired_hashes
if ! expired_hashes=$(jq -r "to_entries[] | select(.value.uploaded_at < \"$cutoff_date\") | .key" "$INDEX_FILE" 2>/dev/null); then
error_exit "查找过期记录失败"
fi
local count=0
local failed_count=0
while IFS= read -r hash; do
[ -z "$hash" ] && continue
local record
if ! record=$(get_record "$hash"); then
log "WARN" "无法读取记录: $hash"
((failed_count++))
continue
fi
local remote_path
if ! remote_path=$(echo "$record" | jq -r '.remote_path' 2>/dev/null); then
log "WARN" "无法解析远程路径: $hash"
((failed_count++))
continue
fi
log "INFO" "删除过期文件: $remote_path"
# 删除远程文件(允许失败,可能已经被删除)
if ! rclone delete "${RCLONE_REMOTE}:${CONTAINER}/$remote_path" 2>/dev/null; then
log "WARN" "删除远程文件失败或文件不存在: $remote_path"
fi
# 从索引中删除
if ! jq "del(.\"$hash\")" "$INDEX_FILE" > "${INDEX_FILE}.tmp" 2>/dev/null; then
log "WARN" "更新索引失败: $hash"
((failed_count++))
continue
fi
if ! mv "${INDEX_FILE}.tmp" "$INDEX_FILE"; then
log "WARN" "保存索引失败: $hash"
((failed_count++))
continue
fi
((count++))
done <<< "$expired_hashes"
if [ "$VERBOSE" = true ]; then
if [ $failed_count -gt 0 ]; then
log "WARN" "已清理 $count 个过期文件,$failed_count 个失败"
else
log "SUCCESS" "已清理 $count 个过期文件"
fi
fi
echo "$count"
}
# 列出所有记录
list_images() {
# 检查索引文件是否存在
if [ ! -f "$INDEX_FILE" ]; then
if [ "$VERBOSE" = true ]; then
log "WARN" "索引文件不存在: $INDEX_FILE"
fi
echo "{}"
return 0
fi
# 验证索引文件可读性
if [ ! -r "$INDEX_FILE" ]; then
if [ "$VERBOSE" = true ]; then
log "ERROR" "索引文件不可读: $INDEX_FILE"
fi
echo "{}"
return 1
fi
# 验证JSON格式
if ! jq empty "$INDEX_FILE" 2>/dev/null; then
if [ "$VERBOSE" = true ]; then
log "ERROR" "索引文件JSON格式无效: $INDEX_FILE"
fi
echo "{}"
return 1
fi
# 检查索引文件是否为空或无效
local record_count
if ! record_count=$(jq -r 'keys | length' "$INDEX_FILE" 2>/dev/null); then
if [ "$VERBOSE" = true ]; then
log "ERROR" "无法读取索引文件内容"
fi
echo "{}"
return 1
fi
if [ "$record_count" -eq 0 ]; then
if [ "$VERBOSE" = true ]; then
log "INFO" "暂无图片记录"
fi
echo "{}"
return 0
fi
if [ "$VERBOSE" = true ]; then
log "INFO" "图片记录列表 (共 $record_count 个):"
# 使用更安全的方式处理输出
local temp_output
if ! temp_output=$(jq -r 'to_entries[] | "\(.key[0:8])... \(.value.local_path // "N/A") -> \(.value.remote_path) (\(.value.uploaded_at))"' "$INDEX_FILE" 2>/dev/null); then
log "ERROR" "格式化记录列表失败"
echo "{}"
return 1
fi
if [ -n "$temp_output" ]; then
echo "$temp_output" | while IFS= read -r line; do
if [ -n "$line" ]; then
echo " $line"
fi
done
fi
else
# 非verbose模式,直接输出JSON(验证后)
if ! cat "$INDEX_FILE" 2>/dev/null; then
echo "{}"
return 1
fi
fi
}
# ============================================================================
# 帮助信息
# ============================================================================
show_help() {
cat << EOF
$SCRIPT_NAME v$SCRIPT_VERSION - 智能图片管理工具
用法:
$SCRIPT_NAME [选项] <图片文件>
$SCRIPT_NAME [选项] cleanup [天数]
$SCRIPT_NAME [选项] list
$SCRIPT_NAME [选项] download <哈希|URL> [输出目录]
$SCRIPT_NAME migrate # 迁移旧版本配置文件
选项:
-h, --help 显示此帮助信息
-v, --verbose 详细输出
-n, --dry-run 试运行模式
-f, --force 强制重新上传/覆盖下载
-c, --config FILE 指定配置文件 (默认: $DEFAULT_CONFIG_FILE)
-i, --index FILE 指定索引文件 (默认: $DEFAULT_INDEX_FILE)
cleanup [DAYS] 清理过期文件 (默认: $AUTO_CLEANUP_DAYS 天)
list 列出所有记录
download HASH|URL [DIR] 下载图片到指定目录 (默认: 当前目录)
init-config 创建默认配置文件
migrate 迁移旧版本配置到新目录结构
示例:
$SCRIPT_NAME image.jpg # 上传图片
$SCRIPT_NAME --verbose image.png # 详细模式上传
$SCRIPT_NAME --force image.jpg # 强制重新上传
$SCRIPT_NAME --cleanup 3 # 清理3天前的文件
$SCRIPT_NAME --list # 列出所有记录
$SCRIPT_NAME --download 0858f70b # 用哈希下载(支持部分匹配)
$SCRIPT_NAME --download "https://..." ./downloads/ # 用URL下载到指定目录
$SCRIPT_NAME --dry-run image.jpg # 试运行
配置:
配置文件位置: $DEFAULT_CONFIG_FILE
索引文件位置: $DEFAULT_INDEX_FILE
支持的存储提供商: Azure Blob, AWS S3, Google Cloud Storage
通过 rclone 配置存储后端
依赖:
- rclone: 多云存储工具
- jq: JSON 处理工具
- curl 或 wget: 下载工具
EOF
}
# ============================================================================
# 主程序
# ============================================================================
main() {
local action=""
local target_file=""
local cleanup_days=""
local download_target=""
local download_dir=""
# 解析命令行参数
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-v|--verbose)
VERBOSE=true
shift
;;
-n|--dry-run)
DRY_RUN=true
shift
;;
-f|--force)
FORCE_UPLOAD=true
shift
;;
-c|--config)
CONFIG_FILE="$2"
shift 2
;;
-i|--index)
INDEX_FILE="$2"
shift 2
;;
--cleanup)
action="cleanup"
if [[ $# -gt 1 && $2 =~ ^[0-9]+$ ]]; then
cleanup_days="$2"
shift 2
else
shift
fi
;;
list)
action="list"
shift
;;
cleanup)
action="cleanup"
if [[ $# -gt 1 && $2 =~ ^[0-9]+$ ]]; then
cleanup_days="$2"
shift 2
else
shift
fi
;;
download)
action="download"
if [[ $# -gt 1 ]]; then
download_target="$2"
shift 2
# 检查是否有可选的输出目录参数
if [[ $# -gt 0 && ! $1 =~ ^- ]]; then
download_dir="$1"
shift
fi
else
error_exit "download 需要指定哈希或URL参数"
fi
;;
init-config)
create_default_config
exit 0
;;
migrate)
migrate_old_config
exit 0
;;
-*)
error_exit "未知选项: $1"
;;
*)
if [ -z "$target_file" ]; then
target_file="$1"
action="upload"
else
error_exit "只能指定一个图片文件"
fi
shift
;;
esac
done
# 检查依赖
check_dependencies
# 加载配置
load_config
# 初始化索引文件
init_index_file
# 执行操作
case "$action" in
"upload")
if [ -z "$target_file" ]; then
error_exit "请指定要上传的图片文件"
fi
process_image "$target_file"
;;
"cleanup")
cleanup_expired "${cleanup_days:-$AUTO_CLEANUP_DAYS}"
;;
"list")
list_images
;;
"download")
if [ -z "$download_target" ]; then
error_exit "请指定要下载的图片哈希或URL"
fi
check_download_dependencies
download_image "$download_target" "${download_dir:-.}"
;;
"")
error_exit "请指定操作。使用 --help 查看帮助信息"
;;
*)
error_exit "未知操作: $action"
;;
esac
}
# 脚本入口
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment