Jenkins 安全清理孤立工作区(workspace)的 Shell 脚本:原理、实现与实战

前言:如果你是 Jenkins 运维或开发人员,大概率遇到过这样的窘境:
某天监控突然告警“Jenkins 服务器磁盘使用率超 90%”,登录服务器排查,却发现 workspace 目录下堆积了上百个陌生文件夹——它们对应的任务早就被删除,却像“隐形垃圾”一样占据着几十甚至几百 GB 空间。
更让人头疼的是,很多人误以为“Jenkins 配置‘只保留最近 N 次构建’就能自动清理 workspace”,但实际上,这个策略仅会删除构建历史记录和制品文件,对存储构建中间产物、依赖缓存的 workspace 目录“视而不见”。
手动清理?风险太高——一旦误删正在运行的任务 workspace,可能导致构建失败;逐一审核目录是否“有用”?效率低下,尤其对有上百个任务的 Jenkins 集群来说,简直是“体力活”。
正是为了解决这个“痛点”,我们开发了这套 Jenkins 孤立工作区安全清理脚本。它不只是一个简单的删除工具,更像是为 Jenkins 量身定制的“磁盘管家”:默认“预演不执行”,通过多层安全校验(进程占用、目录老化、Jenkins 活跃构建检测)避免误删,还能生成可追溯的计划与日志,让清理工作“安全、可控、可审计”。
接下来,我们从问题背景、脚本功能、使用方法到原理拆解,一步步带你掌握这套清理方案,彻底告别 Jenkins 磁盘“臃肿”难题。

适用环境:Linux 上的 Jenkins Controller 或 Agent
目标:自动发现并清理“孤立”的 workspace 目录(对应的 Jenkins Job 已删除或不再存在)

  • Jenkins 设置“只保留最近 N 次构建”不会自动清理 workspace
  • 这篇文章提供一个安全优先的 Shell 脚本:默认 Dry-Run,多重安全检查(年龄、占用、白/黑名单、Jenkins API 活动构建检查),二次确认,生成计划与日志,才会执行删除。
  • 覆盖 Folder/Multibranch 的命名(%2F)和 @tmp/@2 等后缀的处理。

⚠️ 重要提示:操作前务必对目标工作区目录进行完整备份

由于脚本涉及文件系统的删除操作,为避免意外情况(如误删仍在使用的工作区、环境差异导致的非预期行为等),请在执行任何清理动作前,通过 tarrsync 等工具对 Jenkins 工作区根目录(或相关子目录)创建完整归档备份,确保数据具备可恢复性。

一、背景与问题

  • Jenkins 的构建保留策略numToKeepStr 等)只会清理 构建历史和制品(artifacts),不会自动清理 workspace
  • workspace 目录通常在:
    $JENKINS_HOME/workspace/<job-full-name-with-%2F>
    
    或者在 Agent 节点相应路径。
  • 如果大量 Job 被删除或迁移,可能遗留很多孤立工作区目录(包含 @tmp@2@script 等后缀目录),占用大量磁盘。
  • 团队需要一个安全可审计的自动清理工具。

二、脚本能做什么(功能清单)

  • 发现现存 Job 的“期望工作区名集合”(递归扫描 $JENKINS_HOME/jobs,处理多层 Folder)。
  • $WORKSPACE_ROOT 的实际目录对比,找出疑似孤立的工作区目录。
  • 默认 Dry-run:只生成清理计划,不做删除。
  • 安全保障
    • 老化阈值(例如只删“最近 3 天以外”的目录)。
    • 进程占用检测lsoffuser)。
    • Jenkins API 检查是否有正在构建(可选)。
    • 白/黑名单正则过滤。
    • 删除需二次确认(输入候选数量)。
  • 审计输出:计划文件 + 删除日志文件。
  • 仅在对应 Job 已不存在时,才考虑清理 *@tmp(可选开关)。

三、安全设计(为什么它安全)

  1. 默认预演(Dry-run),不碰任何文件。
  2. 老化检查:避免清理刚产生/仍在使用的目录(默认 3 天)。
  3. 占用检查:检测是否有进程占用目录,避免误删。
  4. Jenkins API 检测:发现活动构建就退出(可选)。
  5. 白/黑名单:逐步放开,防止“一刀切”。
  6. 交互确认:必须手动输入候选数量才会删除。
  7. 计划与日志:可审计、可回溯。

