Files
BeacherZ 2c61b727cb feat: 项目改名为 dailycheckin-plus,README 补 docker run 用法,通知标题带项目名
- 品牌字串全面更换:README / compose / Dockerfile / entrypoint / 面板页面 / CI。
  镜像名变为 ghcr.io/beacherz/dailycheckin-plus:latest;compose 里的服务名与
  container_name 保持 dailycheckin 不动 —— 改了会变成新旧两个容器同时跑,通知收两条。
- 通知标题带上项目名:NOTICE_TITLE 与 main.py 的标题都改用 PROJECT_NAME,
  实际发出来是「【dailycheckin-plus 每日签到 · 日期】」;企业微信卡片署名同步。
  这两处仍在 patches/0002 里,已重新生成并验证「重放后与工作区逐字节一致」。
- 面板:默认 PANEL_TITLE 改为 dailycheckin-plus,浏览器标签页与顶栏品牌跟着走
  (顺手把顶栏一直没被填充的面板版本/引擎状态补上);HTTP Server 头改为
  dailycheckin-plus-panel/2.1.0。
- crontab 托管标记改为 dailycheckin-plus managed,但**仍兼容老标记**并在保存时
  升级;crontab 里若存在裸的签到行,首次保存会整行注释留档后再接管,避免和新任务
  并存导致一天跑两遍、通知收两条。新增 tools/test_schedule.py(27 项断言)锁这条行为。
- README:快速开始拆成 docker compose / docker run 两种方式(含 Windows 路径写法和
  docker run 的升级步骤),更新通知样例、环境变量表、目录结构与常见问题。
- Dockerfile 增加「面板品牌自检」,装进镜像的网页必须是改名后的版本。
2026-10-15 20:54:00 -07:00

