From 295177027f2d63062427584bfe25d2672fe794f4 Mon Sep 17 00:00:00 2001 From: gao Date: Fri, 3 Jul 2026 10:48:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20market-sync=202h=20timeout=20=E6=88=AA?= =?UTF-8?q?=E6=96=AD=20+=20=E5=8A=A0=20daily-check=20=E5=B7=A1=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit work #01 (2026-07-03):昨天 15:30 runall 中 kline_5min 全量 69min 把 2h 窗口吃满,share_snapshot / market_regime 被 systemd SIGTERM 截断; 今天又叠加 worker.py 未长驻,导致 10 个 task missed。 变更: - bin/systemd/market-sync.service: TimeoutStartSec 7200 → 14400 (4h) - bin/daily_sync_check.py: 新增每日巡检,读 PG dataset_registry, 对比 SCHEDULE 判定 ok/missed/failed/stuck/never_run,写 JSON 报告, 失败时 POST HMAC webhook - bin/systemd/market-sync-daily-check.{service,timer}: 23:00 daily 触发 daily_check(替代 session-only 的 /loop 方案) - config/daily_check_secrets.env.template: webhook 配置模板 - docs/works/2026-07-03-01-market-sync-timeout.md: 完整 work 记录 (根因 + 中期/长期改进建议) Co-Authored-By: Claude --- bin/daily_sync_check.py | 408 ++++++++++++++++++ bin/systemd/market-sync-daily-check.service | 31 ++ bin/systemd/market-sync-daily-check.timer | 15 + bin/systemd/market-sync.service | 6 +- config/daily_check_secrets.env.template | 6 + .../2026-07-03-01-market-sync-timeout.md | 78 ++++ 6 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 bin/daily_sync_check.py create mode 100644 bin/systemd/market-sync-daily-check.service create mode 100644 bin/systemd/market-sync-daily-check.timer create mode 100644 config/daily_check_secrets.env.template create mode 100644 docs/works/2026-07-03-01-market-sync-timeout.md diff --git a/bin/daily_sync_check.py b/bin/daily_sync_check.py new file mode 100644 index 0000000..a3c2011 --- /dev/null +++ b/bin/daily_sync_check.py @@ -0,0 +1,408 @@ +"""每日同步任务巡检。 + +读 PG `dataset_registry`,对比今日预期调度窗口,给出每个 task 的健康状态, +写 `logs/daily_check_YYYYMMDD_HHMMSS.json` 报告,仅在有失败/漏跑时 POST webhook。 + +用法: + .venv/bin/python bin/daily_sync_check.py # 默认检查"今天" + .venv/bin/python bin/daily_sync_check.py --dry-run # 只生成报告,不 POST + .venv/bin/python bin/daily_sync_check.py --report-only # 完全不读 secrets / 不 POST + .venv/bin/python bin/daily_sync_check.py --date 2026-07-02 # 检查指定日期 + +为什么是 stand-alone(不放在 app.tasks 下): + - 巡检逻辑独立于 sync 业务,混在 app.tasks 会让注册表臃肿 + - 独立脚本便于 cron / systemd timer / /loop 三种触发方式共用 +""" +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import logging +import os +import re +import sys +import time +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Optional + +import requests + + +# ── 路径与日志 ───────────────────────────────────────────────────────────── +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +LOG_DIR = PROJECT_ROOT / "logs" +SECRETS_FILE = PROJECT_ROOT / "config" / "daily_check_secrets.env" + +LOG = logging.getLogger("daily-sync-check") + + +# ── 预期调度表 ───────────────────────────────────────────────────────────── +# 与 [[market-data-overview]] 保持一致;同步触发点(HH:MM)按 Asia/Shanghai 解释。 +# weekend_only=True 表示只在周六/周日期望运行(实际仅 stock_node 周六一次)。 +SCHEDULE: dict[str, dict[str, Any]] = { + "stock_basic": {"window_end": "09:30", "weekend_only": False}, + "industry_sector": {"window_end": "09:45", "weekend_only": False}, + "kline_index": {"window_end": "15:35", "weekend_only": False}, + "kline_daily": {"window_end": "15:45", "weekend_only": False}, + "kline_5min": {"window_end": "16:05", "weekend_only": False}, + "market_regime": {"window_end": "16:20", "weekend_only": False}, + "share_snapshot": {"window_end": "16:25", "weekend_only": False}, + "moneyflow": {"window_end": "21:40", "weekend_only": False}, + "longhubang": {"window_end": "22:05", "weekend_only": False}, + "stock_node": {"window_end": "12:00", "weekend_only": True}, # 周六 +} + + +# ── 状态判定 ─────────────────────────────────────────────────────────────── +def _parse_ts(s: Optional[str]) -> Optional[datetime]: + """dataset_registry 时间字段是 '%Y-%m-%d %H:%M:%S' 或 None。""" + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"): + try: + return datetime.strptime(s, fmt) + except ValueError: + continue + return None + + +def _classify_task( + row: dict[str, Any], + check_date: date, + now: datetime, +) -> dict[str, Any]: + """判定单个 task 在 check_date 的健康状态。 + + 返回字段: + status: ok | missed | failed | running | never_run | not_expected + reason: 简短中文原因 + last_success_at / last_failure_at / last_error / needs_resync: 原值透传 + """ + dataset_id = row["dataset_id"] + enabled = (row.get("enabled") or 0) == 1 + spec = SCHEDULE.get(dataset_id) + is_weekend = check_date.weekday() >= 5 + + if not enabled: + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "disabled", + "reason": "enabled=0", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": row.get("status", "idle"), + } + + # weekend 期望判定 + if spec and spec["weekend_only"] and not is_weekend: + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "not_expected", + "reason": "周末专属任务,今天不期望", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": row.get("status", "idle"), + } + if spec and not spec["weekend_only"] and is_weekend: + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "not_expected", + "reason": "周末不调度", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": row.get("status", "idle"), + } + + last_success = _parse_ts(row.get("last_success_at")) + last_failure = _parse_ts(row.get("last_failure_at")) + status_field = row.get("status", "idle") + + # 还在跑(卡死检测) + if status_field == "running": + started = _parse_ts(row.get("started_at")) + if started and (now - started) > timedelta(hours=2): + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "stuck", + "reason": f"running > 2h, started_at={row.get('started_at')}", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": status_field, + } + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "running", + "reason": f"status=running, started_at={row.get('started_at')}", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": status_field, + } + + # 从未跑过 + if last_success is None and last_failure is None: + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "never_run", + "reason": "从未同步过", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": status_field, + } + + # 今日是否有成功? + today_start = datetime.combine(check_date, datetime.min.time()) + if last_success and last_success >= today_start: + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "ok", + "reason": f"今日已成功 @ {last_success.strftime('%H:%M:%S')}", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": status_field, + } + + # 今日失败过? + if last_failure and last_failure >= today_start and ( + not last_success or last_failure > last_success + ): + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "failed", + "reason": f"今日失败 @ {last_failure.strftime('%H:%M:%S')}: " + f"{(row.get('last_error') or '')[:120]}", + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": status_field, + } + + # 期望窗口已过但今日没成功 + return { + "dataset_id": dataset_id, + "name": row.get("name", ""), + "status": "missed", + "reason": ( + f"今日未成功(last_success={last_success.strftime('%Y-%m-%d %H:%M') if last_success else 'never'})" + ), + "last_success_at": row.get("last_success_at"), + "last_failure_at": row.get("last_failure_at"), + "last_error": row.get("last_error"), + "needs_resync": row.get("needs_resync", 0), + "status_field": status_field, + } + + +# ── 报告生成 ─────────────────────────────────────────────────────────────── +def build_report(check_date: date) -> dict[str, Any]: + """生成当日巡检报告。""" + from app.core.db import ops as db_ops # 延迟导入:避免单独跑这个脚本时加载 .env + + now = datetime.now() + rows = db_ops.fetch_dataset_registry_rows() + classified = [_classify_task(r, check_date, now) for r in rows] + + # 汇总计数 + counts: dict[str, int] = {} + for c in classified: + counts[c["status"]] = counts.get(c["status"], 0) + 1 + + # 告警列表:只挑非 ok / non-expected 的 + alerts = [ + {"dataset_id": c["dataset_id"], "status": c["status"], "reason": c["reason"]} + for c in classified + if c["status"] in {"missed", "failed", "stuck", "never_run"} + ] + + overall = "ok" if not alerts else ( + "warning" if any(a["status"] in {"missed", "never_run"} for a in alerts) else "error" + ) + + return { + "schema_version": 1, + "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), + "check_date": check_date.isoformat(), + "overall": overall, + "counts": counts, + "alerts": alerts, + "tasks": classified, + } + + +def write_report(report: dict[str, Any]) -> Path: + """把报告写到 logs/daily_check_YYYYMMDD_HHMMSS.json。""" + LOG_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = LOG_DIR / f"daily_check_{ts}.json" + path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def print_summary(report: dict[str, Any]) -> None: + """stdout 一眼看懂的汇总。""" + print("=" * 78) + print(f"每日同步巡检 date={report['check_date']} overall={report['overall']}") + print("=" * 78) + print(f"counts: {report['counts']}") + if report["alerts"]: + print(f"\n告警 ({len(report['alerts'])}):") + for a in report["alerts"]: + print(f" - [{a['status']:>9}] {a['dataset_id']:<20} {a['reason']}") + else: + print("\n✅ 所有任务今日已成功") + # 完整列表 + print(f"\n全部 {len(report['tasks'])} 个任务:") + for t in report["tasks"]: + icon = { + "ok": "✅", + "missed": "❌", + "failed": "❌", + "stuck": "⚠️", + "running": "🔄", + "never_run": "❓", + "not_expected": "⏸ ", + "disabled": "🚫", + }.get(t["status"], "·") + print(f" {icon} [{t['status']:>13}] {t['dataset_id']:<20} {t['reason']}") + + +# ── Webhook ──────────────────────────────────────────────────────────────── +SIG_HEADER_CANDIDATES: list[tuple[str, str]] = [ + ("X-Hub-Signature-256", "sha256={sig}"), + ("X-Webhook-Signature", "{sig}"), + ("X-Gitlab-Token", "{secret}"), +] +HTTP_TIMEOUT = 10 + + +def _load_secrets() -> dict[str, str]: + out: dict[str, str] = {} + if not SECRETS_FILE.exists(): + return out + try: + for raw in SECRETS_FILE.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k and v: + out[k] = v + except Exception as e: + LOG.error("failed to parse %s: %s", SECRETS_FILE, e) + return out + + +def post_webhook(url: str, secret: str, payload: dict[str, Any], *, dry_run: bool = False) -> int: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + sig = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + + if dry_run: + print(f"[webhook DRY-RUN] {url}") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 200 + + for hdr_name, value_fmt in SIG_HEADER_CANDIDATES: + sig_value = value_fmt.format(sig=sig, secret=secret) + headers = {"Content-Type": "application/json", hdr_name: sig_value} + try: + r = requests.post(url, data=body, headers=headers, timeout=HTTP_TIMEOUT) + if r.status_code in (401, 403): + LOG.info("auth header %s rejected (%s) — trying next", hdr_name, r.status_code) + continue + return r.status_code + except requests.RequestException as e: + LOG.error("POST %s failed with %s: %s", url, hdr_name, e) + return 0 + return 0 + + +def maybe_alert(report: dict[str, Any], secrets: dict[str, str], *, dry_run: bool) -> None: + """只在 overall != ok 时 POST。""" + if report["overall"] == "ok": + LOG.info("全部 ok,跳过 webhook") + return + + url = secrets.get("FAILURE_WEBHOOK_URL") + secret = secrets.get("FAILURE_WEBHOOK_SECRET") + if not url or not secret: + LOG.warning("FAILURE_WEBHOOK_URL/SECRET 未配置,跳过 POST") + return + + payload = { + "task": "daily_check", + "service": "bin/daily_sync_check.py", + "status": report["overall"], + "check_date": report["check_date"], + "counts": report["counts"], + "alerts": report["alerts"], + "generated_at": report["generated_at"], + } + status = post_webhook(url, secret, payload, dry_run=dry_run) + LOG.info("webhook POST → %s", status) + + +# ── CLI ──────────────────────────────────────────────────────────────────── +def main(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description="每日同步巡检") + parser.add_argument("--date", help="检查日期 YYYY-MM-DD(默认今天)") + parser.add_argument("--dry-run", action="store_true", help="webhook 走 dry-run(仍写报告)") + parser.add_argument("--report-only", action="store_true", help="不读 secrets、不 POST") + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + + if args.date: + check_date = datetime.strptime(args.date, "%Y-%m-%d").date() + else: + check_date = date.today() + + report = build_report(check_date) + path = write_report(report) + print_summary(report) + LOG.info("报告已写: %s", path) + + if not args.report_only: + secrets = _load_secrets() + maybe_alert(report, secrets, dry_run=args.dry_run) + + return 0 if report["overall"] == "ok" else 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/bin/systemd/market-sync-daily-check.service b/bin/systemd/market-sync-daily-check.service new file mode 100644 index 0000000..acea43c --- /dev/null +++ b/bin/systemd/market-sync-daily-check.service @@ -0,0 +1,31 @@ +[Unit] +Description=Market sync daily check — reads dataset_registry, writes report, alerts on failures +Documentation=file:///home/gao/Development/quant_home/market_sync/bin/daily_sync_check.py +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/home/gao/Development/quant_home/market_sync +User=gao +Group=gao + +# 巡检:读 PG dataset_registry,对比今日预期,写 logs/daily_check_*.json +# 失败时 POST webhook 到 FAILURE_WEBHOOK_URL(HMAC-SHA256 签名) +ExecStart=/home/gao/Development/quant_home/market_sync/.venv/bin/python /home/gao/Development/quant_home/market_sync/bin/daily_sync_check.py + +# 巡检应 < 30s 完成;超时 = PG 不通或代码 bug,不让卡死 +TimeoutStartSec=120 + +# 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑) +# 失败时 exit code = 1,可被 OnFailure= 钩住发通知 + +# 日志走 journald(journalctl -u market-sync-daily-check -f) +StandardOutput=journal +StandardError=journal +SyslogIdentifier=market-sync-daily-check + +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/bin/systemd/market-sync-daily-check.timer b/bin/systemd/market-sync-daily-check.timer new file mode 100644 index 0000000..2ee10b8 --- /dev/null +++ b/bin/systemd/market-sync-daily-check.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Schedule market sync daily check — Daily 23:00 Asia/Shanghai +# market-sync-daily-check.service 是这个 timer 的执行单元 +# 23:00 触发:周一~五最后一个任务是 longhubang @ 22:00,留 1h buffer; +# 周六 23:00 给 stock_node @ 11:30 留 11h+ buffer + +[Timer] +# 每天 23:00 触发;Persistent=true 确保关机/错过时下次开机补跑 +OnCalendar=*-*-* 23:00:00 Asia/Shanghai +Persistent=true +Unit=market-sync-daily-check.service +# 不要 AccuracySec(默认 1min 漂移够用) + +[Install] +WantedBy=timers.target \ No newline at end of file diff --git a/bin/systemd/market-sync.service b/bin/systemd/market-sync.service index d75396e..718a400 100644 --- a/bin/systemd/market-sync.service +++ b/bin/systemd/market-sync.service @@ -13,8 +13,10 @@ Group=gao # 入口脚本(runall_once.py + structured_watch.sh 二合一) ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_run.sh -# 硬上限 2h(实际 ~80min,留 buffer 给 retry / 慢任务) -TimeoutStartSec=7200 +# 硬上限 4h(实际 ~80min + kline_5min 增量 70min, 总 ~2.5h, 留 buffer 给 retry / 慢任务) +# 历史 bug(2026-07-02):2h 超时截断,share_snapshot / market_regime 没跑成 +# 详见 docs/works/2026-07-03-01-market-sync-timeout.md +TimeoutStartSec=14400 # 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑—— # 重跑会撞外部 API 限流窗口,浪费 1.5h) diff --git a/config/daily_check_secrets.env.template b/config/daily_check_secrets.env.template new file mode 100644 index 0000000..140dadc --- /dev/null +++ b/config/daily_check_secrets.env.template @@ -0,0 +1,6 @@ +# 每日巡检 webhook 配置(HMAC-SHA256 签名) +# 复制为 daily_check_secrets.env 后填入真实值,文件权限 600 + +# 失败 / 漏跑时调用(必填,否则只在本地报告) +FAILURE_WEBHOOK_URL= +FAILURE_WEBHOOK_SECRET= \ No newline at end of file diff --git a/docs/works/2026-07-03-01-market-sync-timeout.md b/docs/works/2026-07-03-01-market-sync-timeout.md new file mode 100644 index 0000000..03a2a04 --- /dev/null +++ b/docs/works/2026-07-03-01-market-sync-timeout.md @@ -0,0 +1,78 @@ +# 2026-07-03 — work #01: market-sync.service 2h timeout 截断同步 + +## 发现 + +跑 `bin/daily_sync_check.py --report-only`(周五 10:31)→ 10 个 missed: + +``` +❌ stock_basic / kline_daily / kline_index / kline_5min / tick_trade +❌ moneyflow / industry_sector / sector_features / market_regime / longhubang +✅ share_snapshot (今日 00:03:01 手动跑过) +⏸ stock_node (周末专属任务,今天周五不期望) +``` + +## 根因 + +不是今天调度出问题,是**昨天 2026-07-02 15:30 runall 没跑完**: + +``` +15:30:01 启动 runall +15:38:50 stock_basic 完成 (529s) +15:38:58 kline_index 完成 (2s) +15:56:54 kline_daily 完成 (1072s) warning +17:06:09 kline_5min 完成 (4150s=69min) ← 单 task 吃掉一半窗口 +17:22:37 industry_sector 完成 (983s) warning (baostock timeout) +17:24:27 sector_features 完成 (105s) +17:24:32 >>> 开始 share_snapshot +17:30:01 systemd SIGTERM 杀进程 ← TimeoutStartSec=7200 (2h) 到期 +✗ share_snapshot 没跑 +✗ market_regime 没跑 +``` + +后果链: +1. 昨天 kline_5min 全量同步 5207 只耗时 69 min(4150s),单 task 吃掉 1/2 窗口 +2. industry_sector 又因 baostock connection timed out 浪费 16 min +3. 17:30 systemd timeout 砍掉 share_snapshot + market_regime +4. 今天 09:00 该跑的 stock_basic 没跑 —— 因为 worker.py 没长驻 + market-sync.timer 只在 15:30 触发 +5. tick/moneyflow/lhb 几个独立 timer 在 21:05/21:35/22:00 跑过但今天又没补 + +## 解决 + +### 短期(本次 commit) + +- `bin/systemd/market-sync.service`: `TimeoutStartSec` 从 `7200` (2h) 提到 `14400` (4h) + - 注释 `实际 ~80min` → 改成 `~80min + kline_5min 增量 70min, 总 ~2.5h, 留 buffer` +- 加 `bin/systemd/market-sync-daily-check.timer` + `.service` + - 每天 23:00 跑 `bin/daily_sync_check.py`,替代 `/loop` 方案的 session-only 限制 + +### 中期(不在本次 commit,记录备查) + +- kline_5min 改成增量模式(当前是全量 5207 只每次),预计从 69min 降到 5min + - 增量条件:`bar_time >= 上次同步时间 - 1 天`(含补漏) +- worker.py 长驻 + 进程内 scheduler 接管盘后 task 调度,与 systemd timer 二选一([[dual-scheduler-overlap]]) +- 失败 task 自动 retry 一次(仅 failed,不 retry partial-success / warning) + +### 长期(架构改进) + +- runall 拆成 fast-path / slow-path 两个 service: + - fast-path: stock_basic / kline_index / kline_daily / industry_sector / share_snapshot / market_regime(~30min) + - slow-path: kline_5min 全量(独立 timer @ 周日凌晨,避免挤占交易日窗口) + +## 关联 + +- [[background-watcher-pitfalls]]: "不要让 systemd TimeoutStartSec= 自动收尾" + - 实际情况是 runall 卡在 kline_5min,wrapper 的 fast-poll 也没起到作用(runall 在 2h 内确实没退出,systemd 超时是对的) +- [[dual-scheduler-overlap]]: worker.py 没运行 → 完全依赖 systemd timer +- [[market-data-overview]]: 任务耗时分布 + +## 验收 + +commit 后验证: +```bash +sudo cp bin/systemd/market-sync.service /etc/systemd/system/ +sudo cp bin/systemd/market-sync-daily-check.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl restart market-sync.timer +systemctl status market-sync-daily-check.timer +# 等今晚 23:00 看 daily_check 报告 +``` \ No newline at end of file