添加自动更新功能
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,7 @@ data/*.db-shm
|
|||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
data/*.backup*
|
data/*.backup*
|
||||||
data/secret_key.txt
|
data/secret_key.txt
|
||||||
|
data/update/
|
||||||
|
|
||||||
# Cookies(敏感用户凭据)
|
# Cookies(敏感用户凭据)
|
||||||
data/cookies/
|
data/cookies/
|
||||||
@@ -59,6 +60,7 @@ Thumbs.db
|
|||||||
# 部署脚本(含服务器信息)
|
# 部署脚本(含服务器信息)
|
||||||
deploy_*.sh
|
deploy_*.sh
|
||||||
verify_*.sh
|
verify_*.sh
|
||||||
|
deploy.sh
|
||||||
|
|
||||||
# 内部文档
|
# 内部文档
|
||||||
docs/
|
docs/
|
||||||
|
|||||||
27
admin-frontend/src/api/update.js
Normal file
27
admin-frontend/src/api/update.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export async function fetchUpdateStatus() {
|
||||||
|
const { data } = await api.get('/update/status')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUpdateResult() {
|
||||||
|
const { data } = await api.get('/update/result')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUpdateLog(params = {}) {
|
||||||
|
const { data } = await api.get('/update/log', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestUpdateCheck() {
|
||||||
|
const { data } = await api.post('/update/check', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestUpdateRun() {
|
||||||
|
const { data } = await api.post('/update/run', {})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
import { fetchSystemConfig, updateSystemConfig, executeScheduleNow } from '../api/system'
|
import { fetchSystemConfig, updateSystemConfig, executeScheduleNow } from '../api/system'
|
||||||
import { fetchProxyConfig, testProxy, updateProxyConfig } from '../api/proxy'
|
import { fetchProxyConfig, testProxy, updateProxyConfig } from '../api/proxy'
|
||||||
|
import { fetchUpdateLog, fetchUpdateResult, fetchUpdateStatus, requestUpdateCheck, requestUpdateRun } from '../api/update'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -28,6 +29,16 @@ const autoApproveEnabled = ref(false)
|
|||||||
const autoApproveHourlyLimit = ref(10)
|
const autoApproveHourlyLimit = ref(10)
|
||||||
const autoApproveVipDays = ref(7)
|
const autoApproveVipDays = ref(7)
|
||||||
|
|
||||||
|
// 自动更新
|
||||||
|
const updateLoading = ref(false)
|
||||||
|
const updateActionLoading = ref(false)
|
||||||
|
const updateStatus = ref(null)
|
||||||
|
const updateStatusError = ref('')
|
||||||
|
const updateResult = ref(null)
|
||||||
|
const updateLog = ref('')
|
||||||
|
const updateLogTruncated = ref(false)
|
||||||
|
let updatePollTimer = null
|
||||||
|
|
||||||
const weekdaysOptions = [
|
const weekdaysOptions = [
|
||||||
{ label: '周一', value: '1' },
|
{ label: '周一', value: '1' },
|
||||||
{ label: '周二', value: '2' },
|
{ label: '周二', value: '2' },
|
||||||
@@ -59,6 +70,59 @@ function normalizeBrowseType(value) {
|
|||||||
return '应读'
|
return '应读'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortCommit(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return '-'
|
||||||
|
return text.length > 12 ? `${text.slice(0, 12)}…` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUpdateInfo({ withLog = true } = {}) {
|
||||||
|
updateLoading.value = true
|
||||||
|
updateStatusError.value = ''
|
||||||
|
try {
|
||||||
|
const [statusRes, resultRes] = await Promise.all([fetchUpdateStatus(), fetchUpdateResult()])
|
||||||
|
|
||||||
|
if (statusRes?.ok) {
|
||||||
|
updateStatus.value = statusRes.data || null
|
||||||
|
} else {
|
||||||
|
updateStatus.value = null
|
||||||
|
updateStatusError.value = statusRes?.error || '未发现更新状态(Update-Agent 可能未运行)'
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResult.value = resultRes?.ok ? resultRes.data : null
|
||||||
|
|
||||||
|
const jobId = updateResult.value?.job_id
|
||||||
|
if (withLog && jobId) {
|
||||||
|
const logRes = await fetchUpdateLog({ job_id: jobId, max_bytes: 200000 })
|
||||||
|
updateLog.value = logRes?.log || ''
|
||||||
|
updateLogTruncated.value = !!logRes?.truncated
|
||||||
|
} else {
|
||||||
|
updateLog.value = ''
|
||||||
|
updateLogTruncated.value = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
updateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpdatePolling() {
|
||||||
|
if (updatePollTimer) return
|
||||||
|
updatePollTimer = setInterval(async () => {
|
||||||
|
if (updateResult.value?.status === 'running') {
|
||||||
|
await loadUpdateInfo()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopUpdatePolling() {
|
||||||
|
if (updatePollTimer) {
|
||||||
|
clearInterval(updatePollTimer)
|
||||||
|
updatePollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -85,6 +149,9 @@ async function loadAll() {
|
|||||||
proxyEnabled.value = (proxy.proxy_enabled ?? 0) === 1
|
proxyEnabled.value = (proxy.proxy_enabled ?? 0) === 1
|
||||||
proxyApiUrl.value = proxy.proxy_api_url || ''
|
proxyApiUrl.value = proxy.proxy_api_url || ''
|
||||||
proxyExpireMinutes.value = proxy.proxy_expire_minutes ?? 3
|
proxyExpireMinutes.value = proxy.proxy_expire_minutes ?? 3
|
||||||
|
|
||||||
|
await loadUpdateInfo({ withLog: false })
|
||||||
|
startUpdatePolling()
|
||||||
} catch {
|
} catch {
|
||||||
// handled by interceptor
|
// handled by interceptor
|
||||||
} finally {
|
} finally {
|
||||||
@@ -233,7 +300,48 @@ async function saveAutoApprove() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCheckUpdate() {
|
||||||
|
updateActionLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await requestUpdateCheck()
|
||||||
|
ElMessage.success(res?.success ? '已触发检查更新' : '已提交检查请求')
|
||||||
|
setTimeout(() => loadUpdateInfo({ withLog: false }), 800)
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
updateActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRunUpdate() {
|
||||||
|
const status = updateStatus.value
|
||||||
|
const remote = status?.remote_commit ? shortCommit(status.remote_commit) : '-'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定开始“一键更新”吗?\n\n目标版本: ${remote}\n\n更新将会重建并重启服务,页面可能短暂不可用;系统会先备份数据库。`,
|
||||||
|
'一键更新确认',
|
||||||
|
{ confirmButtonText: '开始更新', cancelButtonText: '取消', type: 'warning' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActionLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await requestUpdateRun()
|
||||||
|
ElMessage.success(res?.message || '已提交更新请求')
|
||||||
|
startUpdatePolling()
|
||||||
|
setTimeout(() => loadUpdateInfo(), 800)
|
||||||
|
} catch {
|
||||||
|
// handled by interceptor
|
||||||
|
} finally {
|
||||||
|
updateActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadAll)
|
onMounted(loadAll)
|
||||||
|
onBeforeUnmount(stopUpdatePolling)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -347,6 +455,84 @@ onMounted(loadAll)
|
|||||||
|
|
||||||
<el-button type="primary" @click="saveAutoApprove">保存自动审核配置</el-button>
|
<el-button type="primary" @click="saveAutoApprove">保存自动审核配置</el-button>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="updateLoading">
|
||||||
|
<h3 class="section-title">版本与更新</h3>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="updateStatus?.update_available"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="检测到新版本:可以在此页面点击“一键更新”升级并自动重启服务。"
|
||||||
|
style="margin-bottom: 10px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="updateStatusError"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
:title="updateStatusError"
|
||||||
|
style="margin-bottom: 10px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-descriptions border :column="1" size="small" style="margin-bottom: 10px">
|
||||||
|
<el-descriptions-item label="本地版本(commit)">
|
||||||
|
{{ shortCommit(updateStatus?.local_commit) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="远端版本(commit)">
|
||||||
|
{{ shortCommit(updateStatus?.remote_commit) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="是否有更新">
|
||||||
|
<el-tag v-if="updateStatus?.update_available" type="danger">有</el-tag>
|
||||||
|
<el-tag v-else type="success">无</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="工作区修改">
|
||||||
|
<el-tag v-if="updateStatus?.dirty" type="warning">有未提交修改</el-tag>
|
||||||
|
<el-tag v-else type="info">干净</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近检查时间">
|
||||||
|
{{ updateStatus?.checked_at || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="updateStatus?.error" label="检查错误">
|
||||||
|
{{ updateStatus?.error }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button @click="loadUpdateInfo" :disabled="updateActionLoading">刷新更新信息</el-button>
|
||||||
|
<el-button @click="onCheckUpdate" :loading="updateActionLoading">检查更新</el-button>
|
||||||
|
<el-button type="danger" @click="onRunUpdate" :loading="updateActionLoading" :disabled="!updateStatus?.update_available">
|
||||||
|
一键更新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left">最近一次更新结果</el-divider>
|
||||||
|
<el-descriptions v-if="updateResult" border :column="1" size="small" style="margin-bottom: 10px">
|
||||||
|
<el-descriptions-item label="job_id">{{ updateResult.job_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag v-if="updateResult.status === 'running'" type="warning">运行中</el-tag>
|
||||||
|
<el-tag v-else-if="updateResult.status === 'success'" type="success">成功</el-tag>
|
||||||
|
<el-tag v-else type="danger">失败</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="阶段">{{ updateResult.stage || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开始时间">{{ updateResult.started_at || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="结束时间">{{ updateResult.finished_at || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="耗时(秒)">{{ updateResult.duration_seconds ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新前(commit)">{{ shortCommit(updateResult.from_commit) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新后(commit)">{{ shortCommit(updateResult.to_commit) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="健康检查">
|
||||||
|
<span v-if="updateResult.health_ok === true">通过({{ updateResult.health_message }})</span>
|
||||||
|
<span v-else-if="updateResult.health_ok === false">失败({{ updateResult.health_message }})</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="updateResult.error" label="错误">{{ updateResult.error }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div v-else class="help">暂无更新记录。</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left">更新日志</el-divider>
|
||||||
|
<div class="help" v-if="updateLogTruncated">日志过长,仅展示末尾内容。</div>
|
||||||
|
<el-input v-model="updateLog" type="textarea" :rows="10" readonly placeholder="暂无日志" />
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ def register_blueprints(app) -> None:
|
|||||||
from routes.api_schedules import api_schedules_bp
|
from routes.api_schedules import api_schedules_bp
|
||||||
from routes.api_screenshots import api_screenshots_bp
|
from routes.api_screenshots import api_screenshots_bp
|
||||||
from routes.api_user import api_user_bp
|
from routes.api_user import api_user_bp
|
||||||
|
from routes.health import health_bp
|
||||||
from routes.pages import pages_bp
|
from routes.pages import pages_bp
|
||||||
|
|
||||||
app.register_blueprint(pages_bp)
|
app.register_blueprint(pages_bp)
|
||||||
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(api_auth_bp)
|
app.register_blueprint(api_auth_bp)
|
||||||
app.register_blueprint(api_user_bp)
|
app.register_blueprint(api_user_bp)
|
||||||
app.register_blueprint(api_accounts_bp)
|
app.register_blueprint(api_accounts_bp)
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
|
|||||||
|
|
||||||
# Import side effects: register routes on blueprint
|
# Import side effects: register routes on blueprint
|
||||||
from routes.admin_api import core as _core # noqa: F401
|
from routes.admin_api import core as _core # noqa: F401
|
||||||
|
from routes.admin_api import update as _update # noqa: F401
|
||||||
|
|||||||
146
routes/admin_api/update.py
Normal file
146
routes/admin_api/update.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import jsonify, request, session
|
||||||
|
|
||||||
|
from routes.admin_api import admin_api_bp
|
||||||
|
from routes.decorators import admin_required
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
from services.update_files import (
|
||||||
|
ensure_update_dirs,
|
||||||
|
get_update_job_log_path,
|
||||||
|
get_update_request_path,
|
||||||
|
get_update_result_path,
|
||||||
|
get_update_status_path,
|
||||||
|
load_json_file,
|
||||||
|
sanitize_job_id,
|
||||||
|
tail_text_file,
|
||||||
|
write_json_atomic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_ip() -> str:
|
||||||
|
try:
|
||||||
|
return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _make_job_id(prefix: str = "upd") -> str:
|
||||||
|
now_str = get_beijing_now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
rand = uuid.uuid4().hex[:8]
|
||||||
|
return f"{prefix}_{now_str}_{rand}"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_pending_request() -> bool:
|
||||||
|
try:
|
||||||
|
return os.path.exists(get_update_request_path())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/update/status", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def get_update_status_api():
|
||||||
|
"""读取宿主机 Update-Agent 写入的 update/status.json。"""
|
||||||
|
ensure_update_dirs()
|
||||||
|
status_path = get_update_status_path()
|
||||||
|
data, err = load_json_file(status_path)
|
||||||
|
if err:
|
||||||
|
return jsonify({"ok": False, "error": f"读取 status 失败: {err}", "data": data}), 200
|
||||||
|
if not data:
|
||||||
|
return jsonify({"ok": False, "error": "未发现更新状态(Update-Agent 可能未运行)"}), 200
|
||||||
|
data.setdefault("update_available", False)
|
||||||
|
return jsonify({"ok": True, "data": data}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/update/result", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def get_update_result_api():
|
||||||
|
"""读取 update/result.json(最近一次更新执行结果)。"""
|
||||||
|
ensure_update_dirs()
|
||||||
|
result_path = get_update_result_path()
|
||||||
|
data, err = load_json_file(result_path)
|
||||||
|
if err:
|
||||||
|
return jsonify({"ok": False, "error": f"读取 result 失败: {err}", "data": data}), 200
|
||||||
|
if not data:
|
||||||
|
return jsonify({"ok": True, "data": None}), 200
|
||||||
|
return jsonify({"ok": True, "data": data}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/update/log", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def get_update_log_api():
|
||||||
|
"""读取 update/jobs/<job_id>.log 的末尾内容(用于后台展示进度)。"""
|
||||||
|
ensure_update_dirs()
|
||||||
|
|
||||||
|
job_id = sanitize_job_id(request.args.get("job_id"))
|
||||||
|
if not job_id:
|
||||||
|
# 若未指定,则尝试用 result.json 的 job_id
|
||||||
|
result_data, _ = load_json_file(get_update_result_path())
|
||||||
|
job_id = sanitize_job_id(result_data.get("job_id") if isinstance(result_data, dict) else None)
|
||||||
|
|
||||||
|
if not job_id:
|
||||||
|
return jsonify({"ok": True, "job_id": None, "log": "", "truncated": False}), 200
|
||||||
|
|
||||||
|
max_bytes = request.args.get("max_bytes", "200000")
|
||||||
|
try:
|
||||||
|
max_bytes_i = int(max_bytes)
|
||||||
|
except Exception:
|
||||||
|
max_bytes_i = 200_000
|
||||||
|
max_bytes_i = max(10_000, min(2_000_000, max_bytes_i))
|
||||||
|
|
||||||
|
log_path = get_update_job_log_path(job_id)
|
||||||
|
text, truncated = tail_text_file(log_path, max_bytes=max_bytes_i)
|
||||||
|
return jsonify({"ok": True, "job_id": job_id, "log": text, "truncated": truncated}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/update/check", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def request_update_check_api():
|
||||||
|
"""请求宿主机 Update-Agent 立刻执行一次检查更新。"""
|
||||||
|
ensure_update_dirs()
|
||||||
|
if _has_pending_request():
|
||||||
|
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409
|
||||||
|
|
||||||
|
job_id = _make_job_id(prefix="chk")
|
||||||
|
payload = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"action": "check",
|
||||||
|
"requested_at": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"requested_by": session.get("admin_username") or "",
|
||||||
|
"requested_ip": _request_ip(),
|
||||||
|
}
|
||||||
|
write_json_atomic(get_update_request_path(), payload)
|
||||||
|
return jsonify({"success": True, "job_id": job_id}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/update/run", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def request_update_run_api():
|
||||||
|
"""请求宿主机 Update-Agent 执行一键更新并重启服务。"""
|
||||||
|
ensure_update_dirs()
|
||||||
|
if _has_pending_request():
|
||||||
|
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409
|
||||||
|
|
||||||
|
job_id = _make_job_id(prefix="upd")
|
||||||
|
payload = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"action": "update",
|
||||||
|
"requested_at": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"requested_by": session.get("admin_username") or "",
|
||||||
|
"requested_ip": _request_ip(),
|
||||||
|
}
|
||||||
|
write_json_atomic(get_update_request_path(), payload)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"message": "已提交更新请求,服务将重启(页面可能短暂不可用),请等待1-2分钟后刷新",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
31
routes/health.py
Normal file
31
routes/health.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
import database
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
health_bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@health_bp.route("/health", methods=["GET"])
|
||||||
|
def health_check():
|
||||||
|
"""轻量健康检查:用于宿主机/负载均衡探活。"""
|
||||||
|
db_ok = True
|
||||||
|
db_error = ""
|
||||||
|
try:
|
||||||
|
database.get_system_config()
|
||||||
|
except Exception as e:
|
||||||
|
db_ok = False
|
||||||
|
db_error = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"ok": db_ok,
|
||||||
|
"time": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"db_ok": db_ok,
|
||||||
|
"db_error": db_error,
|
||||||
|
}
|
||||||
|
return jsonify(payload), (200 if db_ok else 500)
|
||||||
|
|
||||||
109
services/update_files.py
Normal file
109
services/update_files.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from app_config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(path: Path) -> Path:
|
||||||
|
try:
|
||||||
|
return path.expanduser().resolve()
|
||||||
|
except Exception:
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_dir() -> Path:
|
||||||
|
"""返回 data 目录(默认由 DB_FILE 的父目录推导)。"""
|
||||||
|
db_file = Path(str(config.DB_FILE or "data/app_data.db"))
|
||||||
|
return _resolve_path(db_file).parent
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_dir() -> Path:
|
||||||
|
return get_data_dir() / "update"
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_status_path() -> Path:
|
||||||
|
return get_update_dir() / "status.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_request_path() -> Path:
|
||||||
|
return get_update_dir() / "request.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_result_path() -> Path:
|
||||||
|
return get_update_dir() / "result.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_jobs_dir() -> Path:
|
||||||
|
return get_update_dir() / "jobs"
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_job_log_path(job_id: str) -> Path:
|
||||||
|
return get_update_jobs_dir() / f"{job_id}.log"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_update_dirs() -> None:
|
||||||
|
get_update_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json_file(path: Path) -> Tuple[dict, str | None]:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return dict(json.load(f) or {}), None
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}, None
|
||||||
|
except Exception as e:
|
||||||
|
return {}, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_atomic(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(f"{path.suffix}.tmp.{os.getpid()}.{int(time.time() * 1000)}")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def tail_text_file(path: Path, *, max_bytes: int = 200_000) -> Tuple[str, bool]:
|
||||||
|
"""读取文件末尾 max_bytes,返回(text, truncated)。"""
|
||||||
|
max_bytes = max(1, int(max_bytes))
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
size = f.tell()
|
||||||
|
start = max(0, size - max_bytes)
|
||||||
|
f.seek(start, os.SEEK_SET)
|
||||||
|
data = f.read()
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
truncated = start > 0
|
||||||
|
if truncated:
|
||||||
|
parts = text.splitlines(True)
|
||||||
|
if len(parts) > 1:
|
||||||
|
text = "".join(parts[1:])
|
||||||
|
return text, truncated
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "", False
|
||||||
|
except Exception as e:
|
||||||
|
return f"[tail_error] {type(e).__name__}: {e}\n", False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_job_id(value: object) -> str | None:
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", text):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
@@ -3,22 +3,22 @@
|
|||||||
"file": "assets/datetime-ZCuLLiQt.js",
|
"file": "assets/datetime-ZCuLLiQt.js",
|
||||||
"name": "datetime"
|
"name": "datetime"
|
||||||
},
|
},
|
||||||
"_tasks-D0zj3VJF.js": {
|
"_tasks-CFlwbjTx.js": {
|
||||||
"file": "assets/tasks-D0zj3VJF.js",
|
"file": "assets/tasks-CFlwbjTx.js",
|
||||||
"name": "tasks",
|
"name": "tasks",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_users-7Bk6NvSS.js": {
|
"_users-B1Ww8zl1.js": {
|
||||||
"file": "assets/users-7Bk6NvSS.js",
|
"file": "assets/users-B1Ww8zl1.js",
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"index.html": {
|
"index.html": {
|
||||||
"file": "assets/index-8uFy3xP6.js",
|
"file": "assets/index-CKlvOJnw.js",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
"src": "index.html",
|
"src": "index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/AnnouncementsPage.vue": {
|
"src/pages/AnnouncementsPage.vue": {
|
||||||
"file": "assets/AnnouncementsPage-BaAke3LD.js",
|
"file": "assets/AnnouncementsPage-DptOTv9v.js",
|
||||||
"name": "AnnouncementsPage",
|
"name": "AnnouncementsPage",
|
||||||
"src": "src/pages/AnnouncementsPage.vue",
|
"src": "src/pages/AnnouncementsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/EmailPage.vue": {
|
"src/pages/EmailPage.vue": {
|
||||||
"file": "assets/EmailPage-CQ54ILNk.js",
|
"file": "assets/EmailPage-A0u1uqDL.js",
|
||||||
"name": "EmailPage",
|
"name": "EmailPage",
|
||||||
"src": "src/pages/EmailPage.vue",
|
"src": "src/pages/EmailPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/FeedbacksPage.vue": {
|
"src/pages/FeedbacksPage.vue": {
|
||||||
"file": "assets/FeedbacksPage-DE47SeFp.js",
|
"file": "assets/FeedbacksPage-2XhnmXAD.js",
|
||||||
"name": "FeedbacksPage",
|
"name": "FeedbacksPage",
|
||||||
"src": "src/pages/FeedbacksPage.vue",
|
"src": "src/pages/FeedbacksPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -74,13 +74,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/LogsPage.vue": {
|
"src/pages/LogsPage.vue": {
|
||||||
"file": "assets/LogsPage-UzX26IA-.js",
|
"file": "assets/LogsPage-C6eCwbXQ.js",
|
||||||
"name": "LogsPage",
|
"name": "LogsPage",
|
||||||
"src": "src/pages/LogsPage.vue",
|
"src": "src/pages/LogsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-7Bk6NvSS.js",
|
"_users-B1Ww8zl1.js",
|
||||||
"_tasks-D0zj3VJF.js",
|
"_tasks-CFlwbjTx.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -88,12 +88,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/PendingPage.vue": {
|
"src/pages/PendingPage.vue": {
|
||||||
"file": "assets/PendingPage-BxcKr1rh.js",
|
"file": "assets/PendingPage-CsinHQq-.js",
|
||||||
"name": "PendingPage",
|
"name": "PendingPage",
|
||||||
"src": "src/pages/PendingPage.vue",
|
"src": "src/pages/PendingPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-7Bk6NvSS.js",
|
"_users-B1Ww8zl1.js",
|
||||||
"index.html",
|
"index.html",
|
||||||
"_datetime-ZCuLLiQt.js"
|
"_datetime-ZCuLLiQt.js"
|
||||||
],
|
],
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SettingsPage.vue": {
|
"src/pages/SettingsPage.vue": {
|
||||||
"file": "assets/SettingsPage-MZ5dlYEy.js",
|
"file": "assets/SettingsPage-D9ploTWf.js",
|
||||||
"name": "SettingsPage",
|
"name": "SettingsPage",
|
||||||
"src": "src/pages/SettingsPage.vue",
|
"src": "src/pages/SettingsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -114,12 +114,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/StatsPage.vue": {
|
"src/pages/StatsPage.vue": {
|
||||||
"file": "assets/StatsPage-LZRKsKip.js",
|
"file": "assets/StatsPage-CxBcYwKE.js",
|
||||||
"name": "StatsPage",
|
"name": "StatsPage",
|
||||||
"src": "src/pages/StatsPage.vue",
|
"src": "src/pages/StatsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_tasks-D0zj3VJF.js",
|
"_tasks-CFlwbjTx.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SystemPage.vue": {
|
"src/pages/SystemPage.vue": {
|
||||||
"file": "assets/SystemPage-DLPz_RK8.js",
|
"file": "assets/SystemPage-DMNoY1AU.js",
|
||||||
"name": "SystemPage",
|
"name": "SystemPage",
|
||||||
"src": "src/pages/SystemPage.vue",
|
"src": "src/pages/SystemPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -135,16 +135,16 @@
|
|||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/SystemPage-b-S9OiVi.css"
|
"assets/SystemPage-B0TEzUGV.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/UsersPage.vue": {
|
"src/pages/UsersPage.vue": {
|
||||||
"file": "assets/UsersPage-whzb1Tl7.js",
|
"file": "assets/UsersPage-BzRNJLnT.js",
|
||||||
"name": "UsersPage",
|
"name": "UsersPage",
|
||||||
"src": "src/pages/UsersPage.vue",
|
"src": "src/pages/UsersPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-7Bk6NvSS.js",
|
"_users-B1Ww8zl1.js",
|
||||||
"_datetime-ZCuLLiQt.js",
|
"_datetime-ZCuLLiQt.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{f as E,a as I,r as A}from"./users-7Bk6NvSS.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-8uFy3xP6.js";import{p as L}from"./datetime-ZCuLLiQt.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};
|
import{f as E,a as I,r as A}from"./users-B1Ww8zl1.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-CKlvOJnw.js";import{p as L}from"./datetime-ZCuLLiQt.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};
|
||||||
@@ -1 +1 @@
|
|||||||
import{B as m,_ as T,r as p,c as h,a as r,b as a,w as s,d as u,g as k,h as b,m as d,E as x}from"./index-8uFy3xP6.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function E(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function P(){const{data:o}=await m.post("/logout");return o}const U={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),i=p(""),n=p(!1);async function f(){try{await P()}catch{}finally{window.location.href="/yuyx"}}async function B(){const l=t.value.trim();if(!l){d.error("请输入新用户名");return}try{await x.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),d.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function V(){const l=i.value;if(!l){d.error("请输入新密码");return}if(l.length<6){d.error("密码至少6个字符");return}try{await x.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await E(l),d.success("密码修改成功,请重新登录"),i.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",U,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[2]||(e[2]=[b("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:i.value,"onUpdate:modelValue":e[1]||(e[1]=c=>i.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[4]||(e[4]=[b("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},M=T(N,[["__scopeId","data-v-2f4b840f"]]);export{M as default};
|
import{B as m,_ as T,r as p,c as h,a as r,b as a,w as s,d as u,g as k,h as b,m as d,E as x}from"./index-CKlvOJnw.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function E(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function P(){const{data:o}=await m.post("/logout");return o}const U={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),i=p(""),n=p(!1);async function f(){try{await P()}catch{}finally{window.location.href="/yuyx"}}async function B(){const l=t.value.trim();if(!l){d.error("请输入新用户名");return}try{await x.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),d.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function V(){const l=i.value;if(!l){d.error("请输入新密码");return}if(l.length<6){d.error("密码至少6个字符");return}try{await x.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await E(l),d.success("密码修改成功,请重新登录"),i.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",U,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[2]||(e[2]=[b("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:i.value,"onUpdate:modelValue":e[1]||(e[1]=c=>i.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[4]||(e[4]=[b("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},M=T(N,[["__scopeId","data-v-2f4b840f"]]);export{M as default};
|
||||||
File diff suppressed because one or more lines are too long
1
static/admin/assets/SystemPage-B0TEzUGV.css
Normal file
1
static/admin/assets/SystemPage-B0TEzUGV.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.page-stack[data-v-b35a5b19]{display:flex;flex-direction:column;gap:12px}.card[data-v-b35a5b19]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-b35a5b19]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-b35a5b19]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-b35a5b19]{display:flex;flex-wrap:wrap;gap:10px}
|
||||||
File diff suppressed because one or more lines are too long
20
static/admin/assets/SystemPage-DMNoY1AU.js
Normal file
20
static/admin/assets/SystemPage-DMNoY1AU.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.page-stack[data-v-3a1fcdf4]{display:flex;flex-direction:column;gap:12px}.card[data-v-3a1fcdf4]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-3a1fcdf4]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-3a1fcdf4]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-3a1fcdf4]{display:flex;flex-wrap:wrap;gap:10px}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{B as a}from"./index-8uFy3xP6.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};
|
import{B as a}from"./index-CKlvOJnw.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};
|
||||||
@@ -1 +1 @@
|
|||||||
import{B as a}from"./index-8uFy3xP6.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};
|
import{B as a}from"./index-CKlvOJnw.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>后台管理 - 知识管理平台</title>
|
<title>后台管理 - 知识管理平台</title>
|
||||||
<script type="module" crossorigin src="./assets/index-8uFy3xP6.js"></script>
|
<script type="module" crossorigin src="./assets/index-CKlvOJnw.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BIDpnzAs.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BIDpnzAs.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
462
tools/update_agent.py
Normal file
462
tools/update_agent.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
ZSGLPT Update-Agent(宿主机运行)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 定期检查 Git 远端是否有新版本(写入 data/update/status.json)
|
||||||
|
- 接收后台写入的 data/update/request.json 请求(check/update)
|
||||||
|
- 执行 git reset --hard origin/<branch> + docker compose build/up
|
||||||
|
- 更新前备份数据库 data/app_data.db
|
||||||
|
- 写入 data/update/result.json 与 data/update/jobs/<job_id>.log
|
||||||
|
|
||||||
|
仅使用标准库,便于在宿主机直接运行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def ts_str() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def json_load(path: Path) -> Tuple[dict, Optional[str]]:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return dict(json.load(f) or {}), None
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}, None
|
||||||
|
except Exception as e:
|
||||||
|
return {}, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def json_dump_atomic(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(f"{path.suffix}.tmp.{os.getpid()}.{int(time.time() * 1000)}")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_job_id(value: object) -> str:
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return f"job_{uuid.uuid4().hex[:8]}"
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", text):
|
||||||
|
return f"job_{uuid.uuid4().hex[:8]}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], *, cwd: Path, log_fp, env: Optional[dict] = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
log_fp.write(f"[{ts_str()}] $ {' '.join(cmd)}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
merged_env = os.environ.copy()
|
||||||
|
if env:
|
||||||
|
merged_env.update(env)
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
env=merged_env,
|
||||||
|
stdout=log_fp,
|
||||||
|
stderr=log_fp,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_rev_parse(ref: str, *, cwd: Path) -> str:
|
||||||
|
out = subprocess.check_output(["git", "rev-parse", ref], cwd=str(cwd), text=True).strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _git_is_dirty(*, cwd: Path) -> bool:
|
||||||
|
out = subprocess.check_output(["git", "status", "--porcelain"], cwd=str(cwd), text=True)
|
||||||
|
return bool(out.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_cmd() -> list[str]:
|
||||||
|
# 优先使用 docker compose(v2)
|
||||||
|
try:
|
||||||
|
subprocess.check_output(["docker", "compose", "version"], stderr=subprocess.STDOUT, text=True)
|
||||||
|
return ["docker", "compose"]
|
||||||
|
except Exception:
|
||||||
|
return ["docker-compose"]
|
||||||
|
|
||||||
|
|
||||||
|
def _http_healthcheck(url: str, *, timeout: float = 5.0) -> Tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "zsglpt-update-agent/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
code = int(getattr(resp, "status", 200) or 200)
|
||||||
|
if 200 <= code < 400:
|
||||||
|
return True, f"HTTP {code}"
|
||||||
|
return False, f"HTTP {code}"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return False, f"HTTPError {e.code}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Paths:
|
||||||
|
repo_dir: Path
|
||||||
|
data_dir: Path
|
||||||
|
update_dir: Path
|
||||||
|
status_path: Path
|
||||||
|
request_path: Path
|
||||||
|
result_path: Path
|
||||||
|
jobs_dir: Path
|
||||||
|
|
||||||
|
|
||||||
|
def build_paths(repo_dir: Path, data_dir: Optional[Path] = None) -> Paths:
|
||||||
|
repo_dir = repo_dir.resolve()
|
||||||
|
data_dir = (data_dir or (repo_dir / "data")).resolve()
|
||||||
|
update_dir = data_dir / "update"
|
||||||
|
return Paths(
|
||||||
|
repo_dir=repo_dir,
|
||||||
|
data_dir=data_dir,
|
||||||
|
update_dir=update_dir,
|
||||||
|
status_path=update_dir / "status.json",
|
||||||
|
request_path=update_dir / "request.json",
|
||||||
|
result_path=update_dir / "result.json",
|
||||||
|
jobs_dir=update_dir / "jobs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs(paths: Paths) -> None:
|
||||||
|
paths.jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def check_updates(*, paths: Paths, branch: str, log_fp=None) -> dict:
|
||||||
|
env = {"GIT_TERMINAL_PROMPT": "0"}
|
||||||
|
err = ""
|
||||||
|
local = ""
|
||||||
|
remote = ""
|
||||||
|
dirty = False
|
||||||
|
try:
|
||||||
|
if log_fp:
|
||||||
|
_run(["git", "fetch", "origin", branch], cwd=paths.repo_dir, log_fp=log_fp, env=env)
|
||||||
|
else:
|
||||||
|
subprocess.run(["git", "fetch", "origin", branch], cwd=str(paths.repo_dir), env={**os.environ, **env}, check=True)
|
||||||
|
local = _git_rev_parse("HEAD", cwd=paths.repo_dir)
|
||||||
|
remote = _git_rev_parse(f"origin/{branch}", cwd=paths.repo_dir)
|
||||||
|
dirty = _git_is_dirty(cwd=paths.repo_dir)
|
||||||
|
except Exception as e:
|
||||||
|
err = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
update_available = bool(local and remote and local != remote) if not err else False
|
||||||
|
return {
|
||||||
|
"branch": branch,
|
||||||
|
"checked_at": ts_str(),
|
||||||
|
"local_commit": local,
|
||||||
|
"remote_commit": remote,
|
||||||
|
"update_available": update_available,
|
||||||
|
"dirty": dirty,
|
||||||
|
"error": err,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def backup_db(*, paths: Paths, log_fp, keep: int = 20) -> str:
|
||||||
|
db_path = paths.data_dir / "app_data.db"
|
||||||
|
backups_dir = paths.data_dir / "backups"
|
||||||
|
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_path = backups_dir / f"app_data.db.{stamp}.bak"
|
||||||
|
|
||||||
|
if db_path.exists():
|
||||||
|
log_fp.write(f"[{ts_str()}] backup db: {db_path} -> {backup_path}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
shutil.copy2(db_path, backup_path)
|
||||||
|
else:
|
||||||
|
log_fp.write(f"[{ts_str()}] backup skipped: db not found: {db_path}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
|
||||||
|
# 简单保留策略:按文件名排序,保留最近 keep 个
|
||||||
|
try:
|
||||||
|
items = sorted([p for p in backups_dir.glob("app_data.db.*.bak") if p.is_file()], key=lambda p: p.name)
|
||||||
|
if len(items) > keep:
|
||||||
|
for p in items[: len(items) - keep]:
|
||||||
|
try:
|
||||||
|
p.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return str(backup_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_result(paths: Paths, data: dict) -> None:
|
||||||
|
json_dump_atomic(paths.result_path, data)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_request(paths: Paths) -> Tuple[dict, Optional[str]]:
|
||||||
|
data, err = json_load(paths.request_path)
|
||||||
|
if err:
|
||||||
|
# 避免解析失败导致死循环:将坏文件移走
|
||||||
|
try:
|
||||||
|
bad_name = f"request.bad.{datetime.now().strftime('%Y%m%d_%H%M%S')}.{uuid.uuid4().hex[:6]}.json"
|
||||||
|
bad_path = paths.update_dir / bad_name
|
||||||
|
paths.request_path.rename(bad_path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
paths.request_path.unlink(missing_ok=True) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}, err
|
||||||
|
if not data:
|
||||||
|
return {}, None
|
||||||
|
try:
|
||||||
|
paths.request_path.unlink(missing_ok=True) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.remove(paths.request_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_update_job(*, paths: Paths, branch: str, health_url: str, job_id: str, requested_by: str) -> None:
|
||||||
|
ensure_dirs(paths)
|
||||||
|
log_path = paths.jobs_dir / f"{job_id}.log"
|
||||||
|
with open(log_path, "a", encoding="utf-8") as log_fp:
|
||||||
|
log_fp.write(f"[{ts_str()}] job start: {job_id}, branch={branch}, by={requested_by}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
|
||||||
|
result: Dict[str, object] = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"action": "update",
|
||||||
|
"status": "running",
|
||||||
|
"stage": "start",
|
||||||
|
"message": "",
|
||||||
|
"started_at": ts_str(),
|
||||||
|
"finished_at": None,
|
||||||
|
"duration_seconds": None,
|
||||||
|
"requested_by": requested_by,
|
||||||
|
"branch": branch,
|
||||||
|
"from_commit": None,
|
||||||
|
"to_commit": None,
|
||||||
|
"backup_db": None,
|
||||||
|
"health_url": health_url,
|
||||||
|
"health_ok": None,
|
||||||
|
"health_message": None,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
write_result(paths, result)
|
||||||
|
|
||||||
|
start_ts = time.time()
|
||||||
|
try:
|
||||||
|
result["stage"] = "backup"
|
||||||
|
result["message"] = "备份数据库"
|
||||||
|
write_result(paths, result)
|
||||||
|
result["backup_db"] = backup_db(paths=paths, log_fp=log_fp)
|
||||||
|
|
||||||
|
result["stage"] = "git_fetch"
|
||||||
|
result["message"] = "拉取远端代码"
|
||||||
|
write_result(paths, result)
|
||||||
|
_run(["git", "fetch", "origin", branch], cwd=paths.repo_dir, log_fp=log_fp, env={"GIT_TERMINAL_PROMPT": "0"})
|
||||||
|
|
||||||
|
from_commit = _git_rev_parse("HEAD", cwd=paths.repo_dir)
|
||||||
|
result["from_commit"] = from_commit
|
||||||
|
|
||||||
|
result["stage"] = "git_reset"
|
||||||
|
result["message"] = f"切换到 origin/{branch}"
|
||||||
|
write_result(paths, result)
|
||||||
|
_run(["git", "reset", "--hard", f"origin/{branch}"], cwd=paths.repo_dir, log_fp=log_fp, env={"GIT_TERMINAL_PROMPT": "0"})
|
||||||
|
|
||||||
|
to_commit = _git_rev_parse("HEAD", cwd=paths.repo_dir)
|
||||||
|
result["to_commit"] = to_commit
|
||||||
|
|
||||||
|
compose = _compose_cmd()
|
||||||
|
result["stage"] = "docker_build"
|
||||||
|
result["message"] = "构建容器镜像"
|
||||||
|
write_result(paths, result)
|
||||||
|
_run([*compose, "build"], cwd=paths.repo_dir, log_fp=log_fp)
|
||||||
|
|
||||||
|
result["stage"] = "docker_up"
|
||||||
|
result["message"] = "重建并启动服务"
|
||||||
|
write_result(paths, result)
|
||||||
|
_run([*compose, "up", "-d", "--force-recreate"], cwd=paths.repo_dir, log_fp=log_fp)
|
||||||
|
|
||||||
|
result["stage"] = "health_check"
|
||||||
|
result["message"] = "健康检查"
|
||||||
|
write_result(paths, result)
|
||||||
|
|
||||||
|
ok = False
|
||||||
|
health_msg = ""
|
||||||
|
deadline = time.time() + 180
|
||||||
|
while time.time() < deadline:
|
||||||
|
ok, health_msg = _http_healthcheck(health_url, timeout=5.0)
|
||||||
|
if ok:
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
result["health_ok"] = ok
|
||||||
|
result["health_message"] = health_msg
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"healthcheck failed: {health_msg}")
|
||||||
|
|
||||||
|
result["status"] = "success"
|
||||||
|
result["stage"] = "done"
|
||||||
|
result["message"] = "更新完成"
|
||||||
|
except Exception as e:
|
||||||
|
result["status"] = "failed"
|
||||||
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
result["stage"] = result.get("stage") or "failed"
|
||||||
|
result["message"] = "更新失败"
|
||||||
|
log_fp.write(f"[{ts_str()}] ERROR: {result['error']}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
finally:
|
||||||
|
result["finished_at"] = ts_str()
|
||||||
|
result["duration_seconds"] = int(time.time() - start_ts)
|
||||||
|
write_result(paths, result)
|
||||||
|
|
||||||
|
# 更新 status(成功/失败都尽量写一份最新状态)
|
||||||
|
try:
|
||||||
|
status = check_updates(paths=paths, branch=branch, log_fp=log_fp)
|
||||||
|
json_dump_atomic(paths.status_path, status)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log_fp.write(f"[{ts_str()}] job end: {job_id}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_check_job(*, paths: Paths, branch: str, job_id: str, requested_by: str) -> None:
|
||||||
|
ensure_dirs(paths)
|
||||||
|
log_path = paths.jobs_dir / f"{job_id}.log"
|
||||||
|
with open(log_path, "a", encoding="utf-8") as log_fp:
|
||||||
|
log_fp.write(f"[{ts_str()}] job start: {job_id}, action=check, branch={branch}, by={requested_by}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
status = check_updates(paths=paths, branch=branch, log_fp=log_fp)
|
||||||
|
json_dump_atomic(paths.status_path, status)
|
||||||
|
log_fp.write(f"[{ts_str()}] job end: {job_id}\n")
|
||||||
|
log_fp.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="ZSGLPT Update-Agent (host)")
|
||||||
|
parser.add_argument("--repo-dir", default=".", help="部署仓库目录(包含 docker-compose.yml)")
|
||||||
|
parser.add_argument("--data-dir", default="", help="数据目录(默认 <repo>/data)")
|
||||||
|
parser.add_argument("--branch", default="master", help="允许更新的分支名(默认 master)")
|
||||||
|
parser.add_argument("--health-url", default="http://127.0.0.1:51232/", help="更新后健康检查URL")
|
||||||
|
parser.add_argument("--check-interval-seconds", type=int, default=300, help="自动检查更新间隔(秒)")
|
||||||
|
parser.add_argument("--poll-seconds", type=int, default=5, help="轮询 request.json 的间隔(秒)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
repo_dir = Path(args.repo_dir).resolve()
|
||||||
|
if not (repo_dir / "docker-compose.yml").exists():
|
||||||
|
print(f"[fatal] docker-compose.yml not found in {repo_dir}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not (repo_dir / ".git").exists():
|
||||||
|
print(f"[fatal] .git not found in {repo_dir} (need git repo)", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data_dir = Path(args.data_dir).resolve() if args.data_dir else None
|
||||||
|
paths = build_paths(repo_dir, data_dir=data_dir)
|
||||||
|
ensure_dirs(paths)
|
||||||
|
|
||||||
|
last_check_ts = 0.0
|
||||||
|
check_interval = max(30, int(args.check_interval_seconds))
|
||||||
|
poll_seconds = max(2, int(args.poll_seconds))
|
||||||
|
branch = str(args.branch or "master").strip()
|
||||||
|
health_url = str(args.health_url or "").strip()
|
||||||
|
|
||||||
|
# 启动时先写一次状态,便于后台立即看到
|
||||||
|
try:
|
||||||
|
status = check_updates(paths=paths, branch=branch)
|
||||||
|
json_dump_atomic(paths.status_path, status)
|
||||||
|
last_check_ts = time.time()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 1) 优先处理 request
|
||||||
|
req, err = consume_request(paths)
|
||||||
|
if err:
|
||||||
|
# request 文件损坏:写入 result 便于后台看到
|
||||||
|
write_result(
|
||||||
|
paths,
|
||||||
|
{
|
||||||
|
"job_id": f"badreq_{uuid.uuid4().hex[:8]}",
|
||||||
|
"action": "unknown",
|
||||||
|
"status": "failed",
|
||||||
|
"stage": "parse_request",
|
||||||
|
"message": "request.json 解析失败",
|
||||||
|
"error": err,
|
||||||
|
"started_at": ts_str(),
|
||||||
|
"finished_at": ts_str(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif req:
|
||||||
|
action = str(req.get("action") or "").strip().lower()
|
||||||
|
job_id = sanitize_job_id(req.get("job_id"))
|
||||||
|
requested_by = str(req.get("requested_by") or "")
|
||||||
|
|
||||||
|
# 只允许固定分支,避免被注入/误操作
|
||||||
|
if action not in ("check", "update"):
|
||||||
|
write_result(
|
||||||
|
paths,
|
||||||
|
{
|
||||||
|
"job_id": job_id,
|
||||||
|
"action": action,
|
||||||
|
"status": "failed",
|
||||||
|
"stage": "validate",
|
||||||
|
"message": "不支持的 action",
|
||||||
|
"error": f"unsupported action: {action}",
|
||||||
|
"started_at": ts_str(),
|
||||||
|
"finished_at": ts_str(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif action == "check":
|
||||||
|
handle_check_job(paths=paths, branch=branch, job_id=job_id, requested_by=requested_by)
|
||||||
|
else:
|
||||||
|
handle_update_job(
|
||||||
|
paths=paths,
|
||||||
|
branch=branch,
|
||||||
|
health_url=health_url,
|
||||||
|
job_id=job_id,
|
||||||
|
requested_by=requested_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_check_ts = time.time()
|
||||||
|
|
||||||
|
# 2) 周期性 check
|
||||||
|
now = time.time()
|
||||||
|
if now - last_check_ts >= check_interval:
|
||||||
|
try:
|
||||||
|
status = check_updates(paths=paths, branch=branch)
|
||||||
|
json_dump_atomic(paths.status_path, status)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
last_check_ts = now
|
||||||
|
|
||||||
|
time.sleep(poll_seconds)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
Reference in New Issue
Block a user