feat: 军事科技每日资讯推送系统 - Docker部署 + 日志系统 + 数据目录重组

This commit is contained in:
poiuy
2026-07-12 20:01:02 +08:00
commit 54ca4b1b6a
267 changed files with 47047 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""根据 config.json 渲染 systemd 单元模板。
被 scripts/install.sh 调用。本身不直接读环境,需要的运行时参数(user/group/uv 路径)
通过 CLI 注入,便于 install.sh 处理 sudo 场景。
"""
import argparse
import json
import sys
from pathlib import Path
def cron_to_oncalendar(expr: str) -> str:
"""5 段 cron 表达式 → systemd OnCalendar 字符串。
本项目 push_cron 只用到 minute/hour,其他位必须为 `*`。
遇到不支持的语法直接报错,避免静默生成错误的 timer。
"""
parts = expr.split()
if len(parts) != 5:
raise ValueError(f"无效 cron 表达式(必须 5 段): {expr}")
minute, hour, dom, mon, dow = parts
for label, val in [("day-of-month", dom), ("month", mon), ("day-of-week", dow)]:
if val != "*":
raise ValueError(f"暂不支持非 * 的 {label} 字段: {expr}")
try:
m, h = int(minute), int(hour)
except ValueError as e:
raise ValueError(f"minute/hour 必须是整数: {expr}") from e
if not (0 <= m <= 59 and 0 <= h <= 23):
raise ValueError(f"minute/hour 越界: {expr}")
return f"*-*-* {h:02d}:{m:02d}:00"
def render(template_path: Path, variables: dict) -> str:
text = template_path.read_text(encoding="utf-8")
for key, val in variables.items():
text = text.replace(f"{{{{{key}}}}}", str(val))
return text
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project-dir", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--group", required=True)
parser.add_argument("--uv-bin", required=True)
parser.add_argument("--output-dir", required=True)
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
output_dir = Path(args.output_dir).resolve()
template_dir = project_dir / "systemd"
config_path = project_dir / "config.json"
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
fetch_min = config["schedule"]["fetch_interval_minutes"]
if not isinstance(fetch_min, int) or fetch_min <= 0:
raise ValueError(f"fetch_interval_minutes 必须是正整数: {fetch_min}")
push_crons = config["schedule"]["push_cron"]
if not push_crons:
raise ValueError("config.schedule.push_cron 不能为空")
push_lines = "\n".join(
f"OnCalendar={cron_to_oncalendar(c)}" for c in push_crons
)
log_retention = config.get("log", {}).get("retention_days", 7)
if not isinstance(log_retention, int) or log_retention <= 0:
raise ValueError(f"log.retention_days 必须是正整数: {log_retention}")
common = {
"PROJECT_DIR": str(project_dir),
"USER": args.user,
"GROUP": args.group,
"UV_BIN": args.uv_bin,
}
output_dir.mkdir(parents=True, exist_ok=True)
renderings = [
("dnews-fetch.service.tmpl", "dnews-fetch.service", {}),
("dnews-fetch.timer.tmpl", "dnews-fetch.timer",
{"FETCH_INTERVAL_MIN": fetch_min}),
("dnews-push.service.tmpl", "dnews-push.service", {}),
("dnews-push.timer.tmpl", "dnews-push.timer",
{"PUSH_ONCALENDAR_LINES": push_lines}),
("journald-dnews.conf.tmpl", "journald-dnews.conf",
{"LOG_RETENTION_DAYS": log_retention}),
]
print(f"📂 输出目录: {output_dir}")
for tpl_name, out_name, extra in renderings:
tpl_path = template_dir / tpl_name
if not tpl_path.exists():
print(f"❌ 模板不存在: {tpl_path}", file=sys.stderr)
return 2
rendered = render(tpl_path, {**common, **extra})
(output_dir / out_name).write_text(rendered, encoding="utf-8")
print(f"{out_name}")
print(f"\n生成参数:")
print(f" fetch 间隔 → 每 {fetch_min} 分钟(间隔触发,从上次完成开始计时)")
for c, line in zip(push_crons, push_lines.split("\n")):
print(f" push '{c}'{line}")
print(f" 日志保留 → {log_retention}")
return 0
if __name__ == "__main__":
sys.exit(main())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# daily-news - Daily News 系统服务管理包装脚本
# 由 scripts/install.sh 自动生成;项目路径:{{PROJECT_DIR}}
set -euo pipefail
TIMERS=(dnews-fetch.timer dnews-push.timer)
SERVICES=(dnews-fetch.service dnews-push.service)
PROJECT_DIR="{{PROJECT_DIR}}"
usage() {
cat <<EOF
用法: daily-news <command>
命令:
start 启动 fetch 和 push timer
stop 停止两个 timer
restart 重启两个 timer(不会立即触发 service,仅重置调度)
status [N] 查看 timer / service 状态 + 最近 N 行日志(默认 15)
logs 实时跟随日志(Ctrl+C 退出)
其他操作:
$PROJECT_DIR/scripts/install.sh 重新生成单元并安装(改 config.json 后用)
$PROJECT_DIR/scripts/uninstall.sh 完全卸载(不删数据)
EOF
}
cmd="${1:-}"
case "$cmd" in
start)
sudo systemctl start "${TIMERS[@]}"
echo "✓ Daily News 已启动"
;;
stop)
sudo systemctl stop "${TIMERS[@]}"
echo "✓ Daily News 已停止"
;;
restart)
sudo systemctl restart "${TIMERS[@]}"
echo "✓ Daily News 已重启"
;;
status)
lines="${2:-15}"
echo "═══ Timer ═══"
systemctl list-timers 'dnews-*' --no-pager || true
echo ""
echo "═══ Service 状态 ═══"
systemctl status "${SERVICES[@]}" --no-pager --lines=0 || true
echo ""
echo "═══ 最近 $lines 行日志 ═══"
journalctl --namespace=dnews -u dnews-fetch -u dnews-push -n "$lines" --no-pager
echo ""
echo "💡 实时跟随:daily-news logs"
;;
logs)
journalctl --namespace=dnews -u dnews-fetch -u dnews-push -f
;;
-h|--help|help|"")
usage
;;
*)
echo "❌ 未知命令: $cmd" >&2
echo ""
usage
exit 1
;;
esac
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Daily News - 系统服务一键安装
# 用法:./scripts/install.sh
# 不要用 sudo 直接调用本脚本;脚本会在需要时自行 sudo 提权。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# ─── 用户检测 ────────────────────────────────────────────────
if [[ $EUID -eq 0 ]]; then
if [[ -z "${SUDO_USER:-}" ]]; then
echo "❌ 请勿直接用 root 运行本脚本,请用普通用户(脚本按需自动提权)"
exit 1
fi
RUN_USER="$SUDO_USER"
else
RUN_USER="$USER"
fi
RUN_GROUP="$(id -gn "$RUN_USER")"
# ─── uv 检测 ─────────────────────────────────────────────────
UV_BIN="$(command -v uv || true)"
if [[ -z "$UV_BIN" ]]; then
echo "❌ 找不到 uv 命令,请先安装:https://docs.astral.sh/uv/"
exit 1
fi
# ─── systemd 检测 ────────────────────────────────────────────
if ! command -v systemctl >/dev/null 2>&1; then
echo "❌ 系统未检测到 systemctl(非 systemd 系统),本脚本仅支持 systemd Linux"
exit 1
fi
cd "$PROJECT_DIR"
echo "🚀 Daily News - 系统服务安装"
echo "────────────────────────────────────────"
echo " 项目目录: $PROJECT_DIR"
echo " 运行身份: $RUN_USER:$RUN_GROUP"
echo " uv 路径: $UV_BIN"
echo "────────────────────────────────────────"
echo ""
# 提前提权一次,后续 sudo 命令直接复用凭据缓存
echo "🔐 需要 sudo 权限来写入 /etc/systemd/system/"
sudo -v
echo ""
# ─── [1/6] 同步依赖 ──────────────────────────────────────────
echo "📦 [1/6] 同步依赖 (uv sync)..."
uv sync
echo "✓ 依赖已就绪"
echo ""
# ─── [2/6] 验证 .env ────────────────────────────────────────
echo "🔐 [2/6] 验证 .env..."
if [[ ! -f "$PROJECT_DIR/.env" ]]; then
echo "❌ .env 不存在,请先复制 .env.example 并填入 API Key"
exit 1
fi
echo "✓ .env 存在"
echo ""
# ─── [3/6] LLM 健康检查 ─────────────────────────────────────
echo "🔍 [3/6] 校验 LLM 接口(仅在此处校验,运行时不再校验)..."
if ! uv run python -m src.main check; then
echo ""
echo "❌ LLM 校验失败,已中止安装"
echo "请检查 config.json 中的 llm.baseUrl/model 以及 .env 中的 API Key"
exit 1
fi
echo ""
# ─── [4/6] 生成单元文件 ─────────────────────────────────────
echo "📝 [4/6] 渲染 systemd 单元模板..."
STAGE_DIR="$(mktemp -d)"
trap 'rm -rf "$STAGE_DIR"' EXIT
uv run python "$PROJECT_DIR/scripts/_gen_units.py" \
--project-dir "$PROJECT_DIR" \
--user "$RUN_USER" \
--group "$RUN_GROUP" \
--uv-bin "$UV_BIN" \
--output-dir "$STAGE_DIR"
# 同时渲染 daily-news 包装脚本(/usr/local/bin/daily-news
sed "s|{{PROJECT_DIR}}|$PROJECT_DIR|g" \
"$PROJECT_DIR/scripts/daily-news.tmpl" > "$STAGE_DIR/daily-news"
echo " ✓ daily-news"
echo ""
# ─── [5/6] 安装到 systemd ───────────────────────────────────
echo "📥 [5/6] 安装到系统..."
sudo install -m 644 "$STAGE_DIR/dnews-fetch.service" /etc/systemd/system/
sudo install -m 644 "$STAGE_DIR/dnews-fetch.timer" /etc/systemd/system/
sudo install -m 644 "$STAGE_DIR/dnews-push.service" /etc/systemd/system/
sudo install -m 644 "$STAGE_DIR/dnews-push.timer" /etc/systemd/system/
# 日志保留策略(journald 命名空间 drop-in
sudo mkdir -p /etc/systemd/journald@dnews.conf.d
sudo install -m 644 "$STAGE_DIR/journald-dnews.conf" \
/etc/systemd/journald@dnews.conf.d/retention.conf
# daily-news 包装脚本到系统 PATH
sudo install -m 755 "$STAGE_DIR/daily-news" /usr/local/bin/daily-news
sudo systemctl daemon-reload
# 命名空间 journald 实例首次会在 fetch/push 首次产出日志时自动拉起;
# 若已存在(重装场景),重启以加载新保留策略。
sudo systemctl restart systemd-journald@dnews.service 2>/dev/null || true
echo "✓ 单元文件 + daily-news 包装脚本已安装"
echo ""
# ─── [6/6] 启用并启动 timer ─────────────────────────────────
echo "🚦 [6/6] 启用并(重)启动 timer..."
# 先 enable 注册开机自启
sudo systemctl enable dnews-fetch.timer dnews-push.timer
# 再 restart 强制按新单元文件重置内存中的 timer 状态
# enable --now 对已运行的 timer 是 no-op,无法应用新 OnCalendar/OnActiveSec 等改动)
sudo systemctl restart dnews-fetch.timer dnews-push.timer
echo ""
# ─── 完成 ───────────────────────────────────────────────────
echo "✅ 安装完成!"
echo ""
echo "下次触发时间:"
systemctl list-timers 'dnews-*' --no-pager || true
echo ""
echo "常用命令(系统 PATH 中可直接调用):"
echo " daily-news status 查看状态 + 最近日志"
echo " daily-news logs 实时跟随日志"
echo " daily-news stop 停止"
echo " daily-news start 启动"
echo " daily-news restart 重启 timer"
echo ""
echo "手动立即触发一次任务(不影响下次调度):"
echo " sudo systemctl start dnews-fetch.service"
echo " sudo systemctl start dnews-push.service"
echo ""
echo "卸载:$PROJECT_DIR/scripts/uninstall.sh"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Daily News - 状态查看
# 显示:timer 下次触发时间、service 上次结果、最近日志
set -euo pipefail
LINES="${1:-15}"
echo "═══ Timer ═══"
systemctl list-timers 'dnews-*' --no-pager || true
echo ""
echo "═══ Service 状态 ═══"
systemctl status dnews-fetch.service --no-pager --lines=0 || true
echo "──────────────────────────────"
systemctl status dnews-push.service --no-pager --lines=0 || true
echo ""
echo "═══ 最近 $LINES 行日志 ═══"
journalctl --namespace=dnews \
-u dnews-fetch.service \
-u dnews-push.service \
-n "$LINES" \
--no-pager
echo ""
echo "💡 实时跟随:journalctl --namespace=dnews -f"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Daily News - 卸载系统服务(不删数据)
set -euo pipefail
echo "🗑 Daily News - 卸载"
echo ""
if ! command -v systemctl >/dev/null 2>&1; then
echo "❌ 系统未检测到 systemctl"
exit 1
fi
echo "🛑 停止并禁用 timer..."
sudo systemctl disable --now dnews-fetch.timer dnews-push.timer 2>/dev/null || true
echo "🧹 清理单元文件..."
sudo rm -f /etc/systemd/system/dnews-fetch.service \
/etc/systemd/system/dnews-fetch.timer \
/etc/systemd/system/dnews-push.service \
/etc/systemd/system/dnews-push.timer
echo "🧹 清理日志保留策略 drop-in..."
sudo rm -f /etc/systemd/journald@dnews.conf.d/retention.conf
sudo rmdir /etc/systemd/journald@dnews.conf.d 2>/dev/null || true
echo "🧹 清理 daily-news 包装脚本..."
sudo rm -f /usr/local/bin/daily-news
sudo systemctl daemon-reload
sudo systemctl stop systemd-journald@dnews.service 2>/dev/null || true
echo ""
echo "✅ 已卸载(news-data/ 与项目代码未删除)"
echo ""
echo "如需清理历史日志:sudo journalctl --namespace=dnews --vacuum-time=1s"