添加自动更新功能
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
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}
|
|
||||||
+1
-1
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>
|
||||||
|
|||||||
@@ -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