青龙面板v2.21.0任务卡在「运行中」还刷 jwt malformed和状态更新失败(Validation failed):三层错因的完整排查记录

青龙面板v2.21.0任务卡在「运行中」还刷 jwt malformed和状态更新失败(Validation failed):三层错因的完整排查记录

时间煮雨-Master
2026-09-21 / 0 评论 / 3 阅读 / 正在检测是否收录...

青龙面板v2.21.0任务卡在「运行中」还刷 jwt malformed和状态更新失败(Validation failed):三层错因的完整排查记录

先说现象

升级到青龙面板 v2.21.0 之后,跑任何脚本,日志尾部都会多出这样的东西:

## 开始执行... 2026-09-21 09:51:08
...
, 状态更新失败(Validation failed)
jwt malformed
## 完成 ✅... 2026-09-21 09:51:09  耗时 1 秒, 状态更新失败(Validation failed) 

脚本本身跑完了,业务逻辑也没问题。但面板上的任务状态永远停在「运行中」,得手动去点停止。日志里既报 Validation failed 又报 jwt malformed,看着像两个毛病,实际上你面对的是三个。

这篇记录把三层错因一层层剥开,每层都给可复现的验证命令和最小改动的修复方案。


一键修复

把文末的脚本存成 ql-fix-all.sh,丢进容器执行:

docker cp ql-fix-all.sh qinglong:/tmp/
docker exec qinglong bash /tmp/ql-fix-all.sh

容器名用 docker ps 查,按实际的改。已经在容器里的话直接 bash ql-fix-all.sh

脚本自动备份、改完做语法校验、失败自动回滚,重复跑也没副作用。另有两个开关:

bash ql-fix-all.sh -n        # 只看诊断,不改文件
bash ql-fix-all.sh --revert  # 还原

跑完去面板随便跑一个任务,日志结尾只剩 ## 完成 ✅ 这一行,状态能变回「已结束」,就是好了。

不想用脚本也行,下面三个改动手工做一样的效果。


两个必须知道的点

改完那一次运行,日志里还会再报一次同样的错。 因为 shell 是在任务启动时才 source api.sh 的,你改文件的时候,那个任务早就把旧函数读进内存了。它会带着旧函数收尾,所以再刷一次 404。这是正常的「最后一响」,从下一个任务起才干净,不是补丁没生效。

全程不用重启面板。 三处改动都是改文件或改缓存,立即生效。重启面板反而会杀掉正在运行的任务进程,让任务又一次卡在「运行中」——等于自己把刚修好的问题重新制造一遍。


根治