四、前置条件与环境

  • 操作系统:Linux(Bash 环境)
  • 执行用户:建议 jenkins 或具备删除权限的用户(谨慎使用 root)
  • 命令工具:bashfindstatdatesedawk 等基础工具
  • 可选工具:lsoffuser(占用检查,不装也可用,但安全性降低)
  • 可选 Jenkins API:curl + JENKINS_URL/JENKINS_USER/JENKINS_API_TOKEN

五、脚本源码

文件名建议:safe-clean-orphan-workspaces.sh

#!/usr/bin/env bash
# safe-clean-orphan-workspaces.sh
# Safely detect and (optionally) clean Jenkins orphaned workspaces.
# Author: You & Copilot
# License: MIT

set -Eeuo pipefail

# -------- Defaults (override via CLI flags) --------
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/data1/var/lib/jenkins/workspace}"
JENKINS_HOME="${JENKINS_HOME:-/data1/var/lib/jenkins}"
OLDER_THAN_DAYS="${OLDER_THAN_DAYS:-3}"     # only delete if dir mtime older than this many days
APPLY=false                                 # dry-run by default
INCLUDE_TMP=false                           # also delete *@tmp when base is orphan
CHECK_OPEN_FILES=true                       # use lsof/fuser if available
VERBOSE=false
EXCLUDE_PATTERN=""                          # e.g. "^(keep-this|dont-touch-.*)$" (regex on dirname)
INCLUDE_PATTERN=""                          # optional regex to narrow candidates
PLAN_DIR="${PLAN_DIR:-./}"                  # where to write plan & logs

# Optional: if set, we try to detect active builds and abort if any
JENKINS_URL="${JENKINS_URL:-}"              # e.g. http://127.0.0.1:8080
JENKINS_USER="${JENKINS_USER:-}"            # user for API
JENKINS_API_TOKEN="${JENKINS_API_TOKEN:-}"  # API token

# --------- Helpers ----------
log() { printf '%s\n' "$*" >&2; }
vlog() { $VERBOSE && printf '[verbose] %s\n' "$*" >&2 || true; }

usage() {
  cat <<'EOF'
Usage:
  safe-clean-orphan-workspaces.sh [options]

Options:
  --workspace-root PATH     Workspace root (default: /data1/var/lib/jenkins/workspace)
  --jenkins-home PATH       Jenkins home (default: /data1/var/lib/jenkins)
  --older-than DAYS         Only delete dirs older than DAYS (default: 3)
  --include-tmp             Also delete *@tmp when base job is orphan (default: off)
  --no-open-files-check     Skip lsof/fuser check (default: on)
  --include-regex REGEX     Only consider dirnames matching this regex
  --exclude-regex REGEX     Skip dirnames matching this regex
  --plan-dir PATH           Where to write the plan & logs (default: ./)
  --verbose                 More logs
  --apply                   Actually delete (requires interactive confirm)
  -h, --help                Show this help

Optional safety integration with Jenkins API (to avoid cleaning during active builds):
  env JENKINS_URL, JENKINS_USER, JENKINS_API_TOKEN

Examples:
  Dry-run (preview):
    ./safe-clean-orphan-workspaces.sh --workspace-root /data1/var/lib/jenkins/workspace \
      --jenkins-home /data1/var/lib/jenkins --verbose

  Apply (after preview looks good):
    ./safe-clean-orphan-workspaces.sh --apply

EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --workspace-root) WORKSPACE_ROOT="$2"; shift 2;;
    --jenkins-home)   JENKINS_HOME="$2"; shift 2;;
    --older-than)     OLDER_THAN_DAYS="$2"; shift 2;;
    --include-tmp)    INCLUDE_TMP=true; shift;;
    --no-open-files-check) CHECK_OPEN_FILES=false; shift;;
    --include-regex)  INCLUDE_PATTERN="$2"; shift 2;;
    --exclude-regex)  EXCLUDE_PATTERN="$2"; shift 2;;
    --plan-dir)       PLAN_DIR="$2"; shift 2;;
    --verbose)        VERBOSE=true; shift;;
    --apply)          APPLY=true; shift;;
    -h|--help)        usage; exit 0;;
    *) log "Unknown arg: $1"; usage; exit 1;;
  esac
