feat: 添加系统日志功能,记录运行时错误和AI调用失败,提供日志查看页面
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ data/*.sqlite
|
|||||||
data/*.sqlite3
|
data/*.sqlite3
|
||||||
data/ai_settings.json
|
data/ai_settings.json
|
||||||
data/box_types.json
|
data/box_types.json
|
||||||
|
data/*.log
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -124,6 +124,16 @@ $env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
|
|||||||
- 页面:`/ai/settings`
|
- 页面:`/ai/settings`
|
||||||
- 保存文件:`data/ai_settings.json`
|
- 保存文件:`data/ai_settings.json`
|
||||||
|
|
||||||
|
### 2.6 系统日志
|
||||||
|
|
||||||
|
为便于定位 AI 请求失败、配置错误、未处理异常,系统提供日志查看页面:
|
||||||
|
|
||||||
|
- 页面入口:右上角 `账号` -> `系统日志`
|
||||||
|
- 快速入口:`仓库概览` 页面 AI 补货建议卡片中的 `日志`
|
||||||
|
- 日志文件:`data/app.log`
|
||||||
|
|
||||||
|
当 AI 补货建议出现“请求失败,请稍后重试”时,优先打开系统日志查看最近的 `ERROR` 或 `WARNING` 记录。
|
||||||
|
|
||||||
## 3. 页面说明
|
## 3. 页面说明
|
||||||
|
|
||||||
### 3.1 首页 `/`
|
### 3.1 首页 `/`
|
||||||
|
|||||||
139
app.py
139
app.py
@@ -11,11 +11,14 @@ import re
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
import difflib
|
import difflib
|
||||||
import base64
|
import base64
|
||||||
import random
|
import random
|
||||||
|
import socket
|
||||||
import string
|
import string
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import traceback
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -23,9 +26,11 @@ import urllib.request
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
from flask import Flask, Response, redirect, render_template, request, session, url_for
|
from flask import Flask, Response, redirect, render_template, request, session, url_for
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +38,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
DB_DIR = os.path.join(BASE_DIR, "data")
|
DB_DIR = os.path.join(BASE_DIR, "data")
|
||||||
os.makedirs(DB_DIR, exist_ok=True)
|
os.makedirs(DB_DIR, exist_ok=True)
|
||||||
DB_PATH = os.path.join(DB_DIR, "inventory.db")
|
DB_PATH = os.path.join(DB_DIR, "inventory.db")
|
||||||
|
APP_LOG_PATH = os.path.join(DB_DIR, "app.log")
|
||||||
|
|
||||||
# Flask 和 SQLAlchemy 基础初始化。
|
# Flask 和 SQLAlchemy 基础初始化。
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -44,6 +50,58 @@ app.config["SECRET_KEY"] = os.environ.get(
|
|||||||
)
|
)
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_app_logger() -> None:
|
||||||
|
"""初始化应用日志文件。
|
||||||
|
|
||||||
|
中文说明:把运行期错误、AI调用失败等信息写入 data/app.log,
|
||||||
|
方便在系统内直接查看,不再依赖控制台输出。
|
||||||
|
"""
|
||||||
|
if getattr(app, "_inventory_log_ready", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
APP_LOG_PATH,
|
||||||
|
maxBytes=512 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
|
|
||||||
|
for existing_handler in app.logger.handlers:
|
||||||
|
if getattr(existing_handler, "baseFilename", "") == handler.baseFilename:
|
||||||
|
app._inventory_log_ready = True
|
||||||
|
return
|
||||||
|
|
||||||
|
app.logger.addHandler(handler)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
app._inventory_log_ready = True
|
||||||
|
|
||||||
|
|
||||||
|
def _log_event(level: int, event: str, **context) -> None:
|
||||||
|
parts = []
|
||||||
|
for key, value in context.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).replace("\n", " ").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if len(text) > 280:
|
||||||
|
text = text[:277] + "..."
|
||||||
|
parts.append(f"{key}={text}")
|
||||||
|
message = event if not parts else f"{event} | {' | '.join(parts)}"
|
||||||
|
app.logger.log(level, message)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_lines(limit: int = 200) -> list[str]:
|
||||||
|
if not os.path.exists(APP_LOG_PATH):
|
||||||
|
return []
|
||||||
|
with open(APP_LOG_PATH, "r", encoding="utf-8", errors="ignore") as file_obj:
|
||||||
|
return [line.rstrip("\n") for line in file_obj.readlines()[-limit:]]
|
||||||
|
|
||||||
|
|
||||||
|
_setup_app_logger()
|
||||||
|
|
||||||
# 这里集中放全局常量,避免后面函数里散落硬编码。
|
# 这里集中放全局常量,避免后面函数里散落硬编码。
|
||||||
LOW_STOCK_THRESHOLD = 5
|
LOW_STOCK_THRESHOLD = 5
|
||||||
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
|
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
|
||||||
@@ -2807,6 +2865,8 @@ def _call_siliconflow_chat(
|
|||||||
raise RuntimeError(f"AI 服务返回 HTTP {exc.code}: {detail[:200]}") from exc
|
raise RuntimeError(f"AI 服务返回 HTTP {exc.code}: {detail[:200]}") from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise RuntimeError(f"AI 服务连接失败: {exc.reason}") from exc
|
raise RuntimeError(f"AI 服务连接失败: {exc.reason}") from exc
|
||||||
|
except (TimeoutError, socket.timeout) as exc:
|
||||||
|
raise RuntimeError(f"AI 服务读取超时(>{timeout}秒),请稍后重试或在 AI 参数中调大超时") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
@@ -2856,6 +2916,28 @@ def logout_page():
|
|||||||
return redirect(url_for("login_page"))
|
return redirect(url_for("login_page"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/system/logs")
|
||||||
|
def system_logs_page():
|
||||||
|
"""查看系统日志。
|
||||||
|
|
||||||
|
中文说明:用于定位 AI 调用失败、未处理异常、配置错误等问题,
|
||||||
|
默认展示最近 200 行,可通过 query 参数 lines 调整。
|
||||||
|
"""
|
||||||
|
raw_lines = (request.args.get("lines", "200") or "200").strip()
|
||||||
|
try:
|
||||||
|
line_limit = max(50, min(int(raw_lines), 1000))
|
||||||
|
except ValueError:
|
||||||
|
line_limit = 200
|
||||||
|
|
||||||
|
log_lines = _read_log_lines(line_limit)
|
||||||
|
return render_template(
|
||||||
|
"logs.html",
|
||||||
|
log_lines=log_lines,
|
||||||
|
line_limit=line_limit,
|
||||||
|
log_path=APP_LOG_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/account/password", methods=["GET", "POST"])
|
@app.route("/account/password", methods=["GET", "POST"])
|
||||||
def change_password_page():
|
def change_password_page():
|
||||||
"""登录后修改当前账号密码。
|
"""登录后修改当前账号密码。
|
||||||
@@ -4244,6 +4326,14 @@ def ai_restock_plan():
|
|||||||
timeout=ai_settings["timeout"],
|
timeout=ai_settings["timeout"],
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
_log_event(
|
||||||
|
logging.WARNING,
|
||||||
|
"ai_restock_plan_runtime_error",
|
||||||
|
error=str(exc),
|
||||||
|
model=ai_settings.get("model", ""),
|
||||||
|
api_url=ai_settings.get("api_url", ""),
|
||||||
|
low_stock_count=len(data.get("low_stock_items", [])),
|
||||||
|
)
|
||||||
fallback_plan = _build_rule_based_plan()
|
fallback_plan = _build_rule_based_plan()
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
@@ -4251,6 +4341,22 @@ def ai_restock_plan():
|
|||||||
"plan": fallback_plan,
|
"plan": fallback_plan,
|
||||||
"data": data,
|
"data": data,
|
||||||
}, 400
|
}, 400
|
||||||
|
except Exception as exc:
|
||||||
|
_log_event(
|
||||||
|
logging.ERROR,
|
||||||
|
"ai_restock_plan_unexpected_error",
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
model=ai_settings.get("model", ""),
|
||||||
|
api_url=ai_settings.get("api_url", ""),
|
||||||
|
)
|
||||||
|
fallback_plan = _build_rule_based_plan()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "服务器内部错误,请到系统日志查看详情",
|
||||||
|
"plan": fallback_plan,
|
||||||
|
"data": data,
|
||||||
|
}, 500
|
||||||
|
|
||||||
parse_warning = ""
|
parse_warning = ""
|
||||||
try:
|
try:
|
||||||
@@ -4799,6 +4905,39 @@ def clear_stats_logs():
|
|||||||
return redirect(url_for("stats_page", days=days, box_type=box_type_filter, notice=notice))
|
return redirect(url_for("stats_page", days=days, box_type=box_type_filter, notice=notice))
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_app_exception(exc: Exception):
|
||||||
|
if isinstance(exc, HTTPException):
|
||||||
|
status_code = exc.code or 500
|
||||||
|
title = exc.name or "请求失败"
|
||||||
|
message = exc.description or "请求处理失败"
|
||||||
|
else:
|
||||||
|
status_code = 500
|
||||||
|
title = "服务器异常"
|
||||||
|
message = "服务器内部错误,请到系统日志查看详情。"
|
||||||
|
|
||||||
|
_log_event(
|
||||||
|
logging.ERROR if status_code >= 500 else logging.WARNING,
|
||||||
|
"unhandled_exception",
|
||||||
|
status_code=status_code,
|
||||||
|
path=request.path,
|
||||||
|
method=request.method,
|
||||||
|
error=str(exc),
|
||||||
|
traceback=traceback.format_exc() if status_code >= 500 else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
render_template(
|
||||||
|
"error.html",
|
||||||
|
status_code=status_code,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
back_url=request.referrer or url_for("types_page"),
|
||||||
|
),
|
||||||
|
status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> None:
|
def bootstrap() -> None:
|
||||||
"""应用启动时初始化数据库。
|
"""应用启动时初始化数据库。
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"api_url": "https://api.siliconflow.cn/v1/chat/completions",
|
"api_url": "https://api.siliconflow.cn/v1/chat/completions",
|
||||||
"model": "Pro/zai-org/GLM-5",
|
"model": "Pro/zai-org/GLM-5",
|
||||||
"api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo",
|
"api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo",
|
||||||
"timeout": 30,
|
"timeout": 120,
|
||||||
"restock_threshold": 2,
|
"restock_threshold": 2,
|
||||||
"restock_limit": 24,
|
"restock_limit": 24,
|
||||||
"lcsc_base_url": "https://open-api.jlc.com",
|
"lcsc_base_url": "https://open-api.jlc.com",
|
||||||
|
|||||||
@@ -613,6 +613,11 @@ body {
|
|||||||
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
|
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-viewer {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.box-list {
|
.box-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<p class="account-menu-meta">在线时长:{{ auth_online_for }}</p>
|
<p class="account-menu-meta">在线时长:{{ auth_online_for }}</p>
|
||||||
<p class="account-menu-meta">最后活动:{{ auth_last_active_at }}</p>
|
<p class="account-menu-meta">最后活动:{{ auth_last_active_at }}</p>
|
||||||
<p class="account-menu-meta">空闲时长:{{ auth_idle_for }}</p>
|
<p class="account-menu-meta">空闲时长:{{ auth_idle_for }}</p>
|
||||||
|
<a class="account-menu-item" href="{{ url_for('system_logs_page') }}" role="menuitem">系统日志</a>
|
||||||
<a class="account-menu-item" href="{{ url_for('change_password_page') }}" role="menuitem">修改密码</a>
|
<a class="account-menu-item" href="{{ url_for('change_password_page') }}" role="menuitem">修改密码</a>
|
||||||
<a class="account-menu-item" href="{{ url_for('logout_page') }}" role="menuitem">退出登录</a>
|
<a class="account-menu-item" href="{{ url_for('logout_page') }}" role="menuitem">退出登录</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
超时(秒)
|
超时(秒)
|
||||||
<input type="number" name="timeout" min="5" value="{{ settings.timeout }}">
|
<input type="number" name="timeout" min="5" value="{{ settings.timeout }}">
|
||||||
</label>
|
</label>
|
||||||
|
<p class="hint full">若使用较慢模型(如 GLM-5、较大推理模型)生成补货建议超时,可先将这里调到 60-90 秒再重试。</p>
|
||||||
<label>
|
<label>
|
||||||
低库存阈值
|
低库存阈值
|
||||||
<input type="number" name="restock_threshold" min="0" value="{{ settings.restock_threshold }}">
|
<input type="number" name="restock_threshold" min="0" value="{{ settings.restock_threshold }}">
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
<p class="alert">{{ message }}</p>
|
<p class="alert">{{ message }}</p>
|
||||||
<div class="actions" style="margin-top: 10px;">
|
<div class="actions" style="margin-top: 10px;">
|
||||||
<a class="btn" href="{{ back_url }}">返回上一页</a>
|
<a class="btn" href="{{ back_url }}">返回上一页</a>
|
||||||
|
{% if status_code >= 500 %}
|
||||||
|
<a class="btn btn-light" href="{{ url_for('system_logs_page') }}">查看系统日志</a>
|
||||||
|
{% endif %}
|
||||||
<button class="btn btn-light" type="button" onclick="history.back()">浏览器返回</button>
|
<button class="btn btn-light" type="button" onclick="history.back()">浏览器返回</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
43
templates/logs.html
Normal file
43
templates/logs.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>系统日志</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="hero slim">
|
||||||
|
<div>
|
||||||
|
<h1>系统日志</h1>
|
||||||
|
<p>查看最近运行日志,优先关注 ERROR、WARNING 与 AI 接口失败记录</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||||||
|
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">AI参数</a>
|
||||||
|
{% include '_account_menu.html' %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="group-title-row">
|
||||||
|
<div>
|
||||||
|
<h2>日志文件</h2>
|
||||||
|
<p class="hint">路径:{{ log_path }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-light" href="{{ url_for('system_logs_page', lines=100) }}">最近100行</a>
|
||||||
|
<a class="btn btn-light" href="{{ url_for('system_logs_page', lines=200) }}">最近200行</a>
|
||||||
|
<a class="btn btn-light" href="{{ url_for('system_logs_page', lines=500) }}">最近500行</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if log_lines %}
|
||||||
|
<pre class="ai-panel-content log-viewer">{{ log_lines | join('\n') }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">当前还没有日志记录。首次出现错误后会自动写入。</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -91,7 +91,10 @@
|
|||||||
<section class="panel ai-panel" id="ai-panel">
|
<section class="panel ai-panel" id="ai-panel">
|
||||||
<div class="ai-panel-head">
|
<div class="ai-panel-head">
|
||||||
<h2>AI补货建议</h2>
|
<h2>AI补货建议</h2>
|
||||||
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">参数</a>
|
<div class="hero-actions">
|
||||||
|
<a class="btn btn-light" href="{{ url_for('system_logs_page') }}">日志</a>
|
||||||
|
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">参数</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">根据低库存与近30天出库数据,生成可执行补货建议。</p>
|
<p class="hint">根据低库存与近30天出库数据,生成可执行补货建议。</p>
|
||||||
<button class="btn" id="ai-restock-btn" type="button">生成建议</button>
|
<button class="btn" id="ai-restock-btn" type="button">生成建议</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user