Files
inventory_analytics/upload_inventory_page.py
T

1060 lines
32 KiB
Python

#!/usr/bin/env python3
"""Local inventory file upload page.
Run:
python3 upload_inventory_page.py
Then open:
http://127.0.0.1:8080
"""
from __future__ import annotations
from dataclasses import dataclass
from email import policy
from email.parser import BytesParser
import json
import logging
from logging.handlers import RotatingFileHandler
from datetime import datetime
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import re
import sys
import time
from typing import Any, Iterable
from urllib.parse import parse_qs, urlparse
import pandas as pd
import psycopg2
from dotenv import load_dotenv
HOST = "127.0.0.1"
PORT = 8080
PROJECT_ROOT = Path(__file__).resolve().parent
UPLOAD_DIR = PROJECT_ROOT / "batch_inventory_data"
LOG_DIR = PROJECT_ROOT / "logs"
LOG_FILE = LOG_DIR / "upload_inventory_page.log"
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
DB_SCHEMA = "raw_data"
DB_TABLE = "wangdian_batch_inventory"
DATA_FIELD = "inventory_data"
LOGGER_NAME = "inventory_upload"
logger = logging.getLogger(LOGGER_NAME)
SENSITIVE_PATTERN = re.compile(r"(?i)(password|passwd|secret|token|authorization)\s*[:=]\s*([^\s,;]+)")
LOG_FIELD_LABELS = {
"advanced_permission_enabled": "高级权限已开启",
"app_token": "应用Token",
"bytes": "字节数",
"client": "客户端",
"columns": "列数",
"content_type": "内容类型",
"database": "数据库",
"db_schema": "数据库Schema",
"db_table": "数据库表",
"elapsed_ms": "耗时毫秒",
"encoding": "编码",
"env_path": "环境文件",
"error": "错误",
"field_name": "字段名",
"host": "主机",
"is_error": "是否错误",
"max_bytes": "最大字节数",
"max_upload_bytes": "最大上传字节数",
"message": "消息",
"log_file": "日志文件",
"original_filename": "原始文件名",
"path": "路径",
"port": "端口",
"rows": "行数",
"payload": "写入内容",
"schema": "Schema",
"sheet_name": "工作表",
"sheets": "工作表数",
"source_file": "源文件",
"stored_file": "归档文件",
"stored_filename": "归档文件名",
"suffix": "文件后缀",
"table": "表名",
"target_path": "目标路径",
"upload_dir": "上传目录",
"url": "地址",
}
def configure_logging() -> None:
LOG_DIR.mkdir(parents=True, exist_ok=True)
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8")
file_handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(stream_handler)
root_logger.addHandler(file_handler)
def sanitize_value(value: Any) -> Any:
if isinstance(value, str):
return SENSITIVE_PATTERN.sub(r"\1=***", value)
return value
def log_event(level: int, event: str, **fields: Any) -> None:
safe_event = sanitize_value(event)
details = " ".join(
f"{LOG_FIELD_LABELS.get(key, key)}={sanitize_value(value)!r}" for key, value in fields.items()
)
logger.log(level, "事件=%s%s", safe_event, f" {details}" if details else "")
@dataclass(frozen=True)
class UploadedFile:
filename: str
content: bytes
@property
def size(self) -> int:
return len(self.content)
@dataclass(frozen=True)
class RawDbConfig:
host: str
port: str
user: str
password: str
name: str
@classmethod
def from_env(cls) -> "RawDbConfig":
env_path = PROJECT_ROOT / ".env"
load_dotenv(env_path)
values = {
"host": os.getenv("RAW_DB_HOST", ""),
"port": os.getenv("RAW_DB_PORT", ""),
"user": os.getenv("RAW_DB_USER", ""),
"password": os.getenv("RAW_DB_PASSWORD", ""),
"name": os.getenv("RAW_DB_NAME", ""),
}
missing = [f"RAW_DB_{key.upper()}" for key, value in values.items() if not value]
if missing:
raise RuntimeError(f"缺少 Raw 数据库配置: {', '.join(missing)}")
log_event(
logging.INFO,
"已加载Raw数据库配置",
env_path=env_path,
database=values["name"],
)
return cls(**values)
def connect(self):
log_event(
logging.INFO,
"开始连接Raw数据库",
database=self.name,
)
return psycopg2.connect(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
dbname=self.name,
)
def get_create_table_sql(schema_name: str, table_name: str) -> str:
full_table_name = f"{schema_name}.{table_name}"
return f"""
CREATE SCHEMA IF NOT EXISTS {schema_name};
CREATE TABLE IF NOT EXISTS {full_table_name} (
payload JSONB NOT NULL,
start_time TIMESTAMP WITH TIME ZONE,
end_time TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
reserved_1 TEXT,
reserved_2 TEXT
);
CREATE INDEX IF NOT EXISTS idx_{table_name}_created_at ON {full_table_name} (created_at);
"""
def safe_filename(filename: str) -> str:
name = Path(filename or "inventory_file").name.strip()
name = re.sub(r"[^\w.\-\u4e00-\u9fff()() ]+", "_", name)
name = re.sub(r"\s+", "_", name)
return name or "inventory_file"
def unique_upload_path(filename: str) -> Path:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = safe_filename(filename)
candidate = UPLOAD_DIR / f"{timestamp}_{base_name}"
counter = 1
while candidate.exists():
candidate = UPLOAD_DIR / f"{timestamp}_{counter}_{base_name}"
counter += 1
log_event(
logging.INFO,
"已分配上传归档路径",
original_filename=filename,
stored_filename=candidate.name,
target_path=candidate,
)
return candidate
def parse_inventory_upload(headers: Any, body: bytes) -> UploadedFile | None:
content_type = headers.get("Content-Type", "")
if "multipart/form-data" not in content_type:
log_event(logging.WARNING, "上传请求内容类型不支持", content_type=content_type)
return None
message = BytesParser(policy=policy.default).parsebytes(
b"Content-Type: " + content_type.encode("utf-8") + b"\r\n"
b"MIME-Version: 1.0\r\n\r\n"
+ body
)
for part in message.iter_parts():
if part.get_param("name", header="content-disposition") != "inventory_file":
continue
filename = part.get_filename()
content = part.get_payload(decode=True)
if filename and content is not None:
upload = UploadedFile(filename=filename, content=content)
log_event(
logging.INFO,
"已解析上传文件",
filename=upload.filename,
bytes=upload.size,
content_type=part.get_content_type(),
)
return upload
log_event(logging.WARNING, "上传表单缺少文件字段", field_name="inventory_file")
return None
def normalize_cell(value: Any) -> Any:
if pd.isna(value):
return None
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def dataframe_to_rows(frame: pd.DataFrame, sheet_name: str | None = None) -> list[dict[str, Any]]:
frame = frame.dropna(how="all")
rows: list[dict[str, Any]] = []
for record in frame.to_dict(orient="records"):
row = {str(key): normalize_cell(value) for key, value in record.items()}
if sheet_name is not None:
row["_sheet_name"] = sheet_name
rows.append(row)
return rows
def read_inventory_rows(file_path: Path) -> list[dict[str, Any]]:
suffix = file_path.suffix.lower()
log_event(logging.INFO, "开始解析库存文件", path=file_path, suffix=suffix)
if suffix in {".xlsx", ".xlsm", ".xls"}:
sheets = pd.read_excel(file_path, sheet_name=None, dtype=object)
include_sheet_name = len(sheets) > 1
rows: list[dict[str, Any]] = []
for sheet_name, frame in sheets.items():
sheet_rows = dataframe_to_rows(frame, sheet_name if include_sheet_name else None)
rows.extend(sheet_rows)
log_event(
logging.INFO,
"已解析Excel工作表",
path=file_path.name,
sheet_name=sheet_name,
rows=len(sheet_rows),
columns=len(frame.columns),
)
log_event(logging.INFO, "库存文件解析完成", path=file_path.name, rows=len(rows), sheets=len(sheets))
return rows
if suffix == ".csv":
last_error: Exception | None = None
for encoding in ("utf-8-sig", "gb18030"):
try:
frame = pd.read_csv(file_path, dtype=object, encoding=encoding)
rows = dataframe_to_rows(frame)
log_event(
logging.INFO,
"已解析CSV文件",
path=file_path.name,
rows=len(rows),
columns=len(frame.columns),
encoding=encoding,
)
return rows
except UnicodeDecodeError as exc:
last_error = exc
log_event(logging.WARNING, "CSV编码解析失败", path=file_path.name, encoding=encoding, error=exc)
if last_error is not None:
raise last_error
raise ValueError("仅支持 Excel 或 CSV 文件写入数据库。")
def save_inventory_file_to_raw_db(file_path: Path, original_filename: str) -> int:
started_at = time.monotonic()
rows = read_inventory_rows(file_path)
if not rows:
log_event(logging.WARNING, "文件无数据跳过Raw写入", path=file_path.name)
return 0
config = RawDbConfig.from_env()
conn = config.connect()
try:
with conn:
with conn.cursor() as cur:
log_event(logging.INFO, "开始确认Raw数据表", schema=DB_SCHEMA, table=DB_TABLE)
cur.execute(get_create_table_sql(DB_SCHEMA, DB_TABLE))
log_event(logging.INFO, "Raw数据表确认完成", schema=DB_SCHEMA, table=DB_TABLE)
upload_time = datetime.now().isoformat(timespec="seconds")
total_count = len(rows)
log_event(
logging.INFO,
"开始写入Raw数据",
schema=DB_SCHEMA,
table=DB_TABLE,
source_file=original_filename,
stored_file=file_path.name,
rows=total_count,
)
for item in rows:
payload = {
"source_file": original_filename,
"stored_file": file_path.name,
"uploaded_at": upload_time,
"total_count": total_count,
DATA_FIELD: item,
}
log_event(
logging.INFO,
"写入Raw数据库单行数据",
stored_file=file_path.name,
payload=json.dumps(payload, ensure_ascii=False),
)
cur.execute(
f"""
INSERT INTO {DB_SCHEMA}.{DB_TABLE} (
payload, start_time, end_time
) VALUES (
%s::jsonb, %s, %s
);
""",
(json.dumps(payload, ensure_ascii=False), None, None),
)
elapsed_ms = int((time.monotonic() - started_at) * 1000)
log_event(
logging.INFO,
"Raw数据写入完成",
schema=DB_SCHEMA,
table=DB_TABLE,
stored_file=file_path.name,
rows=len(rows),
elapsed_ms=elapsed_ms,
)
return len(rows)
finally:
conn.close()
log_event(logging.INFO, "Raw数据库连接已关闭", schema=DB_SCHEMA, table=DB_TABLE)
def list_uploaded_files(limit: int = 20) -> list[Path]:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
files = [path for path in UPLOAD_DIR.iterdir() if path.is_file() and not path.name.startswith(".")]
return sorted(files, key=lambda item: item.stat().st_mtime, reverse=True)[:limit]
def human_size(size: int) -> str:
units = ["B", "KB", "MB", "GB"]
value = float(size)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}"
value /= 1024
return f"{size} B"
def render_recent_files(files: Iterable[Path]) -> str:
rows = []
for path in files:
stat = path.stat()
uploaded_at = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
rows.append(
"<tr>"
f"<td>{escape(path.name)}</td>"
f"<td>{escape(human_size(stat.st_size))}</td>"
f"<td>{escape(uploaded_at)}</td>"
"</tr>"
)
if not rows:
return '<p class="empty">还没有上传文件。上传成功后,最近文件会显示在这里。</p>'
return (
'<table class="file-table">'
"<thead><tr><th>文件名</th><th>大小</th><th>上传时间</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody>"
"</table>"
)
def render_page(message: str = "", is_error: bool = False) -> bytes:
message_html = ""
if message:
message_class = "message error" if is_error else "message success"
message_html = f'<div class="{message_class}">{escape(message)}</div>'
html = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>委外批次库存文件上传</title>
<style>
:root {{
--ink: #101828;
--muted: #667085;
--line: #e4e7ec;
--surface: #ffffff;
--soft: #f7f4ec;
--dark: #172033;
--accent: #087f5b;
--accent-2: #b7791f;
--accent-soft: #ecfdf3;
--danger: #b42318;
--shadow: 0 24px 70px rgba(16, 24, 40, .14);
}}
* {{
box-sizing: border-box;
}}
body {{
min-height: 100vh;
margin: 0;
color: var(--ink);
font-family: "Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at 8% 8%, rgba(183, 121, 31, .16), transparent 24rem),
radial-gradient(circle at 92% 6%, rgba(8, 127, 91, .14), transparent 26rem),
linear-gradient(180deg, #f8fafc 0%, #f3f0e8 100%);
}}
main {{
width: min(1180px, calc(100% - 36px));
margin: 0 auto;
padding: 28px 0 48px;
}}
.shell {{
overflow: hidden;
border: 1px solid rgba(228, 231, 236, .92);
border-radius: 34px;
background: rgba(255, 255, 255, .74);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}}
.header {{
position: relative;
padding: 20px 26px;
color: #fff;
background:
linear-gradient(135deg, rgba(23, 32, 51, .98), rgba(15, 68, 65, .95)),
radial-gradient(circle at 88% 20%, rgba(183, 121, 31, .42), transparent 18rem);
}}
.header::after {{
content: "";
position: absolute;
right: -70px;
bottom: -150px;
width: 210px;
height: 210px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 50%;
box-shadow: -42px -26px 0 rgba(255, 255, 255, .05);
}}
.nav {{
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}}
.brand {{
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
font-weight: 900;
letter-spacing: .08em;
}}
.brand-icon {{
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 10px;
color: var(--dark);
background: #f2c46d;
font-weight: 900;
}}
.usage {{
padding: 7px 11px;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 999px;
color: rgba(255, 255, 255, .78);
font-size: 13px;
font-weight: 800;
}}
.title-row {{
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
gap: 18px;
align-items: center;
}}
h1 {{
max-width: 760px;
margin: 0;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.04;
letter-spacing: -1.5px;
}}
.subtitle {{
max-width: 720px;
margin: 10px 0 0;
color: rgba(255, 255, 255, .74);
font-size: 15px;
line-height: 1.58;
}}
.storage-card {{
width: min(330px, 100%);
padding: 13px 14px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 16px;
background: rgba(255, 255, 255, .09);
}}
.storage-card span {{
display: block;
margin-bottom: 5px;
color: rgba(255, 255, 255, .58);
font-size: 12px;
font-weight: 900;
letter-spacing: .12em;
}}
.storage-card strong {{
display: block;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.4;
}}
.workspace {{
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr);
gap: 24px;
padding: 26px;
background:
linear-gradient(180deg, rgba(248, 250, 252, .96), rgba(247, 244, 236, .96));
}}
.drop-panel, .side-panel, .recent {{
border: 1px solid var(--line);
border-radius: 28px;
background: var(--surface);
box-shadow: 0 16px 44px rgba(16, 24, 40, .08);
}}
.drop-panel {{
padding: 18px;
}}
.message {{
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 16px;
font-weight: 800;
}}
.success {{
color: #05603a;
background: #ecfdf3;
border: 1px solid #abefc6;
}}
.error {{
color: var(--danger);
background: #fef3f2;
border: 1px solid #fecdca;
}}
.dropzone {{
display: grid;
place-items: center;
min-height: 470px;
padding: 44px;
border: 2px dashed #98a2b3;
border-radius: 24px;
text-align: center;
background:
linear-gradient(#fff, #fff) padding-box,
repeating-linear-gradient(45deg, rgba(8, 127, 91, .08) 0 10px, rgba(183, 121, 31, .08) 10px 20px);
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease, background .18s ease;
}}
.dropzone.dragging {{
border-color: var(--accent);
background:
linear-gradient(#f0fdf4, #ffffff) padding-box,
repeating-linear-gradient(45deg, rgba(8, 127, 91, .16) 0 10px, rgba(183, 121, 31, .10) 10px 20px);
box-shadow: inset 0 0 0 8px rgba(8, 127, 91, .08);
transform: scale(.992);
}}
.drop-icon {{
display: grid;
place-items: center;
width: 108px;
height: 108px;
margin: 0 auto 26px;
border-radius: 32px;
color: #fff;
background:
linear-gradient(135deg, var(--accent), #0f5f56 62%, var(--accent-2));
box-shadow: 0 18px 40px rgba(8, 127, 91, .28);
font-size: 48px;
font-weight: 900;
}}
.drop-kicker {{
margin-bottom: 12px;
color: var(--accent-2);
font-size: 12px;
font-weight: 900;
letter-spacing: .18em;
}}
.dropzone h2 {{
margin: 0;
font-size: clamp(30px, 4vw, 48px);
line-height: 1.05;
letter-spacing: -1.6px;
}}
.dropzone p {{
max-width: 520px;
margin: 16px auto 0;
color: var(--muted);
font-size: 16px;
line-height: 1.75;
}}
.drop-status {{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 220px;
margin-top: 28px;
padding: 13px 18px;
border-radius: 999px;
color: #344054;
background: #f9fafb;
border: 1px solid var(--line);
font-weight: 900;
}}
.dropzone.dragging .drop-status {{
color: #05603a;
background: #dcfae6;
border-color: #75e0a7;
}}
.side-panel {{
padding: 24px;
}}
.side-panel h2, .recent h2 {{
margin: 0 0 16px;
font-size: 22px;
letter-spacing: -.4px;
}}
.rule-list {{
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}}
.rule-list li {{
display: grid;
grid-template-columns: 34px 1fr;
gap: 12px;
align-items: start;
padding: 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: #fcfcfd;
}}
.rule-index {{
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 12px;
color: #fff;
background: var(--dark);
font-size: 13px;
font-weight: 900;
}}
.rule-list strong {{
display: block;
margin-bottom: 4px;
font-size: 15px;
}}
.rule-list span {{
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}}
.recent {{
grid-column: 1 / -1;
padding: 24px;
}}
.file-table {{
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 18px;
background: #fff;
}}
th, td {{
padding: 14px 16px;
border-bottom: 1px solid var(--line);
text-align: left;
font-size: 14px;
}}
th {{
color: #344054;
background: #f9fafb;
font-size: 12px;
letter-spacing: .08em;
}}
.empty {{
margin: 0;
color: var(--muted);
}}
@media (max-width: 900px) {{
main {{
width: min(100% - 24px, 760px);
padding: 22px 0 36px;
}}
.header {{
padding: 20px;
}}
.nav, .title-row {{
display: grid;
grid-template-columns: 1fr;
}}
.nav {{
margin-bottom: 16px;
}}
.workspace {{
grid-template-columns: 1fr;
padding: 18px;
}}
.dropzone {{
min-height: 380px;
padding: 28px;
}}
}}
</style>
</head>
<body>
<main>
<div class="shell">
<section class="header">
<div class="nav">
<div class="brand"><span class="brand-icon">委</span><span>委外批次库存上传台</span></div>
<div class="usage">每周约 2 次使用</div>
</div>
<div class="title-row">
<div>
<h1>投递委外批次库存文件</h1>
<p class="subtitle">拖入委外仓、加工方或批次盘点文件,系统会按上传时间自动归档,便于后续同步、核对和追溯。</p>
</div>
<div class="storage-card">
<span>ARCHIVE PATH</span>
<strong>{escape(str(UPLOAD_DIR))}</strong>
</div>
</div>
</section>
<section class="workspace">
<div class="drop-panel">
{message_html}
<div class="dropzone" id="dropzone">
<div>
<div class="drop-icon">⇣</div>
<div class="drop-kicker">DROP FILE HERE</div>
<h2>拖入文件,立即上传</h2>
<p>支持 Excel、CSV 等委外批次库存文件。松开鼠标后自动保存到 batch_inventory_data,无需点击选择文件。</p>
<div class="drop-status" id="dropStatus">等待拖入文件</div>
</div>
</div>
</div>
<aside class="side-panel">
<h2>上传规范</h2>
<ul class="rule-list">
<li><span class="rule-index">01</span><div><strong>文件类型</strong><span>建议上传 Excel 或 CSV 格式的委外批次库存文件。</span></div></li>
<li><span class="rule-index">02</span><div><strong>文件命名</strong><span>系统会自动追加上传时间,不需要手动改名。</span></div></li>
<li><span class="rule-index">03</span><div><strong>大小限制</strong><span>单文件最大 {escape(human_size(MAX_UPLOAD_BYTES))}。</span></div></li>
<li><span class="rule-index">04</span><div><strong>保存位置</strong><span>上传后统一进入 batch_inventory_data 目录。</span></div></li>
</ul>
</aside>
<section class="recent">
<h2>最近上传的委外批次文件</h2>
{render_recent_files(list_uploaded_files())}
</section>
</section>
</div>
</main>
<script>
const dropzone = document.getElementById("dropzone");
const dropStatus = document.getElementById("dropStatus");
function stopDefaults(event) {{
event.preventDefault();
event.stopPropagation();
}}
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {{
document.addEventListener(eventName, stopDefaults);
}});
["dragenter", "dragover"].forEach((eventName) => {{
dropzone.addEventListener(eventName, () => {{
dropzone.classList.add("dragging");
dropStatus.textContent = "松开鼠标开始上传";
}});
}});
["dragleave", "drop"].forEach((eventName) => {{
dropzone.addEventListener(eventName, () => {{
dropzone.classList.remove("dragging");
}});
}});
dropzone.addEventListener("drop", async (event) => {{
const files = event.dataTransfer && event.dataTransfer.files;
if (!files || !files.length) {{
dropStatus.textContent = "没有检测到文件,请重新拖入。";
return;
}}
dropStatus.textContent = `上传中:${{files[0].name}}`;
const formData = new FormData();
formData.append("inventory_file", files[0], files[0].name);
try {{
const response = await fetch("/upload", {{
method: "POST",
body: formData,
}});
window.location.href = response.url || "/";
}} catch (error) {{
dropStatus.textContent = "上传失败,请重试。";
}}
}});
</script>
</body>
</html>
"""
return html.encode("utf-8")
class UploadHandler(BaseHTTPRequestHandler):
server_version = "InventoryUpload/1.0"
def do_GET(self) -> None:
parsed = urlparse(self.path)
log_event(logging.INFO, "收到GET请求", client=self.client_address[0], path=parsed.path)
if parsed.path == "/favicon.ico":
self.send_response(204)
self.end_headers()
return
if parsed.path != "/":
log_event(logging.WARNING, "请求路径不存在", client=self.client_address[0], path=parsed.path)
self.send_error(404, "Not Found")
return
params = parse_qs(parsed.query)
message = params.get("message", [""])[0]
is_error = params.get("error", ["0"])[0] == "1"
self.respond_html(render_page(message, is_error))
def do_POST(self) -> None:
path = urlparse(self.path).path
request_started_at = time.monotonic()
log_event(logging.INFO, "开始处理POST请求", client=self.client_address[0], path=path)
if path != "/upload":
log_event(logging.WARNING, "请求路径不存在", client=self.client_address[0], path=path)
self.send_error(404, "Not Found")
return
content_length = int(self.headers.get("Content-Length", "0") or "0")
log_event(logging.INFO, "收到上传请求", client=self.client_address[0], bytes=content_length)
if content_length <= 0:
log_event(logging.WARNING, "上传请求为空", client=self.client_address[0])
self.redirect("/", "没有收到上传文件。", is_error=True)
return
if content_length > MAX_UPLOAD_BYTES:
log_event(
logging.WARNING,
"上传文件超过大小限制",
client=self.client_address[0],
bytes=content_length,
max_bytes=MAX_UPLOAD_BYTES,
)
self.redirect("/", f"文件太大,最大支持 {human_size(MAX_UPLOAD_BYTES)}。", is_error=True)
return
upload = parse_inventory_upload(self.headers, self.rfile.read(content_length))
if upload is None:
self.redirect("/", "请选择需要上传的文件。", is_error=True)
return
target_path = unique_upload_path(upload.filename)
with target_path.open("wb") as output:
output.write(upload.content)
log_event(
logging.INFO,
"上传文件已保存",
original_filename=upload.filename,
stored_file=target_path.name,
path=target_path,
bytes=upload.size,
)
try:
saved_rows = save_inventory_file_to_raw_db(target_path, upload.filename)
except Exception as exc:
log_event(
logging.ERROR,
"上传文件写入Raw数据库失败",
stored_file=target_path.name,
error=exc,
elapsed_ms=int((time.monotonic() - request_started_at) * 1000),
)
self.redirect("/", f"文件已保存,但写入数据库失败:{exc}", is_error=True)
return
log_event(
logging.INFO,
"上传请求处理完成",
original_filename=upload.filename,
stored_file=target_path.name,
rows=saved_rows,
elapsed_ms=int((time.monotonic() - request_started_at) * 1000),
)
self.redirect("/", f"上传成功:{target_path.name},已写入 {saved_rows} 行到 {DB_SCHEMA}.{DB_TABLE}")
def respond_html(self, body: bytes) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def redirect(self, path: str, message: str, is_error: bool = False) -> None:
from urllib.parse import urlencode
log_event(logging.INFO, "页面重定向", path=path, is_error=is_error, message=message)
query = urlencode({"message": message, "error": "1" if is_error else "0"})
self.send_response(303)
self.send_header("Location", f"{path}?{query}")
self.end_headers()
def log_message(self, format: str, *args: object) -> None:
log_event(logging.INFO, "HTTP服务日志", client=self.address_string(), message=format % args)
def main() -> int:
configure_logging()
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
log_event(
logging.INFO,
"上传服务启动中",
host=HOST,
port=PORT,
upload_dir=UPLOAD_DIR,
max_upload_bytes=MAX_UPLOAD_BYTES,
db_schema=DB_SCHEMA,
db_table=DB_TABLE,
log_file=LOG_FILE,
)
server = ThreadingHTTPServer((HOST, PORT), UploadHandler)
log_event(logging.INFO, "上传服务已启动", url=f"http://{HOST}:{PORT}", upload_dir=UPLOAD_DIR, log_file=LOG_FILE)
try:
server.serve_forever()
except KeyboardInterrupt:
log_event(logging.INFO, "收到键盘中断准备停止服务")
finally:
server.server_close()
log_event(logging.INFO, "上传服务已停止", host=HOST, port=PORT)
return 0
if __name__ == "__main__":
raise SystemExit(main())