done

timestamp="$(date +'%Y%m%d-%H%M%S')"
PLAN_FILE="${PLAN_DIR%/}/orphan-workspaces-plan-${timestamp}.txt"
LOG_FILE="${PLAN_DIR%/}/orphan-workspaces-delete-${timestamp}.log"

# ---- Preflight checks ----
[[ -d "$WORKSPACE_ROOT" ]] || { log "Workspace root not found: $WORKSPACE_ROOT"; exit 1; }
[[ -d "$JENKINS_HOME/jobs" ]] || { log "Jenkins jobs dir not found: $JENKINS_HOME/jobs"; exit 1; }

if [[ -n "$JENKINS_URL" && -n "$JENKINS_USER" && -n "$JENKINS_API_TOKEN" ]]; then
  log "Checking Jenkins executors for active builds via API..."
  # crumb not required for GET; we only look for '"building":true'
  if curl -fsS -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" \
      "$JENKINS_URL/computer/api/json?depth=1" | grep -q '"building":true'; then
    log "Detected active builds. For safety, aborting now. (Unset JENKINS_* to skip this check.)"
    exit 1
  else
    vlog "No active builds reported by Jenkins API."
  fi
fi

# ---- Build expected workspace basenames set from $JENKINS_HOME/jobs ----
# For any config.xml under jobs/..../jobs/<name>/..., we extract each <name> after '/jobs/' and join with '%2F'.
declare -A EXPECTED
vlog "Discovering existing job full names under: $JENKINS_HOME/jobs"

# We intentionally avoid parsing XML; we rely on folder structure.
while IFS= read -r -d '' cfg; do
  rel="${cfg#$JENKINS_HOME/jobs/}"         # strip prefix
  # Extract every component that follows '/jobs/' (e.g., jobs/f1/jobs/f2/jobs/jobA/config.xml => f1 f2 jobA)
  # shellcheck disable=SC2001
  names=$(sed -E 's#(^|/)jobs/#\n#g' <<<"$rel" | tr '\n' ' ' | awk '{for(i=1;i<=NF;i++) print $i}' | sed -E 's#/.*##')
  # join with %2F
  full=""
  for n in $names; do
    [[ -z "$n" ]] && continue
    if [[ -z "$full" ]]; then full="$n"; else full="${full}%2F${n}"; fi
  done
  [[ -n "$full" ]] && EXPECTED["$full"]=1
done < <(find "$JENKINS_HOME/jobs" -type f -name 'config.xml' -print0)

vlog "Total expected workspace basenames discovered: ${#EXPECTED[@]}"

# ---- Enumerate actual workspace dirs ----
log "Scanning workspace root: $WORKSPACE_ROOT"
mapfile -d '' DIRS < <(find "$WORKSPACE_ROOT" -mindepth 1 -maxdepth 1 -type d -print0)

# Prepare plan & header
{
  echo "# Orphan workspace deletion plan @ ${timestamp}"
  echo "# WORKSPACE_ROOT=$WORKSPACE_ROOT"
  echo "# JENKINS_HOME=$JENKINS_HOME"
  echo "# OLDER_THAN_DAYS=$OLDER_THAN_DAYS  INCLUDE_TMP=$INCLUDE_TMP  APPLY=$APPLY"
  echo "# INCLUDE_PATTERN=${INCLUDE_PATTERN:-<none>}  EXCLUDE_PATTERN=${EXCLUDE_PATTERN:-<none>}"
  echo
  printf "%-8s | %-50s | %-19s | %s\n" "CANDID." "DIRNAME" "LAST_MODIFIED" "FULL_PATH"
  echo "---------+----------------------------------------------------+---------------------+----------------------------------------"
} > "$PLAN_FILE"

candidates=0
skipped=0
considered=0

