From d2d63d5e61d226e222b9df59ae8c01d91b9f6d3b Mon Sep 17 00:00:00 2001 From: wangbeihong Date: Sat, 14 Mar 2026 00:11:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E7=B3=BB=E7=BB=9F=E5=AE=89=E5=85=A8=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8F=90=E4=BE=9B=E4=BF=AE=E6=94=B9=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E9=80=80=E5=87=BA=E7=99=BB=E5=BD=95=E9=80=89?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-DEPLOY.md | 16 ++- README.md | 43 ++++++- app.py | 209 ++++++++++++++++++++++++++++++++- static/css/style.css | 63 ++++++++++ templates/_account_menu.html | 11 ++ templates/ai_settings.html | 1 + templates/box.html | 1 + templates/change_password.html | 50 ++++++++ templates/edit.html | 1 + templates/index.html | 1 + templates/login.html | 42 +++++++ templates/search.html | 1 + templates/stats.html | 1 + templates/type_edit.html | 1 + templates/types.html | 1 + 15 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 templates/_account_menu.html create mode 100644 templates/change_password.html create mode 100644 templates/login.html diff --git a/README-DEPLOY.md b/README-DEPLOY.md index 47ad54e..043166c 100644 --- a/README-DEPLOY.md +++ b/README-DEPLOY.md @@ -44,7 +44,17 @@ gunicorn -w 2 -b 127.0.0.1:5000 app:app 说明:建议只监听 `127.0.0.1`,由 Nginx/宝塔反向代理。 -## 4. 每次发布更新 +## 4. 生产环境变量(建议) + +登录认证默认开启,建议在生产环境显式设置管理员账号和会话密钥: + +```bash +export INVENTORY_ADMIN_USERNAME="your_admin" +export INVENTORY_ADMIN_PASSWORD="your_strong_password" +export INVENTORY_SECRET_KEY="replace_with_random_long_secret" +``` + +## 5. 每次发布更新 ### 本地 @@ -66,13 +76,13 @@ pip install -r requirements.txt 然后在宝塔中重启 Python 项目(或重启 Gunicorn 进程)。 -## 5. 数据库备份 +## 6. 数据库备份 ```bash cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db ``` -## 6. 快速排查 +## 7. 快速排查 查看服务端口是否监听: diff --git a/README.md b/README.md index d76410d..00055ce 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,48 @@ python app.py 默认访问:`http://127.0.0.1:5000` -### 2.4 可选:启用 AI 补货建议(硅基流动) +### 2.4 登录认证(默认开启) + +系统启动后会自动启用登录保护,未登录用户会被重定向到登录页。 + +- 默认管理员用户名:`admin` +- 默认管理员密码:`admin123456` + +可通过环境变量覆盖默认账号: + +```powershell +$env:INVENTORY_ADMIN_USERNAME="你的管理员用户名" +$env:INVENTORY_ADMIN_PASSWORD="你的管理员密码" +``` + +首次登录后,建议立即在页面右上角进入“修改密码”完成改密。 + +#### 2.4.1 忘记密码救砖 + +如果还能登录: + +- 右上角 `账号` -> `修改密码` + +如果已经无法登录,可在项目根目录执行下面命令重置 `admin` 密码(将示例密码替换为你自己的强密码): + +```powershell +c:/Users/BeihongWang/Desktop/inventory/.venv/Scripts/python.exe -c "from app import app,db,User,generate_password_hash; ctx=app.app_context(); ctx.push(); u=User.query.filter_by(username='admin').first(); u.password_hash=generate_password_hash('NewPass123!'); db.session.commit(); print('admin password reset ok'); ctx.pop();" +``` + +执行完成后,用新密码重新登录。 + +如果要重置非 `admin` 账号,可用通用模板(把 `target_username` 和 `NewPass123!` 改成你的值): + +```powershell +c:/Users/BeihongWang/Desktop/inventory/.venv/Scripts/python.exe -c "from app import app,db,User,generate_password_hash; target_username='your_username'; new_password='NewPass123!'; ctx=app.app_context(); ctx.push(); u=User.query.filter_by(username=target_username).first(); print('user found:', bool(u)); (setattr(u, 'password_hash', generate_password_hash(new_password)), db.session.commit(), print('password reset ok')) if u else print('skip reset'); ctx.pop();" +``` + +注意: + +- `INVENTORY_ADMIN_PASSWORD` 仅在“系统中还没有任何用户”时用于创建默认管理员。 +- 如果数据库里已经有用户,修改该环境变量不会自动改已有账号密码。 + +### 2.5 可选:启用 AI 补货建议(硅基流动) 在启动前设置环境变量: diff --git a/app.py b/app.py index e61bd55..bee8d08 100644 --- a/app.py +++ b/app.py @@ -24,8 +24,9 @@ from copy import deepcopy from io import StringIO from datetime import datetime, timedelta -from flask import Flask, Response, redirect, render_template, request, url_for +from flask import Flask, Response, redirect, render_template, request, session, url_for from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import check_password_hash, generate_password_hash BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -37,12 +38,18 @@ DB_PATH = os.path.join(DB_DIR, "inventory.db") app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["SECRET_KEY"] = os.environ.get( + "INVENTORY_SECRET_KEY", + hashlib.sha256(f"inventory-local::{BASE_DIR}".encode("utf-8")).hexdigest(), +) db = SQLAlchemy(app) # 这里集中放全局常量,避免后面函数里散落硬编码。 LOW_STOCK_THRESHOLD = 5 BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json") AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json") +DEFAULT_ADMIN_USERNAME = os.environ.get("INVENTORY_ADMIN_USERNAME", "admin").strip() or "admin" +DEFAULT_ADMIN_PASSWORD = os.environ.get("INVENTORY_ADMIN_PASSWORD", "admin123456") LCSC_BASE_URL = "https://open-api.jlc.com" LCSC_BASIC_PATH = "/lcsc/openapi/sku/product/basic" AI_SETTINGS_DEFAULT = { @@ -520,6 +527,15 @@ class InventoryEvent(db.Model): created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), nullable=False, unique=True) + password_hash = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) + + def _add_column_if_missing(table_name: str, column_name: str, ddl: str) -> None: columns = { row[1] @@ -563,6 +579,116 @@ def ensure_schema() -> None: db.session.commit() +def _get_session_user() -> User | None: + user_id = session.get("user_id") + if not user_id: + return None + try: + return User.query.get(int(user_id)) + except (TypeError, ValueError): + return None + + +def _is_authenticated() -> bool: + return _get_session_user() is not None + + +def _login_user(user: User) -> None: + now_iso = datetime.utcnow().isoformat(timespec="seconds") + session["user_id"] = int(user.id) + session["username"] = user.username + session["login_at"] = now_iso + session["last_active_at"] = now_iso + + +def _logout_user() -> None: + session.pop("user_id", None) + session.pop("username", None) + session.pop("login_at", None) + session.pop("last_active_at", None) + + +def _parse_iso_datetime(raw_value: str) -> datetime | None: + value = (raw_value or "").strip() + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def _build_session_status() -> tuple[str, str, str, str]: + raw_login_at = (session.get("login_at") or "").strip() + raw_last_active_at = (session.get("last_active_at") or "").strip() + login_at = _parse_iso_datetime(raw_login_at) + last_active_at = _parse_iso_datetime(raw_last_active_at) + + if login_at is None: + return "-", "-", "-", "-" + if last_active_at is None: + last_active_at = login_at + + online_minutes = max(0, int((datetime.utcnow() - login_at).total_seconds() // 60)) + idle_minutes = max(0, int((datetime.utcnow() - last_active_at).total_seconds() // 60)) + login_label = login_at.strftime("%Y-%m-%d %H:%M:%S") + last_active_label = last_active_at.strftime("%Y-%m-%d %H:%M:%S") + online_label = "刚刚" if online_minutes <= 0 else f"{online_minutes} 分钟" + idle_label = "刚刚" if idle_minutes <= 0 else f"{idle_minutes} 分钟" + return login_label, online_label, last_active_label, idle_label + + +def _ensure_default_admin_user() -> None: + """确保系统至少存在一个可登录用户。 + + 中文说明:首次初始化时自动创建管理员账号,避免系统开启登录保护后无人可进。 + 用户名和密码可通过环境变量 INVENTORY_ADMIN_USERNAME / INVENTORY_ADMIN_PASSWORD 覆盖。 + """ + existing_user = User.query.order_by(User.id.asc()).first() + if existing_user: + return + + admin = User( + username=DEFAULT_ADMIN_USERNAME, + password_hash=generate_password_hash(DEFAULT_ADMIN_PASSWORD), + ) + db.session.add(admin) + db.session.commit() + + +@app.context_processor +def inject_auth_context(): + current_user = _get_session_user() + login_label, online_label, last_active_label, idle_label = _build_session_status() + return { + "auth_username": current_user.username if current_user else "", + "auth_logged_in": current_user is not None, + "auth_login_at": login_label, + "auth_online_for": online_label, + "auth_last_active_at": last_active_label, + "auth_idle_for": idle_label, + } + + +@app.before_request +def require_login_for_app_routes(): + open_endpoints = { + "login_page", + "logout_page", + "static", + } + endpoint = request.endpoint or "" + if endpoint in open_endpoints or endpoint.startswith("static"): + return None + + if _is_authenticated(): + session["last_active_at"] = datetime.utcnow().isoformat(timespec="seconds") + return None + + next_path = request.full_path if request.query_string else request.path + return redirect(url_for("login_page", next=next_path.rstrip("?"))) + + def slot_code_for_box(box: Box, slot_index: int) -> str: serial = box.start_number + slot_index - 1 return f"{box.slot_prefix}{serial}" @@ -2689,6 +2815,86 @@ def _call_siliconflow_chat( raise RuntimeError("AI 返回格式无法解析") from exc +def _is_safe_next_path(path: str) -> bool: + candidate = (path or "").strip() + if not candidate: + return False + return candidate.startswith("/") and not candidate.startswith("//") + + +@app.route("/login", methods=["GET", "POST"]) +def login_page(): + if _is_authenticated(): + return redirect(url_for("types_page")) + + error = "" + notice = request.args.get("notice", "").strip() + next_path = request.args.get("next", "").strip() + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + next_path = request.form.get("next", "").strip() + + user = User.query.filter_by(username=username).first() + if not user or not check_password_hash(user.password_hash, password): + error = "用户名或密码错误" + else: + _login_user(user) + if _is_safe_next_path(next_path): + return redirect(next_path) + return redirect(url_for("types_page")) + + if not _is_safe_next_path(next_path): + next_path = "" + + return render_template("login.html", error=error, notice=notice, next_path=next_path) + + +@app.route("/logout") +def logout_page(): + _logout_user() + return redirect(url_for("login_page")) + + +@app.route("/account/password", methods=["GET", "POST"]) +def change_password_page(): + """登录后修改当前账号密码。 + + 中文说明:为了避免长期使用默认密码,提供页面自助改密。 + 改密成功后会强制重新登录,确保会话状态干净。 + """ + current_user = _get_session_user() + if current_user is None: + return redirect(url_for("login_page")) + + error = "" + notice = "" + + if request.method == "POST": + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + confirm_password = request.form.get("confirm_password", "") + + if not check_password_hash(current_user.password_hash, current_password): + error = "当前密码不正确" + elif len(new_password) < 8: + error = "新密码至少需要 8 位" + elif new_password != confirm_password: + error = "两次输入的新密码不一致" + elif check_password_hash(current_user.password_hash, new_password): + error = "新密码不能与当前密码相同" + else: + current_user.password_hash = generate_password_hash(new_password) + db.session.commit() + _logout_user() + return redirect(url_for("login_page", notice="密码修改成功,请使用新密码重新登录")) + + if not error: + notice = "建议使用强密码(字母+数字+符号),并定期更换。" + + return render_template("change_password.html", error=error, notice=notice) + + @app.route("/") def index(): return redirect(url_for("types_page")) @@ -4602,6 +4808,7 @@ def bootstrap() -> None: db.create_all() ensure_schema() normalize_legacy_data() + _ensure_default_admin_user() bootstrap() diff --git a/static/css/style.css b/static/css/style.css index e31387a..f1eb52d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -555,6 +555,64 @@ body { background: color-mix(in srgb, var(--danger) 85%, #000 15%); } +.account-menu { + position: relative; +} + +.account-menu > summary { + list-style: none; +} + +.account-menu > summary::-webkit-details-marker { + display: none; +} + +.account-menu > summary::after { + content: "▾"; + margin-left: 8px; + font-size: 12px; + color: var(--muted); +} + +.account-menu[open] > summary::after { + content: "▴"; +} + +.account-menu-list { + position: absolute; + right: 0; + top: calc(100% + 8px); + min-width: 160px; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + padding: 6px; + z-index: 20; + display: grid; + gap: 4px; +} + +.account-menu-meta { + margin: 0; + padding: 4px 10px; + color: var(--muted); + font-size: 12px; +} + +.account-menu-item { + display: block; + color: var(--text); + text-decoration: none; + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 10px; +} + +.account-menu-item:hover { + background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%); +} + .box-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); @@ -1420,4 +1478,9 @@ th { .chart-row { grid-template-columns: 100px 1fr 52px; } + + .account-menu-list { + right: auto; + left: 0; + } } diff --git a/templates/_account_menu.html b/templates/_account_menu.html new file mode 100644 index 0000000..5986e53 --- /dev/null +++ b/templates/_account_menu.html @@ -0,0 +1,11 @@ +
+ 账号:{{ auth_username or '未登录' }} + +
diff --git a/templates/ai_settings.html b/templates/ai_settings.html index 48702cd..e6cbd6c 100644 --- a/templates/ai_settings.html +++ b/templates/ai_settings.html @@ -14,6 +14,7 @@
返回仓库概览 + {% include '_account_menu.html' %}
diff --git a/templates/box.html b/templates/box.html index 83f4fe5..77ba88b 100644 --- a/templates/box.html +++ b/templates/box.html @@ -22,6 +22,7 @@ 快速搜索 统计页 导出打标CSV + {% include '_account_menu.html' %} diff --git a/templates/change_password.html b/templates/change_password.html new file mode 100644 index 0000000..a59d370 --- /dev/null +++ b/templates/change_password.html @@ -0,0 +1,50 @@ + + + + + + 修改密码 + + + +
+
+

