# StudyBase 每日自动同步:检查 代码/数据库(s/studybase.db)/Media 是否有变更,
# 有变更则 git 提交并推送至 origin(本机 /var/lib/git/studybase.git) 与 github。
# 由 /etc/cron.d/studybase-sync 于每日 02:00 调用。
+# 提交说明由 auto_sync_msg.py 生成(对比 HEAD 中上次同步的数据库快照):
+# 新增题目ID范围、新增试卷ID范围、反馈结果的试卷ID及统计。
# 安全约定:本脚本只执行 add/commit/push,绝不执行 reset/checkout/clean 等覆盖工作区的命令。
# 日志:/var/log/studybase-auto-sync.log(置于仓库外,避免日志本身进入提交)
exit 0
fi
-git add -A || { echo "git add 失败,本次终止"; exit 1; }
+# 生成提交说明(对比 HEAD 中的上次数据库快照)
+MSGFILE=$(mktemp) || MSGFILE=""
+PREVDB=$(mktemp) || PREVDB=""
+if [ -n "$MSGFILE" ] && [ -n "$PREVDB" ] && git show HEAD:s/studybase.db > "$PREVDB" 2>/dev/null; then
+ if ! python3 "$REPO/auto_sync_msg.py" "$PREVDB" "$MSGFILE"; then
+ echo "提交说明生成失败,改用通用说明"
+ : > "$MSGFILE"
+ fi
+else
+ echo "(无法提取上次数据库快照,改用通用说明)"
+ : > "$MSGFILE"
+fi
+
+git add -A || { echo "git add 失败,本次终止"; rm -f "$PREVDB" "$MSGFILE"; exit 1; }
if [ -z "$(git status --porcelain)" ]; then
echo "无实质变更,无需提交"
+ rm -f "$PREVDB" "$MSGFILE"
exit 0
fi
-git commit -m "自动同步:代码/数据/Media 更新($(date '+%F %T'))" || { echo "git commit 失败,本次终止"; exit 1; }
+if [ -s "$MSGFILE" ]; then
+ git commit -F "$MSGFILE" || { echo "git commit 失败,本次终止"; rm -f "$PREVDB" "$MSGFILE"; exit 1; }
+else
+ git commit -m "自动同步:代码/数据/Media 更新($(date '+%F %T'))" || { rm -f "$PREVDB" "$MSGFILE"; exit 1; }
+fi
+rm -f "$PREVDB" "$MSGFILE"
echo "--- push origin ---"
if git push origin main; then
--- /dev/null
+#!/usr/bin/env python3
+"""生成自动同步提交说明:对比上次同步的数据库快照与当前数据库。
+
+用法: auto_sync_msg.py <prev_db_path> <msg_output_path> [cur_db_override]
+- prev_db_path: 上次同步时的 s/studybase.db(由调用方从 git HEAD 提取)
+- 当前库默认通过 sqlite3 在线备份读取(不影响运行中的后端);
+ 提供第三个参数时改用指定文件(用于测试)。
+
+说明内容包括:
+- 新增题目ID(区间压缩,如 1015-1041, 1044)
+- 新增试卷ID
+- 反馈结果的试卷ID(判定行 result_type 有变化的试卷,附当前统计)
+"""
+import sqlite3, sys, tempfile, os
+from datetime import datetime
+
+prev_path, msg_path = sys.argv[1], sys.argv[2]
+cur_override = sys.argv[3] if len(sys.argv) > 3 else None
+LIVE = 's/studybase.db'
+
+
+def backup_live():
+ fd, tmp = tempfile.mkstemp(suffix='.sqlite')
+ os.close(fd)
+ src = sqlite3.connect(LIVE)
+ dst = sqlite3.connect(tmp)
+ src.backup(dst)
+ dst.close()
+ src.close()
+ return tmp
+
+
+def load(path):
+ c = sqlite3.connect(path)
+ qs = {r[0] for r in c.execute("SELECT id FROM questions")}
+ ps = {r[0] for r in c.execute("SELECT id FROM papers")}
+ pq = {}
+ for pid, qid, rt in c.execute("SELECT paper_id, question_id, result_type FROM paper_questions"):
+ pq.setdefault(pid, {})[qid] = rt
+ c.close()
+ return qs, ps, pq
+
+
+try:
+ p_qs, p_ps, p_pq = load(prev_path)
+except Exception:
+ p_qs, p_ps, p_pq = set(), set(), {}
+
+if cur_override:
+ cur_tmp = None
+ c_qs, c_ps, c_pq = load(cur_override)
+else:
+ cur_tmp = backup_live()
+ c_qs, c_ps, c_pq = load(cur_tmp)
+ os.unlink(cur_tmp)
+
+
+def ranges(ids):
+ ids = sorted(ids)
+ if not ids:
+ return "无"
+ parts = []
+ s = e = ids[0]
+ for i in ids[1:]:
+ if i == e + 1:
+ e = i
+ else:
+ parts.append(str(s) if s == e else "%d-%d" % (s, e))
+ s = e = i
+ parts.append(str(s) if s == e else "%d-%d" % (s, e))
+ return ", ".join(parts)
+
+
+def decided(pq):
+ return {pid: {q: rt for q, rt in m.items() if rt != 0} for pid, m in pq.items()}
+
+
+new_q = c_qs - p_qs
+new_p = c_ps - p_ps
+p_dec, c_dec = decided(p_pq), decided(c_pq)
+fb = {pid for pid in set(p_dec) | set(c_dec)
+ if (p_dec.get(pid) or {}) != (c_dec.get(pid) or {})}
+
+
+def fb_detail(pid):
+ m = c_pq.get(pid, {})
+ total = len(m)
+ n1 = sum(1 for v in m.values() if v == 1)
+ n2 = sum(1 for v in m.values() if v == 2)
+ n3 = sum(1 for v in m.values() if v == 3)
+ return "%d/%d题:正确%d、错误%d、部分错误%d" % (n1 + n2 + n3, total, n1, n3, n2)
+
+
+lines = ["自动同步:代码/数据/Media 更新(%s)" % datetime.now().strftime('%Y-%m-%d %H:%M:%S')]
+lines.append("新增题目ID:%s%s" % (ranges(new_q), "(共%d题)" % len(new_q) if new_q else ""))
+lines.append("新增试卷ID:%s%s" % (ranges(new_p), "(共%d份)" % len(new_p) if new_p else ""))
+if fb:
+ lines.append("反馈结果:" + "、".join("试卷%d(%s)" % (pid, fb_detail(pid)) for pid in sorted(fb)))
+else:
+ lines.append("反馈结果:无")
+
+with open(msg_path, 'w', encoding='utf-8') as f:
+ f.write("\n".join(lines) + "\n")