is_in_use() {
  local d="$1"
  if ! $CHECK_OPEN_FILES; then return 1; fi
  if command -v lsof >/dev/null 2>&1; then
    timeout 5s lsof +D "$d" >/dev/null 2>&1 && return 0 || return 1
  elif command -v fuser >/dev/null 2>&1; then
    fuser -s "$d" 2>/dev/null && return 0 || return 1
  else
    return 1
  fi
}

for d in "${DIRS[@]}"; do
  ((considered++)) || true
  name="$(basename "$d")"
  base="${name%%@*}"   # strip '@...' suffix (covers @tmp, @2, @script, etc.)

  # Include/Exclude filters
  if [[ -n "$INCLUDE_PATTERN" ]]; then
    if ! [[ "$name" =~ $INCLUDE_PATTERN ]]; then
      ((skipped++)) || true; $VERBOSE && vlog "Skip by include-regex: $name"; continue
    fi
  fi
  if [[ -n "$EXCLUDE_PATTERN" ]]; then
    if [[ "$name" =~ $EXCLUDE_PATTERN ]]; then
      ((skipped++)) || true; $VERBOSE && vlog "Skip by exclude-regex: $name"; continue
    fi
  fi

  # If base is expected -> not orphan; skip (and do not touch its @tmp)
  if [[ -n "${EXPECTED[$base]:-}" ]]; then
    ((skipped++)) || true
    vlog "Keeps (expected job): $name"
    continue
  fi

  # If it's an @tmp (or @N) but base exists in workspace (rare), still treat via base rule above.
  # Now base is NOT expected -> consider orphan
  # Age check
  if ! find "$d" -prune -mtime +"$OLDER_THAN_DAYS" | grep -q .; then
    ((skipped++)) || true
    vlog "Skip (too new, <= ${OLDER_THAN_DAYS}d): $name"
    continue
  fi

  # If this is a pure '@tmp' of an existing base? Already excluded by EXPECTED check.
  # For extra safety: if name ends with '@tmp' and INCLUDE_TMP=false, skip.
  if [[ "$name" == *"@tmp" && "$INCLUDE_TMP" != true ]]; then
    ((skipped++)) || true
    vlog "Skip @tmp (INCLUDE_TMP=false): $name"
    continue
  fi

  # Check if directory is being used by some process (best-effort)
  if is_in_use "$d"; then
    ((skipped++)) || true
    vlog "Skip (in use by process): $name"
    continue
  fi

  # Record candidate
  lm="$(date -d "@$(stat -c %Y "$d")" +'%F %T' 2>/dev/null || stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$d")"
  printf "%-8s | %-50s | %-19s | %s\n" "YES" "$name" "$lm" "$d" >> "$PLAN_FILE"
  ((candidates++)) || true
done

# Report summary
{
  echo
  echo "# Summary:"
  echo "# Considered: $considered"
  echo "# Skipped:    $skipped"
  echo "# Candidates: $candidates"
} >> "$PLAN_FILE"

log "Plan written to: $PLAN_FILE"
log "Candidates found: $candidates"

if ! $APPLY; then
  log "Dry-run only. Review the plan above. Re-run with --apply to actually delete."
  exit 0
fi

# Apply mode: ask for interactive confirmation
echo
echo "🚨 You are about to DELETE $candidates directories listed in:"
echo "    $PLAN_FILE"
read -r -p "Type exactly the number of candidates ($candidates) to confirm: " confirm
if [[ "$confirm" != "$candidates" ]]; then
  log "Confirmation failed. Aborting."
  exit 1
fi

# Execute deletions according to plan file
deleted=0
failed=0
echo "# Delete log @ $timestamp" > "$LOG_FILE"

while IFS= read -r line; do
  # Only lines marked as candidate ("YES | ... | ... | /full/path")
  [[ "$line" =~ ^YES\ \ \ \ \ \ \ \ \ \|\  ]] || continue
  path="$(awk -F '|' '{print $4}' <<<"$line" | sed -E 's#^[[:space:]]+##')"
  if [[ -d "$path" ]]; then
    if rm -rf --one-file-system -- "$path"; then
      echo "[OK] $(date +'%F %T') Deleted: $path" | tee -a "$LOG_FILE"
      ((deleted++)) || true
    else
      echo "[ERR] $(date +'%F %T') FAILED:  $path" | tee -a "$LOG_FILE"
      ((failed++)) || true
    fi
  else
    echo "[SKIP] $(date +'%F %T') Missing (already gone): $path" | tee -a "$LOG_FILE"
  fi