修改密码

+

当前用户:{{ auth_username }}

+
+ +
+ +
+
+ {% if error %} +

{{ error }}

+ {% endif %} + {% if notice %} +

{{ notice }}

+ {% endif %} + +
+ + + +
+ +
+
+
+
+ + diff --git a/templates/edit.html b/templates/edit.html index cf779d0..0cd6c37 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -16,6 +16,7 @@ 返回快速搜索 统计页 返回宫格 + {% include '_account_menu.html' %} diff --git a/templates/index.html b/templates/index.html index f60ec7c..8e1a5e1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,6 +17,7 @@ 快速搜索 新增库存 统计页 + {% include '_account_menu.html' %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..25d7624 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,42 @@ + + + + + + 系统登录 + + + +
+
+

库存系统登录

+

请输入用户名和密码后继续使用系统

+
+
+ +
+
+ {% if error %} +

{{ error }}

+ {% endif %} + {% if notice %} +

{{ notice }}

+ {% endif %} +
+ + + +
+ +
+
+
+
+ + diff --git a/templates/search.html b/templates/search.html index 81a1ed6..0417470 100644 --- a/templates/search.html +++ b/templates/search.html @@ -15,6 +15,7 @@ diff --git a/templates/stats.html b/templates/stats.html index 563ff9a..d8014c8 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -15,6 +15,7 @@ diff --git a/templates/type_edit.html b/templates/type_edit.html index f914f37..561ee01 100644 --- a/templates/type_edit.html +++ b/templates/type_edit.html @@ -14,6 +14,7 @@
返回仓库概览 + {% include '_account_menu.html' %}
diff --git a/templates/types.html b/templates/types.html index d02a915..41f221f 100644 --- a/templates/types.html +++ b/templates/types.html @@ -16,6 +16,7 @@ 添加容器 快速搜索 统计页 + {% include '_account_menu.html' %}