144 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""crontab 托管区块自测(离线,纯标准库,不碰真实 crontab)。
重点是**改项目名之后老部署不能出问题**:
面板只维护自己那段带标记的区块,标记从 `dailycheckin-compose managed` 换成了
`dailycheckin-plus managed`。老用户的 crontab 里还是老标记,
如果不认它,面板会以为「这段不是我管的」,再追加一段新的签到任务 ——
结果就是一天跑两遍、TG 收到两条通知。这里把这条路堵死并锁进测试。
跑法:python3 tools/test_schedule.py
"""
import importlib
import os
import shutil
import sys
import tempfile
import types
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 用临时目录当工作目录,别碰到真实数据;环境变量必须在导入 paths 之前设好
TMP = tempfile.mkdtemp(prefix="dc-test-schedule-")
os.makedirs(os.path.join(TMP, "cron"), exist_ok=True)
os.environ["DC_HOME"] = TMP
os.environ["DC_CRON_PATH"] = os.path.join(TMP, "cron", "crontab_list.sh")
# 只把 panel 当成一个「壳」导入,避免执行 panel/__init__.py(它会连带导入整个服务端)
sys.path.insert(0, ROOT)
pkg = types.ModuleType("panel")
pkg.__path__ = [os.path.join(ROOT, "panel")]
sys.modules["panel"] = pkg
paths = importlib.import_module("panel.paths")
schedule = importlib.import_module("panel.schedule")
passed, failed = [], []
def check(name, cond, detail=""):
(passed if cond else failed).append(name if cond else f"{name} :: {detail}")
print((" [OK] " if cond else " [FAIL] ") + name + ("" if cond else " :: " + str(detail)))
def write(text):
with open(paths.CRON_PATH, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def read():
with open(paths.CRON_PATH, encoding="utf-8") as f:
return f.read()
def active_calls(text):
"""真正会被 cron 执行的签到行(注释掉的不算)。"""
return [
line
for line in text.splitlines()
if not line.strip().startswith("#") and "dailycheckin " in line and "cd " in line
]
OLD_BEGIN = "# >>> dailycheckin-compose managed >>>"
OLD_END = "# <<< dailycheckin-compose managed <<<"
RUN_LINE = f"0 9 * * * cd {paths.HOME} && dailycheckin >> {paths.LOG_DIR}/dailycheckin.log 2>&1"
# 老部署的文件:用户自己还有一条别的定时任务,加上老名字的托管区块
LEGACY = (
f"TZ=Asia/Shanghai\nPATH=/usr/local/bin:/usr/bin:/bin\n\n"
f"* * * * * echo 用户自己的任务\n\n"
f"##############每日签到任务##############\n{OLD_BEGIN}\n{RUN_LINE}\n{OLD_END}\n"
)
print("=" * 66)
print("crontab 托管区块:改名后老标记仍要认,且升级成一整段")
# 1) 新标记是 dailycheckin-plus
check("托管标记用的是新项目名", schedule.BEGIN == "# >>> dailycheckin-plus managed >>>", schedule.BEGIN)
check("老标记进了兼容清单", "dailycheckin-compose" in schedule.MANAGED_MARKERS, schedule.MANAGED_MARKERS)
# 2) 文件不存在时,默认内容就是新标记
if os.path.exists(paths.CRON_PATH):
os.remove(paths.CRON_PATH)
default = schedule.read_text()
check("默认 crontab 用新标记", schedule.BEGIN in default and schedule.END in default, default[-120:])
# 3) 老标记文件:要能被认出来(enabled、cron 时间都对),并且认出这不是「漂移检测」
write(LEGACY)
info = schedule.parse()
check("老标记被识别为已启用", info["enabled"] is True, info)
check("老标记里的 cron 时间读得出来", info["cron"] == "0 9 * * *", info)
check("老标记不算「检测到的野任务」", info["detected"] is False, info)
# 4) 改时间保存:老区块被就地替换成新标记,不能多出一段
back = schedule.set_schedule(True, "30 7 * * *")
check("保存返回已生效状态", back["enabled"] is True and back["cron"] == "30 7 * * *", back)
text = read()
check("升级后只剩一段托管区块", text.count("managed >>>") == 1, text)
check("老标记已被清掉", "dailycheckin-compose" not in text, text)
check("区块内是新时间", "30 7 * * *" in text, text)
check("用户自己的任务没被动", "* * * * * echo 用户自己的任务" in text, text)
check("只保留一个签到调用", len(active_calls(text)) == 1, text)
# 5) 关掉定时签到:同样只替换这一段的正文,不新增区块
schedule.set_schedule(False, "30 7 * * *")
text = read()
check("关闭后仍只有一段区块", text.count("managed >>>") == 1, text)
check("关闭后正文是注释", "# 定时签到已在面板中关闭" in text, text)
check("关闭后不再有签到调用", not active_calls(text), text)
# 6) 再打开:还是那一段
schedule.set_schedule(True, "0 8,20 * * *")
text = read()
check("重新开启仍只有一段区块", text.count("managed >>>") == 1, text)
check("重新开启写回了新时间", "0 8,20 * * *" in text, text)
check("重新开启后只有一个签到调用", len(active_calls(text)) == 1, text)
# 7) 从官方镜像迁过来的 crontab(没有托管标记,只有一行裸调用)
# 这行必须被接管并注释留档,否则它和新区块并存 = 一天跑两遍
write(f"TZ=Asia/Shanghai\n{RUN_LINE}\n")
info = schedule.parse()
check("裸调用也能识别为已启用", info["enabled"] is True and info["cron"] == "0 9 * * *", info)
check("裸调用标记为「检测到的」", info["detected"] is True, info)
schedule.set_schedule(True, "0 9 * * *")
text = read()
check("裸调用被包进新的托管区块", text.count("managed >>>") == 1, text)
check("接管后只剩一个真实签到调用", len(active_calls(text)) == 1, text)
check("裸调用原文被注释留档", "# 已由 dailycheckin-plus 面板接管" in text, text)
block_body = schedule.BLOCK_RE.search(text).group(1)
check("唯一的真实调用就在托管区块里", all(l in block_body for l in active_calls(text)), text)
# 8) cron 表达式校验
check("5 字段以外拒绝", schedule.validate("0 9 * *") is not None, schedule.validate("0 9 * *"))
check("含非法字符拒绝", schedule.validate("0 9 * * 天") is not None, schedule.validate("0 9 * * 天"))
check("合法表达式通过", schedule.validate("0 */6 * * *") is None)
print("=" * 66)
print(f"通过 {len(passed)} 失败 {len(failed)}")
for one in failed:
print(" ✗", one)
shutil.rmtree(TMP, ignore_errors=True)
sys.exit(1 if failed else 0)