done < "$PLAN_FILE"

echo
log "Deletion finished. Deleted=$deleted, Failed=$failed"
log "Log written to: $LOG_FILE"

六、工作原理详解

1)构建“期望存在的工作区名集合”

  • Jenkins 在 $JENKINS_HOME/jobs 下按 Folder/Job 分层保存:jobs/<folder>/jobs/<sub-folder>/jobs/<job>/config.xml
  • 脚本不解析 XML,而是利用目录结构:遇到每个 config.xml,就把路径中的层级名提取出来并用 %2F 连接(Jenkins 会把 Job 全名中的斜杠替换为 %2F 作为 workspace 名称的一部分)。
  • 例如:jobs/TeamA/jobs/Service/jobs/build-api/config.xmlTeamA%2FService%2Fbuild-api

2)枚举实际的工作区目录

  • 枚举 $WORKSPACE_ROOT(如 /data1/var/lib/jenkins/workspace)下一层目录,每个目录名可能是:
    • 标准 workspace 名:TeamA%2FService%2Fbuild-api
    • 临时/派生目录:xxx@tmpxxx@2xxx@script
  • 将目录名按 @ 切分取“基础名”(base="${name%%@*}"),用于与“期望集合”匹配。

3)筛选与安全过滤

  • 包含/排除正则:你可以只针对特定目录处理(--include-regex)或排除一些目录(--exclude-regex)。
  • 年龄:只清理修改时间超过 N 天的目录(--older-than)。
  • @tmp 策略:默认不清理 *@tmp;若加 --include-tmp,仅在对应基础名不在期望集合里(即 Job 真正不存在)时才会清理。

4)占用检测

  • 如果安装了 lsoffuser,脚本会检查目录是否被进程使用。被占用 → 跳过。

5)Jenkins API 检测(可选)

  • 如果配置了 JENKINS_URL/JENKINS_USER/JENKINS_API_TOKEN,脚本会访问:
    /computer/api/json?depth=1
    
    若发现 "building": true(有活跃构建),直接退出,避免在构建高峰清理。

6)计划文件与汇总

  • Dry-run 阶段会生成一个 plan 文件orphan-workspaces-plan-*.txt),列出候选目录、最后修改时间、完整路径及汇总信息。

7)执行与日志

  • --apply 会进入执行模式,需要二次确认(输入候选数量)。
  • 删除动作与结果写入 删除日志orphan-workspaces-delete-*.log)。

七、使用指南(完整范例)

1)创建与赋权

nano safe-clean-orphan-workspaces.sh    # 粘贴脚本
chmod +x safe-clean-orphan-workspaces.sh

2)Dry-run(默认仅预览)

./safe-clean-orphan-workspaces.sh \
  --workspace-root /data1/var/lib/jenkins/workspace \
  --jenkins-home   /data1/var/lib/jenkins \
  --verbose

输出中会提示计划文件位置,打开查看候选列表。

3)加过滤器(逐步放开)

  • 只清理包含关键词的目录:
./safe-clean-orphan-workspaces.sh --include-regex 'Webhook|Timer'
  • 排除某类:
./safe-clean-orphan-workspaces.sh --exclude-regex '^(keep-.*|do-not-touch)$'

4)清理 *@tmp(可选)

./safe-clean-orphan-workspaces.sh --include-tmp

只有当对应基础工作区名不在期望集合里(即 Job 已删除)时才会考虑清理 @tmp

5)调整“老化天数”

./safe-clean-orphan-workspaces.sh --older-than 7

6)启用 Jenkins API 安全检测(推荐)

export JENKINS_URL="http://127.0.0.1:8080"
export JENKINS_USER="jenkins-admin"
export JENKINS_API_TOKEN="********"
./safe-clean-orphan-workspaces.sh

一旦检测到 "building": true,脚本会退出

7)真正执行删除

./safe-clean-orphan-workspaces.sh --apply

