feat: 添加系统日志功能,记录运行时错误和AI调用失败,提供日志查看页面

This commit is contained in:
2026-03-14 00:32:20 +08:00
parent d2d63d5e61
commit dc7efb8ff8
10 changed files with 208 additions and 2 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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
View File

@@ -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:
"""应用启动时初始化数据库。 """应用启动时初始化数据库。

View File

@@ -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",

View File

@@ -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));

View File

@@ -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>

View File

@@ -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 }}">

View File

@@ -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
View 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>

View File

@@ -91,8 +91,11 @@
<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>
<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> <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>
<p class="hint" id="ai-panel-status"></p> <p class="hint" id="ai-panel-status"></p>