这些都是壳补丁,是在不想重建容器的前提下最轻的止血办法。真正的根治只有一条:把后端/镜像更新到与 shell/*.sh 同版本。升级之后记得把补丁撤掉:

bash ql-fix-all.sh --revert


点击展开:一键修复脚本 ql-fix-all.sh 全文

#!/usr/bin/env bash
# shellcheck shell=bash disable=SC2086,SC2016
# =============================================================================
#  ql-fix-all.sh  v1.0   —— 青龙「任务卡运行中 + jwt malformed」一键修复
#
#  一次修完三层(全来自同一个根因:shell 比后端新 + 数据卷留了旧状态):
#    1) , 状态更新失败(Validation failed)   → 任务永远卡「运行中」
#       api.sh 的 update_cron 多发 exit_code,后端 Joi 拒收
#    2) 401 jwt malformed                    → 鉴权被拒
#       keyv:apps 缓存里 system 应用缺 dashboard scope
#    3) 404 Cannot POST dashboard/record     → 日志噪音
#       后端无此路由,而 record_cron_stat 只写统计表,屏蔽即可
#
#  用法:
#    bash ql-fix-all.sh            # 一键修复
#    bash ql-fix-all.sh -n         # 只看诊断,不改任何文件
#    bash ql-fix-all.sh --revert   # 还原 api.sh 补丁
#
#  安全:改前自动备份;语法校验失败自动回滚;可重复执行(幂等)。
#
#  ⚠️ 打完补丁的那一次运行,日志里仍会再出现一次 404 + jq 报错。
#     因为 shell 是在任务【启动时】才 source api.sh 的,
#     该任务内存里还是旧函数。从下一个任务起才干净,不是没生效。
# =============================================================================

if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
  bash "${BASH_SOURCE[0]}" ${@+"$@"} 2>/dev/null
  return $?
fi

set -uo pipefail

API=/ql/shell/api.sh
DB=/ql/data/db/database.sqlite
KV=/ql/data/db/keyv.sqlite
TOKF=/ql/data/config/token.json
PORT="${ql_port:-5700}"
DRY=0; REVERT=0

while [ $# -gt 0 ]; do
  case "$1" in
    -n|--dry-run) DRY=1 ;;
    --revert)     REVERT=1 ;;
    -h|--help)    sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
  esac
  shift
done

G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; N=$'\033[0m'
[ -t 1 ] || { G=; R=; Y=; N=; }
ok()   { printf '  %s\n' "${G}[OK]${N}   $*"; }
bad()  { printf '  %s\n' "${R}[!!]${N}   $*"; }
warn() { printf '  %s\n' "${Y}[? ]${N}   $*"; }
step() { printf '\n==> %s\n' "$*"; }

# --- 不在容器内时,自动钻进去重跑(宿主机上也能一键) -----------------------
if [ ! -f "$API" ]; then
  CN="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -iE 'qinglong|^ql' | head -1)"
  if [ -z "$CN" ]; then
    bad "既不在青龙容器内,也找不到运行中的青龙容器"
    echo "     请在宿主机执行 docker ps 确认容器名,或把本脚本放进容器再跑"
    exit 1
  fi
  ok "检测到容器 $CN,自动进入容器执行 ..."
  docker cp "$0" "$CN:/tmp/ql-fix-all.sh" >/dev/null 2>&1 || { bad "拷贝脚本进容器失败"; exit 1; }
  docker exec "$CN" bash /tmp/ql-fix-all.sh "$@"
  exit $?
fi

STAMP="$(date +%Y%m%d-%H%M%S)"
BAK="$API.bak.$STAMP"

# =============================================================================
# 还原
# =============================================================================
if [ "$REVERT" = "1" ]; then
  step "还原 api.sh"
  LAST="$(ls -1t "$API".bak.* 2>/dev/null | head -1)"
  if [ -z "$LAST" ]; then bad "找不到备份 $API.bak.*"; exit 1; fi
  cp -a "$LAST" "$API" && ok "已还原 ← $LAST"
  bash -n "$API" && ok "语法校验通过"
  echo
  echo "  scopes 的改动无需还原(多一个 dashboard 是无害的,升级镜像后自然一致)"
  exit 0
fi

# =============================================================================
step "1/4  诊断"

P1=0; P3=0
grep -q '\[qlfix\]' "$API" 2>/dev/null && P1=1
grep -q '\[qlfix-dashcall\]' "$API" 2>/dev/null && P3=1
printf '  api.sh  第1层补丁(去 exit_code) : %s\n' "$([ "$P1" = 1 ] && echo "${G}已打${N}" || echo "${Y}未打${N}")"
printf '  api.sh  第3层补丁(屏蔽上报)     : %s\n' "$([ "$P3" = 1 ] && echo "${G}已打${N}" || echo "${Y}未打${N}")"

SCOPE_NEED=0; SKIP_DB=1; KVKEY="-"; KVSC=""; KV_ST=""; DBSC=""

if command -v python3 >/dev/null 2>&1; then
  S2="$(python3 - "$KV" "$DB" <<'PYEOF'
import sys, os, json, sqlite3
kv, db = sys.argv[1], sys.argv[2]

def jload(v, d):
    if isinstance(v, (list, dict)):
        return v
    try:
        return json.loads(v) if v else d
    except Exception:
        return d

def read_cache():
    if not os.path.exists(kv):
        return "NOFILE", None, ""
    con = sqlite3.connect("file:%s?mode=ro" % kv, uri=True)
    try:
        keys = [r[0] for r in con.execute("SELECT key FROM keyv")]
        kn = next((k for k in ("apps", "keyv:apps") if k in keys), None) \
             or next((k for k in keys if str(k).endswith(":apps")), None)
        if not kn:
            return "NOKEY", None, ",".join(str(x) for x in keys)
        row = next(iter(con.execute("SELECT value FROM keyv WHERE key=?", (kn,))), None)
    finally:
        con.close()
    sc = None
    for a in jload(row[0] if row else "[]", []):
        if isinstance(a, dict) and a.get("name") == "system":
            sc = jload(a.get("scopes"), [])
    return "OK", kn, sc

try:
    st, kn, val = read_cache()
    if st == "OK" and kn:
        print("KV|OK|%s|%s" % (kn, ",".join(str(x) for x in (val or []))))
    else:
        print("KV|BAD|%s" % st)
except Exception as e:
    print("KV|BAD|ERR %s" % e)

try:
    con = sqlite3.connect("file:%s?mode=ro" % db, uri=True)
    try:
        r = next(iter(con.execute("SELECT scopes FROM Apps WHERE name='system'")), None)
    finally:
        con.close()
    if r is None:
        print("DB|NOSYS")
    else:
        print("DB|%s" % ",".join(str(x) for x in jload(r[0], [])))
except Exception as e:
    print("DB|BAD|%s" % e)
PYEOF
)"
  KV_ST="$(printf '%s\n' "$S2" | awk -F'|' '$1=="KV"{print $2}' | head -1)"
  if [ "$KV_ST" = "OK" ]; then
    KVKEY="$(printf '%s\n' "$S2" | awk -F'|' '$1=="KV"{print $3}' | head -1)"
    KVSC="$(printf  '%s\n' "$S2" | awk -F'|' '$1=="KV"{print $4}' | head -1)"
  fi
  DB_ST="$(printf '%s\n' "$S2" | awk -F'|' '$1=="DB"{print $2}' | head -1)"
  case "$DB_ST" in ""|BAD|NOSYS) SKIP_DB=1 ;; *) DBSC="$DB_ST"; SKIP_DB=0 ;; esac

  printf '  system 应用的 scopes:\n'
  printf '    keyv 缓存 (%s)  : %s\n' "$KVKEY" "${KVSC:-读不到}"
  printf '    database.sqlite : %s\n' "${DBSC:-读不到}"

  hasd() { printf '%s' "$1" | tr ',' '\n' | grep -qx dashboard; }
  if [ -z "$KVSC" ]; then
    SCOPE_NEED=1; warn "读不到 keyv 缓存(状态 $KV_ST),将直接尝试写入"
  elif hasd "$KVSC"; then
    ok "缓存已含 dashboard —— 第2层不成立"
  else
    SCOPE_NEED=1; bad "★ 缓存缺 dashboard —— 这就是 401 jwt malformed 的原因"
  fi
  if [ "$SKIP_DB" = 0 ] && ! hasd "$DBSC"; then
    SCOPE_NEED=1; bad "源头 database.sqlite 也缺 dashboard(当前 $DBSC)"
  fi
else
  bad "容器内没有 python3,无法自动修第2层"
fi

# --- 实测对照:同一 token 打两个端点 ---------------------------------------
TOK="$(jq -r '.value // .token // empty' "$TOKF" 2>/dev/null)"
if [ -n "$TOK" ]; then
  probe() {
    curl -s -o /dev/null -w '%{http_code}' --noproxy '*' -m 8 \
      -X "$1" "http://localhost:$PORT$2" \
      -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json;charset=UTF-8' \
      --data-raw "$3" 2>/dev/null
  }
  C1="$(probe PUT /open/crons/status '{"ids":[999999999],"status":"1","pid":"1","log_path":"/tmp/x.log","last_execution_time":0,"last_running_time":1}')"
  C2="$(probe POST /open/dashboard/record '{"ref_id":999999999,"code":0,"elapsed":1}')"
  printf '  crons/status                  : HTTP %s\n' "${C1:-ERR}"
  printf '  dashboard/record              : HTTP %s\n' "${C2:-ERR}"
  if [ "$C1" = "200" ] && [ "$C2" = "401" ]; then
    bad "★ 对照成立:crons 通、dashboard 401 ⇒ 就是第2层(缓存缺 scope)"
  fi
else
  warn "读不到 token,跳过端点实测"
fi

if [ "$DRY" = "1" ]; then
  step "诊断结束(-n 模式,未改任何文件)"
  exit 0
fi

# =============================================================================
step "2/4  修第2层:给 system 应用补 dashboard scope"

if [ "$SCOPE_NEED" = "0" ]; then
  ok "无需修改"
else
  cp -a "$KV" "$KV.bak.$STAMP" 2>/dev/null && ok "已备份 → $(basename "$KV").bak.$STAMP"
  [ "$SKIP_DB" = 0 ] && [ -f "$DB" ] && cp -a "$DB" "$DB.bak.$STAMP" 2>/dev/null \
    && ok "已备份 → $(basename "$DB").bak.$STAMP"

  RES="$(python3 - "$KV" "$DB" <<'PYEOF'
import sys, os, json, sqlite3
kv, db = sys.argv[1], sys.argv[2]

def jload(v, d):
    if isinstance(v, (list, dict)):
        return v
    try:
        return json.loads(v) if v else d
    except Exception:
        return d

DONE = 0

# ---- keyv.sqlite(鉴权真正读的,改完立即生效,无需重启) ----
try:
    if os.path.exists(kv):
        con = sqlite3.connect(kv, timeout=20)
        try:
            keys = [r[0] for r in con.execute("SELECT key FROM keyv")]
            kn = next((k for k in ("apps", "keyv:apps") if k in keys), None) \
                 or next((k for k in keys if str(k).endswith(":apps")), None)
            if not kn:
                print("KV|NOKEY|键=%s" % ",".join(str(x) for x in keys))
            else:
                row = next(iter(con.execute("SELECT value FROM keyv WHERE key=?", (kn,))), None)
                apps = jload(row[0] if row else "[]", [])
                if not isinstance(apps, list):
                    print("KV|BADJSON|%s" % kn)
                else:
                    hit = False
                    for a in apps:
                        if not isinstance(a, dict) or a.get("name") != "system":
                            continue
                        hit = True
                        sc = jload(a.get("scopes"), [])
                        if not isinstance(sc, list):
                            sc = []
                        if "dashboard" not in sc:
                            sc.append("dashboard")
                            a["scopes"] = sc
                            con.execute("UPDATE keyv SET value=? WHERE key=?",
                                        (json.dumps(apps, ensure_ascii=False), kn))
                            con.commit()
                            print("KV|ADDED|%s" % kn)
                            DONE += 1
                        else:
                            print("KV|HAS|%s" % kn)
                    if not hit:
                        print("KV|NOSYS|缓存里没有 name=system 的应用")
        finally:
            con.close()
    else:
        print("KV|NOFILE|%s" % kv)
except Exception as e:
    print("KV|ERR|%s" % e)

# ---- database.sqlite(源头,保证下次重启后也一致) ----
try:
    if os.path.exists(db):
        con = sqlite3.connect(db, timeout=20)
        try:
            r = next(iter(con.execute("SELECT scopes FROM Apps WHERE name='system'")), None)
            if r is None:
                print("DB|NOSYS")
            else:
                sc = jload(r[0], [])
                if not isinstance(sc, list):
                    sc = []
                if "dashboard" not in sc:
                    sc.append("dashboard")
                    con.execute("UPDATE Apps SET scopes=? WHERE name='system'",
                                (json.dumps(sc, ensure_ascii=False),))
                    con.commit()
                    print("DB|ADDED")
                    DONE += 1
                else:
                    print("DB|HAS")
        finally:
            con.close()
    else:
        print("DB|NOFILE|%s" % db)
except Exception as e:
    print("DB|ERR|%s" % e)

print("DONE|%d" % DONE)
PYEOF
)"
  printf '%s\n' "$RES" | sed 's/^/        /'
  case "$RES" in
    *"|ADDED"*) ok "已补上 dashboard scope(即时生效,无需重启)" ;;
    *"|HAS"*)   ok "已是最新,无需修改" ;;
    *)          warn "未能确认修复结果,见上方明细" ;;
  esac
fi

# =============================================================================
step "3/4  修第1、3层:给 api.sh 打补丁"

if [ "$P1" = "1" ] && [ "$P3" = "1" ]; then
  ok "两个补丁都已存在,跳过"
else
  cp -a "$API" "$BAK" && ok "已备份 → $(basename "$BAK")" || { bad "备份失败,中止"; exit 1; }

  python3 - "$API" <<'PYEOF'
import re, sys
p = sys.argv[1]
s = open(p, encoding="utf-8").read()
out = []

# --- 第1层:屏蔽 update_cron 里 exit_code 的追加 ---
if "[qlfix]" in s:
    out.append("L1|HAS")
else:
    m = re.search(r'^([ \t]*)if \[\[ -n \$exitCode \]\]; then[ \t]*$', s, re.M)
    if m:
        ins = (m.group(1) + "if false; then  # [qlfix] 后端 Joi 不含 exit_code,"
               "发送会被拒为 400 Validation failed")
        s = s[:m.start()] + ins + s[m.end():]
        out.append("L1|ADDED")
    else:
        out.append("L1|NOTFOUND")

# --- 第3层:屏蔽 record_cron_stat ---
if "[qlfix-dashcall]" in s:
    out.append("L3|HAS")
else:
    m = re.search(r'^record_cron_stat\(\)[ \t]*\{[ \t]*$', s, re.M)
    if m:
        ins = ("\n  # [qlfix-dashcall] 后端无 /open/dashboard/record 路由(404)\n"
               "  # 该调用只写面板统计,与任务状态回写无关,屏蔽以保持日志干净\n"
               "  return 0")
        s = s[:m.end()] + ins + s[m.end():]
        out.append("L3|ADDED")
    else:
        out.append("L3|NOTFOUND")

open(p, "w", encoding="utf-8").write(s)
print(" ".join(out))
PYEOF

  P1=0; P3=0
  grep -q '\[qlfix\]' "$API" && P1=1
  grep -q '\[qlfix-dashcall\]' "$API" && P3=1
  [ "$P1" = 1 ] && ok "第1层补丁就位" || warn "第1层未匹配(该文件写法可能不同,已跳过)"
  [ "$P3" = 1 ] && ok "第3层补丁就位" || warn "第3层未匹配(该文件写法可能不同,已跳过)"
fi

# =============================================================================
step "4/4  语法校验"

if bash -n "$API" 2>/dev/null; then
  ok "api.sh 语法正确"
else
  bad "语法错误!自动回滚 ..."
  [ -f "$BAK" ] && cp -a "$BAK" "$API" && ok "已回滚到原文件"
  exit 1
fi

if [ -n "$TOK" ]; then
  C1="$(curl -s -o /dev/null -w '%{http_code}' --noproxy '*' -m 8 \
    -X PUT "http://localhost:$PORT/open/crons/status" \
    -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json;charset=UTF-8' \
    --data-raw '{"ids":[999999999],"status":"1","pid":"1","log_path":"/tmp/x.log","last_execution_time":0,"last_running_time":1}' 2>/dev/null)"
  [ "$C1" = "200" ] && ok "端点实测:crons/status → HTTP 200" \
                    || warn "端点实测:crons/status → HTTP ${C1:-ERR}(401 说明第2层没修上)"
fi

echo
ok "处理完成"
cat <<EOF

  下一步:去面板随便跑一个任务,确认日志结尾只有
            ## 完成 ✅  这一行
          且任务状态能变回「已结束」。

  ⚠️ 打完补丁的那一次运行,日志里还会再出现一次 404 + jq 报错。
     这是正常的「最后一响」—— shell 是任务启动时才 source 的,
     那个任务内存里还是旧函数。下一个任务才干净。

  改动与还原:
    · api.sh 补丁  备份 $(basename "$BAK")     还原  bash $0 --revert
    · scopes 改动  多一个 dashboard 是无害的,无需还原
    · 诊断命令     bash $0 -n

EOF

0

评论 (0)

取消