会要求你输入候选数量以确认。执行结果会写入日志 orphan-workspaces-delete-*.log

8)自动化(建议仍然保留 Dry-run + 人工审核)

  • 生成计划(每天凌晨 2 点):
# /etc/crontab
0 2 * * * jenkins /path/safe-clean-orphan-workspaces.sh --plan-dir /var/log/jenkins-clean-plans > /var/log/jenkins-clean-cron.log 2>&1
  • 由管理员审核计划文件后,手动执行 --apply

八、常见场景与建议

  • 多分支/PR(Multibranch):每个分支/PR 都是一个“Job 全名”,脚本会正确处理 %2F 命名映射。
  • 自定义 workspace:如果某些 Job 使用了“自定义 workspace”(不在标准路径/命名),脚本可能无法关联。建议对这类路径手工确认或通过 --include/--exclude-regex 精准控制。
  • 多节点(Agent):每个 Agent 有自己的 workspace 根目录,需要在各节点分别运行脚本。
  • 缓存策略node_modules/.m2/.gradle 等缓存很大。若你想保留以加速构建,不建议“每次构建后清理 workspace”。可以只定期清理孤儿目录。
  • 与 WS Cleanup 插件配合:日常构建层面用 cleanWs() 处理临时文件;存量垃圾、孤儿目录用本文脚本处理。
  • 权限与安全:尽量以 jenkins 用户执行,避免 root;生产环境先 Dry-run,确认后再 Apply。
  • 回滚与审计:删除本质不可逆;通过计划与日志实现事前/事后审计。关键服务器建议配合快照或备份策略

九、故障排查(FAQ)

  1. 计划里一个候选都没有?
    • 说明没有孤儿目录,或 --older-than 太大,或正则过滤过于严格。
  2. 全都提示 “too new”?
    • 调小 --older-than,或再等几天。
  3. lsof / fuser 不可用?
    • 占用检查会自动跳过(安全性降低),建议安装 lsof 或改为 --no-open-files-check 明确跳过。
  4. 目录名有空格/特殊字符?
    • 脚本对路径做了适当引用和 -print0 读取,通常安全。仍建议避免手工改名。
  5. 没有生成计划文件?
    • 检查 --plan-dir 路径是否存在/可写;查看标准输出中的报错信息。
  6. Jenkins API 认证失败?
    • 确认 URL 正确、用户与 Token 有权限访问 /computer/api/json,网络可达。

十、扩展

A)只清理“孤儿 @tmp”的极简命令

在确认 Job 已删除的前提下清理所有 @tmp 残留(仍建议先 Dry-run):

find /data1/var/lib/jenkins/workspace -maxdepth 1 -type d -name '*@tmp' -mtime +1 -print
# 确认后:
find /data1/var/lib/jenkins/workspace -maxdepth 1 -type d -name '*@tmp' -mtime +1 -exec rm -rf {} +

这个方法不检查对应 Job 是否存在,不如本文脚本安全,除非你已在 Jenkins 中确认 Job 确实删除。

B)Groovy 版本(思路)

  • 在 Jenkins Script Console 中用 Groovy 调用 Jenkins 内部 API,可以直接获取 Job 与 workspace 的真实映射,精度更高,能跨控制器/节点执行。但需要管理员权限,且必须非常谨慎。
  • 建议 Groovy 版本仅用于打印候选清单,删除仍通过你完全理解的 Shell 脚本执行。

十一、完整使用流程建议(SOP)

  1. Dry-run 生成计划文件 → 人工审核
  2. (可选)重复 Dry-run,添加 --include-regex / --exclude-regex 精准控制。
  3. 非工作时段确保无构建(或启用 API 检查)时,执行 --apply
  4. 归档计划与日志以便审计。
  5. 周期性重复:结合 cron 实现“先计划后审批”的流程。

十二、结语

上面这套方案,既解决了 Jenkins 长期运行后 workspace 垃圾堆积 的痛点,又通过多层安全策略确保稳、准、狠

  • 先发现,再确认;
  • 不在高峰时段下手;
  • 有日志可查;
  • 避免影响仍在使用的目录。
    在这里插入图片描述
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Linux运维技术栈

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值