Dev qing #9
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# micro_servers
|
||||
|
||||
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python 与 Go 混合实现。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
|
||||
|
||||
## 架构总览
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
│ waf │ │ gateway │ │ static │ │ dnfs │ │ db │ │ ai_review │
|
||||
│ WAF 网关 │ │ 应用层网关 │ │前端托管│ │ 树状菜单 │ │ 通用数据库 │ │ AI内容审核 │
|
||||
│ :443 (入口) │ │ :8443 (内部) │ │ :9200 │ │ :8006 │ │ :8005 │ │ :8007 │
|
||||
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL-over-HTTP│ │ DeepSeek+RAG │
|
||||
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL+日志收集 │ │ DeepSeek+RAG │
|
||||
└──────┬───────┘ └──────┬───────┘ └────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
|
||||
│ HTTPS终止 │ 反向代理 │ 调用
|
||||
└──────────────────►│ ├──────────► db:8005
|
||||
├────────────────► static:9200
|
||||
└────────────────► dnfs:8006 ──► ai_review:8007
|
||||
|
||||
日志链路: waf / gateway / static / dnfs / ai_review / captcha ──(log_client)──► db:8005/log/ingest
|
||||
```
|
||||
|
||||
### 请求链路
|
||||
@@ -34,6 +36,7 @@
|
||||
4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
|
||||
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
|
||||
6. `dnfs` 处理节点增删改查时,通过 HTTP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
|
||||
7. 各服务通过 `log_client.py` SDK 异步批量推送运行日志到 `db:8005/log/ingest`,实现集中式日志收集(fail-safe,db 不可用时静默丢弃)。
|
||||
|
||||
## 各服务说明
|
||||
|
||||
@@ -44,7 +47,7 @@
|
||||
| [gateway](gateway/) | C++20 | 8443 | 应用层网关(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、反向代理到内部服务 |
|
||||
| [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 |
|
||||
| [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 |
|
||||
| [db](db/) | Python(Flask) | 8005 | 通用数据库服务,基于 SQLAlchemy Core 的 SQL-over-HTTP,支持 select/insert/update/delete,参数化查询防注入 |
|
||||
| [db](db/) | Python(Flask) | 8005 | 通用数据库服务,基于 SQLAlchemy Core 的 SQL-over-HTTP,支持 select/insert/update/delete,参数化查询防注入;内置集中式日志收集(/log/* 路由) |
|
||||
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||
|
||||
### core(核心模块 · 进程托管服务)
|
||||
@@ -98,6 +101,15 @@
|
||||
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
|
||||
- 仅监听 127.0.0.1,单线程,自动回滚,不暴露 DDL 操作。
|
||||
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
|
||||
- **内置日志子模块**([log_module.py](db/log_module.py)):
|
||||
- `log_internal()` — db 进程内部直接写 Logs 表,无 HTTP 往返,防递归设计。
|
||||
- `/log/ingest` — 接收外部服务批量推送日志(单次上限 100 条,校验 service / level / message)。
|
||||
- `/log/query` — 结构化查询(service / level / trace_id / 时间区间 / 关键字模糊搜索 / 排序分页)。
|
||||
- `/log/stats` — 按 service / level 分组统计。
|
||||
- `/log/purge` — 手动清理指定时间之前的日志。
|
||||
- `/log/health` — 健康检查。
|
||||
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
|
||||
- **统一日志客户端 SDK**([log_client.py](db/log_client.py)):各服务复制即用,纯标准库实现(urllib),异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
|
||||
|
||||
### ai_review(AI 内容审核服务)
|
||||
|
||||
@@ -116,6 +128,13 @@
|
||||
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
|
||||
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
|
||||
|
||||
### Python 共享组件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [log_client.py](db/log_client.py) | 统一日志客户端 SDK,各服务复制即用(纯标准库,异步批量推送) |
|
||||
| [security_middleware.py](db/security_middleware.py) | Flask 安全响应头中间件(CSP / X-Frame-Options / nosniff 等),各服务各持一份副本 |
|
||||
|
||||
## 端口一览
|
||||
|
||||
| 端口 | 服务 | 绑定地址 | 说明 |
|
||||
@@ -207,6 +226,7 @@ gateway 改为内部 HTTP 模式([example.ini](gateway/example.ini) 中 `tls =
|
||||
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)混合,通过 HTTP 通信。
|
||||
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 HTTP 接口,业务服务无需直连数据库。
|
||||
- **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。
|
||||
- **集中式可观测性**:`db` 内置日志收集服务(/log/* 路由),各服务通过 `log_client.py` SDK 异步推送日志,实现全链路汇聚、查询与统计,无需引入额外中间件。
|
||||
- **安全分层**:`waf` 在边缘做规则匹配(OWASP CRS / 自定义 SecLang 规则),`gateway` 在应用层做鉴权,`db` 仅监听本机,多层防护。
|
||||
|
||||
与云原生微服务的差异:未引入服务注册发现、容器编排、消息总线,服务地址在 `waf/config.yaml` 与 `gateway/example.ini` 中静态配置。对于个人/中小规模的 DIY 场景,这种取舍在简洁性与可控性上是合理的。
|
||||
|
||||
@@ -34,4 +34,5 @@ inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 w
|
||||
inst_module "static"
|
||||
inst_module "dnfs"
|
||||
inst_module "db"
|
||||
inst_module "smtp"
|
||||
inst_module "core" # core last
|
||||
Regular → Executable
File mode changed.
+5
-1
@@ -1,7 +1,11 @@
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/db/_run.sh
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/dnfs/_run.sh
|
||||
bash /opt/micro_servers/smtp/_run.sh
|
||||
bash /opt/micro_servers/static/_run.sh
|
||||
bash /opt/micro_servers/waf/_run.sh
|
||||
bash /opt/micro_servers/gateway/_run.sh
|
||||
bash /opt/micro_servers/captcha/_run.sh
|
||||
#
|
||||
#ssh-copy-id user@your-server
|
||||
#ssh -D 1080 -N user@your-server
|
||||
+14
-13
@@ -279,20 +279,21 @@ try {
|
||||
return;
|
||||
}
|
||||
|
||||
else if (typ == "kill") {
|
||||
int trg = jv.at("kill").as_int64();
|
||||
auto flag = pmc_mtd::kill(trg);
|
||||
std::string msg = flag ? "OK" : "Nope";
|
||||
pipe.writePipe(msg);
|
||||
return;
|
||||
}
|
||||
/* 20260727(qing): 在身份验证之前禁止操作 */
|
||||
//else if (typ == "kill") {
|
||||
// int trg = jv.at("kill").as_int64();
|
||||
// auto flag = pmc_mtd::kill(trg);
|
||||
// std::string msg = flag ? "OK" : "Nope";
|
||||
// pipe.writePipe(msg);
|
||||
// return;
|
||||
//}
|
||||
|
||||
else if (typ == "exec"){
|
||||
std::string cmd = jv.at("exec").as_string().c_str();
|
||||
std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
|
||||
pipe.writePipe(msg);
|
||||
return;
|
||||
}
|
||||
//else if (typ == "exec"){
|
||||
// std::string cmd = jv.at("exec").as_string().c_str();
|
||||
// std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
|
||||
// pipe.writePipe(msg);
|
||||
// return;
|
||||
//}
|
||||
}
|
||||
|
||||
/* 捕获异常后,仅仅只是打印出来? */
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./venv/bin/activate
|
||||
#exec ./main.dist/main.bin
|
||||
exec python3 main.py
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./venv/bin/activate
|
||||
#exec ./main.dist/main.bin
|
||||
exec python3 dbv2.py
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#nuitka --standalone main.py
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#nuitka --standalone main.py
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""查看 database.db 各表数据 (非交互式)"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "database.db")
|
||||
|
||||
|
||||
def get_tables(cur):
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def print_rows(cols, rows):
|
||||
if not rows:
|
||||
print("(空)")
|
||||
return
|
||||
widths = [len(c) for c in cols]
|
||||
str_rows = []
|
||||
for row in rows:
|
||||
str_row = [str(v) if v is not None else "NULL" for v in row]
|
||||
str_rows.append(str_row)
|
||||
for i, v in enumerate(str_row):
|
||||
widths[i] = max(widths[i], min(len(v), 50))
|
||||
print(" | ".join(c.ljust(widths[i]) for i, c in enumerate(cols)))
|
||||
print("-+-".join("-" * w for w in widths))
|
||||
for str_row in str_rows:
|
||||
print(" | ".join(v[:50].ljust(widths[i]) for i, v in enumerate(str_row)))
|
||||
print(f"\n({len(rows)} 行)")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="查看 database.db")
|
||||
parser.add_argument("table", nargs="?", help="要查看的表名 (不指定则列出所有表)")
|
||||
parser.add_argument("-n", "--limit", type=int, default=50, help="最多显示行数 (默认 50)")
|
||||
parser.add_argument("-w", "--where", help="WHERE 条件, 如: \"level='ERROR'\"")
|
||||
parser.add_argument("-s", "--sql", help="自由 SQL 查询 (忽略 table/where/limit)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"数据库不存在: {DB_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
tables = get_tables(cur)
|
||||
|
||||
# 自由 SQL
|
||||
if args.sql:
|
||||
cur.execute(args.sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
print_rows(cols, cur.fetchall())
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 列出所有表
|
||||
if not args.table:
|
||||
print(f"数据库: {DB_PATH}\n")
|
||||
for t in tables:
|
||||
cur.execute(f"SELECT COUNT(*) FROM [{t}]")
|
||||
print(f" {t} ({cur.fetchone()[0]} 行)")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 查看指定表
|
||||
if args.table not in tables:
|
||||
print(f"表 '{args.table}' 不存在, 可用: {', '.join(tables)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sql = f"SELECT * FROM [{args.table}]"
|
||||
if args.where:
|
||||
sql += f" WHERE {args.where}"
|
||||
sql += f" LIMIT {args.limit}"
|
||||
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
print_rows(cols, cur.fetchall())
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
|
||||
|
||||
替代 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
|
||||
协议:每条消息为完整 JSON,新增 ``_route`` 字段对应原 HTTP 路由路径。
|
||||
|
||||
请求示例::
|
||||
|
||||
{
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"where": {"age": {"gt": 20}},
|
||||
"order_by": [{"column": "id", "desc": true}],
|
||||
"limit": 10
|
||||
}
|
||||
|
||||
支持的路由::
|
||||
|
||||
execute → 通用 CRUD(select / insert / update / delete)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine, MetaData, Table,
|
||||
select, insert, update, delete, func, and_,
|
||||
)
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
|
||||
# ── 数据库配置 ──────────────────────────────────────────────────────────
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
|
||||
|
||||
# ── 日志模块常量 ────────────────────────────────────────────────────────
|
||||
|
||||
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
|
||||
MAX_EXTRA_LEN = 4096 # extra 上限 4KB
|
||||
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_MAX_RECORDS = 1_000_000
|
||||
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# DBServer — 主服务类
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class DBServer:
|
||||
"""基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8005):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self.engine = create_engine(DATABASE_URL)
|
||||
self.metadata = MetaData()
|
||||
self._server = MsgServer(addr, port)
|
||||
|
||||
# 日志表缓存(避免重复反射)
|
||||
self._logs_table_cache = {}
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"execute": self._handle_execute,
|
||||
}
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
|
||||
self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前)
|
||||
self._start_cleanup_thread()
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req, sock=sock)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str))
|
||||
|
||||
# ── 表反射 ---------------------------------------------------------
|
||||
def _get_table(self, table_name):
|
||||
if table_name not in self.metadata.tables:
|
||||
Table(table_name, self.metadata, autoload_with=self.engine)
|
||||
return self.metadata.tables[table_name]
|
||||
|
||||
def _build_where(self, table, conditions):
|
||||
if not conditions:
|
||||
return None
|
||||
clauses = []
|
||||
for col, val in conditions.items():
|
||||
column = table.columns[col]
|
||||
if isinstance(val, dict):
|
||||
if "eq" in val:
|
||||
clauses.append(column == val["eq"])
|
||||
if "ne" in val:
|
||||
clauses.append(column != val["ne"])
|
||||
if "gt" in val:
|
||||
clauses.append(column > val["gt"])
|
||||
if "lt" in val:
|
||||
clauses.append(column < val["lt"])
|
||||
if "ge" in val:
|
||||
clauses.append(column >= val["ge"])
|
||||
if "le" in val:
|
||||
clauses.append(column <= val["le"])
|
||||
if "like" in val:
|
||||
clauses.append(column.like(val["like"]))
|
||||
if "in" in val:
|
||||
clauses.append(column.in_(val["in"]))
|
||||
else:
|
||||
clauses.append(column == val)
|
||||
return and_(*clauses)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理 — execute
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_execute(self, data, **kw):
|
||||
action = data.get("action", "select")
|
||||
table_name = data.get("table")
|
||||
if not table_name:
|
||||
raise ValueError("缺少 table 字段")
|
||||
|
||||
try:
|
||||
table = self._get_table(table_name)
|
||||
except Exception as e:
|
||||
raise ValueError(f"表加载失败: {e}")
|
||||
|
||||
with self.engine.connect() as conn:
|
||||
if action == "select":
|
||||
return self._do_select(conn, table, data)
|
||||
elif action == "insert":
|
||||
return self._do_insert(conn, table, data)
|
||||
elif action == "update":
|
||||
return self._do_update(conn, table, data)
|
||||
elif action == "delete":
|
||||
return self._do_delete(conn, table, data)
|
||||
else:
|
||||
raise ValueError(f"不支持的操作: {action}")
|
||||
|
||||
def _do_select(self, conn, table, data):
|
||||
columns = data.get("columns")
|
||||
if columns:
|
||||
cols = [table.columns[c] for c in columns]
|
||||
else:
|
||||
cols = [table]
|
||||
|
||||
stmt = select(*cols)
|
||||
where_cond = self._build_where(table, data.get("where"))
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
|
||||
order_by = data.get("order_by")
|
||||
if order_by:
|
||||
for item in order_by:
|
||||
col = table.columns[item["column"]]
|
||||
stmt = stmt.order_by(col.desc() if item.get("desc") else col.asc())
|
||||
|
||||
limit = data.get("limit")
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
offset = data.get("offset")
|
||||
if offset is not None:
|
||||
stmt = stmt.offset(offset)
|
||||
|
||||
res = conn.execute(stmt)
|
||||
return [dict(row._mapping) for row in res.fetchall()]
|
||||
|
||||
def _do_insert(self, conn, table, data):
|
||||
values = data.get("values", {})
|
||||
stmt = insert(table).values(**values)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
return [{"lastrowid": res.lastrowid}] if hasattr(res, "lastrowid") else []
|
||||
|
||||
def _do_update(self, conn, table, data):
|
||||
values = data.get("values", {})
|
||||
where_cond = self._build_where(table, data.get("where"))
|
||||
stmt = update(table).values(**values)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
return [{"rowcount": res.rowcount}]
|
||||
|
||||
def _do_delete(self, conn, table, data):
|
||||
where_cond = self._build_where(table, data.get("where"))
|
||||
stmt = delete(table)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
return [{"rowcount": res.rowcount}]
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 日志表管理 + 后台清理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── 日志表管理 ────────────────────────────────────────────────────
|
||||
def _ensure_logs_table(self):
|
||||
if "Logs" in self._logs_table_cache:
|
||||
return self._logs_table_cache["Logs"]
|
||||
|
||||
with self.engine.connect() as conn:
|
||||
conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
)
|
||||
""")
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
table = Table("Logs", self.metadata, autoload_with=self.engine)
|
||||
self._logs_table_cache["Logs"] = table
|
||||
return table
|
||||
|
||||
# ── 内部日志(直接写库,无 HTTP)──────────────────────────────────
|
||||
def _log_internal(self, service, level, message,
|
||||
trace_id=None, extra=None):
|
||||
try:
|
||||
table = self._ensure_logs_table()
|
||||
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
|
||||
if len(message) > MAX_MESSAGE_LEN:
|
||||
message = message[:MAX_MESSAGE_LEN]
|
||||
if extra_json and len(extra_json) > MAX_EXTRA_LEN:
|
||||
extra_json = extra_json[:MAX_EXTRA_LEN]
|
||||
|
||||
with self.engine.connect() as conn:
|
||||
conn.execute(insert(table).values(
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
service=service,
|
||||
level=level,
|
||||
message=message,
|
||||
trace_id=trace_id,
|
||||
extra=extra_json,
|
||||
))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 后台自动清理线程 ──────────────────────────────────────────────
|
||||
def _start_cleanup_thread(self):
|
||||
def _loop():
|
||||
while True:
|
||||
time.sleep(DEFAULT_CLEANUP_INTERVAL)
|
||||
try:
|
||||
self._do_cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import threading
|
||||
t = threading.Thread(target=_loop, daemon=True, name="dbv2-cleanup")
|
||||
t.start()
|
||||
|
||||
def _do_cleanup(self):
|
||||
table = self._ensure_logs_table()
|
||||
cutoff = (
|
||||
datetime.now(timezone.utc) - timedelta(days=DEFAULT_RETENTION_DAYS)
|
||||
).isoformat()
|
||||
|
||||
with self.engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
delete(table).where(table.columns["ts"] < cutoff)
|
||||
)
|
||||
conn.commit()
|
||||
expired_deleted = result.rowcount
|
||||
|
||||
count = conn.execute(
|
||||
select(func.count()).select_from(table)
|
||||
).scalar() or 0
|
||||
trimmed = 0
|
||||
if count > DEFAULT_MAX_RECORDS:
|
||||
excess = count - DEFAULT_MAX_RECORDS
|
||||
cutoff_row = conn.execute(
|
||||
select(table.columns["ts"])
|
||||
.order_by(table.columns["ts"].asc())
|
||||
.limit(1).offset(DEFAULT_MAX_RECORDS)
|
||||
).fetchone()
|
||||
if cutoff_row:
|
||||
trim_result = conn.execute(
|
||||
delete(table).where(table.columns["ts"] < cutoff_row[0])
|
||||
)
|
||||
conn.commit()
|
||||
trimmed = trim_result.rowcount
|
||||
|
||||
if expired_deleted or trimmed:
|
||||
self._log_internal(
|
||||
"db", "INFO",
|
||||
f"自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8005, help="监听端口")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = DBServer(args.addr, args.port)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
统一日志客户端 SDK — 各服务复制即用。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
复制到目标服务目录后,按需修改 service_name 即可::
|
||||
|
||||
from log_client import logs
|
||||
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
service_name 用于在 dbv2 连接中标识来源,各服务复制时自行修改。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="example",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.0, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
# 各服务复制时,把 "example" 改为自己的服务名即可。
|
||||
# 例如 dnfs/log_client.py → logs = LogClient(service_name="dnfs")
|
||||
# 例如 smtp/log_client.py → logs = LogClient(service_name="smtp")
|
||||
|
||||
logs = LogClient()
|
||||
+17
-38
@@ -16,7 +16,7 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import (
|
||||
Table, insert, select, delete, func, and_, or_, MetaData
|
||||
)
|
||||
@@ -120,7 +120,6 @@ def init_log_routes(app, engine, metadata,
|
||||
max_records=DEFAULT_MAX_RECORDS,
|
||||
cleanup_interval=DEFAULT_CLEANUP_INTERVAL):
|
||||
"""把日志蓝图注册到 Flask app,并注入 engine/metadata。"""
|
||||
print('[logs] 正在初始化日志模块 ...')
|
||||
|
||||
app.config['LOG_ENGINE'] = engine
|
||||
app.config['LOG_METADATA'] = metadata
|
||||
@@ -130,37 +129,17 @@ def init_log_routes(app, engine, metadata,
|
||||
|
||||
# 建表 + 反射
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
with engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
select(func.count()).select_from(table)
|
||||
).scalar() or 0
|
||||
print(f'[logs] Logs 表就绪,当前 {count} 条日志')
|
||||
_ensure_logs_table(metadata, engine)
|
||||
except Exception as e:
|
||||
print(f'[logs] 警告: Logs 表初始化失败: {e}')
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'Logs 表初始化失败: {e}')
|
||||
|
||||
# 注册路由
|
||||
app.register_blueprint(bp)
|
||||
routes = [
|
||||
'POST /log/ingest',
|
||||
'POST /log/query',
|
||||
'POST /log/stats',
|
||||
'POST /log/purge',
|
||||
'GET /log/health',
|
||||
]
|
||||
for r in routes:
|
||||
print(f'[logs] 路由注册: {r}')
|
||||
|
||||
# 配置摘要
|
||||
print(f'[logs] 保留策略: {retention_days} 天 / 上限 {max_records} 条')
|
||||
print(f'[logs] 自动清理: 每 {cleanup_interval} 秒执行一次')
|
||||
|
||||
# 启动后台清理线程
|
||||
_start_cleanup_thread(engine, metadata,
|
||||
retention_days, max_records, cleanup_interval)
|
||||
print('[logs] 后台清理线程已启动')
|
||||
|
||||
print('[logs] 日志模块初始化完成')
|
||||
|
||||
|
||||
# ── 5.1 推送日志 ──────────────────────────────────────────────────────
|
||||
@@ -181,8 +160,8 @@ def ingest():
|
||||
'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条'
|
||||
}), 400
|
||||
|
||||
engine = app.config['LOG_ENGINE']
|
||||
metadata = app.config['LOG_METADATA']
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
@@ -252,8 +231,8 @@ def query():
|
||||
"""结构化查询日志。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
engine = app.config['LOG_ENGINE']
|
||||
metadata = app.config['LOG_METADATA']
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
@@ -353,8 +332,8 @@ def stats():
|
||||
"""日志分类统计。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
engine = app.config['LOG_ENGINE']
|
||||
metadata = app.config['LOG_METADATA']
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
@@ -416,8 +395,8 @@ def purge():
|
||||
if not before:
|
||||
return jsonify({'success': False, 'error': '缺少 before 字段'}), 400
|
||||
|
||||
engine = app.config['LOG_ENGINE']
|
||||
metadata = app.config['LOG_METADATA']
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
@@ -445,8 +424,8 @@ def purge():
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""日志模块健康检查。"""
|
||||
engine = app.config['LOG_ENGINE']
|
||||
metadata = app.config['LOG_METADATA']
|
||||
engine = current_app.config['LOG_ENGINE']
|
||||
metadata = current_app.config['LOG_METADATA']
|
||||
|
||||
try:
|
||||
table = _ensure_logs_table(metadata, engine)
|
||||
@@ -506,6 +485,6 @@ def _do_cleanup(engine, metadata, retention_days, max_records):
|
||||
conn.commit()
|
||||
trimmed = trim_result.rowcount
|
||||
|
||||
print(f'[logs] 自动清理完成: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
|
||||
if expired_deleted or trimmed:
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import os
|
||||
from flask import Flask, request, jsonify, g
|
||||
from sqlalchemy import (
|
||||
create_engine, MetaData, Table, select, insert, update, delete, text
|
||||
)
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from waitress import serve
|
||||
from security_middleware import apply_security_headers
|
||||
from log_module import init_log_routes, log_internal
|
||||
|
||||
app = Flask(__name__)
|
||||
apply_security_headers(app)
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL = os.environ.get('DATABASE_URL', 'sqlite:///database.db')
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# 缓存反射的元数据(避免每次请求都反射)
|
||||
metadata = MetaData()
|
||||
|
||||
# 注册日志模块(/log/* 路由 + 后台清理线程)
|
||||
init_log_routes(app, engine, metadata)
|
||||
|
||||
def get_table(table_name):
|
||||
"""反射表对象(若已缓存则直接返回)"""
|
||||
if table_name not in metadata.tables:
|
||||
Table(table_name, metadata, autoload_with=engine)
|
||||
return metadata.tables[table_name]
|
||||
|
||||
def build_where(table, conditions):
|
||||
"""将条件字典转换为 SQLAlchemy 条件表达式"""
|
||||
if not conditions:
|
||||
return None
|
||||
clauses = []
|
||||
for col, val in conditions.items():
|
||||
column = table.columns[col]
|
||||
if isinstance(val, dict):
|
||||
# 支持多种比较操作
|
||||
if 'eq' in val:
|
||||
clauses.append(column == val['eq'])
|
||||
if 'ne' in val:
|
||||
clauses.append(column != val['ne'])
|
||||
if 'gt' in val:
|
||||
clauses.append(column > val['gt'])
|
||||
if 'lt' in val:
|
||||
clauses.append(column < val['lt'])
|
||||
if 'ge' in val:
|
||||
clauses.append(column >= val['ge'])
|
||||
if 'le' in val:
|
||||
clauses.append(column <= val['le'])
|
||||
if 'like' in val:
|
||||
clauses.append(column.like(val['like']))
|
||||
if 'in' in val:
|
||||
clauses.append(column.in_(val['in']))
|
||||
else:
|
||||
# 直接等值比较
|
||||
clauses.append(column == val)
|
||||
from sqlalchemy import and_
|
||||
return and_(*clauses)
|
||||
|
||||
@app.route('/execute', methods=['POST'])
|
||||
def execute_structured():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': '请求体不能为空'}), 400
|
||||
|
||||
action = data.get('action', 'select')
|
||||
table_name = data.get('table')
|
||||
if not table_name:
|
||||
return jsonify({'success': False, 'error': '缺少 table 字段'}), 400
|
||||
|
||||
try:
|
||||
table = get_table(table_name)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'表加载失败: {str(e)}'}), 400
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = engine.connect()
|
||||
result = []
|
||||
if action == 'select':
|
||||
columns = data.get('columns', None)
|
||||
if columns:
|
||||
cols = [table.columns[c] for c in columns]
|
||||
else:
|
||||
cols = [table] # select *
|
||||
|
||||
stmt = select(*cols)
|
||||
where_cond = build_where(table, data.get('where'))
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
|
||||
# 排序
|
||||
order_by = data.get('order_by')
|
||||
if order_by:
|
||||
for item in order_by:
|
||||
col = table.columns[item['column']]
|
||||
stmt = stmt.order_by(col.desc() if item.get('desc') else col.asc())
|
||||
|
||||
# 分页
|
||||
limit = data.get('limit')
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
offset = data.get('offset')
|
||||
if offset is not None:
|
||||
stmt = stmt.offset(offset)
|
||||
|
||||
res = conn.execute(stmt)
|
||||
rows = res.fetchall()
|
||||
result = [dict(row._mapping) for row in rows]
|
||||
|
||||
elif action == 'insert':
|
||||
values = data.get('values', {})
|
||||
stmt = insert(table).values(**values)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
result = [{'lastrowid': res.lastrowid}] if hasattr(res, 'lastrowid') else []
|
||||
|
||||
elif action == 'update':
|
||||
values = data.get('values', {})
|
||||
where_cond = build_where(table, data.get('where'))
|
||||
stmt = update(table).values(**values)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
result = [{'rowcount': res.rowcount}]
|
||||
|
||||
elif action == 'delete':
|
||||
where_cond = build_where(table, data.get('where'))
|
||||
stmt = delete(table)
|
||||
if where_cond is not None:
|
||||
stmt = stmt.where(where_cond)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
result = [{'rowcount': res.rowcount}]
|
||||
|
||||
else:
|
||||
return jsonify({'success': False, 'error': f'不支持的操作: {action}'}), 400
|
||||
|
||||
# 记录操作日志(同进程直接写库,无 HTTP 往返,无循环依赖)
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
f'{action} {table_name} OK',
|
||||
extra={'rowcount': len(result) if isinstance(result, list) else 0,
|
||||
'client_ip': request.remote_addr})
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'{action} {table_name} FAILED: {e}',
|
||||
extra={'client_ip': request.remote_addr})
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
log_internal(engine, metadata, 'db', 'ERROR',
|
||||
f'{action} {table_name} EXCEPTION: {e}',
|
||||
extra={'client_ip': request.remote_addr})
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 日志模块的初始化输出在 init_log_routes 调用时已完成
|
||||
log_internal(engine, metadata, 'db', 'INFO',
|
||||
'服务启动,监听 http://127.0.0.1:8005')
|
||||
# 单线程单进程,绑定 127.0.0.1
|
||||
serve(app, host='127.0.0.1', port=8005, threads=1)
|
||||
@@ -2,3 +2,4 @@ nuitka
|
||||
flask
|
||||
SQLAlchemy
|
||||
waitress
|
||||
../pymsg
|
||||
+115
-67
@@ -1,38 +1,67 @@
|
||||
# DNFS — 树状网络文件系统
|
||||
# dnfs — 树状网络文件系统
|
||||
|
||||
Dendrachy Network File-System,基于父子关系(pid)组织的树状节点服务。
|
||||
|
||||
监听 `127.0.0.1:8006`,提供节点的增删改查,支持文件以 Base64 编码形式存储在节点 content 中。
|
||||
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
客户端 (static/dnfs.html)
|
||||
│
|
||||
│ Bearer Token
|
||||
▼
|
||||
dnfs :8006 (Flask + Waitress)
|
||||
│
|
||||
├──► db :8005 数据持久化(SQL-over-HTTP)
|
||||
└──► ai_review :8007 内容审核(仅 add_note 时调用)
|
||||
├──► db :8005 数据持久化(SQL-over-HTTP,/execute)
|
||||
└──► ai_review :8007 内容审核(仅 add_note / update_note 时调用)
|
||||
```
|
||||
|
||||
dnfs 不再直连数据库,所有数据操作通过 HTTP 调用 `db` 服务(`http://127.0.0.1:8005/execute`)完成。新增节点前会调用 `ai_review` 服务(`http://127.0.0.1:8007/review`)进行内容审核,审核不通过则拒绝写入。
|
||||
- 不直连数据库,所有数据操作通过 HTTP 调用 `db` 服务(`http://127.0.0.1:8005/execute`)。
|
||||
- 新增/更新节点前调用 `ai_review` 服务(`http://127.0.0.1:8007/review`)进行内容审核,审核不通过则拒绝写入。
|
||||
- 通常部署在 gateway 之后(`Proxy.opened` 白名单中),由 gateway 完成 Token 验签后转发,dnfs 仅做所有权校验。
|
||||
|
||||
## 数据模型
|
||||
|
||||
对应 `db` 服务中的 `Notes` 表(见 [db/create_sqlite3.sql](../db/create_sqlite3.sql)):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER PK | 节点唯一标识 |
|
||||
| pid | INTEGER NOT NULL | 父节点 id,0 表示根 |
|
||||
| owner | VARCHAR(128) | 创建者账号(来自 Token payload 的 `user` 字段,默认 `'Unknow'`) |
|
||||
| title | VARCHAR(128) NOT NULL | 节点标题 |
|
||||
| content | TEXT NOT NULL | 节点内容(可含 Base64 文件数据) |
|
||||
|
||||
## 鉴权与所有权
|
||||
|
||||
写操作(`add_note` / `delete_note` / `update_note`)需在请求头携带:
|
||||
|
||||
```
|
||||
Authorization: Bearer <base64_payload>_<rsa_sign>
|
||||
```
|
||||
|
||||
- Token 由 gateway 在 `/login` 签发,dnfs 通过 [dnfs.py:163](dnfs.py#L163) `decode_token` 仅解 Base64 取 payload,**不验签**(验签由 gateway 完成)。
|
||||
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
||||
- `delete_note` 与 `update_note` 校验所有权:
|
||||
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
||||
- **例外**:`user == 'admin'` 可删除任意节点(管理员豁免,仅 delete)。
|
||||
- `get_notes` 不需要鉴权。
|
||||
|
||||
## 接口文档
|
||||
|
||||
所有接口均为 `POST`,Content-Type 为 `application/json`。
|
||||
所有接口均为 `POST`,`Content-Type: application/json`。
|
||||
|
||||
---
|
||||
|
||||
### POST /get_notes
|
||||
|
||||
获取指定父节点下的子节点列表。
|
||||
获取指定父节点下的子节点列表。**无需鉴权**。
|
||||
|
||||
**Request**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| pid | int | 否 | 父节点 id,0 表示根节点,缺省默认 0 |
|
||||
| pid | int | 否 | 父节点 id,0 表示根,缺省默认 0 |
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8006/get_notes \
|
||||
@@ -40,66 +69,58 @@ curl -X POST http://localhost:8006/get_notes \
|
||||
-d '{"pid": 0}'
|
||||
```
|
||||
|
||||
**Response 200**
|
||||
|
||||
直接返回节点数组:
|
||||
**Response 200** — 直接返回节点数组:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": 1, "pid": 0, "title": "根节点", "content": "hello"},
|
||||
{"id": 2, "pid": 0, "title": "另一个", "content": "world"}
|
||||
{"id": 1, "pid": 0, "owner": "admin", "title": "根节点", "content": "hello"},
|
||||
{"id": 2, "pid": 0, "owner": "alice", "title": "另一个", "content": "world"}
|
||||
]
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 节点唯一标识 |
|
||||
| pid | int | 父节点 id |
|
||||
| title | str | 节点标题 |
|
||||
| content | str | 节点内容(可能含 Base64 文件数据) |
|
||||
|
||||
空目录返回 `[]`。
|
||||
|
||||
---
|
||||
|
||||
### POST /add_note
|
||||
|
||||
添加新节点。流程:校验参数 → 校验父节点存在 → AI 内容审核 → 写入数据库。
|
||||
添加新节点。流程:校验参数 → 校验 Token → 校验父节点存在 → AI 内容审核 → 写入数据库(含 owner)。
|
||||
|
||||
**Request**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| pid | int | 是 | 父节点 id,0 为根节点,非 0 时校验父节点是否存在 |
|
||||
| pid | int | 是 | 父节点 id,0 为根,非 0 时校验父节点是否存在 |
|
||||
| title | str | 是 | 节点标题,不可为空 |
|
||||
| content | str | 是 | 节点内容,不可为空(可含 Base64 文件数据) |
|
||||
|
||||
请求头需带 `Authorization: Bearer <token>`。
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8006/add_note \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-d '{"pid": 0, "title": "新节点", "content": "节点内容"}'
|
||||
```
|
||||
|
||||
**Response 200** — 成功
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{"success": true}
|
||||
```
|
||||
|
||||
**Response 400** — 失败
|
||||
|
||||
| 场景 | 响应体 |
|
||||
|------|--------|
|
||||
| 缺少必填字段 | `{"error": "Pid, title and content are required"}` |
|
||||
| 父节点不存在(pid≠0) | `{"error": "the new node need a parent"}` |
|
||||
| AI 内容审核不通过 | `{"error": "review failed"}` |
|
||||
| 数据库写入失败 | `{"success": false}` |
|
||||
| 状态码 | 场景 | 响应体 |
|
||||
|--------|------|--------|
|
||||
| 200 | 成功 | `{"success": true}` |
|
||||
| 400 | 缺少必填字段 | `{"error": "Pid, title and content are required"}` |
|
||||
| 400 | 父节点不存在(pid≠0) | `{"error": "the new node need a parent"}` |
|
||||
| 400 | AI 内容审核不通过 | `{"error": "review failed"}` |
|
||||
| 400 | 数据库写入失败 | `{"success": false}` |
|
||||
| 401 | 未携带 Token 或 Token 解析失败 | `{"error": "Warning: no Authorization"}` |
|
||||
|
||||
---
|
||||
|
||||
### POST /delete_note
|
||||
|
||||
删除指定节点。存在子节点时拒绝删除(保护树结构完整性)。
|
||||
删除指定节点。流程:校验参数 → 校验 Token → 检查子节点 → 校验所有权 → 删除。
|
||||
|
||||
存在子节点时拒绝删除(保护树结构完整性)。**admin 可删除任意节点**,普通用户仅能删除自己拥有的节点。
|
||||
|
||||
**Request**
|
||||
|
||||
@@ -107,31 +128,33 @@ curl -X POST http://localhost:8006/add_note \
|
||||
|------|------|------|------|
|
||||
| id | int | 是 | 要删除的节点 id |
|
||||
|
||||
请求头需带 `Authorization: Bearer <token>`。
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8006/delete_note \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-d '{"id": 1}'
|
||||
```
|
||||
|
||||
**Response 200** — 成功
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{"success": true}
|
||||
```
|
||||
|
||||
**Response 400** — 失败
|
||||
|
||||
| 场景 | 响应体 |
|
||||
|------|--------|
|
||||
| 缺少 id 字段 | `{"error": "Note ID is required"}` |
|
||||
| 存在子节点 | `{"error": "this node has a child"}` |
|
||||
| 数据库删除失败 | `{"success": false}` |
|
||||
| 状态码 | 场景 | 响应体 |
|
||||
|--------|------|--------|
|
||||
| 200 | 成功 | `{"success": true}` |
|
||||
| 400 | 缺少 id | `{"error": "Note ID is required"}` |
|
||||
| 400 | 存在子节点 | `{"error": "this node has a child "}` |
|
||||
| 400 | 数据库删除失败 | `{"success": false}` |
|
||||
| 401 | 未携带 Token | `{"error": "Warning: no Authorization"}` |
|
||||
| 403 | 非本人节点且非 admin | `{"error": "It's not your note"}` |
|
||||
|
||||
---
|
||||
|
||||
### POST /update_note
|
||||
|
||||
更新节点的标题、内容和父节点(可用于迁移节点到其他位置)。
|
||||
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
||||
|
||||
仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。
|
||||
|
||||
**Request**
|
||||
|
||||
@@ -142,27 +165,47 @@ curl -X POST http://localhost:8006/delete_note \
|
||||
| title | str | 是 | 新标题,不可为空 |
|
||||
| content | str | 是 | 新内容,不可为空 |
|
||||
|
||||
请求头需带 `Authorization: Bearer <token>`。
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8006/update_note \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-d '{"id": 1, "pid": 2, "title": "新标题", "content": "新内容"}'
|
||||
```
|
||||
|
||||
**Response 200** — 成功
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{"success": true}
|
||||
```
|
||||
|
||||
**Response 400** — 失败
|
||||
|
||||
| 场景 | 响应体 |
|
||||
|------|--------|
|
||||
| 缺少必填字段 | `{"error": "Invalid param"}` |
|
||||
| 数据库更新失败 | `{"success": false}` |
|
||||
| 状态码 | 场景 | 响应体 |
|
||||
|--------|------|--------|
|
||||
| 200 | 成功 | `{"success": true}` |
|
||||
| 400 | 缺少必填字段 | `{"error": "Invalid param"}` |
|
||||
| 400 | AI 内容审核不通过 | `{"error": "review failed"}` |
|
||||
| 400 | 数据库更新失败 | `{"success": false}` |
|
||||
| 401 | 未携带 Token | `{"error": "Warning: no Authorization"}` |
|
||||
| 403 | 非本人节点 | `{"error": "It's not your note"}` |
|
||||
|
||||
---
|
||||
|
||||
## 安全特性
|
||||
|
||||
- **安全响应头**([security_middleware.py](security_middleware.py)):`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、`Referrer-Policy`、`Permissions-Policy`、CSP(`default-src 'self'`、`frame-ancestors 'none'`),并移除 `Server` 头。
|
||||
- **Token 鉴权**:写操作强制校验,payload 取 `user` 字段。
|
||||
- **所有权隔离**:普通用户只能改/删自己的节点。
|
||||
- **内容审核**:写入与更新前调用 `ai_review`,色情/违禁/赌博/诈骗/暴力等内容会被拒绝。
|
||||
|
||||
## 配置
|
||||
|
||||
服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py):
|
||||
|
||||
| 常量 | 值 | 说明 |
|
||||
|------|----|------|
|
||||
| `ADDR` / `PORT` | `127.0.0.1` / `8006` | 监听地址 |
|
||||
| `DB_SERVER_URL` | `http://127.0.0.1:8005/execute` | db 服务 |
|
||||
| `REVIEW_SERVER_URL` | `http://127.0.0.1:8007/review` | ai_review 服务 |
|
||||
|
||||
调用超时均为 10 秒。
|
||||
|
||||
## 前端
|
||||
|
||||
三文件架构:
|
||||
@@ -175,7 +218,7 @@ static/js/script.js 交互逻辑
|
||||
|
||||
功能:节点树浏览、新增/编辑/删除节点、文件 Base64 上传与下载、节点迁移(移动到其他父节点下)。
|
||||
|
||||
> 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 static 服务的 `dnfs.html` 承载。
|
||||
> 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 `static` 服务的 `dnfs.html` 承载。
|
||||
|
||||
## 依赖
|
||||
|
||||
@@ -186,16 +229,21 @@ waitress
|
||||
nuitka # 可选,编译为独立二进制
|
||||
```
|
||||
|
||||
运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动。
|
||||
> `requirements.txt` 仍列有 `flask_sqlalchemy`,但代码已不使用(SQLAlchemy 直连代码已注释),属遗留依赖。
|
||||
|
||||
运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动;对外访问需经 gateway 转发并验签。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
bash _set.sh # 创建 venv + 安装依赖
|
||||
bash _run.sh # 启动服务
|
||||
bash _run.sh # 启动服务(waitress)
|
||||
```
|
||||
|
||||
## 已知问题
|
||||
## 已知问题与风险
|
||||
|
||||
- `delete_note` 中子节点检查逻辑有误:查询条件为 `where id = note_id`(查节点自身),应为 `where pid = note_id`(查子节点),导致任何节点都无法删除。
|
||||
- `review_content` 返回值类型不一致:正常返回 `bool`,HTTPError 时返回 `dict`。
|
||||
- **`decode_token` 未验签**([dnfs.py:167](dnfs.py#L167)):仅解 Base64 取 payload,存在 `# FIXME: 验证数字签名` 标记。当前安全性依赖"必须经 gateway 转发"这一部署假设;若 dnfs 被直接访问(绕过 gateway),任意伪造的 Token 都能通过。建议补 RSA 验签或在部署上确保仅 gateway 可达。
|
||||
- **`review_content` 返回类型不一致**([dnfs.py:73-119](dnfs.py#L73)):正常路径返回 `bool`([L105](dnfs.py#L105)),HTTPError 路径返回 `dict`([L113](dnfs.py#L113))。调用处用 `True != review` 判断,HTTPError 时 `dict != True` 恒为真,会一律判为审核失败并吞掉具体错误信息。建议统一返回类型或在调用处 catch 异常。
|
||||
- **CORS 全开**:`CORS(app)` 未配置白名单,允许任意 Origin 跨域访问。考虑到写操作需 Token,风险可控,但仍建议按 gateway 的 `[CORS] allowed_origins` 方式收紧。
|
||||
- **`call_db_service` 异常未捕获**:调用处([L208](dnfs.py#L208)、[L261](dnfs.py#L261)、[L318](dnfs.py#L318)、[L347](dnfs.py#L347)、[L440](dnfs.py#L440))直接取 `result['success']` / `notes['data']`,若 db 服务返回非预期结构或网络异常会抛 500。
|
||||
- **`get_notes` 不鉴权**:任何能访问 dnfs 的人都能枚举所有节点内容。若内容含敏感 Base64 文件,需在 gateway 层做路径级鉴权。
|
||||
+83
-44
@@ -27,47 +27,29 @@ import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Any
|
||||
from security_middleware import apply_security_headers
|
||||
from log_client import logs
|
||||
|
||||
DB_SERVER_URL = 'http://127.0.0.1:8005/execute'
|
||||
def call_db_service(url: str, payload: dict, timeout: int = 10) -> dict:
|
||||
'''
|
||||
使用标准库向数据库服务发送 JSON 请求并返回解析后的响应。
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
Args:
|
||||
url: 服务端点 URL (例如 'http://127.0.0.1:8005/execute')
|
||||
payload: 要发送的 JSON 数据字典
|
||||
timeout: 请求超时秒数
|
||||
|
||||
Returns:
|
||||
dict: 服务返回的 JSON 响应
|
||||
|
||||
Raises:
|
||||
urllib.error.URLError: 网络连接错误
|
||||
urllib.error.HTTPError: HTTP 状态错误 (4xx, 5xx)
|
||||
json.JSONDecodeError: 响应不是有效的 JSON
|
||||
'''
|
||||
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST'
|
||||
)
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
||||
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
||||
自动注入 ``_route: "execute"``。
|
||||
"""
|
||||
payload = {**payload, "_route": "execute"}
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
raw = response.read().decode('utf-8')
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
# HTTP错误,尝试读取错误响应体
|
||||
error_body = e.read().decode('utf-8', errors='replace')
|
||||
raise urllib.error.HTTPError(
|
||||
e.url, e.code, f'HTTP Error {e.code}: {error_body}', e.hdrs, e.fp
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
||||
resp_text = client.call(json.dumps(payload), timeout=timeout)
|
||||
return json.loads(resp_text)
|
||||
except Exception as e:
|
||||
raise ConnectionError(
|
||||
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
|
||||
) from None
|
||||
except urllib.error.URLError as e:
|
||||
raise urllib.error.URLError(f'连接失败: {e.reason}') from None
|
||||
|
||||
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
|
||||
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
@@ -184,6 +166,7 @@ def add_note():
|
||||
|
||||
if pid is None or not title or not content:
|
||||
|
||||
logs.warning('dnfs', 'add_note 参数缺失', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Pid, title and content are required"}), 400
|
||||
|
||||
info = None
|
||||
@@ -193,6 +176,7 @@ def add_note():
|
||||
info = decode_token(token)
|
||||
|
||||
if info is None:
|
||||
logs.warning('dnfs', 'add_note 未授权访问', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Warning: no Authorization"}), 401
|
||||
|
||||
if pid != 0:
|
||||
@@ -205,10 +189,11 @@ def add_note():
|
||||
}
|
||||
}
|
||||
|
||||
notes = call_db_service(DB_SERVER_URL, select_payload)
|
||||
notes = _call_dbv2(select_payload)
|
||||
|
||||
# 不存在的父节点
|
||||
if len( notes['data']) == 0:
|
||||
logs.warning('dnfs', f'add_note 父节点不存在 pid={pid}', extra={'user': info['user']})
|
||||
return jsonify({'error': 'the new node need a parent'}), 400
|
||||
|
||||
|
||||
@@ -216,6 +201,7 @@ def add_note():
|
||||
review = review_content(title + '\n' + content, 10)
|
||||
if True != review:
|
||||
#return jsonify({'error': 'review failed', 'data': review}), 400
|
||||
logs.warning('dnfs', f'add_note 内容审核未通过 title={title}', extra={'user': info['user']})
|
||||
return jsonify({'error': 'review failed'}), 400
|
||||
|
||||
insert_payload = {
|
||||
@@ -229,13 +215,15 @@ def add_note():
|
||||
}
|
||||
}
|
||||
|
||||
result = call_db_service(DB_SERVER_URL, insert_payload)
|
||||
result = _call_dbv2(insert_payload)
|
||||
|
||||
success = result['success']
|
||||
|
||||
if success is True:
|
||||
logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']})
|
||||
return jsonify({'success': success}), 200
|
||||
else:
|
||||
logs.error('dnfs', f'add_note 写入失败 title={title}', extra={'user': info['user']})
|
||||
return jsonify({'success': success}), 400
|
||||
|
||||
|
||||
@@ -258,10 +246,11 @@ def get_notes():
|
||||
}
|
||||
}
|
||||
|
||||
notes = call_db_service(DB_SERVER_URL, select_payload)
|
||||
notes = _call_dbv2(select_payload)
|
||||
|
||||
result = notes['data']
|
||||
|
||||
logs.info('dnfs', f'get_notes 查询 pid={pid} 返回{len(result)}条', extra={'ip': request.remote_addr})
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@@ -276,6 +265,7 @@ def delete_note():
|
||||
|
||||
if not note_id:
|
||||
|
||||
logs.warning('dnfs', 'delete_note 参数缺失 id', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Note ID is required"}), 400
|
||||
|
||||
info = None
|
||||
@@ -285,6 +275,7 @@ def delete_note():
|
||||
info = decode_token(token)
|
||||
|
||||
if info is None:
|
||||
logs.warning('dnfs', 'delete_note 未授权访问', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Warning: no Authorization"}), 401
|
||||
|
||||
#with app.app_context():
|
||||
@@ -315,11 +306,12 @@ def delete_note():
|
||||
}
|
||||
}
|
||||
|
||||
notes = call_db_service(DB_SERVER_URL, select_payload)
|
||||
notes = _call_dbv2(select_payload)
|
||||
|
||||
# 有子节点的情况不可删除
|
||||
if len(notes['data']) > 0:
|
||||
|
||||
logs.warning('dnfs', f'delete_note 存在子节点 id={note_id}', extra={'user': info['user']})
|
||||
return jsonify({'error': 'this node has a child '}), 400
|
||||
|
||||
# 检查所有权
|
||||
@@ -331,10 +323,11 @@ def delete_note():
|
||||
}
|
||||
}
|
||||
|
||||
data = call_db_service(DB_SERVER_URL, select_payload)
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data['data']
|
||||
if not info['user'] == 'admin':
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
|
||||
|
||||
@@ -344,16 +337,18 @@ def delete_note():
|
||||
'where': {'id': note_id}
|
||||
}
|
||||
|
||||
result = call_db_service(DB_SERVER_URL, delete_payload)
|
||||
result = _call_dbv2(delete_payload)
|
||||
|
||||
result = result['success']
|
||||
|
||||
if result is True:
|
||||
|
||||
logs.info('dnfs', f'delete_note 成功 id={note_id}', extra={'user': info['user']})
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
else:
|
||||
|
||||
logs.error('dnfs', f'delete_note 失败 id={note_id}', extra={'user': info['user']})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
|
||||
@@ -379,6 +374,7 @@ def update_note():
|
||||
|
||||
if id is None or pid is None or not title or not content:
|
||||
|
||||
logs.warning('dnfs', 'update_note 参数缺失', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Invalid param"}), 400
|
||||
|
||||
# 获取用户名
|
||||
@@ -389,6 +385,7 @@ def update_note():
|
||||
info = decode_token(token)
|
||||
|
||||
if info is None:
|
||||
logs.warning('dnfs', 'update_note 未授权访问', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Warning: no Authorization"}), 401
|
||||
|
||||
|
||||
@@ -401,15 +398,17 @@ def update_note():
|
||||
}
|
||||
}
|
||||
|
||||
data = call_db_service(DB_SERVER_URL, select_payload)
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data['data']
|
||||
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
|
||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
|
||||
# 内容审查
|
||||
review = review_content(title + '\n' + content, 10)
|
||||
if True != review:
|
||||
#return jsonify({'error': 'review failed', 'data': review}), 400
|
||||
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']})
|
||||
return jsonify({'error': 'review failed'}), 400
|
||||
|
||||
#with app.app_context():
|
||||
@@ -437,18 +436,57 @@ def update_note():
|
||||
'where': {'id': id}
|
||||
}
|
||||
|
||||
result = call_db_service(DB_SERVER_URL, update_payload)
|
||||
result = _call_dbv2(update_payload)
|
||||
|
||||
result = result['success']
|
||||
|
||||
if result is True:
|
||||
|
||||
logs.info('dnfs', f'update_note 成功 id={id} title={title}', extra={'user': info['user']})
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
else:
|
||||
|
||||
logs.error('dnfs', f'update_note 失败 id={id}', extra={'user': info['user']})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
## by shufeng
|
||||
## 读取单个节点
|
||||
'''@app.route("/get_note", methods=["POST"])
|
||||
|
||||
def get_note():
|
||||
data = request.get_json()
|
||||
|
||||
id: int = data.get("id")
|
||||
auth: str = request.headers.get("Authorization", "")
|
||||
info = None
|
||||
|
||||
if auth.startswith("Bearer "):
|
||||
token: str = auth[7:]
|
||||
info = decode_token(token)
|
||||
|
||||
if id is None:
|
||||
logs.error("dnfs", "get_note: 参数不足,缺少id")
|
||||
return jsonify({"error": "Invalid param"}), 401
|
||||
|
||||
select_payload = {
|
||||
"method": "select",
|
||||
"tables": "Notes",
|
||||
"where": {
|
||||
"id": id
|
||||
}
|
||||
}
|
||||
|
||||
data = _call_dbv2(select_payload)
|
||||
notes = data["data"]
|
||||
|
||||
if not len(notes) == 1 or not notes[0]["owner"] == info["user"]:
|
||||
logs.error("dnfs", "没有读取权")
|
||||
return jsonify({"error": "你没有读取这个的权力"}), 403
|
||||
|
||||
logs.info("dnfs", f"get_note 查询 id={id}")
|
||||
return jsonify()
|
||||
'''
|
||||
|
||||
|
||||
|
||||
@@ -460,6 +498,7 @@ def update_note():
|
||||
ADDR='127.0.0.1'
|
||||
PORT=8006
|
||||
if __name__ == "__main__":
|
||||
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
|
||||
print(f'Listen at http://{ADDR}:{PORT}')
|
||||
#app.run(host="127.0.0.1", port=8006)
|
||||
serve(app, host='127.0.0.1', port=8006)
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>树状笔记</title>
|
||||
|
||||
<!-- Font Awesome 6.0.0 是矢量图标库,提供 7,000+ 免费图标及 17,000+ 专业图标。 -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<!-- 外部CSS文件 -->
|
||||
<link rel="stylesheet" href="static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-sitemap"></i> 树状笔记</h1>
|
||||
<div class="header-actions">
|
||||
<button onclick="resetForm()" class="btn btn-primary"><i class="fas fa-plus"></i> 新增节点</button>
|
||||
<button onclick="goBack()" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> 返回上级</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点迁移模式提示栏 -->
|
||||
<div id="move-mode-bar" class="move-mode-bar" style="display: none;">
|
||||
<div class="move-mode-info">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
正在迁移节点:<strong id="moving-node-title"></strong>
|
||||
</div>
|
||||
<div class="move-mode-actions">
|
||||
<button onclick="confirmMoveHere()" class="btn btn-move-here"><i class="fas fa-arrow-down"></i> 移动到此</button>
|
||||
<button onclick="cancelMove()" class="btn btn-cancel-move"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="form-section">
|
||||
<!-- 表单标题 -->
|
||||
<h3 class="form-title"><i class="fas fa-pen"></i> <span id="form-mode-text">新增节点</span></h3>
|
||||
<input type="hidden" id="edit-id" value=""> <!-- 隐藏输入框,要编辑节点的id -->
|
||||
<!-- 输入节点标题 -->
|
||||
<div class="form-group">
|
||||
<label for="node-title"><i class="fas fa-heading"></i> 标题</label>
|
||||
<input type="text" id="node-title" class="form-control" placeholder="输入节点标题">
|
||||
</div>
|
||||
<!-- 输入节点内容 -->
|
||||
<div class="form-group">
|
||||
<label for="node-content"><i class="fas fa-file-alt"></i> 节点说明</label>
|
||||
<textarea id="node-content" class="form-control" placeholder="输入节点的文字说明"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="form-group">
|
||||
<label><i class="fas fa-upload"></i> 文件上传</label>
|
||||
<div class="file-upload-area" onclick="document.getElementById('file-upload').click()">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size: 2em; color: #888; margin-bottom: 10px;"></i>
|
||||
<p>点击或拖拽文件到此处上传</p>
|
||||
<p style="font-size: 0.8em; color: #999;">文件将以编码形式保存,同一节点内数据不要超过1M</p>
|
||||
<input type="file" id="file-upload" style="display: none;" onchange="handleFileUpload(this.files)"> <!-- 隐藏输入框 -->
|
||||
</div>
|
||||
|
||||
<!-- 选择文件状态信息 -->
|
||||
<div class="file-info" id="file-info">未选择文件</div>
|
||||
|
||||
<!-- 文件元信息显示 -->
|
||||
<div id="file-meta-container" style="display: none;">
|
||||
<div id="file-meta-info" class="file-meta-info">
|
||||
<p><i class="fas fa-file"></i> 文件名:<span id="meta-filename"></span></p>
|
||||
<p><i class="fas fa-hdd"></i> 文件大小:<span id="meta-filesize"></span></p>
|
||||
<p><i class="fas fa-calendar-alt"></i> 上传时间:<span id="meta-uploadtime"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据修复提示 -->
|
||||
<div id="data-repair-hint" class="data-repair-hint" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>检测到文件元数据损坏。请尝试重新上传文件以修复此问题。</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件进度条 -->
|
||||
<div class="file-progress">
|
||||
<div class="file-progress-bar" id="file-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件下载区域(仅在有文件时显示) -->
|
||||
<div class="form-group" id="download-section" style="display: none;">
|
||||
<label><i class="fas fa-download"></i> 文件操作</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="decodeAndDownload(null, null, true)" class="btn btn-success">
|
||||
<i class="fas fa-download"></i> 下载文件
|
||||
</button>
|
||||
<button onclick="removeFile()" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> 移除文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的Base64存储字段 - 用户永远不会看到 -->
|
||||
<textarea id="hidden-base64" class="form-control hidden-field" placeholder="Base64编码内容(自动生成,隐藏显示)"></textarea>
|
||||
<input type="hidden" id="file-metadata" value="">
|
||||
|
||||
<!-- 动作表单 -->
|
||||
<div class="form-actions">
|
||||
<button onclick="saveNode()" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
|
||||
<button onclick="resetForm()" class="btn btn-secondary"><i class="fas fa-undo"></i> 重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点表单 -->
|
||||
<div id="node-list" class="node-list">
|
||||
<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div id="delete-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title"><i class="fas fa-exclamation-triangle"></i> 确认删除</h3>
|
||||
<button class="modal-close" onclick="closeDeleteModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="delete-message">确定删除该节点吗?</p>
|
||||
<p id="delete-node-title-container" style="font-weight: bold; margin: 8px 0;"></p>
|
||||
<p id="delete-file-hint" style="font-size:.85em;color:#e74c3c;display:none;"><i class="fas fa-exclamation-circle"></i> 此节点包含文件,删除后文件将无法恢复</p>
|
||||
<p style="font-size:.85em;color:#777">此操作不可恢复</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="confirmDelete()" class="btn btn-danger"><i class="fas fa-trash"></i> 删除</button>
|
||||
<button onclick="closeDeleteModal()" class="btn btn-secondary"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- 外部JS文件 -->
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
统一日志客户端 — 各服务共用。
|
||||
|
||||
设计要点:
|
||||
- 使用 urllib.request(标准库,不引入 requests)
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
@@ -17,32 +17,37 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
DB_LOG_URL = 'http://127.0.0.1:8005/log/ingest'
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # HTTP 请求超时(秒)
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
def __init__(self, url=DB_LOG_URL, batch_size=_BATCH_SIZE,
|
||||
def __init__(self, batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._url = url
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
@@ -76,7 +81,9 @@ class LogClient:
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志。"""
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
@@ -88,6 +95,7 @@ class LogClient:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -97,25 +105,25 @@ class LogClient:
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = max(0, self._flush_interval - (time.monotonic() - last_flush))
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=timeout)
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
# 继续非阻塞地取,直到满 batch_size
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass # 超时,没有新日志
|
||||
pass
|
||||
|
||||
# 满了或超时了就发
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush:
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
@@ -125,21 +133,35 @@ class LogClient:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""发送一批日志到 db 服务。失败时静默丢弃。"""
|
||||
data = json.dumps({'logs': batch}).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
self._url, data=data,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST'
|
||||
)
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||
resp.read() # 消费响应体
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError):
|
||||
# fail-safe:日志服务不可用,静默丢弃,不影响业务
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
@@ -4,3 +4,4 @@ flask-cors
|
||||
flask_sqlalchemy
|
||||
#gunicorn
|
||||
waitress
|
||||
../pymsg
|
||||
@@ -1,504 +0,0 @@
|
||||
|
||||
* { /* 通配符选择器,表示匹配页面中所有 HTML 元素 */
|
||||
margin:0;
|
||||
padding:0;
|
||||
box-sizing:border-box
|
||||
}
|
||||
|
||||
body { /* 针对 <body> 元素的样式设置,主要用于定义页面的整体基础样式 */
|
||||
font-family:system-ui,sans-serif; /* 页面的默认字体。system-ui 表示优先使用操作系统的默认系统字体 */
|
||||
background:#f5f5f5; /* 设置页面的背景色为浅灰色(#f5f5f5),这是一种常见的浅色调背景,既能与内容形成柔和对比,又不会像纯白色(#fff)那样刺眼,提升长时间阅读的舒适度。 */
|
||||
min-height:100vh; /* 最小高度为视口高度。即使页面内容较少(不足以占满整个屏幕),背景色也能铺满整个视口,避免底部出现空白区域,保证页面视觉完整性。 */
|
||||
padding:10px /* 给 <body> 元素设置内边距为 10px,使页面内容与浏览器窗口边缘保持一定距离,避免内容紧贴屏幕边框,提升布局的呼吸感和美观度。 */
|
||||
}
|
||||
|
||||
.container { /* 针对类名为 container 的元素设置的样式,通常通常用于包裹页面主要内容的容器 */
|
||||
max-width:800px; /* 限制容器的最大宽度为 800px。作用是避免内容在大屏幕上过度拉伸,保证文字阅读的舒适行宽(过宽的文本行不利于阅读) */
|
||||
margin:0 auto; /* 设置容器的上下外边距为 0,左右外边距为 auto(自动计算),这是让块级元素在父容器中水平居中的经典写法。 */
|
||||
background:#fff; /* 设置容器的背景色为纯白色(#fff),与前面 <body> 设置的浅灰色(#f5f5f5)背景形成对比,突出容器内的内容区域。 */
|
||||
border-radius:6px; /* 给容器添加 6px 的圆角边框,使容器边角从直角变为圆弧,增加视觉柔和度,提升设计感。 */
|
||||
box-shadow:0 1px 3px #ddd /* 为容器添加轻微的阴影效果:水平偏移 0、垂直偏移 1px、模糊半径 3px,阴影颜色为浅灰色(#ddd)。 */
|
||||
} /* 作用是让容器在页面中产生轻微的 "悬浮" 感,与背景区分开,增强页面的层次感。 */
|
||||
|
||||
.header { /* 定义类名为 .header 的元素样式,通常适用于网页的头部导航栏或标题区域 */
|
||||
background:#888; /* 设置元素的背景色为深灰色(#888),这是一种中性深色,适合作为头部背景以突出内容。 */
|
||||
color:#fff; /* 定义元素内文字的颜色为纯白色(#fff),与深灰色背景形成强烈对比,确保文字清晰可读 */
|
||||
padding:12px; /* 为元素设置内边距为 12px,使头部内容(如标题、菜单等)与边框保持一定距离,避免内容紧贴边缘,提升视觉舒适度。 */
|
||||
display:flex; /* 将元素设置为 Flex 布局容器,使其内部子元素可以使用 Flexbox 的特性进行灵活排列,这是现代网页布局中常用的高效方式。 */
|
||||
justify-content:space-between; /* 控制 Flex 容器中子元素在水平方向(主轴) 的对齐方式:子元素会沿水平方向分布,两端的子元素分别靠向容器的左右边缘,中间的剩余空间均匀分配在子元素之间。 */
|
||||
align-items:center /* 控制 Flex 容器中子元素在垂直方向(交叉轴) 的对齐方式:所有子元素会在垂直方向上居中对齐,确保不同高度的元素(如文字和图标)在视觉上保持水平一致。 */
|
||||
}
|
||||
|
||||
.header h1 { /* 用于设置类名为 .header 内部的 <h1> 标题元素的样式 */
|
||||
font-size:1.4em /* 定义该 <h1> 标题的字体大小为 1.4em。 其中 em 是相对单位,基于父元素的字体大小进行计算(1em = 父元素字体大小)。 */
|
||||
}
|
||||
|
||||
.header-actions { /* 用于定义类名为 .header-actions 的元素样式,该元素通常是页面头部(.header)中存放操作按钮、图标等交互元素的容器 */
|
||||
display:flex; /* 设置为 Flex 布局容器,使其内部的子元素(如按钮、图标)能够沿水平方向(默认主轴方向)排列,且可通过 Flex 特性灵活控制子元素的对齐和分布,适配头部区域的横向布局需求。 */
|
||||
gap:6px /* 为 Flex 容器内的子元素之间设置 6px 的间距,包括水平和垂直方向(若子元素换行)。作用是避免子元素(如 “编辑”“删除” 按钮、功能图标)紧贴在一起,让布局更宽松、清晰,同时无需手动给每个子元素设置 margin,简化样式代码。 */
|
||||
}
|
||||
|
||||
.main-content { /* 定义类名为 .main-content 的元素样式,通常该元素是页面中承载主要内容(如文章、列表、表单等)的容器 */
|
||||
padding:15px /* 为 .main-content 元素设置内边距为 15px,即元素内部的内容与元素边框(或边缘)之间保持 15px 的距离。 */
|
||||
}
|
||||
|
||||
.form-section { /* 用于定义类名为 .form-section 的元素样式,从命名和样式特征来看,这个类通常用于包裹表单相关内容的区域 */
|
||||
background:#f5f5f5; /* 设置背景色为浅灰色(#f5f5f5),与周围可能的白色(#fff)内容区域形成轻微对比,从而视觉上区分出表单区域。 */
|
||||
padding:15px; /* 为表单区域设置 15px 的内边距,让表单内的输入框、标签等元素与边框保持距离,避免内容拥挤,提升填写体验。 */
|
||||
border-radius:4px; /* 给区域添加 4px 的圆角边框,使边角呈现柔和的圆弧效果,减少生硬感,增强视觉友好度。 */
|
||||
border:1px dashed #ddd; /* 设置 1px 宽的虚线边框,颜色为浅灰色(#ddd)。虚线样式相比实线更显轻盈,既能勾勒出表单区域的边界,又不会过于抢眼,同时与浅灰色背景形成协调的视觉效果。 */
|
||||
margin-bottom:15px /* 在表单区域底部设置 15px 的外边距,使该区域与下方的其他内容保持距离,避免不同内容区块紧贴在一起,增强页面布局的层次感。 */
|
||||
}
|
||||
|
||||
.form-title { /* 用于定义类名为 .form-title 的元素样式,从命名来看,这通常是表单区域的标题样式设置 */
|
||||
color:#555; /* 设置标题文字颜色为中灰色(#555),相比纯黑色(#000)更柔和,既保证可读性,又不会过于抢眼,适合作为区域标题的视觉表现 */
|
||||
margin-bottom:12px; /* 在标题底部设置 12px 的外边距,使标题与下方的表单内容(如输入框、按钮等)保持距离,形成清晰的内容分隔,提升表单区域的层次感。 */
|
||||
font-size:1.1em; /* 定义标题字体大小为 1.1em(相对单位),基于父元素字体大小计算(若父元素默认 16px,则约为 17.6px)。这个大小既比普通内容文字略大,突出标题属性,又不会过于夸张,保持整体协调。 */
|
||||
display:flex; /* 将标题元素设置为 Flex 布局容器,使其内部可能包含的子元素(如图标和文字)能够横向排列。 */
|
||||
align-items:center; /* 让 Flex 容器内的子元素(如标题前的图标和文字)在垂直方向上居中对齐,确保不同元素在视觉上保持水平一致。 */
|
||||
gap:6px /* 为标题内的子元素(如图标和文字)之间设置 6px 的间距,避免元素紧贴,使布局更清晰。 */
|
||||
}
|
||||
|
||||
.form-group { /* 用于定义类名为 .form-group 的元素样式,从命名来看,该类通常用于包裹表单中的一组相关元素(如一个输入框与其对应的标签) */
|
||||
margin-bottom:12px /* 设置 .form-group 元素底部的外边距为 12px,即每个表单组与下方的其他表单组之间保持 12px 的距离 */
|
||||
}
|
||||
|
||||
.form-group label { /* 用于设置 .form-group 容器内的 <label> 标签样式,主要针对表单中输入项的说明文字进行美化 */
|
||||
display:block; /* 将 <label> 从默认的行内元素(inline)改为块级元素(block),使其独占一行,强制后续的输入框(如 <input>)另起一行显示,形成 “标签在上,输入框在下” 的经典表单布局,更符合用户阅读和填写习惯。 */
|
||||
margin-bottom:4px; /* 在标签底部设置 4px 的外边距,使标签与下方的输入框保持微小距离,避免两者紧贴,提升视觉清晰度。 */
|
||||
color:#555; /* 标签文字使用中灰色(#555),相比黑色更柔和,既保证可读性,又不会抢夺输入框的视觉焦点(表单中输入区域通常需要更突出)。 */
|
||||
font-size:.9em /* 标签字体大小为 0.9em(相对单位),比默认文字略小(若父元素默认 16px,则约为 14.4px),通过尺寸差异进一步弱化标签的视觉权重,让用户注意力更易集中在输入区域。 */
|
||||
}
|
||||
|
||||
.form-control { /* 这段这段这段 CSS 代码用于定义类名为 .form-control 的元素样式,这类元素通常通常是表单中的输入控件(如文本框、下拉框等) */
|
||||
width:100%; /* 设置输入控件的宽度为父容器(通常是 .form-group)的 100%,使其填满满整个表单组的宽度,保证表单表单控件在水平方向上对齐统一,形成整齐的视觉效果。 */
|
||||
padding:8px; /* 为控件控件内部设置 8px 的内边距,让输入的文字与边框保持一定距离,避免仅避免文字紧贴边框影响阅读,也增加了控件的可点击区域和视觉面积,提升操作体验。 */
|
||||
border:1px solid #ddd; /* 给控件添加 1px 宽的实线边框,颜色为浅灰色(#ddd),既明确了控件的边界,又不会过于醒目,保持整体风格的简洁柔和。 */
|
||||
border-radius:3px; /* 设置 3px 的圆角边框,使控件边角变得圆润,相比直角边框更亲和用户,与表单其他元素的圆角设计(如 .form-section 的 border-radius: 4px)形成呼应,保持持视觉一致性。 */
|
||||
font-size:.9em /* 控件内文字大小为 0.9em(相对单位),与前面表单标签的字体大小保持一致,确保表单内文字尺寸的协调性,避免视觉混乱。 */
|
||||
}
|
||||
|
||||
textarea.form-control { /* 针对同时具有 form-control 类的 <textarea> 元素(多行文本输入框)的样式补充,用于优化多行输入框的特定表现 */
|
||||
resize:vertical; /* vertical 表示用户只能垂直(上下)拖动文本框边缘来改变其高度,而不能水平(左右)调整宽度。 */
|
||||
min-height:120px; /* 设置文本框的最小高度为 120px,确保初始状态下有足够的可视区域供用户输入多行内容(如备注、描述等),避免默认状态下文本框过小影响使用。 */
|
||||
line-height:1.5 /* 设置文本框内文字的行高为 1.5 倍字号,增加多行文本之间的垂直间距,使长文本更易阅读和编辑,提升可读性。 */
|
||||
}
|
||||
|
||||
.form-actions { /* 用于定义类名为 .form-actions 的元素样式,从命名和样式来看,这个类通常用于包裹表单底部的操作按钮(如 “提交”“取消” 按钮) */
|
||||
display:flex; /* 将 .form-actions 设为 Flex 布局容器,使内部的按钮元素能够沿水平方向排列,方便控制按钮间的间距和对齐方式。 */
|
||||
gap:8px; /* 为容器内的按钮之间设置 8px 的间距,避免多个操作按钮紧贴在一起,使布局更清晰,操作区域更易区分。 */
|
||||
justify-content:flex-end; /* 控制 Flex 容器内按钮在水平方向的对齐方式:将所有按钮靠右对齐。这是表单设计的常见做法,符合用户视觉习惯(操作按钮通常放在表单底部右侧)。 */
|
||||
margin-top:15px /* 在操作按钮区域顶部设置 15px 的外边距,使按钮区域与上方的表单输入内容保持距离,形成明显的视觉分隔,让用户完成输入后能快速定位到操作按钮。 */
|
||||
}
|
||||
|
||||
.node-list { /* 用于定义类名为 .node-list 的元素样式,从命名推测,该元素可能是用于展示一系列节点(如列表项、卡片、条目等)的容器 */
|
||||
margin:15px 0 /* 设置 .node-list 元素的上下外边距为 15px,左右外边距为 0。 */
|
||||
}
|
||||
|
||||
.node { /* 用于定义类名为 .node 的元素样式,从样式特征来看,这类元素通常是列表中的单个条目(如内容节点、信息卡片等) */
|
||||
background:#fafafa; /* 设置节点的背景色为极浅的灰色。既能与周围区域形成微妙区分,又保持了整体的柔和基调*/
|
||||
padding:15px; /* 为节点内部设置 15px 的内边距,让节点内的内容(如文本、图标等)与边框保持距离,避免拥挤,提升可读性 */
|
||||
border-radius:4px; /* 给节点添加 4px 的圆角边框,使边角呈现柔和的圆弧效果,减少生硬感,与页面中其他元素(如表单区域)的圆角设计形成风格统一。 */
|
||||
border-left:3px solid #888; /* 在节点左侧设置 3px 宽的深灰色(#888)实线边框,作为视觉标识。这种左侧边框设计常用于突出条目重要性或进行分类标记,能快速吸引用户注意力并形成视觉焦点。 */
|
||||
box-shadow:0 1px 2px #eee; /* 为节点添加轻微的阴影效果(水平偏移 0、垂直偏移 1px、模糊半径 2px,颜色为极浅灰色 #eee),使节点产生轻微的悬浮感,增强与背景的层次感,但又不会过于抢眼。 */
|
||||
margin-bottom:10px; /* 在节点底部设置 10px 的外边距,使列表中的多个节点之间保持适当距离,避免条目紧贴,保证列表的清晰可读性。 */
|
||||
cursor:pointer /* 整张卡片可点击进入节点,显示手型光标 */
|
||||
}
|
||||
|
||||
.node:hover { /* .node 元素的鼠标悬浮状态样式,用于在用户将鼠标指针移到节点条目上时,提供视觉反馈以增强交互体验 */
|
||||
box-shadow:0 2px 4px #ddd /* 垂直偏移量从 1px 增加到 2px:阴影向下偏移更多,增强 “悬浮抬起” 的视觉感受;模糊半径从 2px 增加到 4px:阴影范围更扩散,让悬浮效果更明显; 阴影颜色从 #eee(极浅灰)加深到 #ddd(浅灰):阴影对比度提高,进一步强化视觉反馈。 */
|
||||
}
|
||||
|
||||
/* 文件节点特殊样式 */
|
||||
.node.has-file { /* 这是一个多类选择器,表示同时拥有 node 和 has-file 两个类的元素。 */
|
||||
border-left-color: #2ecc71; /* 通过左侧边框的颜色差异,直观区分出 “包含文件的节点” 与普通节点,让用户能快速识别节点类型,提升信息扫描效率。 */
|
||||
}
|
||||
|
||||
.node-title { /* 用于定义类名为 .node-title 的元素样式,从命名和样式来看,这通常是节点(.node)内部的标题部分 */
|
||||
font-size:1.1em; /* 比节点内的普通文字略大,突出标题的层级地位,方便用户快速定位每个节点的核心信息 */
|
||||
color:#333; /* 比普通内容文字的颜色更深,通过颜色对比差异强化标题的视觉权重,同时相比纯黑色(#000)更柔和,避免视觉刺眼 */
|
||||
margin-bottom:8px; /* 使其与下方的节点内容(如描述文字、附加信息等)保持距离,形成清晰的内容层次 */
|
||||
cursor:pointer; /* 当鼠标指针悬停在标题上时,光标会变成手型(指针样式),暗示该标题是可点击的(可能关联展开 / 折叠、跳转等交互功能),增强交互的直观性 */
|
||||
display:flex; /* 使其内部元素(如图标和文字)能横向排列。 */
|
||||
align-items:center; /* 让标题内的子元素(如图标和文字)在垂直方向上居中对齐,保证视觉上的整齐度。 */
|
||||
gap:6px /* 避免元素紧贴,提升布局清晰度。 */
|
||||
}
|
||||
|
||||
.node-content { /* 用于定义类名为 .node-content 的元素样式,从命名和样式来看,这通常是节点(.node)内部的正文内容区域 */
|
||||
color:#666; /* 通过颜色差异区分内容层级,让标题更突出,同时保持内容的可读性 */
|
||||
font-size:.9em; /* 进一步通过尺寸差异强化标题与内容的层级关系,使节点结构更清晰 */
|
||||
margin-bottom:12px; /* 使其与节点内下方的其他元素(如操作按钮、附加信息等)保持距离,避免内容拥挤,提升整体布局的呼吸感 */
|
||||
white-space:pre-wrap; /* 控制文本的换行和空格处理:保留文本中的换行符和空格,同时当内容超出容器宽度时自动换行。 */
|
||||
line-height:1.5 /* 增加多行文本之间的垂直间距,使长文本更易阅读,减轻视觉疲劳,提升内容的可读性。 */
|
||||
}
|
||||
|
||||
.file-indicator { /* 用于定义类名为 .file-indicator 的元素样式,从命名和样式来看,这通常是用于标识节点中包含文件的小标签或提示元素 */
|
||||
display: inline-block; /* 将元素设置为行内块级元素,使其既可以像行内元素一样与其他内容(如文本)在同一行显示,又可以像块级元素一样设置宽高、内外边距等样式,适合作为小型标签使用 */
|
||||
background: #e9f5e9; /* 与包含文件的节点左侧边框颜色(#2ecc71)相呼应,形成统一的视觉标识系统,直观关联 “文件” 这一特征。 */
|
||||
padding: 3px 8px; /* 使标签文字与边框保持适当距离,既保证标签小巧紧凑,又避免文字过于拥挤。 */
|
||||
border-radius: 3px; /* 使其外观更柔和,与节点整体的圆角设计风格统一。 */
|
||||
font-size: 0.8em; /* 比节点内容文字更小,作为辅助信息不抢占主要内容的视觉焦点。 */
|
||||
color: #27ae60; /* 与背景色形成柔和对比,保证文字清晰可读,同时与节点左侧的文件标识色属同一色系,强化 “文件” 相关的视觉关联 */
|
||||
margin-top: 5px; /* 使其与上方的内容(如节点描述)保持微小距离,避免紧贴,提升布局清晰度。 */
|
||||
}
|
||||
|
||||
.node-actions { /* 用于定义类名为 .node-actions 的元素样式,从命名和样式来看,该元素通常是节点(.node)内部的操作按钮容器(如 “编辑””删除””查看” 等按钮) */
|
||||
display:flex; /* 使内部的操作按钮能够沿水平方向排列,方便控制按钮间的间距和对齐方式 */
|
||||
gap:8px; /* 避免多个按钮紧贴在一起,使每个操作选项更易区分,提升点击准确性 */
|
||||
justify-content:flex-end; /* 控制按钮在水平方向的对齐方式:将所有操作按钮靠右对齐。这是内容条目中操作区的常见布局,既不干扰左侧主要内容的阅读,又符合用户寻找操作按钮的视觉习惯(通常在条目右侧)。 */
|
||||
cursor:default /* 操作区不跟随卡片的 pointer 光标,提示用户这里是按钮区 */
|
||||
}
|
||||
|
||||
.btn { /* 定义了一个基础按钮(.btn)的通用样式,适用于页面中各类按钮元素,具体解析如下 */
|
||||
padding:5px 10px; /* 使按钮保持紧凑但不拥挤的尺寸,既方便点击又不会占用过多空间。 */
|
||||
border:none; /* 移除默认边框,避免浏览器自带的按钮边框样式干扰自定义设计,使按钮风格更统一 */
|
||||
border-radius:3px; /* 让按钮边角呈现柔和效果,与页面中其他元素(如表单、节点)的圆角设计保持风格一致 */
|
||||
cursor:pointer; /* 鼠标悬停时显示手型光标,明确提示该元素可点击,增强交互的直观性。 */
|
||||
font-size:.85em; /* 比普通内容文字略小,保持按钮的紧凑感,同时保证文字清晰可读 */
|
||||
display:flex; /* 使用 Flex 布局,使按钮内可能包含的图标和文字能够横向排列。 */
|
||||
align-items:center; /* 让按钮内的图标和文字在垂直方向上居中对齐,确保视觉上的整齐度。 */
|
||||
gap:3px /* 避免元素紧贴,提升按钮内部布局的清晰度。 */
|
||||
}
|
||||
|
||||
.btn-primary { /* 用于强调页面中的重要操作按钮 */
|
||||
background:#888; /* 与页面头部的背景色保持一致,形成视觉呼应,强化主要操作按钮的辨识度 */
|
||||
color:#fff /* 与深灰色背景形成强烈对比,确保文字清晰可读,同时突出按钮的重要性 */
|
||||
}
|
||||
|
||||
.btn-danger { /* 危险 / 警告类按钮样式,通常用于表示具有破坏性或重要警告性质的操作(如 “删除”“取消”“确认删除” 等) */
|
||||
background:#e74c3c; /* 设置按钮背景色为鲜红色,这是国际通用的警告 / 危险色,能快速引起用户注意,暗示该操作可能具有不可逆的后果(如删除数据) */
|
||||
color:#fff /* 文字颜色为纯白色,与鲜红色背景形成高对比度,确保文字清晰可读,同时强化视觉冲击力,进一步突出操作的特殊性。 */
|
||||
}
|
||||
|
||||
.btn-secondary { /* 用于表示次要按钮(次要操作按钮),通常用于次要或辅助性的交互操作 */
|
||||
background:#aaa; /* 按钮背景色为中灰色,比主要按钮更浅,视觉权重更低,通过颜色明暗差异直观体现操作的次要地位 */
|
||||
color:#fff /* 文字仍为纯白色,与中灰色背景保持足够对比度以保证可读性,同时保持与其他按钮在文字样式上的一致性。 */
|
||||
}
|
||||
|
||||
.btn-warning { /* 用于表示警告类按钮,通常用于需要用户注意但不及危险操作严重的场景(如 “修改”“覆盖” 等可能带来一定影响的操作) */
|
||||
background:#f39c12; /* 按钮背景色为橙色,这是国际通用的警告色,视觉上比主要按钮(灰色)更醒目,但比危险按钮(红色)柔和,既提示用户关注操作,又不会造成过度紧张感 */
|
||||
color:#fff /* 文字为纯白色,与橙色背景形成高对比度,确保文字清晰可读,同时保持与其他按钮在文字样式上的一致性。 */
|
||||
}
|
||||
|
||||
.btn-success { /* 用于表示成功状态或正向操作的按钮(如 “保存成功”“提交完成”“确认通过” 等场景) */
|
||||
background:#2ecc71; /* 按钮背景色为青绿色,这是国际通用的成功 / 正向操作色,视觉上给人积极、安全的感受,常用于标识操作成功或具有明确积极意义的交互(如 “确认”“完成” 等)。 */
|
||||
color:#fff /* 文字为纯白色,与绿色背景形成高对比度,确保文字文字清晰可读,同时保持与其他按钮在文字样式上的一致性。 */
|
||||
}
|
||||
|
||||
.empty-state, .loading { /* 这是一个群组选择器,同时对两个类应用相同的样式,避免代码重复,确保空状态和加载状态在基础视觉表现上保持一致。 */
|
||||
text-align:center; /* 使内容(如提示文字、加载图标等)在容器中水平居中对齐,这种居中布局符合用户对状态提示的视觉预期,显得更规整。 */
|
||||
padding:30px; /* 让状态提示区域与周围内容保持足够距离,形成独立的视觉区块,突出状态信息 */
|
||||
color:#777 /* 文字颜色为中灰色,既保证可读性,又不会过于抢眼,适合作为辅助性的状态提示(不干扰页面核心内容) */
|
||||
}
|
||||
|
||||
.empty-state i, .loading i { /* 群组选择器,同时作用于空状态和加载状态容器内的 <i> 标签(图标元素),确保两种状态下的图标样式统一。 */
|
||||
font-size:2em; /* 将图标大小设置为 2em(相对单位),比普通文字大得多,使图标成为状态提示中的视觉焦点,通过醒目的图标快速传递状态信息(如空状态可能用文件夹或文件图标,加载状态可能用旋转图标) */
|
||||
margin-bottom:10px /* 使其与下方的文字说明(如 “暂无数据”“加载中...”)保持距离,避免图标与文字紧贴,提升状态提示区域的布局清晰度 */
|
||||
}
|
||||
|
||||
@keyframes spin { /* 声明一个名为 spin 的动画,后续可以通过 animation 属性将此动画应用到任意元素上 */
|
||||
|
||||
0% {/* 动画开始时(0% 进度),元素旋转角度为 0 度(初始状态) */
|
||||
transform:rotate(0deg)
|
||||
}
|
||||
|
||||
100% { /* 动画结束时(100% 进度),元素旋转角度为 360 度(完整旋转一周) */
|
||||
transform:rotate(360deg)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.loading i { /* 选择器定位到类为 .loading 的容器内的 <i> 标签(通常是图标元素),专门针对加载状态的图标应用样式 */
|
||||
animation:spin 1s linear infinite /* 这是动画属性的简写。 动画持续时间为 1 秒,即图标旋转一周需要 1 秒。 */
|
||||
} /* 动画速度曲线为匀速,使旋转过程平滑无加速或减速。动画循环次数为无限,确保在加载状态持续期间,图标一直保持旋转 */
|
||||
|
||||
.toast { /* 定义了一个名为 .toast 的样式类,用于创建页面中的轻量级提示框(类似 “Toast 通知”),通常用于操作成功 / 失败、系统提示等场景 */
|
||||
position:fixed; /* 采用固定定位,使提示框脱离文档流,位置相对于浏览器窗口固定,不会随页面滚动而移动,确保用户始终能看到提示。 */
|
||||
top:10px; /* 定位在页面右上角,这是 Toast 通知的经典位置(不遮挡主要内容,又能快速被注意到) */
|
||||
right:10px; /* ... */
|
||||
padding:8px 12px; /* 内部上下内边距 8px、左右 12px,让提示文字与边框保持适当距离,避免拥挤。 */
|
||||
border-radius:3px; /* 使提示框外观更柔和,符合现代 UI 设计风格。 */
|
||||
color:#fff; /* 文字颜色为白色,通常会配合不同背景色(如成功用绿色、错误用红色)使用,保证文字清晰可读。 */
|
||||
font-size:.85em; /* 文字大小略小于默认字体,作为辅助提示信息不抢占视觉焦点。 */
|
||||
z-index:1000; /* 较高的层级值,确保提示框显示在其他元素上方,不会被页面内容遮挡。 */
|
||||
transform:translateX(100%); /* 默认状态下,通过 X 轴平移(向右移动自身宽度的 100%)将提示框移出视口右侧,实现 “隐藏” 效果。 */
|
||||
transition:transform .3s /* 为 transform 属性添加过渡动画,使提示框的显示 / 隐藏过程平滑无突兀感。 */
|
||||
}
|
||||
|
||||
.toast.show { /* 这是一个多类选择器,仅作用于同时拥有 toast 和 show 两个类的元素。结合之前定义的 .toast 基础样式,这个类专门用于触发提示框的 “显示” 状态 */
|
||||
transform:translateX(0) /* 将提示框的 X 轴偏移量设为 0,使其从右侧滑入并完全显示在视口内(右上角位置)由于设置了过渡时间,会以动画的形式完成 */
|
||||
}
|
||||
|
||||
.toast.success { /* 它在 .toast 基础样式(定位、尺寸、动画等)的基础上,专门定制 “成功” 状态的视觉特征 */
|
||||
background:#4caf50 /* 设置背景色为鲜亮的绿色,通过颜色直观传递 “操作成功” 的积极信息,与错误状态(通常用红色)、警告状态(通常用黄色)形成明确区分。 */
|
||||
}
|
||||
|
||||
.toast.error { /* 它在 .toast 基础样式(固定定位、尺寸、圆角、文字颜色、过渡动画等)的基础上,专门定制 “错误” 状态的视觉特征 */
|
||||
background:#e74c3c /* 这是经典的 “错误 / 危险色” */
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background:#2196f3 /* 这是经典的 “信息 / 通知色” */
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
background:#f39c12 /* 这是经典的 “警告 / 注意色” */
|
||||
}
|
||||
|
||||
.modal {
|
||||
display:none; /* 模态框默认处于隐藏状态,需要通过 JavaScript 动态修改为display: flex(或其他显示值)来触发显示。 */
|
||||
position:fixed; /* 采用固定定位,使模态框脱离文档流,位置相对于浏览器窗口固定,不随页面滚动而移动,确保弹窗始终在视野内。 */
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:rgba(0,0,0,0.5); /* 设置背景为半透明黑色(透明度 50%),作为模态框的遮罩层,既模糊遮挡底层页面内容,又突出弹窗本身,增强视觉层次感。 */
|
||||
z-index:999; /* 较高的层级值(999)确保模态框显示在页面其他元素之上,不会被底层内容遮挡(通常比普通元素层级高,但低于顶级提示如 Toast)。 */
|
||||
justify-content:center; /* 让所有子元素在主轴方向上居中对齐 */
|
||||
align-items:center; /* 让所有子元素在交叉轴方向上居中对齐。 */
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display:flex /* 将模态框的显示模式设置为 Flex 布局 */
|
||||
}
|
||||
|
||||
.modal-content{ /* 模态框(Modal)内部的内容容器样式(.modal-content) */
|
||||
background:#fff; /* 内容容器背景设为纯白色,与外部 .modal 的半透明黑色遮罩形成强烈对比,使弹窗内容从遮罩中凸显出来,提升可读性和视觉焦点 */
|
||||
padding:15px; /* 让容器内的内容(如标题、文本、按钮)与边框保持适当距离,避免内容紧贴边缘导致的拥挤感,提升排版舒适度 */
|
||||
border-radius:4px; /* 圆角设计,使内容容器的边角更柔和,与模态框的整体现代风格统一,避免直角带来的生硬感。 */
|
||||
width:90%; /* 确保在手机等小屏幕设备上,弹窗不会超出屏幕边缘,保持良好的适配性。 */
|
||||
max-width:500px /* 避免在桌面等大屏幕设备上弹窗过宽(过宽的内容会降低阅读体验),确保内容区域始终保持在舒适的阅读宽度范围内。 */
|
||||
}
|
||||
|
||||
.modal-header { /* 用于组织模态框顶部区域的布局(通常包含标题和关闭按钮) */
|
||||
display:flex; /* 使头部容器内的子元素(如标题、关闭按钮)可以灵活排列,便于实现两端对齐等布局效果 */
|
||||
justify-content:space-between; /* 两端元素分别靠向容器的左右两端,中间自动留白。 */
|
||||
margin-bottom:12px; /* 保持距离,避免视觉上的拥挤 */
|
||||
padding-bottom:8px; /* 让头部内容(标题、按钮)与底部边框之间有一定空隙,提升内部排版的呼吸感 */
|
||||
border-bottom:1px solid #eee /* 头部底部添加一条浅灰色的细边框,作为头部与内容区域的视觉分隔线,强化模态框内部的区域划分,让结构更清晰 */
|
||||
}
|
||||
|
||||
.modal-title { /* 美化模态框头部的标题文字 */
|
||||
font-size:1.1em; /* 既保证标题在视觉上的突出性(区别于正文内容),又不会因过大而显得突兀,符合 “标题略大于正文” 的排版逻辑 */
|
||||
color:#555 /* 标题文字颜色为深灰色,既纯黑色更柔和,避免视觉刺眼,同时比浅灰色更清晰,确保标题的可读性。这种中性调既符合现代 UI 的简约风格,又能与模态框的白色背景形成适当对比,突出标题信息。 */
|
||||
}
|
||||
|
||||
.modal-close { /* 定义了模态框关闭按钮的样式,用于美化和功能化模态框的关闭按钮(通常是 “×” 符号或 “关闭” 文字) */
|
||||
background:none; /* 移除按钮默认的背景样式(如灰色背景或系统默认按钮背景),使按钮呈现 “透明背景” 效果,更贴合现代简约的 UI 设计,避免与模态框头部的白色背景产生视觉冲突。 */
|
||||
border:none; /* 去除按钮默认的边框(如浏览器自带的按钮边框),进一步简化按钮外观,使其仅通过内容(如 “×” 符号)传递功能,不添加多余的视觉元素。 */
|
||||
font-size:1.2em; /* 按钮文字 / 符号大小设置为 1.2em,比模态框标题略大,确保关闭按钮在视觉上足够显眼,用户能快速识别并点击。 */
|
||||
cursor:pointer; /* 明确提示这是一个可点击的交互元素,增强用户操作的引导性。 */
|
||||
color:#999 /* 按钮文字 / 符号颜色为浅灰色,既不会过于抢眼(避免分散对标题的注意力),又能清晰可见,符合其 “辅助功能按钮” 的定位 */
|
||||
}
|
||||
|
||||
.modal-body { /* 用于包裹核心内容的容器类(如表单、文本、列表等主要信息) */
|
||||
margin-bottom:15px /* 是与下方的元素保持距离,避免内容区域与操作区域紧贴在一起,形成清晰的视觉分隔,提升模态框内部布局的层次感和可读性。 */
|
||||
}
|
||||
|
||||
.modal-footer { /* 用于布局模态框底部的操作按钮 */
|
||||
display:flex; /* 使底部区域内的子元素(通常是按钮)能够灵活排列,便于控制按钮之间的间距和对齐方式 */
|
||||
gap:8px; /* 确保多个按钮(如 “取消” 和 “确认”)之间有均匀的空隙,避免拥挤在一起,提升操作区域的整洁度和可读性。 */
|
||||
justify-content:flex-end /* 控制 Flex 容器内子元素在主轴(默认水平方向)上的对齐方式:让所有子元素靠右对齐 */
|
||||
}
|
||||
|
||||
.file-upload-area { /* 用于创建一个直观的 “拖放或点击上传文件” 的交互区域,常见于表单中的文件上传功能 */
|
||||
border: 2px dashed #ccc; /* 虚线边框是文件上传区域的经典设计,通过视觉特征(非实线)暗示这是一个 “可交互的上传区域”,区别于普通容器。 */
|
||||
padding: 20px; /* 为区域内的提示文字(如 “点击或拖放文件到此处”)、图标等内容预留足够空间,避免内容紧贴边框导致的拥挤感。 */
|
||||
text-align: center; /* 区域内的文本和 inline 元素(如图标)水平居中对齐,确保提示信息在视觉上更规整,符合用户对 “上传区域” 的预期布局。 */
|
||||
border-radius: 4px; /* 使边框边角更柔和,与整体 UI(如模态框的圆角风格)保持一致,避免直角带来的生硬感。 */
|
||||
cursor: pointer; /* 鼠标悬停时显示 “手型光标”,明确提示该区域是可点击的交互元素,引导用户点击触发文件选择。 */
|
||||
transition: border-color 0.3s; /* 为边框颜色变化添加 0.3 秒过渡动画,使状态变化(如鼠标悬停时边框变色)更平滑自然。通常会配合 :hover 伪类使用 */
|
||||
margin-bottom: 10px; /* 使上传区域与下方元素(如文件预览区、提交按钮)保持距离,提升整体布局的层次感 */
|
||||
}
|
||||
|
||||
.file-upload-area:hover { /* 示当鼠标指针悬停在元素上时,应用该样式。这是一种常见的交互反馈设计,通过视觉变化告知用户 “当前元素可交互”。 */
|
||||
border-color: #888; /* 深灰色 */
|
||||
}
|
||||
|
||||
.file-info { /* 主要用于显示文件上传相关的辅助信息(如文件名、大小、格式提示等) */
|
||||
font-size: 0.9em; /* 比默认文字大小略小,明确其 “辅助信息” 的定位,避免与主要内容(如上传区域提示文字、按钮文本)争夺视觉焦点。 */
|
||||
color: #666; /* 比正文文字颜色更柔和,既保证可读性,又通过颜色区分强调其 “补充说明” 的性质(如上传后的文件名、格式限制提示等)。 */
|
||||
margin-top: 8px; /* 使文件信息区域与上方元素保持适当距离,避免信息堆叠,提升布局的呼吸感和清晰度。 */
|
||||
}
|
||||
|
||||
.file-progress { /* 文件上传进度条容器的基础样式,用于展示文件上传过程中的进度状态 */
|
||||
height: 5px; /* 用纤细的设计,既不占用过多视觉空间,又能清晰展示进度状态,符合常见进度条的紧凑设计风格 */
|
||||
background-color: #eee; /* 作为进度条的 “底色”,当进度填充时,会被进度颜色覆盖,形成 “已完成进度” 与 “剩余进度” 的视觉对比。 */
|
||||
border-radius: 3px; /* 与上传区域、模态框等组件的圆角风格统一,使界面元素风格协调,避免直角的生硬感。 */
|
||||
overflow: hidden; /* 设置溢出隐藏,确保内部用于展示进度的子元素不会超出容器范围,保持进度条的整齐外观。 */
|
||||
margin-top: 10px; /* 使进度条与上方元素保持距离,避免布局拥挤,提升信息层级的清晰度。 */
|
||||
display: none; /* 默认隐藏进度条容器,因为进度条仅在文件上传过程中需要展示(未上传或上传完成后通常隐藏) */
|
||||
}
|
||||
|
||||
.file-progress-bar { /* 定义了文件上传进度条的核心样式,用于直观展示文件上传的实时进度 */
|
||||
height: 100%; /* 进度条高度设置为父容器的 100%,即填满父容器的 5px 高度,确保进度条在垂直方向上完全覆盖容器,形成完整的进度展示效果。 */
|
||||
background-color: #4caf50; /* 与之前定义的 “成功状态”颜色一致,形成视觉风格统一,同时绿色传递 “正常进度”“成功” 的积极信号,让用户感知上传过程正常。 */
|
||||
width: 0%; /* 初始宽度为 0%,表示上传未开始时进度条完全隐藏。实际使用时,会通过 JavaScript 根据上传进度动态修改该值(如从 0% 递增到 100%),直观反映文件上传的完成比例。 */
|
||||
transition: width 0.3s; /* 为宽度变化添加 0.3 秒过渡动画,使进度条的宽度从一个值平滑过渡到另一个值(如从 30% 到 40%),避免进度跳变带来的突兀感,提升用户对进度变化的感知流畅度。 */
|
||||
}
|
||||
|
||||
/* 文件元信息显示 */
|
||||
.file-meta-info { /* 定义了文件元信息展示区域的样式,用于展示文件的详细元数据(如文件大小、类型、上传时间、修改日期等),通常在文件上传完成后显示 */
|
||||
background: #e9f5e9; /* 低饱和度的 “成功色系”,与文件上传成功的状态形成视觉呼应,传递 “文件已处理完成” 的积极信号,同时浅色调不会过于抢眼。 */
|
||||
padding: 8px; /* 为元信息内容(如文字、列表项)提供足够的呼吸空间,避免内容紧贴边框导致的拥挤感,提升可读性 */
|
||||
border-radius: 3px; /* 与模态框、上传区域等组件的圆角风格统一,使界面元素风格协调,避免直角带来的生硬感。 */
|
||||
border-left: 3px solid #2ecc71; /* 通过左侧的彩色色块色块与浅绿背景形成层次对比,突出元信息区域的边界; 深绿色与背景色同属绿色系,既协调又有区分度,强化 “成功 / 完成” 的状态暗示。 */
|
||||
margin-top: 8px; /* 使元信息区域与上方元素(如进度条、文件信息提示)保持距离,避免信息堆叠,清晰划分不同功能区域(上传操作区→进度展示→元信息详情) */
|
||||
font-size: 0.9em; /* 明确其 “辅助信息” 的定位,既保证内容可读,又不抢占主要操作区域的视觉焦点 */
|
||||
}
|
||||
|
||||
.file-meta-info.error { /* 专门用于标识 “错误状态” 的文件元信息 */
|
||||
background: #fdecea; /* 将背景色从基础样式的浅绿色改为浅红色。这种低饱和度的红色系背景,既与 “错误” 状态的视觉语言呼应,又不会过于刺眼,适合展示错误相关的辅助信息。 */
|
||||
border-left-color: #e74c3c; /* 将左侧强调边框的颜色从基础样式的深绿色改为鲜红色。这个颜色与 .toast.error 中定义的错误状态主色完全一致,形成跨组件的视觉统一 —— 通过左侧醒目的红色边框,快速传递 “文件处理出错” 的状态,与成功状态的绿色边框形成鲜明对比,让用户一眼识别信息的性质。 */
|
||||
}
|
||||
|
||||
.hidden-field { /* 定义了一个用于强制隐藏元素的类,主要用于需要彻底隐藏页面元素(如表单字段、临时内容等)的场景 */
|
||||
display: none !important; /* 核心作用是将元素从页面布局中完全移除:元素不仅不可见,还不会占据任何空间(与 visibility: hidden 不同,后者仅隐藏元素但保留其占位空间)。
|
||||
*/
|
||||
} /* CSS 中的 “重要声明” 标记,用于强制提升该样式的优先级,确保它能覆盖其他可能影响该元素 display 属性的样式规则(即使其他规则的选择器权重更高)。 */
|
||||
|
||||
/* 下载按钮样式 */
|
||||
.download-btn { /* 定义了下载按钮的样式,主要用于文件相关功能中(如下载已上传的文件),设计上突出了可交互性且保持与整体 UI 风格的一致性 */
|
||||
margin-left: 8px; /* 使其与左侧相邻元素(如文件名、其他按钮)保持适当距离,避免元素紧贴,提升布局的呼吸感。 */
|
||||
padding: 2px 6px; /* 小型按钮的紧凑设计,既保证按钮有足够的点击区域(符合交互体验),又不会占用过多空间,适合作为辅助性操作按钮(如在文件信息旁附加下载功能) */
|
||||
font-size: 0.8em; /* 字体大小为 0.8em(小于默认文字大小),配合紧凑的内边距,强化 “小型辅助按钮” 的定位,避免与主要操作按钮(如 “确认”“上传”)争夺视觉焦点。 */
|
||||
background-color: #2ecc71; /* 背景色采用深绿色,与之前定义的 “成功状态” 元素颜色一致,形成跨组件的视觉统一,同时绿色传递 “安全、可执行” 的积极信号,引导用户点击。 */
|
||||
color: white; /* 确保按钮文字清晰可读,即使在小字号下也能轻松识别。 */
|
||||
border-radius: 3px; /* 与模态框、文件元信息等组件的圆角风格统一,使按钮边角更柔和,避免直角带来的生硬感。 */
|
||||
cursor: pointer; /* 鼠标悬停时显示 “手型光标”,明确提示这是可点击的交互元素,增强用户操作的引导性。 */
|
||||
border: none; /* 移除按钮默认边框,使按钮外观更简洁,与绿色背景融为一体,符合现代 UI 的简约设计风格。 */
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: #27ae60; /* 深绿色 */
|
||||
}
|
||||
|
||||
/* 数据修复提示 */
|
||||
.data-repair-hint { /* 数据修复相关的提示信息样式,用于在数据修复过程中向用户展示提示、说明或状态信息(如修复进度、注意事项等) */
|
||||
background: #fff3cd; /* 背景色为浅淡黄色,属于低饱和度的 “提示色系”。这种颜色既比白色背景更醒目(能吸引用户注意),又比红色(错误)、绿色(成功)更柔和,适合传递 “需要关注但非紧急 / 错误” 的信息,符合 “数据修复提示” 的功能定位 */
|
||||
border-left: 3px solid #ffc107; /* 深黄色 */
|
||||
padding: 8px; /* 为提示文字(如 “正在修复损坏的数据...”)预留空间,避免内容紧贴边框导致的拥挤感,提升可读性 */
|
||||
margin: 10px 0; /* 使提示区域在垂直方向上与上下元素(如操作按钮、数据列表)保持距离,避免信息堆叠,清晰划分功能区域。 */
|
||||
border-radius: 3px; /* 与模态框、文件元信息、按钮等组件的圆角风格统一,使界面元素风格协调,避免直角的生硬感。 */
|
||||
font-size: 0.9em; /* 明确其 “辅助提示” 的定位,既保证内容可读,又不抢占主要操作区域的视觉焦点。 */
|
||||
}
|
||||
|
||||
@media (max-width:600px){ /* 响应式媒体查询,用于在屏幕宽度小于或等于 600px(通常对应手机等移动设备)时,调整页面元素的样式,确保在小屏幕上有更好的显示效果和用户体验 */
|
||||
|
||||
.header{
|
||||
flex-direction:column; /* 主轴方向设置为垂直方向(默认是水平方向)。这意味着头部内的子元素(如标题、导航项、操作按钮等)会从上到下依次排列,而非左右并排,适合需要垂直堆叠布局的头部设计(如移动端头部或紧凑布局的头部) */
|
||||
gap:8px; /* 避免子元素紧贴在一起,让布局更透气、层次更清晰。 */
|
||||
padding:10px /* 使头部内容(如文字、按钮)与容器边缘保持一定距离,避免内容贴边导致的拥挤感,同时提升视觉上的舒适感。 */
|
||||
}
|
||||
|
||||
.header-actions{ /* 头部操作区的布局样式,主要用于控制头部区域中操作按钮 / 功能项的排列方式 */
|
||||
width:100%; /* 使头部操作区的宽度占满其父容器的全部宽度,确保操作区在水平方向上充分利用可用空间,避免因宽度不足导致内部元素(如按钮、图标)挤压或换行 */
|
||||
justify-content:center /* 该属性控制其内部子元素(如 “登录”“设置” 按钮、菜单图标等)在水平方向(主轴)上居中对齐 */
|
||||
}
|
||||
|
||||
.form-actions{ /* 主要用于控制表单底部操作按钮(如 “提交”“取消”“重置” 等)的排列行为 */
|
||||
flex-wrap:wrap /* 允许子元素(按钮)在容器宽度不足时自动换行。 */
|
||||
}
|
||||
|
||||
.form-actions .btn{ /* 作用于所有包含在 .form-actions 容器内、类名为 btn 的按钮(即表单底部的 “提交”“取消” 等操作按钮) */
|
||||
flex:1 /* 这是 Flex 布局的核心属性简写,等价于 flex: 1 1 0%,包含三个子属性 */
|
||||
} /* flex-grow: 1:按钮可以根据父容器的剩余空间 “扩展”,扩展比例为 1(所有按钮的扩展比例相同) */
|
||||
/* flex-shrink: 1:当父容器空间不足时,按钮可以 “收缩”,收缩比例为 1(所有按钮按相同比例收缩) */
|
||||
/* flex-basis: 0%:按钮的初始宽度基准为 0,确保分配空间时完全基于 flex-grow 的比例,而非按钮自身内容的宽度 */
|
||||
|
||||
.modal-content{
|
||||
width:95% /* 设置该容器的宽度为其父元素(通常是模态框的外层容器,如 .modal 或直接相对于 <body>)宽度的 95%。
|
||||
这种百分比宽度的设计核心是响应式适配 */
|
||||
}
|
||||
|
||||
textarea.form-control{
|
||||
min-height:100px /* 最小高度为 100 像素 */
|
||||
}
|
||||
|
||||
.move-mode-bar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.move-mode-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ===== 节点迁移功能样式 ===== */
|
||||
|
||||
.btn-move { /* 移动按钮:用于触发节点迁移模式 */
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-move:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.move-mode-bar { /* 迁移模式提示栏:固定在页面顶部,显示正在迁移的节点信息和操作按钮 */
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #2c3e50;
|
||||
color: #fff;
|
||||
padding: 10px 15px;
|
||||
gap: 10px;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.move-mode-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.move-mode-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-move-here { /* 移动到此按钮:确认将节点移到当前位置 */
|
||||
background: #2ecc71;
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.9em;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-move-here:hover {
|
||||
background: #27ae60;
|
||||
}
|
||||
|
||||
.btn-cancel-move { /* 取消迁移按钮 */
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.9em;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-cancel-move:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,144 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>树状笔记</title>
|
||||
|
||||
<!-- Font Awesome 6.0.0 是矢量图标库,提供 7,000+ 免费图标及 17,000+ 专业图标。 -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<!-- 外部CSS文件 -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-sitemap"></i> 树状笔记</h1>
|
||||
<div class="header-actions">
|
||||
<button onclick="resetForm()" class="btn btn-primary"><i class="fas fa-plus"></i> 新增节点</button>
|
||||
<button onclick="goBack()" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> 返回上级</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点迁移模式提示栏 -->
|
||||
<div id="move-mode-bar" class="move-mode-bar" style="display: none;">
|
||||
<div class="move-mode-info">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
正在迁移节点:<strong id="moving-node-title"></strong>
|
||||
</div>
|
||||
<div class="move-mode-actions">
|
||||
<button onclick="confirmMoveHere()" class="btn btn-move-here"><i class="fas fa-arrow-down"></i> 移动到此</button>
|
||||
<button onclick="cancelMove()" class="btn btn-cancel-move"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="form-section">
|
||||
<!-- 表单标题 -->
|
||||
<h3 class="form-title"><i class="fas fa-pen"></i> <span id="form-mode-text">新增节点</span></h3>
|
||||
<input type="hidden" id="edit-id" value=""> <!-- 隐藏输入框,要编辑节点的id -->
|
||||
<!-- 输入节点标题 -->
|
||||
<div class="form-group">
|
||||
<label for="node-title"><i class="fas fa-heading"></i> 标题</label>
|
||||
<input type="text" id="node-title" class="form-control" placeholder="输入节点标题">
|
||||
</div>
|
||||
<!-- 输入节点内容 -->
|
||||
<div class="form-group">
|
||||
<label for="node-content"><i class="fas fa-file-alt"></i> 节点说明</label>
|
||||
<textarea id="node-content" class="form-control" placeholder="输入节点的文字说明"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="form-group">
|
||||
<label><i class="fas fa-upload"></i> 文件上传</label>
|
||||
<div class="file-upload-area" onclick="document.getElementById('file-upload').click()">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size: 2em; color: #888; margin-bottom: 10px;"></i>
|
||||
<p>点击或拖拽文件到此处上传</p>
|
||||
<p style="font-size: 0.8em; color: #999;">文件将以编码形式保存,同一节点内数据不要超过1M</p>
|
||||
<input type="file" id="file-upload" style="display: none;" onchange="handleFileUpload(this.files)"> <!-- 隐藏输入框 -->
|
||||
</div>
|
||||
|
||||
<!-- 选择文件状态信息 -->
|
||||
<div class="file-info" id="file-info">未选择文件</div>
|
||||
|
||||
<!-- 文件元信息显示 -->
|
||||
<div id="file-meta-container" style="display: none;">
|
||||
<div id="file-meta-info" class="file-meta-info">
|
||||
<p><i class="fas fa-file"></i> 文件名:<span id="meta-filename"></span></p>
|
||||
<p><i class="fas fa-hdd"></i> 文件大小:<span id="meta-filesize"></span></p>
|
||||
<p><i class="fas fa-calendar-alt"></i> 上传时间:<span id="meta-uploadtime"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据修复提示 -->
|
||||
<div id="data-repair-hint" class="data-repair-hint" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>检测到文件元数据损坏。请尝试重新上传文件以修复此问题。</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件进度条 -->
|
||||
<div class="file-progress">
|
||||
<div class="file-progress-bar" id="file-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件下载区域(仅在有文件时显示) -->
|
||||
<div class="form-group" id="download-section" style="display: none;">
|
||||
<label><i class="fas fa-download"></i> 文件操作</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button onclick="decodeAndDownload(null, null, true)" class="btn btn-success">
|
||||
<i class="fas fa-download"></i> 下载文件
|
||||
</button>
|
||||
<button onclick="removeFile()" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> 移除文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的Base64存储字段 - 用户永远不会看到 -->
|
||||
<textarea id="hidden-base64" class="form-control hidden-field" placeholder="Base64编码内容(自动生成,隐藏显示)"></textarea>
|
||||
<input type="hidden" id="file-metadata" value="">
|
||||
|
||||
<!-- 动作表单 -->
|
||||
<div class="form-actions">
|
||||
<button onclick="saveNode()" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
|
||||
<button onclick="resetForm()" class="btn btn-secondary"><i class="fas fa-undo"></i> 重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点表单 -->
|
||||
<div id="node-list" class="node-list">
|
||||
<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div id="delete-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title"><i class="fas fa-exclamation-triangle"></i> 确认删除</h3>
|
||||
<button class="modal-close" onclick="closeDeleteModal()"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="delete-message">确定删除该节点吗?</p>
|
||||
<p id="delete-node-title-container" style="font-weight: bold; margin: 8px 0;"></p>
|
||||
<p id="delete-file-hint" style="font-size:.85em;color:#e74c3c;display:none;"><i class="fas fa-exclamation-circle"></i> 此节点包含文件,删除后文件将无法恢复</p>
|
||||
<p style="font-size:.85em;color:#777">此操作不可恢复</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="confirmDelete()" class="btn btn-danger"><i class="fas fa-trash"></i> 删除</button>
|
||||
<button onclick="closeDeleteModal()" class="btn btn-secondary"><i class="fas fa-times"></i> 取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- 外部JS文件 -->
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <us/HttpClient.hpp>
|
||||
#include "Msg.hpp"
|
||||
#include <boost/json.hpp>
|
||||
namespace qing {
|
||||
namespace json = boost::json;
|
||||
class DbC {
|
||||
public:
|
||||
// 【保留】旧版 HTTP 接口:通过 HttpClient POST 到 dbv2 HTTP /execute 端点。
|
||||
// 保留用于兼容性参考,新代码优先使用 call_msg()(pymsg TCP 协议,更可靠)。
|
||||
static json::value call_http(json::value& params) {
|
||||
|
||||
auto client = pmc::net::HttpClient();
|
||||
client.set_header("Content-Type: application/json");
|
||||
std::string payload = json::serialize(params);
|
||||
std::string res = "";
|
||||
client.post_stream_sync(_DBURL, payload, [&res](const auto& ret) {
|
||||
res += ret;
|
||||
});
|
||||
return json::parse(res);
|
||||
}
|
||||
|
||||
// 新版 pymsg 接口:通过 MsgClient(TCP 长度前缀帧协议)调用 dbv2。
|
||||
// 自动添加 _route = "execute",返回 dbv2 pymsg 完整 JSON 响应
|
||||
// {"success":..., "data":[...]}。
|
||||
// 响应格式与 call_http 兼容,调用方可直接替换。
|
||||
static json::value call_msg(const json::value& params,
|
||||
double timeout = 5.0) {
|
||||
json::value jv_route = params;
|
||||
jv_route.as_object()["_route"] = "execute";
|
||||
pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
||||
std::string resp = client.call(
|
||||
json::serialize(jv_route), timeout);
|
||||
return json::parse(resp);
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr const char* _DBURL = "http://127.0.0.1:8005/execute";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
#pragma once
|
||||
|
||||
/** pymsg C++ 实现 — Length-prefixed variable-length socket message framing.
|
||||
*
|
||||
* Protocol: [4-byte big-endian uint32_t length][UTF-8 payload]
|
||||
*
|
||||
* 对应 Python 版 pymsg/pymsg/__init__.py,提供:
|
||||
* - pmsg_send() / pmsg_recv() 底层协议收发函数
|
||||
* - MsgClient 短连接 send / call / 长连接 listen 客户端
|
||||
*
|
||||
* Usage:
|
||||
* #include "Msg.hpp"
|
||||
*
|
||||
* pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
||||
* std::string resp = client.call(R"({"sql":"SELECT 1"})", 5.0);
|
||||
* client.send("hello");
|
||||
*/
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// macOS 没有 MSG_NOSIGNAL,用 0 替代(内核不会发 SIGPIPE)
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
// ============================================================================
|
||||
// Exceptions
|
||||
// ============================================================================
|
||||
|
||||
class MsgException : public std::runtime_error {
|
||||
public:
|
||||
explicit MsgException(const std::string &msg) : std::runtime_error(msg) {}
|
||||
};
|
||||
|
||||
class MsgTimeoutException : public MsgException {
|
||||
public:
|
||||
explicit MsgTimeoutException(const std::string &msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
class MsgConnectionException : public MsgException {
|
||||
public:
|
||||
explicit MsgConnectionException(const std::string &msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Low-level protocol: 4-byte big-endian length prefix + UTF-8 payload
|
||||
// ============================================================================
|
||||
|
||||
namespace detail {
|
||||
|
||||
constexpr size_t kHeaderSize = 4;
|
||||
|
||||
// ---- 4-byte big-endian 编码 / 解码 ----------------------------------------
|
||||
|
||||
inline void writeUint32BE(char *buf, uint32_t val) noexcept {
|
||||
buf[0] = static_cast<char>((val >> 24) & 0xFFu);
|
||||
buf[1] = static_cast<char>((val >> 16) & 0xFFu);
|
||||
buf[2] = static_cast<char>((val >> 8) & 0xFFu);
|
||||
buf[3] = static_cast<char>(val & 0xFFu);
|
||||
}
|
||||
|
||||
inline uint32_t readUint32BE(const char *buf) noexcept {
|
||||
return (static_cast<uint32_t>(static_cast<unsigned char>(buf[0])) << 24) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[1])) << 16) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[2])) << 8) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[3])));
|
||||
}
|
||||
|
||||
// ---- socket 超时设置 ------------------------------------------------------
|
||||
|
||||
inline void setSockTimeout(int sockfd, double seconds) {
|
||||
struct timeval tv {};
|
||||
tv.tv_sec = static_cast<time_t>(seconds);
|
||||
tv.tv_usec =
|
||||
static_cast<long>((seconds - static_cast<double>(tv.tv_sec)) * 1'000'000.0);
|
||||
::setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
::setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
// ---- 精确读取 n 字节 ------------------------------------------------------
|
||||
|
||||
inline void recvExact(int sockfd, char *buf, size_t n) {
|
||||
size_t received = 0;
|
||||
while (received < n) {
|
||||
ssize_t r = ::recv(sockfd, buf + received, n - received, 0);
|
||||
if (r == 0) {
|
||||
throw MsgConnectionException(
|
||||
"connection closed: expected " + std::to_string(n) +
|
||||
" bytes, got " + std::to_string(received));
|
||||
}
|
||||
if (r < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
throw MsgTimeoutException("recv timed out");
|
||||
}
|
||||
throw MsgConnectionException(
|
||||
"recv error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
received += static_cast<size_t>(r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 完整发送 -------------------------------------------------------------
|
||||
|
||||
inline void sendAll(int sockfd, const char *data, size_t len) {
|
||||
size_t sent = 0;
|
||||
while (sent < len) {
|
||||
ssize_t w =
|
||||
::send(sockfd, data + sent, len - sent, MSG_NOSIGNAL);
|
||||
if (w < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
throw MsgTimeoutException("send timed out");
|
||||
}
|
||||
throw MsgConnectionException(
|
||||
"send error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
sent += static_cast<size_t>(w);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 短连接 helper:connect → action → close ------------------------------
|
||||
|
||||
inline int openConnection(const std::string& addr, int port, double timeout) {
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
throw MsgConnectionException(
|
||||
"socket() failed: " + std::string(::strerror(errno)));
|
||||
}
|
||||
|
||||
struct sockaddr_in serv_addr {};
|
||||
serv_addr.sin_family = AF_INET;
|
||||
serv_addr.sin_port = htons(static_cast<uint16_t>(port));
|
||||
if (::inet_pton(AF_INET, addr.c_str(), &serv_addr.sin_addr) <= 0) {
|
||||
::close(fd);
|
||||
throw MsgConnectionException("invalid address: " + addr);
|
||||
}
|
||||
|
||||
setSockTimeout(fd, timeout);
|
||||
|
||||
if (::connect(fd, reinterpret_cast<struct sockaddr *>(&serv_addr),
|
||||
sizeof(serv_addr)) < 0) {
|
||||
::close(fd);
|
||||
throw MsgConnectionException(
|
||||
"could not connect to " + addr + ":" + std::to_string(port) +
|
||||
" - " + ::strerror(errno));
|
||||
}
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ============================================================================
|
||||
// 底层协议函数:pmsg_send / pmsg_recv
|
||||
// ============================================================================
|
||||
|
||||
/// 以 pymsg 协议发送一条字符串消息(长度前缀 + UTF-8 载荷)。
|
||||
inline void pmsg_send(int sockfd, const std::string &data) {
|
||||
size_t payload_size = data.size();
|
||||
if (payload_size > 0xFFFFFFFFu) {
|
||||
throw MsgException("payload too large: " + std::to_string(payload_size));
|
||||
}
|
||||
char header[detail::kHeaderSize];
|
||||
detail::writeUint32BE(header, static_cast<uint32_t>(payload_size));
|
||||
detail::sendAll(sockfd, header, detail::kHeaderSize);
|
||||
detail::sendAll(sockfd, data.c_str(), payload_size);
|
||||
}
|
||||
|
||||
/// 以 pymsg 协议接收一条字符串消息。
|
||||
/// @throws MsgConnectionException 连接在收全消息前断开。
|
||||
/// @throws MsgTimeoutException 等待超时。
|
||||
inline std::string pmsg_recv(int sockfd) {
|
||||
char header[detail::kHeaderSize];
|
||||
detail::recvExact(sockfd, header, detail::kHeaderSize);
|
||||
uint32_t length = detail::readUint32BE(header);
|
||||
if (length > 64 * 1024 * 1024) { // 安全上限 64 MiB
|
||||
throw MsgException("message too large: " + std::to_string(length));
|
||||
}
|
||||
std::string payload(static_cast<size_t>(length), '\0');
|
||||
detail::recvExact(sockfd, payload.data(), length);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgClient
|
||||
// ============================================================================
|
||||
|
||||
/// 基于 pymsg 协议的 TCP 客户端。
|
||||
///
|
||||
/// 支持三种操作模式:
|
||||
/// - send() 短连接发送一条消息(不等待回复),消息带 name 前缀。
|
||||
/// - call() 短连接发送 + 阻塞等待回复(RPC 模式),原始消息不添加前缀。
|
||||
/// - listen() 基于 connect() 维持的长连接,持续接收消息并回调。
|
||||
class MsgClient {
|
||||
public:
|
||||
MsgClient(const std::string &addr, int port, const std::string &name)
|
||||
: _addr(addr), _port(port), _name(name), _sockfd(-1), _running(false) {}
|
||||
|
||||
~MsgClient() { stop(); }
|
||||
|
||||
// ---- 连接 ---------------------------------------------------------------
|
||||
|
||||
/// 创建 TCP 套接字并连接到服务器(长连接,供 listen() 使用)。
|
||||
void connect() {
|
||||
_sockfd = detail::openConnection(_addr, _port, /*timeout=*/10.0);
|
||||
}
|
||||
|
||||
// ---- 发送单条消息(短连接,带名字前缀)----------------------------------
|
||||
|
||||
/// 短连接发送一条消息到服务器,消息格式为 ``"name: msg"``。
|
||||
/// 不等待回复。短连接(每次创建新 socket,发完即关)。
|
||||
void send(const std::string &msg) {
|
||||
int fd = detail::openConnection(_addr, _port, /*timeout=*/3.0);
|
||||
try {
|
||||
pmsg_send(fd, _name + ": " + msg);
|
||||
::close(fd);
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 请求-应答(短连接,RPC 模式)---------------------------------------
|
||||
|
||||
/// 短连接发送一条消息,阻塞等待并返回一条回复。
|
||||
///
|
||||
/// 不会给消息添加 ``name:`` 前缀(适用于 RPC/API 调用)。
|
||||
///
|
||||
/// @param msg 要发送的原始字符串。
|
||||
/// @param timeout 超时秒数,默认 10.0。
|
||||
/// @returns 服务器返回的字符串。
|
||||
std::string call(const std::string &msg, double timeout = 10.0) {
|
||||
int fd = detail::openConnection(_addr, _port, timeout);
|
||||
try {
|
||||
pmsg_send(fd, msg);
|
||||
std::string resp = pmsg_recv(fd);
|
||||
::close(fd);
|
||||
return resp;
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 持续监听(长连接,阻塞回调模式)-----------------------------------
|
||||
|
||||
/// 在长连接上持续接收消息并通过回调处理。
|
||||
///
|
||||
/// 阻塞当前线程直到 stop() 被调用或连接断开。
|
||||
/// 必须先调用 connect()。
|
||||
///
|
||||
/// @param on_message 回调 ``void(const std::string &msg)``。
|
||||
void listen(std::function<void(const std::string &)> on_message) {
|
||||
if (_sockfd < 0) {
|
||||
throw MsgException("must call connect() before listen()");
|
||||
}
|
||||
|
||||
_running = true;
|
||||
while (_running) {
|
||||
fd_set readfds;
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(_sockfd, &readfds);
|
||||
|
||||
struct timeval tv {};
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 100'000; // 100 ms 轮询间隔
|
||||
|
||||
int ready =
|
||||
::select(_sockfd + 1, &readfds, nullptr, nullptr, &tv);
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break; // EBADF or other fatal error
|
||||
}
|
||||
if (ready == 0) continue; // timeout, loop around
|
||||
|
||||
try {
|
||||
std::string text = pmsg_recv(_sockfd);
|
||||
on_message(std::move(text));
|
||||
} catch (const MsgConnectionException &) {
|
||||
break; // 连接断开,退出循环
|
||||
} catch (const MsgTimeoutException &) {
|
||||
continue; // 读超时但连接仍在,继续等待
|
||||
}
|
||||
}
|
||||
|
||||
if (_sockfd >= 0) {
|
||||
::close(_sockfd);
|
||||
_sockfd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 停止 ---------------------------------------------------------------
|
||||
|
||||
/// 设置停止标志并关闭套接字。可从任意线程调用。
|
||||
void stop() noexcept {
|
||||
_running = false;
|
||||
if (_sockfd >= 0) {
|
||||
::close(_sockfd);
|
||||
_sockfd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 访问器 -------------------------------------------------------------
|
||||
|
||||
const std::string &addr() const noexcept { return _addr; }
|
||||
int port() const noexcept { return _port; }
|
||||
const std::string &name() const noexcept { return _name; }
|
||||
|
||||
private:
|
||||
std::string _addr;
|
||||
int _port;
|
||||
std::string _name;
|
||||
int _sockfd;
|
||||
bool _running;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
+424
-437
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,112 @@
|
||||
#prjs
|
||||
*.db
|
||||
*.bin
|
||||
*.build
|
||||
*.dist
|
||||
*.onefile-build
|
||||
nuitka-crash-report.xml
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
@@ -0,0 +1,370 @@
|
||||
"""pymsg - Length-prefixed variable-length socket message framing.
|
||||
|
||||
Protocol: [4-byte big-endian uint32 length][UTF-8 payload]
|
||||
|
||||
Usage:
|
||||
import socket
|
||||
from pymsg import send, recv
|
||||
|
||||
# sender
|
||||
send(sock, "hello world")
|
||||
|
||||
# receiver
|
||||
msg = recv(sock)
|
||||
|
||||
# with timeout (seconds)
|
||||
send(sock, "hello", timeout=5.0)
|
||||
msg = recv(sock, timeout=5.0)
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import select
|
||||
import socket as _socket
|
||||
import struct
|
||||
|
||||
__all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"]
|
||||
|
||||
_HEADER = struct.Struct("!I") # 4 bytes, big-endian unsigned int
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
"""Raised when a send or recv operation exceeds its timeout."""
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _timeout(sock: "_socket.socket", seconds: "float | None"):
|
||||
"""Temporarily set the socket timeout, restoring it on exit."""
|
||||
if seconds is None:
|
||||
yield
|
||||
return
|
||||
original = sock.gettimeout()
|
||||
sock.settimeout(seconds)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sock.settimeout(original)
|
||||
|
||||
|
||||
def send(
|
||||
sock: "_socket.socket",
|
||||
data: str,
|
||||
*,
|
||||
timeout: "float | None" = None,
|
||||
) -> None:
|
||||
"""Send a string over a socket using length-prefixed framing.
|
||||
|
||||
Args:
|
||||
sock: A connected socket object.
|
||||
data: The string message to send.
|
||||
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
|
||||
"""
|
||||
payload = data.encode("utf-8")
|
||||
header = _HEADER.pack(len(payload))
|
||||
with _timeout(sock, timeout):
|
||||
try:
|
||||
sock.sendall(header + payload)
|
||||
except _socket.timeout:
|
||||
raise TimeoutError(
|
||||
f"send timed out after {timeout}s"
|
||||
) from None
|
||||
|
||||
|
||||
def recv(
|
||||
sock: "_socket.socket",
|
||||
*,
|
||||
timeout: "float | None" = None,
|
||||
) -> str:
|
||||
"""Receive a length-prefixed string message from a socket.
|
||||
|
||||
Args:
|
||||
sock: A connected socket object.
|
||||
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
|
||||
|
||||
Returns:
|
||||
The decoded string message.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If the connection is closed before a complete
|
||||
message is received.
|
||||
TimeoutError: If the operation exceeds the timeout.
|
||||
"""
|
||||
with _timeout(sock, timeout):
|
||||
try:
|
||||
header = _recv_exact(sock, _HEADER.size)
|
||||
(length,) = _HEADER.unpack(header)
|
||||
payload = _recv_exact(sock, length)
|
||||
return payload.decode("utf-8")
|
||||
except _socket.timeout:
|
||||
raise TimeoutError(
|
||||
f"recv timed out after {timeout}s"
|
||||
) from None
|
||||
|
||||
|
||||
def _recv_exact(sock: "_socket.socket", n: int) -> bytes:
|
||||
"""Read exactly n bytes from sock, raising on premature close."""
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError(
|
||||
f"connection closed: expected {n} bytes, got {len(buf)}"
|
||||
)
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 高层类:封装基于 pymsg 的客户端 / 服务器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MsgClient:
|
||||
"""TCP 聊天客户端:封装套接字创建、连接、发送、监听与关闭。
|
||||
|
||||
``listen()`` 在调用线程内阻塞运行,可另开线程调用 ``stop()`` 来终止。
|
||||
|
||||
Usage::
|
||||
|
||||
def on_msg(text: str) -> None:
|
||||
print(f"收到: {text}")
|
||||
|
||||
c = MsgClient("localhost", 8003, "Alice")
|
||||
c.connect()
|
||||
c.send("hello") # 发送单条消息
|
||||
c.listen(on_msg) # 阻塞当前线程,持续回调
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int, name: str) -> None:
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self.name = name
|
||||
self._sock: "_socket.socket | None" = None
|
||||
self._running = False
|
||||
|
||||
# ---- 连接 ------------------------------------------------------------
|
||||
def connect(self) -> None:
|
||||
"""创建 TCP 套接字并连接到服务器。"""
|
||||
self._sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
||||
self._sock.connect((self.addr, self.port))
|
||||
|
||||
# ---- 发送单条消息(短连接,不等待回复)--------------------------------
|
||||
def send(self, msg: str) -> None:
|
||||
"""发送一条消息到服务器(使用短连接,pymsg 协议)。"""
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
|
||||
s.settimeout(3)
|
||||
s.connect((self.addr, self.port))
|
||||
send(s, f"{self.name}: {msg}")
|
||||
except ConnectionRefusedError:
|
||||
raise ConnectionRefusedError(
|
||||
f"Could not connect to {self.addr}:{self.port}"
|
||||
)
|
||||
|
||||
# ---- 请求-应答(短连接)------------------------------------------------
|
||||
def call(self, msg: str, *, timeout: "float | None" = None) -> str:
|
||||
"""发送一条消息并阻塞等待单条回复(短连接:connect → send → recv → close)。
|
||||
|
||||
适合请求-应答模式,如 RPC 调用。不会给消息添加 name 前缀。
|
||||
|
||||
Args:
|
||||
msg: 要发送的原始字符串。
|
||||
timeout: 超时秒数,默认 10 秒。
|
||||
|
||||
Returns:
|
||||
str: 服务器返回的字符串。
|
||||
|
||||
Raises:
|
||||
ConnectionRefusedError: 目标服务不可达。
|
||||
TimeoutError: 等待响应超时。
|
||||
ConnectionError: 连接在收全数据前断开。
|
||||
"""
|
||||
_tout = timeout if timeout is not None else 10.0
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
|
||||
s.settimeout(_tout)
|
||||
s.connect((self.addr, self.port))
|
||||
send(s, msg)
|
||||
return recv(s, timeout=_tout)
|
||||
except ConnectionRefusedError:
|
||||
raise ConnectionRefusedError(
|
||||
f"Could not connect to {self.addr}:{self.port}"
|
||||
)
|
||||
|
||||
# ---- 持续监听(阻塞当前线程,回调模式)-------------------------------
|
||||
def listen(self, on_message: "callable") -> None:
|
||||
"""阻塞当前线程,持续接收服务器消息并回调。
|
||||
|
||||
Args:
|
||||
on_message: 回调 ``on_message(text: str) -> None``。
|
||||
"""
|
||||
if self._sock is None:
|
||||
raise RuntimeError("必须先调用 connect()")
|
||||
self._running = True
|
||||
socklist = [self._sock]
|
||||
while self._running:
|
||||
try:
|
||||
readable, _, exception = select.select(
|
||||
socklist, [], socklist, 0.1
|
||||
)
|
||||
if readable:
|
||||
try:
|
||||
text = recv(self._sock)
|
||||
on_message(text)
|
||||
except ConnectionError:
|
||||
break
|
||||
if exception:
|
||||
break
|
||||
except (ConnectionResetError, OSError):
|
||||
break
|
||||
|
||||
# ---- 关闭 ------------------------------------------------------------
|
||||
def stop(self) -> None:
|
||||
"""设置停止标志并断开 TCP 连接。
|
||||
|
||||
可在另一个线程中调用,使 ``listen()`` 退出阻塞。
|
||||
"""
|
||||
self._running = False
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
|
||||
class MsgServer:
|
||||
"""基于 pymsg 长度前缀协议的多客户端 TCP 服务器。
|
||||
|
||||
收到消息时调用回调函数 —— 转发、过滤等逻辑全部在回调中实现。
|
||||
|
||||
Usage::
|
||||
|
||||
def on_msg(sender_sock, addr, text):
|
||||
# 转发给所有人(排除发送者)
|
||||
server.broadcast(f"[{addr}] {text}", except_sock=sender_sock)
|
||||
|
||||
server = MsgServer("0.0.0.0", 8003)
|
||||
server.start(on_msg) # 阻塞当前线程
|
||||
|
||||
# 在另一线程中调用 server.stop() 可优雅退出
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int) -> None:
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._running = False
|
||||
self._server_sock: "_socket.socket | None" = None
|
||||
self._clients: "dict" = {} # socket -> (ip, port)
|
||||
|
||||
# ---- 启动(阻塞)----------------------------------------------------
|
||||
def start(self, on_message: "callable") -> None:
|
||||
"""启动服务器,阻塞当前线程。
|
||||
|
||||
Args:
|
||||
on_message: 回调 ``on_message(sock, addr, text)``
|
||||
- sock: 发送消息的客户端套接字
|
||||
- addr: (ip, port) 元组
|
||||
- text: 解码后的字符串消息
|
||||
"""
|
||||
self._running = True
|
||||
servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
||||
servsock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
||||
servsock.bind((self.addr, self.port))
|
||||
servsock.listen(10)
|
||||
self._server_sock = servsock
|
||||
|
||||
socketlist = [servsock]
|
||||
while self._running:
|
||||
try:
|
||||
readable, _, exception = select.select(
|
||||
socketlist, [], socketlist, 0.1
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
break
|
||||
|
||||
for s in exception:
|
||||
self._remove_client(s, socketlist)
|
||||
|
||||
for s in readable:
|
||||
if s is servsock:
|
||||
self._accept_client(servsock, socketlist)
|
||||
else:
|
||||
self._handle_client(s, socketlist, on_message)
|
||||
|
||||
self._shutdown(servsock, socketlist)
|
||||
|
||||
# ---- 广播 -----------------------------------------------------------
|
||||
def broadcast(
|
||||
self, msg: str, except_sock: "_socket.socket | None" = None
|
||||
) -> None:
|
||||
"""向所有已连接客户端广播消息(pymsg 协议)。
|
||||
|
||||
Args:
|
||||
msg: 要发送的字符串。
|
||||
except_sock: 排除的套接字(通常为发送者),不向其发送。
|
||||
"""
|
||||
disconnected = []
|
||||
for client in list(self._clients):
|
||||
if client is except_sock:
|
||||
continue
|
||||
try:
|
||||
send(client, msg)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
disconnected.append(client)
|
||||
for client in disconnected:
|
||||
self._clients.pop(client, None)
|
||||
try:
|
||||
client.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ---- 应答 -----------------------------------------------------------
|
||||
def reply(self, sock: "_socket.socket", msg: str) -> None:
|
||||
"""向指定客户端套接字发送一条应答消息(pymsg 协议)。
|
||||
|
||||
用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。
|
||||
"""
|
||||
send(sock, msg)
|
||||
|
||||
# ---- 停止 -----------------------------------------------------------
|
||||
def stop(self) -> None:
|
||||
"""设置停止标志并关闭监听套接字(可从另一线程调用)。"""
|
||||
self._running = False
|
||||
if self._server_sock is not None:
|
||||
try:
|
||||
self._server_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ---- 内部方法 -------------------------------------------------------
|
||||
def _accept_client(self, servsock, socketlist):
|
||||
try:
|
||||
client_sock, client_addr = servsock.accept()
|
||||
if client_sock.fileno() != -1:
|
||||
socketlist.append(client_sock)
|
||||
self._clients[client_sock] = client_addr
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _handle_client(self, s, socketlist, on_message):
|
||||
try:
|
||||
text = recv(s)
|
||||
on_message(s, self._clients.get(s, ("unknown", 0)), text)
|
||||
except (ConnectionError, ConnectionResetError, OSError):
|
||||
self._remove_client(s, socketlist)
|
||||
|
||||
def _remove_client(self, s, socketlist):
|
||||
self._clients.pop(s, None)
|
||||
if s in socketlist:
|
||||
socketlist.remove(s)
|
||||
try:
|
||||
s.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _shutdown(self, servsock, socketlist):
|
||||
for s in socketlist:
|
||||
try:
|
||||
s.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._clients.clear()
|
||||
@@ -0,0 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pymsg"
|
||||
version = "0.1.0"
|
||||
description = "Length-prefixed variable-length socket message framing"
|
||||
requires-python = ">=3.9"
|
||||
license = "MIT"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/user/micro_servers"
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.env
|
||||
venv/
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./venv/bin/activate
|
||||
#exec ./smtp.bin
|
||||
exec python3 smtp.py
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
#sudo chmod +x .env/bin/activate
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#nuitka --standalone smtp.py
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("smtp", "验证码发送成功")
|
||||
logs.error("smtp", "验证码发送失败", extra={"errno": 2002})
|
||||
logs.push("smtp", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="smtp",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
# 等待后台线程响应(queue.get 超时最长 0.5s)
|
||||
time.sleep(0.6)
|
||||
# 排空主队列中尚未被线程取走的条目
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
# 限制最长阻塞时间,以便及时响应 flush() 信号
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient(service_name="smtp")
|
||||
@@ -0,0 +1,8 @@
|
||||
../pymsg
|
||||
nuitka
|
||||
flask
|
||||
flask-cors
|
||||
flask_sqlalchemy
|
||||
#gunicorn
|
||||
waitress
|
||||
python-dotenv
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from email.mime.text import MIMEText
|
||||
from email import header
|
||||
from log_client import logs
|
||||
from dotenv import load_dotenv
|
||||
import waitress
|
||||
import smtplib
|
||||
import random
|
||||
import time
|
||||
import sys, os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
|
||||
app = Flask(__name__)
|
||||
captchas: dict[str, list[str, str]] = {}
|
||||
|
||||
@app.route("/send_email", methods=["POST"])
|
||||
|
||||
def send_email():
|
||||
def build_captcha() -> str:
|
||||
captcha: str = ""
|
||||
for i in range(0,6):
|
||||
digit: int = random.randint(0,9)
|
||||
captcha += str(digit)
|
||||
return captcha
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
recipient: str = data.get("email")
|
||||
subject: str = "DNFS 邮箱验证"
|
||||
captcha: str = build_captcha()
|
||||
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
|
||||
|
||||
captchas[recipient] = ["", ""]
|
||||
captchas[recipient][0] = captcha
|
||||
captchas[recipient][1] = str(int(time.time())+60)
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = "dnfs@qsont.me"
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
|
||||
server.login(SMTP_USER, __PASSWORD)
|
||||
server.sendmail(SMTP_USER, [recipient], message.as_string())
|
||||
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
|
||||
return jsonify({"success": "验证码发送成功"}), 200
|
||||
|
||||
except Exception as err:
|
||||
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
|
||||
return jsonify({"error": f"验证码发送失败:{err}"}), 400
|
||||
|
||||
finally:
|
||||
server.quit()
|
||||
|
||||
|
||||
@app.route("/verify_email", methods=["POST"])
|
||||
|
||||
def verify_email():
|
||||
data = request.get_json()
|
||||
|
||||
for i in list(captchas.keys()):
|
||||
if int(captchas[i][1]) < int(time.time()):
|
||||
del captchas[i]
|
||||
|
||||
email: str = data.get("email")
|
||||
input_captcha: str = data.get("input_captcha")
|
||||
|
||||
if captchas[email][0] == input_captcha:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return jsonify({"success": "验证通过"}), 200
|
||||
else:
|
||||
logs.info("smtp", f"{email}: 验证失败")
|
||||
return jsonify({"error": "验证失败"}), 400
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logs.info("smtp", "开始监听http://127.0.0.1:8008")
|
||||
print("Listen at http://127.0.0.1:8008")
|
||||
waitress.serve(app, host="127.0.0.1", port=8008)
|
||||
+576
-303
@@ -1,303 +1,576 @@
|
||||
# file_server.py - 极简版文件托管服务器
|
||||
from flask import Flask, send_from_directory, jsonify, request, Response, send_file
|
||||
from flask_cors import CORS
|
||||
import os
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
import sys
|
||||
from security_middleware import apply_security_headers
|
||||
|
||||
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
|
||||
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
|
||||
if getattr(sys, 'frozen', False):
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
file_handler = RotatingFileHandler(os.path.join(BASE_DIR, 'app.log'), maxBytes=10485760, backupCount=10)
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s %(levelname)s: %(message)s'
|
||||
))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
parser = argparse.ArgumentParser(description='File Server')
|
||||
parser.add_argument('--config', type=str, default='config.ini', help='Configuration file path')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = Flask(__name__)
|
||||
apply_security_headers(app)
|
||||
CORS(app)
|
||||
|
||||
# ==================== 配置文件加载 ====================
|
||||
CONFIG_FILE = args.config
|
||||
if not os.path.isabs(CONFIG_FILE):
|
||||
CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE)
|
||||
|
||||
file_config = {
|
||||
'server': {'host': '0.0.0.0', 'port': '5000'},
|
||||
'directory': {'root': './public'},
|
||||
'ssl': {'enabled': 'false', 'cert_file': 'cert.pem', 'key_file': 'key.pem'},
|
||||
}
|
||||
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(CONFIG_FILE, encoding='utf-8')
|
||||
for section in ['server', 'directory', 'ssl']:
|
||||
if section in config_parser:
|
||||
for key, value in config_parser[section].items():
|
||||
if key in file_config[section]:
|
||||
file_config[section][key] = value
|
||||
|
||||
HTML_ROOT_DIR = file_config['directory']['root']
|
||||
if HTML_ROOT_DIR and not os.path.isabs(HTML_ROOT_DIR):
|
||||
HTML_ROOT_DIR = os.path.join(BASE_DIR, HTML_ROOT_DIR)
|
||||
FILESYSTEM_ENABLED = bool(HTML_ROOT_DIR and HTML_ROOT_DIR.strip())
|
||||
|
||||
if FILESYSTEM_ENABLED:
|
||||
try:
|
||||
Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Created directory: {HTML_ROOT_DIR}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory: {str(e)}")
|
||||
FILESYSTEM_ENABLED = False
|
||||
|
||||
SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
|
||||
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
|
||||
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
|
||||
if not os.path.isabs(SSL_CERT_FILE):
|
||||
SSL_CERT_FILE = os.path.join(BASE_DIR, SSL_CERT_FILE)
|
||||
if not os.path.isabs(SSL_KEY_FILE):
|
||||
SSL_KEY_FILE = os.path.join(BASE_DIR, SSL_KEY_FILE)
|
||||
|
||||
# 视图文件名:根路径与目录访问时固定返回此静态文件,由前端 JS 调用 /api/filelist 拉取数据
|
||||
VIEW_FILE = '__view.html'
|
||||
|
||||
# ==================== 视频文件支持 ====================
|
||||
VIDEO_MIME_TYPES = {
|
||||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
||||
'.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska'
|
||||
}
|
||||
|
||||
AUDIO_MIME_TYPES = {
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4'
|
||||
}
|
||||
|
||||
def is_video_file(filename):
|
||||
return os.path.splitext(filename)[1].lower() in VIDEO_MIME_TYPES
|
||||
|
||||
def is_audio_file(filename):
|
||||
return os.path.splitext(filename)[1].lower() in AUDIO_MIME_TYPES
|
||||
|
||||
def get_media_mime_type(filename):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
return VIDEO_MIME_TYPES.get(ext) or AUDIO_MIME_TYPES.get(ext) or 'application/octet-stream'
|
||||
|
||||
def safe_path_join(base_dir, user_path):
|
||||
if not user_path or user_path.strip() == '' or user_path.strip() == '/':
|
||||
return base_dir
|
||||
if user_path.startswith('/'):
|
||||
user_path = user_path[1:]
|
||||
full_path = os.path.normpath(os.path.join(base_dir, user_path))
|
||||
base_dir_real = os.path.realpath(base_dir)
|
||||
full_path_real = os.path.realpath(full_path)
|
||||
if not full_path_real.startswith(base_dir_real):
|
||||
return None
|
||||
return full_path_real
|
||||
|
||||
def format_file_size(size):
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f}{unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f}TB"
|
||||
|
||||
# ==================== API 接口 ====================
|
||||
@app.route('/api/filelist', methods=['GET'])
|
||||
def get_file_list():
|
||||
try:
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return jsonify({'error': '文件系统未启用'}), 403
|
||||
|
||||
requested_path = request.args.get('path', '')
|
||||
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
|
||||
|
||||
if full_path is None:
|
||||
return jsonify({'error': '无效的路径'}), 403
|
||||
|
||||
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
||||
return jsonify({'error': '目录不存在'}), 404
|
||||
|
||||
items = []
|
||||
for item in os.listdir(full_path):
|
||||
# 过滤系统项目:以双下划线开头的文件和文件夹(如 __pycache__、__view.html、__icons 等)
|
||||
if item.startswith('__'):
|
||||
continue
|
||||
|
||||
item_path = os.path.join(full_path, item)
|
||||
item_stat = os.stat(item_path)
|
||||
is_dir = os.path.isdir(item_path)
|
||||
|
||||
item_rel_path = os.path.join(requested_path, item) if requested_path else item
|
||||
|
||||
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
|
||||
|
||||
items.append({
|
||||
'name': item,
|
||||
'type': file_type,
|
||||
'path': item_rel_path,
|
||||
'size': item_stat.st_size if not is_dir else 0,
|
||||
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
|
||||
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'path': requested_path or '/',
|
||||
'parent_path': os.path.dirname(requested_path) if requested_path and os.path.dirname(requested_path) != '/' else '',
|
||||
'items': items
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get file list error: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/video/<path:file_path>', methods=['GET'])
|
||||
def stream_video(file_path):
|
||||
try:
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return jsonify({'error': '文件系统未启用'}), 403
|
||||
|
||||
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
|
||||
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return jsonify({'error': '文件不存在'}), 404
|
||||
|
||||
if not is_video_file(full_path) and not is_audio_file(full_path):
|
||||
return jsonify({'error': '不支持的文件类型'}), 400
|
||||
|
||||
file_size = os.path.getsize(full_path)
|
||||
mime_type = get_media_mime_type(full_path)
|
||||
|
||||
range_header = request.headers.get('Range', None)
|
||||
|
||||
if range_header:
|
||||
byte_range = range_header.replace('bytes=', '').split('-')
|
||||
start = int(byte_range[0]) if byte_range[0] else 0
|
||||
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
|
||||
|
||||
if start >= file_size:
|
||||
return jsonify({'error': '请求范围超出文件大小'}), 416
|
||||
|
||||
length = end - start + 1
|
||||
|
||||
with open(full_path, 'rb') as f:
|
||||
f.seek(start)
|
||||
data = f.read(length)
|
||||
|
||||
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
|
||||
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
|
||||
response.headers.add('Accept-Ranges', 'bytes')
|
||||
response.headers.add('Content-Length', str(length))
|
||||
response.headers.add('Cache-Control', 'no-cache')
|
||||
return response
|
||||
else:
|
||||
return send_file(full_path, mimetype=mime_type, as_attachment=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Video streaming error: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ==================== 主路由 ====================
|
||||
@app.route('/')
|
||||
def serve_view():
|
||||
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return "文件系统未启用", 403
|
||||
index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
|
||||
if os.path.exists(index_path):
|
||||
return send_file(index_path, mimetype='text/html')
|
||||
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||
if not os.path.exists(view_path):
|
||||
return f"视图文件 {VIEW_FILE} 不存在", 500
|
||||
return send_file(view_path, mimetype='text/html')
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def serve_file(path):
|
||||
try:
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return "文件系统未启用", 403
|
||||
|
||||
full_path = safe_path_join(HTML_ROOT_DIR, path)
|
||||
if full_path is None:
|
||||
return "无效的路径", 403
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return "路径不存在", 404
|
||||
|
||||
# 文件处理
|
||||
if os.path.isfile(full_path):
|
||||
if request.args.get('download') == 'true':
|
||||
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
|
||||
|
||||
if is_video_file(full_path) or is_audio_file(full_path):
|
||||
return send_file(full_path, mimetype=get_media_mime_type(full_path))
|
||||
|
||||
# 普通文件
|
||||
with open(full_path, 'rb') as f:
|
||||
content = f.read()
|
||||
ext = os.path.splitext(full_path)[1].lower()
|
||||
mime_map = {
|
||||
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
|
||||
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain',
|
||||
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
|
||||
}
|
||||
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
|
||||
|
||||
# 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表
|
||||
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||
if os.path.exists(view_path):
|
||||
return send_file(view_path, mimetype='text/html')
|
||||
return "目录无法浏览", 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return f"服务器内部错误: {str(e)}", 500
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'filesystem': {'enabled': FILESYSTEM_ENABLED, 'root': HTML_ROOT_DIR if FILESYSTEM_ENABLED else None}
|
||||
})
|
||||
|
||||
# ==================== 主程序 ====================
|
||||
if __name__ == '__main__':
|
||||
host = file_config['server']['host']
|
||||
port = int(file_config['server']['port'])
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("📁 文件托管服务器 (极简版)")
|
||||
print("="*50)
|
||||
print(f"地址: http://{host}:{port}")
|
||||
print(f"目录: {os.path.abspath(HTML_ROOT_DIR) if FILESYSTEM_ENABLED else '未启用'}")
|
||||
print("="*50)
|
||||
print("\n快捷键: / 或 Ctrl+F 搜索, ESC 清空搜索")
|
||||
print("\n")
|
||||
|
||||
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
|
||||
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
|
||||
else:
|
||||
app.run(host=host, port=port, debug=False)
|
||||
|
||||
# file_server.py - 极简版文件托管服务器
|
||||
from flask import Flask, send_from_directory, jsonify, request, Response, send_file
|
||||
from flask_cors import CORS
|
||||
import os
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
import sys
|
||||
|
||||
# pymsg 日志客户端(集中式日志 → dbv2 Logs 表)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from log_client import logs # noqa: E402
|
||||
|
||||
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
|
||||
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
|
||||
if getattr(sys, 'frozen', False):
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
file_handler = RotatingFileHandler(os.path.join(BASE_DIR, 'app.log'), maxBytes=10485760, backupCount=10)
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s %(levelname)s: %(message)s'
|
||||
))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
parser = argparse.ArgumentParser(description='File Server')
|
||||
parser.add_argument('--config', type=str, default='config.ini', help='Configuration file path')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app, origins=["https://qsont.me", "https://yhdet.top", "http://localhost:9200"])
|
||||
|
||||
# ==================== 配置文件加载 ====================
|
||||
CONFIG_FILE = args.config
|
||||
if not os.path.isabs(CONFIG_FILE):
|
||||
CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE)
|
||||
|
||||
file_config = {
|
||||
'server': {'host': '0.0.0.0', 'port': '5000'},
|
||||
'directory': {'root': './public'},
|
||||
'ssl': {'enabled': 'false', 'cert_file': 'cert.pem', 'key_file': 'key.pem'},
|
||||
}
|
||||
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(CONFIG_FILE, encoding='utf-8')
|
||||
for section in ['server', 'directory', 'ssl']:
|
||||
if section in config_parser:
|
||||
for key, value in config_parser[section].items():
|
||||
if key in file_config[section]:
|
||||
file_config[section][key] = value
|
||||
|
||||
HTML_ROOT_DIR = file_config['directory']['root']
|
||||
if HTML_ROOT_DIR and not os.path.isabs(HTML_ROOT_DIR):
|
||||
HTML_ROOT_DIR = os.path.join(BASE_DIR, HTML_ROOT_DIR)
|
||||
FILESYSTEM_ENABLED = bool(HTML_ROOT_DIR and HTML_ROOT_DIR.strip())
|
||||
|
||||
if FILESYSTEM_ENABLED:
|
||||
try:
|
||||
Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Created directory: {HTML_ROOT_DIR}")
|
||||
logs.info("static", f"目录已创建: {HTML_ROOT_DIR}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory: {str(e)}")
|
||||
logs.error("static", f"目录创建失败: {str(e)}",
|
||||
extra={"path": HTML_ROOT_DIR})
|
||||
FILESYSTEM_ENABLED = False
|
||||
|
||||
SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
|
||||
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
|
||||
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
|
||||
if not os.path.isabs(SSL_CERT_FILE):
|
||||
SSL_CERT_FILE = os.path.join(BASE_DIR, SSL_CERT_FILE)
|
||||
if not os.path.isabs(SSL_KEY_FILE):
|
||||
SSL_KEY_FILE = os.path.join(BASE_DIR, SSL_KEY_FILE)
|
||||
|
||||
# 视图文件名:根路径与目录访问时固定返回此静态文件,由前端 JS 调用 /api/filelist 拉取数据
|
||||
VIEW_FILE = '__view.html'
|
||||
|
||||
# ==================== 视频文件支持 ====================
|
||||
VIDEO_MIME_TYPES = {
|
||||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
||||
'.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska'
|
||||
}
|
||||
|
||||
AUDIO_MIME_TYPES = {
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4'
|
||||
}
|
||||
|
||||
def is_video_file(filename):
|
||||
return os.path.splitext(filename)[1].lower() in VIDEO_MIME_TYPES
|
||||
|
||||
def is_audio_file(filename):
|
||||
return os.path.splitext(filename)[1].lower() in AUDIO_MIME_TYPES
|
||||
|
||||
def get_media_mime_type(filename):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
return VIDEO_MIME_TYPES.get(ext) or AUDIO_MIME_TYPES.get(ext) or 'application/octet-stream'
|
||||
|
||||
def safe_path_join(base_dir, user_path):
|
||||
if not user_path or user_path.strip() == '' or user_path.strip() == '/':
|
||||
return base_dir
|
||||
if user_path.startswith('/'):
|
||||
user_path = user_path[1:]
|
||||
full_path = os.path.normpath(os.path.join(base_dir, user_path))
|
||||
base_dir_real = os.path.realpath(base_dir)
|
||||
full_path_real = os.path.realpath(full_path)
|
||||
if not full_path_real.startswith(base_dir_real):
|
||||
return None
|
||||
return full_path_real
|
||||
|
||||
def format_file_size(size):
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f}{unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f}TB"
|
||||
|
||||
# ==================== API 接口 ====================
|
||||
@app.route('/api/filelist', methods=['GET'])
|
||||
def get_file_list():
|
||||
try:
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return jsonify({'error': '文件系统未启用'}), 403
|
||||
|
||||
requested_path = request.args.get('path', '')
|
||||
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
|
||||
|
||||
if full_path is None:
|
||||
return jsonify({'error': '无效的路径'}), 403
|
||||
|
||||
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
||||
return jsonify({'error': '目录不存在'}), 404
|
||||
|
||||
items = []
|
||||
for item in os.listdir(full_path):
|
||||
# 过滤系统项目:以双下划线开头的文件和文件夹(如 __pycache__、__view.html、__icons 等)
|
||||
if item.startswith('__'):
|
||||
continue
|
||||
|
||||
item_path = os.path.join(full_path, item)
|
||||
item_stat = os.stat(item_path)
|
||||
is_dir = os.path.isdir(item_path)
|
||||
|
||||
item_rel_path = os.path.join(requested_path, item) if requested_path else item
|
||||
|
||||
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
|
||||
|
||||
items.append({
|
||||
'name': item,
|
||||
'type': file_type,
|
||||
'path': item_rel_path,
|
||||
'size': item_stat.st_size if not is_dir else 0,
|
||||
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
|
||||
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'path': requested_path or '/',
|
||||
'parent_path': os.path.dirname(requested_path) if requested_path and os.path.dirname(requested_path) != '/' else '',
|
||||
'items': items
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get file list error: {str(e)}")
|
||||
logs.error("static", f"获取文件列表失败: {str(e)}",
|
||||
extra={"path": requested_path})
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/video/<path:file_path>', methods=['GET'])
|
||||
def stream_video(file_path):
|
||||
try:
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return jsonify({'error': '文件系统未启用'}), 403
|
||||
|
||||
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
|
||||
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return jsonify({'error': '文件不存在'}), 404
|
||||
|
||||
if not is_video_file(full_path) and not is_audio_file(full_path):
|
||||
return jsonify({'error': '不支持的文件类型'}), 400
|
||||
|
||||
file_size = os.path.getsize(full_path)
|
||||
mime_type = get_media_mime_type(full_path)
|
||||
|
||||
range_header = request.headers.get('Range', None)
|
||||
|
||||
if range_header:
|
||||
byte_range = range_header.replace('bytes=', '').split('-')
|
||||
start = int(byte_range[0]) if byte_range[0] else 0
|
||||
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
|
||||
|
||||
if start >= file_size:
|
||||
return jsonify({'error': '请求范围超出文件大小'}), 416
|
||||
|
||||
length = end - start + 1
|
||||
|
||||
with open(full_path, 'rb') as f:
|
||||
f.seek(start)
|
||||
data = f.read(length)
|
||||
|
||||
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
|
||||
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
|
||||
response.headers.add('Accept-Ranges', 'bytes')
|
||||
response.headers.add('Content-Length', str(length))
|
||||
response.headers.add('Cache-Control', 'no-cache')
|
||||
return response
|
||||
else:
|
||||
return send_file(full_path, mimetype=mime_type, as_attachment=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Video streaming error: {str(e)}")
|
||||
logs.error("static", f"视频流传输失败: {str(e)}",
|
||||
extra={"file": file_path})
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
GATEWAY_DB = '/opt/micro_servers/gateway/sqlite3.db'
|
||||
|
||||
|
||||
@app.route('/api/hash', methods=['POST'])
|
||||
def api_hash():
|
||||
from flask import request, jsonify
|
||||
import hashlib
|
||||
data = request.get_json(force=True)
|
||||
h = hashlib.sha256(data.get('password','').encode()).hexdigest()
|
||||
return jsonify({"hash": h})
|
||||
|
||||
|
||||
@app.route('/api/reset_password', methods=['POST'])
|
||||
def api_reset_password():
|
||||
from flask import request, jsonify
|
||||
import sqlite3 as _sql
|
||||
data = request.get_json(force=True)
|
||||
email = data.get('email', '').strip()
|
||||
new_hash = data.get('password_hash', '').strip()
|
||||
if not email or not new_hash:
|
||||
return jsonify({"success": False, "error": "missing params"}), 400
|
||||
conn = _sql.connect(GATEWAY_DB)
|
||||
conn.execute("UPDATE Users SET password_hash=? WHERE email=?", (new_hash, email))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logs.info("static", "密码重置", extra={"email": email[:3] + "***"})
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@app.route('/api/check_user', methods=['POST'])
|
||||
def api_check_user():
|
||||
from flask import request, jsonify
|
||||
import sqlite3 as _sql
|
||||
data = request.get_json(force=True)
|
||||
email = data.get('email', '').strip()
|
||||
if not email:
|
||||
return jsonify({"success": False, "error": "missing email"}), 400
|
||||
conn = _sql.connect(GATEWAY_DB)
|
||||
rows = conn.execute("SELECT email FROM Users WHERE email=?", (email,)).fetchall()
|
||||
conn.close()
|
||||
return jsonify({"success": True, "found": len(rows) > 0})
|
||||
|
||||
|
||||
|
||||
|
||||
YHDET_VERIFY_URL = "https://yhdet.top/api/verify_auth_token"
|
||||
YHDET_SECRET = "yhdet_qsont_oauth_2026_secret_key"
|
||||
GATEWAY_DB_YHDET = '/opt/micro_servers/gateway/sqlite3.db'
|
||||
|
||||
|
||||
def _yhdet_verify_token(token):
|
||||
import traceback
|
||||
import urllib.request, json
|
||||
try:
|
||||
data = json.dumps({"token": token}).encode()
|
||||
req = urllib.request.Request(YHDET_VERIFY_URL, data=data,
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
@app.route('/yhdet_callback', methods=['GET', 'POST'])
|
||||
def yhdet_callback():
|
||||
from flask import request, make_response
|
||||
import hashlib, sqlite3, base64, json as _json, urllib.request as _urlreq
|
||||
token = (request.args.get('yhdet_token', '') or request.form.get('yhdet_token', '')).strip()
|
||||
if not token:
|
||||
return make_response("No token", 400)
|
||||
try:
|
||||
result = _yhdet_verify_token(token)
|
||||
if not result.get("success"):
|
||||
return make_response("Verify failed: " + str(result), 401)
|
||||
username = result.get("username", "")
|
||||
user_id = result.get("user_id", 0)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return make_response("Verify error: " + str(e), 500)
|
||||
email = f"yhdet_{user_id}@yhdet.oauth"
|
||||
password = f"oauth_{user_id}"
|
||||
conn = sqlite3.connect(GATEWAY_DB_YHDET)
|
||||
rows = conn.execute("SELECT email FROM Users WHERE email=?", (email,)).fetchall()
|
||||
if not rows:
|
||||
fake_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
conn.execute("INSERT INTO Users (email, password_hash) VALUES (?, ?)", (email, fake_hash))
|
||||
conn.commit()
|
||||
logs.info("static", "OAuth 新用户注册", extra={"email": email})
|
||||
conn.close()
|
||||
try:
|
||||
login_data = _json.dumps({"username": email, "password": password}).encode()
|
||||
req = _urlreq.Request("https://qsont.me/login", data=login_data,
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=10, context=__import__('ssl')._create_unverified_context()) as resp:
|
||||
login_result = _json.loads(resp.read().decode("utf-8"))
|
||||
real_token = login_result.get("data", {}).get("token", "")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
real_token = ""
|
||||
html = "<!DOCTYPE html><html><head><meta charset='utf-8'></head><body><p>Login...</p><script>"
|
||||
if real_token:
|
||||
html += "localStorage.setItem('q4_token','" + real_token + "');"
|
||||
html += "sessionStorage.setItem('q4_token','" + real_token + "');"
|
||||
html += "location.href='/dnfs.html';"
|
||||
html += "</script></body></html>"
|
||||
return make_response(html, 200)
|
||||
|
||||
@app.route('/api/yhdet_login', methods=['POST'])
|
||||
def yhdet_login():
|
||||
import hashlib, sqlite3, base64
|
||||
data = request.get_json(force=True) if request.is_json else {}
|
||||
email = data.get("email", "").strip()
|
||||
password = data.get("password", "").strip()
|
||||
if not email or not password:
|
||||
return jsonify({"success": False, "error": "Missing params"}), 400
|
||||
pwd_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
conn = sqlite3.connect(GATEWAY_DB_YHDET)
|
||||
rows = conn.execute("SELECT email, password_hash FROM Users WHERE email=?", (email,)).fetchall()
|
||||
conn.close()
|
||||
if not rows:
|
||||
logs.warning("static", "yhdet 登录失败-用户不存在",
|
||||
extra={"email": email[:8] + "***"})
|
||||
return jsonify({"success": False, "error": "Invalid credentials"})
|
||||
if rows[0][1] != pwd_hash:
|
||||
logs.warning("static", "yhdet 登录失败-密码错误",
|
||||
extra={"email": email[:8] + "***"})
|
||||
return jsonify({"success": False, "error": "Invalid credentials"})
|
||||
logs.info("static", "yhdet 登录成功",
|
||||
extra={"email": email[:8] + "***"})
|
||||
token_data = f"{email}:{password}"
|
||||
token = base64.urlsafe_b64encode(token_data.encode()).decode()
|
||||
return jsonify({"success": True, "token": token})
|
||||
|
||||
|
||||
@app.route('/api/yhdet_verify', methods=['POST'])
|
||||
def yhdet_verify():
|
||||
import base64, hashlib, sqlite3
|
||||
data = request.get_json(force=True)
|
||||
token = data.get("token", "")
|
||||
if not token:
|
||||
return jsonify({"success": False}), 400
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(token.encode()).decode()
|
||||
parts = decoded.split(":")
|
||||
if len(parts) == 3 and parts[1] == "oauth":
|
||||
return jsonify({"success": True, "email": parts[0], "type": "oauth"})
|
||||
if len(parts) == 2:
|
||||
email, password = parts
|
||||
pwd_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
conn = sqlite3.connect(GATEWAY_DB_YHDET)
|
||||
rows = conn.execute("SELECT email FROM Users WHERE email=? AND password_hash=?", (email, pwd_hash)).fetchall()
|
||||
conn.close()
|
||||
if rows:
|
||||
return jsonify({"success": True, "email": email, "type": "password"})
|
||||
return jsonify({"success": False})
|
||||
except:
|
||||
return jsonify({"success": False})
|
||||
|
||||
|
||||
|
||||
|
||||
PRIVATE_SECRET = "qsont_me_yhdet_2026_private"
|
||||
YHDET_PRIVATE_API = "https://yhdet.top/api/v1/private"
|
||||
|
||||
|
||||
from flask import make_response
|
||||
import urllib.request as _urlreq, json as _json
|
||||
|
||||
DNFS_URL = "http://127.0.0.1:8006"
|
||||
|
||||
def _cors(resp):
|
||||
resp.headers["Access-Control-Allow-Origin"] = "*"
|
||||
resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
return resp
|
||||
|
||||
@app.route('/get_notes', methods=['POST', 'OPTIONS'])
|
||||
def proxy_get_notes():
|
||||
if request.method == "OPTIONS": return _cors(make_response("", 204))
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
h = {"Content-Type": "application/json"}
|
||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||
req = _urlreq.Request(f"{DNFS_URL}/get_notes", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=15) as r:
|
||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||
except Exception as e:
|
||||
logs.warning("static", "dnfs get_notes 代理失败",
|
||||
extra={"error": str(e)})
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
@app.route('/add_note', methods=['POST', 'OPTIONS'])
|
||||
def proxy_add_note():
|
||||
if request.method == "OPTIONS": return _cors(make_response("", 204))
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
h = {"Content-Type": "application/json"}
|
||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||
req = _urlreq.Request(f"{DNFS_URL}/add_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=15) as r:
|
||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||
except Exception as e:
|
||||
logs.warning("static", "dnfs add_note 代理失败", extra={"error": str(e)})
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
@app.route('/delete_note', methods=['POST', 'OPTIONS'])
|
||||
def proxy_delete_note():
|
||||
if request.method == "OPTIONS": return _cors(make_response("", 204))
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
h = {"Content-Type": "application/json"}
|
||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||
req = _urlreq.Request(f"{DNFS_URL}/delete_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=15) as r:
|
||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||
except Exception as e:
|
||||
logs.warning("static", "dnfs delete_note 代理失败", extra={"error": str(e)})
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
@app.route('/update_note', methods=['POST', 'OPTIONS'])
|
||||
def proxy_update_note():
|
||||
if request.method == "OPTIONS": return _cors(make_response("", 204))
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
h = {"Content-Type": "application/json"}
|
||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||
req = _urlreq.Request(f"{DNFS_URL}/update_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=15) as r:
|
||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||
except Exception as e:
|
||||
logs.warning("static", "dnfs update_note 代理失败",
|
||||
extra={"error": str(e)})
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
|
||||
@app.route('/api/proxy_send_code', methods=['POST'])
|
||||
def proxy_send_code():
|
||||
from flask import request, jsonify
|
||||
import urllib.request as _urlreq, json as _json, __import__ as _imp
|
||||
data = request.get_json(force=True)
|
||||
req_data = _json.dumps(data).encode()
|
||||
req = _urlreq.Request(f"{YHDET_PRIVATE_API}/send_code", data=req_data,
|
||||
headers={"Content-Type": "application/json", "X-Forwarded-For": "8.137.52.11", "X-Secret": PRIVATE_SECRET}, method="POST")
|
||||
ctx = _imp('ssl').create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _imp('ssl').CERT_NONE
|
||||
with _urlreq.urlopen(req, timeout=15, context=ctx) as resp:
|
||||
return jsonify(_json.loads(resp.read().decode("utf-8")))
|
||||
req = _urlreq.Request(f"{YHDET_PRIVATE_API}/check_code", data=req_data,
|
||||
headers={"Content-Type": "application/json", "X-Forwarded-For": "8.137.52.11", "X-Secret": PRIVATE_SECRET}, method="POST")
|
||||
ctx = _imp('ssl').create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _imp('ssl').CERT_NONE
|
||||
with _urlreq.urlopen(req, timeout=15, context=ctx) as resp:
|
||||
return jsonify(_json.loads(resp.read().decode("utf-8")))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==================== 主路由 ====================
|
||||
@app.route('/')
|
||||
def serve_view():
|
||||
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return "文件系统未启用", 403
|
||||
#index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
|
||||
index_path = os.path.join(HTML_ROOT_DIR, 'login.html')
|
||||
if os.path.exists(index_path):
|
||||
return send_file(index_path, mimetype='text/html')
|
||||
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||
if not os.path.exists(view_path):
|
||||
return f"视图文件 {VIEW_FILE} 不存在", 500
|
||||
return send_file(view_path, mimetype='text/html')
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def serve_file(path):
|
||||
try:
|
||||
if not FILESYSTEM_ENABLED:
|
||||
return "文件系统未启用", 403
|
||||
|
||||
full_path = safe_path_join(HTML_ROOT_DIR, path)
|
||||
if full_path is None:
|
||||
return "无效的路径", 403
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return "路径不存在", 404
|
||||
|
||||
# 文件处理
|
||||
if os.path.isfile(full_path):
|
||||
if request.args.get('download') == 'true':
|
||||
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
|
||||
|
||||
if is_video_file(full_path) or is_audio_file(full_path):
|
||||
return send_file(full_path, mimetype=get_media_mime_type(full_path))
|
||||
|
||||
# 普通文件
|
||||
with open(full_path, 'rb') as f:
|
||||
content = f.read()
|
||||
ext = os.path.splitext(full_path)[1].lower()
|
||||
mime_map = {
|
||||
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
|
||||
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain',
|
||||
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
|
||||
}
|
||||
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
|
||||
|
||||
# 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表
|
||||
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||
if os.path.exists(view_path):
|
||||
return send_file(view_path, mimetype='text/html')
|
||||
return "目录无法浏览", 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {str(e)}")
|
||||
logs.error("static", f"静态文件服务错误: {str(e)}",
|
||||
extra={"path": path})
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return f"服务器内部错误: {str(e)}", 500
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
host = file_config['server']['host']
|
||||
port = int(file_config['server']['port'])
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("📁 文件托管服务器 (极简版)")
|
||||
print("="*50)
|
||||
print(f"地址: http://{host}:{port}")
|
||||
print(f"目录: {os.path.abspath(HTML_ROOT_DIR) if FILESYSTEM_ENABLED else '未启用'}")
|
||||
print("="*50)
|
||||
print("\n快捷键: / 或 Ctrl+F 搜索, ESC 清空搜索")
|
||||
print("\n")
|
||||
|
||||
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
|
||||
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
|
||||
else:
|
||||
app.run(host=host, port=port, debug=False)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("static", "服务启动成功")
|
||||
logs.error("static", "文件系统未启用", extra={"path": "/data"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="static",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient(service_name="static")
|
||||
@@ -217,6 +217,85 @@ button[type="button"], input[type="button"] { background: var(--accent-soft); co
|
||||
.recommend-list .card h3 { transition: color 0.15s; }
|
||||
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
|
||||
|
||||
/* ===== Minimap (dnfs) ===== */
|
||||
.minimap {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(253, 246, 238, 0.72);
|
||||
border: 1px solid var(--border-light);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.minimap {
|
||||
background: rgba(30, 23, 18, 0.72);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
.minimap:empty { display: none; }
|
||||
.minimap .tag {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
background: rgba(232, 133, 58, 0.08);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.minimap::-webkit-scrollbar { width: 4px; }
|
||||
.minimap::-webkit-scrollbar-track { background: transparent; }
|
||||
.minimap::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.minimap::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||
.minimap .tag:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.minimap .tag.back {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.minimap .tag.fold-btn {
|
||||
color: var(--fg-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
text-align: center;
|
||||
max-width: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.minimap .tag.fold-btn:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== Node Form (dnfs) ===== */
|
||||
.node-form { margin-top: 1rem; }
|
||||
.node-form .form-actions { margin-top: 0.75rem; }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div id="minimap" class="minimap"></div>
|
||||
<div class="recommend-list" id="recommend-list"></div>
|
||||
<div class="card node-form">
|
||||
<h3>节点操作</h3>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>Forgot Password</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<style>
|
||||
.auth-box{max-width:400px;margin:60px auto;padding:30px;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.06)}
|
||||
.auth-box h2{text-align:center;margin-bottom:24px;color:#8b6914}
|
||||
.auth-box label{display:block;margin:12px 0 4px;font-weight:600;color:#555}
|
||||
.auth-box input[type="text"],.auth-box input[type="password"],.auth-box input[type="email"]{width:100%;padding:10px 12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;box-sizing:border-box}
|
||||
.auth-box input:focus{border-color:#c9a96e;outline:none}
|
||||
.code-row{display:flex;gap:8px;align-items:center}
|
||||
.code-row input{flex:1}
|
||||
.btn{width:100%;padding:12px;margin-top:16px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;color:#fff;background:linear-gradient(135deg,#c9a96e,#8b6914)}
|
||||
.btn:hover{opacity:.9}.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.tip{font-size:13px;margin-top:6px}
|
||||
.tip-ok{color:#2d8a4e}.tip-err{color:#c0392b}
|
||||
.back{display:block;text-align:center;margin-top:16px;color:#999;font-size:14px;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-box">
|
||||
<h2>Forgot Password</h2>
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<input type="email" id="email" placeholder="Enter your email">
|
||||
<div class="code-row">
|
||||
<input type="text" id="code" placeholder="6-digit code" maxlength="6" inputmode="numeric">
|
||||
<button class="btn" id="sendBtn" style="width:auto;margin:0;padding:10px 16px">Send Code</button>
|
||||
</div>
|
||||
<p id="tip1" class="tip"></p>
|
||||
<label>New Password</label>
|
||||
<input type="password" id="newPwd" placeholder="Min 6 characters">
|
||||
<label>Confirm Password</label>
|
||||
<input type="password" id="confirmPwd" placeholder="Enter again">
|
||||
<p id="tip2" class="tip"></p>
|
||||
<button class="btn" id="resetBtn">Reset Password</button>
|
||||
<a class="back" href="login.html">Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var PA="/api/proxy_send_code";
|
||||
var PH={"Content-Type":"application/json"};
|
||||
document.getElementById("sendBtn").onclick=function(){
|
||||
var email=document.getElementById("email").value.trim();
|
||||
if(!email||!email.includes("@")){document.getElementById("tip1").textContent="Invalid email";document.getElementById("tip1").className="tip tip-err";return}
|
||||
var btn=document.getElementById("sendBtn");btn.disabled=true;btn.textContent="Sending...";
|
||||
fetch(PA+"/send_code",{method:"POST",headers:PH,body:JSON.stringify({email:email,subject:"QSONT.me - Password Reset",body:"Your code: {code}\nValid for 10 min."})})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
var t1=document.getElementById("tip1");
|
||||
if(d.success){t1.textContent="Code sent!";t1.className="tip tip-ok"}
|
||||
else{t1.textContent=d.error||"Failed";t1.className="tip tip-err"}
|
||||
btn.disabled=false;btn.textContent="Send Code"
|
||||
}).catch(function(){document.getElementById("tip1").textContent="Network error";document.getElementById("tip1").className="tip tip-err";btn.disabled=false;btn.textContent="Send Code"})
|
||||
};
|
||||
document.getElementById("resetBtn").onclick=function(){
|
||||
var email=document.getElementById("email").value.trim();
|
||||
var code=document.getElementById("code").value.trim();
|
||||
var pwd=document.getElementById("newPwd").value;
|
||||
var cpwd=document.getElementById("confirmPwd").value;
|
||||
var t2=document.getElementById("tip2");
|
||||
if(!email||!code){t2.textContent="Enter email and code";t2.className="tip tip-err";return}
|
||||
if(pwd.length<6){t2.textContent="Password min 6 chars";t2.className="tip tip-err";return}
|
||||
if(pwd!==cpwd){t2.textContent="Passwords don't match";t2.className="tip tip-err";return}
|
||||
var btn=document.getElementById("resetBtn");btn.disabled=true;btn.textContent="Verifying...";
|
||||
fetch("/api/proxy_check_code",{method:"POST",headers:PH,body:JSON.stringify({email:email,code:code})})
|
||||
.then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.success){t2.textContent=d.error||"Invalid code";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
|
||||
return fetch("/api/reset_token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email})})
|
||||
}).then(function(r){if(r)return r.json()}).then(function(d){
|
||||
if(!d||!d.found){t2.textContent="Email not registered";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
|
||||
return fetch("/api/hash",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:pwd})})
|
||||
}).then(function(r){if(r)return r.json()}).then(function(d){
|
||||
if(!d||!d.hash){t2.textContent="Hash failed";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password";return}
|
||||
return fetch("/api/reset_token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email})}).then(function(r){return r.json()}).then(function(tk){return fetch("/api/reset_password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:email,password_hash:d.hash,verify_token:tk.token})})})
|
||||
}).then(function(r){if(r)return r.json()}).then(function(d){
|
||||
if(d&&d.success){t2.textContent="Reset success! Redirecting...";t2.className="tip tip-ok";setTimeout(function(){location.href="login.html"},3000)}
|
||||
else{t2.textContent="Reset failed";t2.className="tip tip-err"}
|
||||
btn.disabled=false;btn.textContent="Reset Password"
|
||||
}).catch(function(){t2.textContent="Network error";t2.className="tip tip-err";btn.disabled=false;btn.textContent="Reset Password"})
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+47
-57
@@ -1,58 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>树状菜单 · 开源社区</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<style>
|
||||
.hero { text-align: center; margin-bottom: 1.5rem; }
|
||||
.hero h1 { font-size: 1.6rem; margin-bottom: 0.4rem; }
|
||||
.hero p { font-size: 0.9rem; color: var(--fg-secondary); line-height: 1.6; }
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-row a {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.8rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.btn-row a:active { transform: scale(0.96); }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-ghost { background: transparent; color: var(--accent-dark); border: 1.5px solid var(--border); }
|
||||
.btn-ghost:hover { border-color: var(--accent); opacity: 0.85; }
|
||||
.tagline {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="hero">
|
||||
<h1>树状菜单</h1>
|
||||
<p>一个轻量的开源社区,用树的结构组织知识与文件。<br>自由生长,自由分享。</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn-primary" href="login.html">登录</a>
|
||||
<a class="btn-ghost" href="register.html">注册</a>
|
||||
</div>
|
||||
<div class="tagline">开源 · 轻量 · 自托管</div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<style>
|
||||
body {
|
||||
align-items: normal;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.right-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.right-group > img,
|
||||
.right-group > b,
|
||||
.right-group > button {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
<title>DNFS 欢迎来到树状笔记</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1 onclick="window.location.href='index.html'">DNFS 树状笔记</h1>
|
||||
<div class="right-group">
|
||||
<img src="favicon.ico" alt="头像加载失败" id="avatar" width="70px" height="70px" style="border-radius: 100px;">
|
||||
<b id="name">DNFS</b>
|
||||
<button onclick="window.location.href='login.html'">登录</button>
|
||||
<button onclick="window.location.href='register.html'">注册</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="main-page">
|
||||
<h2>推荐节点</h2>
|
||||
<div id="recommended-node"></div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+61
-14
@@ -1,3 +1,5 @@
|
||||
// Session restore from localStorage
|
||||
if(!sessionStorage.getItem('q4_token')){var t=localStorage.getItem('q4_token');if(t)sessionStorage.setItem('q4_token',t);}
|
||||
|
||||
/* by qing.
|
||||
*
|
||||
@@ -88,6 +90,7 @@ async function addNode(token, pid, title, content) {
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
getNode(token, stack[stack.length - 1]).then(load_notes);
|
||||
getNode(token, stack[stack.length - 1]).then(loadMap)
|
||||
|
||||
//if (result.success) {
|
||||
alert(`添加节点成功`, result);
|
||||
@@ -140,6 +143,7 @@ async function deleteNode(token, id) {
|
||||
|
||||
stack.pop();
|
||||
getNode(token, stack[stack.length - 1]).then(load_notes);
|
||||
getNode(token, stack[stack.length - 1]).then(loadMap)
|
||||
|
||||
console.log(`删除节点成功`);
|
||||
return result
|
||||
@@ -169,6 +173,8 @@ var input_note_submit = document.getElementById('input-note-submit');
|
||||
var input_note_clear = document.getElementById('input-note-clear');
|
||||
var recommend_list = document.getElementById('recommend-list');
|
||||
var delete_note = document.getElementById('delete-note');
|
||||
var minimap = document.getElementById("minimap");
|
||||
var is_expand = true;
|
||||
|
||||
input_note_submit.addEventListener('click', function(event) {
|
||||
var title = input_note_title.value
|
||||
@@ -219,31 +225,20 @@ function load_notes(main_node) {
|
||||
|
||||
|
||||
recommend_list.replaceChildren();
|
||||
//alert(stack.length)
|
||||
if (stack.length > 0) { /* 如果不在根节点 */
|
||||
var bak = document.createElement('center');
|
||||
bak.addEventListener('click', function (event) {
|
||||
stack.pop();
|
||||
var id = (stack.length > 1) ? stack[stack.length - 1] : 0;
|
||||
getNode(token, id).then(load_notes)
|
||||
});
|
||||
var bak1 = document.createElement('h1');
|
||||
bak1.textContent = "返回上一层";
|
||||
bak.appendChild(bak1)
|
||||
recommend_list.appendChild(bak)
|
||||
}
|
||||
for (let i in main_node) {
|
||||
let item = main_node[i]
|
||||
let card = document.createElement('div');
|
||||
let card = document.createElement('div');
|
||||
card.className = "card";
|
||||
card.addEventListener('click', function(event) {
|
||||
var id = Number(item.id)
|
||||
stack.push(id)
|
||||
getNode(token, id).then(load_notes)
|
||||
getNode(token, id).then(loadMap)
|
||||
});
|
||||
let ttl = document.createElement('center');
|
||||
let ttl1 =document.createElement('h3');
|
||||
ttl1.textContent = item.title;
|
||||
card.id = item.title;
|
||||
ttl.appendChild(ttl1);
|
||||
card.appendChild(ttl);
|
||||
let vit_div = document.createElement('p');
|
||||
@@ -263,5 +258,57 @@ function load_notes(main_node) {
|
||||
}
|
||||
};
|
||||
|
||||
function loadMap(children_json) {
|
||||
let need_return = false;
|
||||
minimap.replaceChildren();
|
||||
var fold_div = document.createElement("div");
|
||||
fold_div.className = "tag fold-btn";
|
||||
if (is_expand == true) {
|
||||
fold_div.textContent = "v 折叠";
|
||||
minimap.appendChild(fold_div);
|
||||
} else {
|
||||
fold_div.textContent = "> 展开";
|
||||
minimap.appendChild(fold_div);
|
||||
need_return = true;
|
||||
}
|
||||
fold_div.addEventListener("click", () => {
|
||||
if (is_expand == true) {
|
||||
is_expand = false;
|
||||
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
|
||||
} else {
|
||||
is_expand = true;
|
||||
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
|
||||
}
|
||||
});
|
||||
if (need_return == true) {
|
||||
return;
|
||||
}
|
||||
if (stack.length > 0) {
|
||||
let back = document.createElement("div");
|
||||
back.className = "tag back";
|
||||
back.textContent = "← 返回上一层";
|
||||
back.addEventListener("click", () => {
|
||||
stack.pop();
|
||||
var id = (stack.length >= 0) ? stack[stack.length - 1] : 0;
|
||||
getNode(token, id).then(load_notes);
|
||||
getNode(token, id).then(loadMap);
|
||||
});
|
||||
minimap.appendChild(back);
|
||||
}
|
||||
for (let i in children_json) {
|
||||
let item = children_json[i];
|
||||
let tag = document.createElement("div");
|
||||
let title = item.title;
|
||||
tag.className = "tag";
|
||||
tag.textContent = title;
|
||||
tag.addEventListener("click", () => {
|
||||
window.location.href = `#${title}`;
|
||||
console.log(`跳转到${title}`);
|
||||
})
|
||||
minimap.appendChild(tag);
|
||||
}
|
||||
}
|
||||
|
||||
var token = sessionStorage.getItem('q4_token')
|
||||
getNode(token, 0).then(load_notes);
|
||||
getNode(token, 0).then(loadMap)
|
||||
Whitespace-only changes.
+14
-86
@@ -1,162 +1,90 @@
|
||||
const msgTip = document.getElementById('msgTip');
|
||||
const pwdTip = document.getElementById('pwdTip');
|
||||
const emlTip = document.getElementById('emlTip');
|
||||
const agreeTip = document.getElementById('agreeTip');
|
||||
const form = document.getElementById('form');
|
||||
|
||||
const usernameInput = document.getElementById('loginUsername');
|
||||
const passwordInput = document.getElementById('loginPassword');
|
||||
const emailInput = document.getElementById('loginEmail');
|
||||
const agreeBox = document.getElementById('agreeBox');
|
||||
const protocol = document.getElementById('agreement');
|
||||
const resetUser = document.getElementById('resetUser');
|
||||
const registerBtn = document.getElementById('registerBtn');
|
||||
const loginButton = document.getElementById('loginBtn');
|
||||
|
||||
resetUser.addEventListener('click', function() {
|
||||
clearTipStyle();
|
||||
});
|
||||
resetUser.addEventListener('click', function() { clearTipStyle(); });
|
||||
|
||||
function clearTipStyle() {
|
||||
msgTip.innerHTML = '';
|
||||
pwdTip.innerHTML = '';
|
||||
agreeTip.innerHTML = '';
|
||||
emlTip.innerHTML = '';
|
||||
|
||||
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
agreeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
protocol.classList.remove('tip-red');
|
||||
protocol.classList.remove('tip-green');
|
||||
agreeTip.classList.remove('tip-red');
|
||||
}
|
||||
clearTipStyle();
|
||||
|
||||
/**
|
||||
* @returns {string|false}
|
||||
*/
|
||||
function checkUsername() {
|
||||
const result = FormValidators.username(usernameInput.value);
|
||||
msgTip.innerHTML = '';
|
||||
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
msgTip.textContent = result.message;
|
||||
msgTip.classList.add(result.className);
|
||||
msgTip.className = result.className;
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|false}
|
||||
*/
|
||||
function checkPwd() {
|
||||
const result = FormValidators.password(passwordInput.value);
|
||||
pwdTip.innerHTML = '';
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
pwdTip.textContent = result.message;
|
||||
pwdTip.classList.add(result.className);
|
||||
pwdTip.className = result.className;
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|false}
|
||||
*/
|
||||
function checkEml() {
|
||||
const result = FormValidators.email(emailInput.value);
|
||||
emlTip.innerHTML = '';
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
emlTip.textContent = result.message;
|
||||
emlTip.classList.add(result.className);
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkAgree() {
|
||||
agreeTip.innerHTML = '';
|
||||
agreeTip.classList.remove('tip-red');
|
||||
protocol.classList.remove('tip-green', 'tip-red');
|
||||
if (!agreeBox.checked) {
|
||||
agreeTip.innerHTML = '请同意用户协议!';
|
||||
agreeTip.classList.add('tip-red');
|
||||
protocol.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
protocol.classList.add('tip-green');
|
||||
return true;
|
||||
}
|
||||
|
||||
usernameInput.addEventListener('focus', checkUsername);
|
||||
passwordInput.addEventListener('focus', checkPwd);
|
||||
|
||||
usernameInput.addEventListener('input', checkUsername);
|
||||
passwordInput.addEventListener('input', checkPwd);
|
||||
|
||||
usernameInput.addEventListener('blur', checkUsername);
|
||||
passwordInput.addEventListener('blur', checkPwd);
|
||||
|
||||
emailInput.addEventListener('focus', checkEml);
|
||||
emailInput.addEventListener('input', checkEml);
|
||||
emailInput.addEventListener('blur', checkEml);
|
||||
|
||||
agreeBox.addEventListener('change', checkAgree);
|
||||
|
||||
registerBtn.addEventListener('click', function() {
|
||||
location.href = 'register.html';
|
||||
});
|
||||
registerBtn.addEventListener('click', function() { location.href = 'register.html'; });
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
clearTipStyle();
|
||||
|
||||
const userOk = checkUsername();
|
||||
const pwdOk = checkPwd();
|
||||
const emlOk = checkEml();
|
||||
const agreeOk = checkAgree();
|
||||
if (!userOk || !pwdOk || !agreeOk) return;
|
||||
|
||||
if (!(userOk && pwdOk && emlOk && agreeOk)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonBody = JSON.stringify({
|
||||
username: userOk,
|
||||
password: pwdOk
|
||||
});
|
||||
|
||||
const jsonBody = JSON.stringify({ username: userOk, password: pwdOk });
|
||||
try {
|
||||
loginButton.disabled = true;
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json' // 告诉服务器内容为json
|
||||
},
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonBody
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error(`登录接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
|
||||
throw new Error('服务器响应格式异常,请稍后重试');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.message || `登录失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok) throw new Error(result.message || '登录失败');
|
||||
if (result.success) {
|
||||
alert(`登陆成功`);
|
||||
localStorage.setItem('q4_token', result.data.token);
|
||||
sessionStorage.setItem('q4_token', result.data.token);
|
||||
location.href = 'dnfs.html';
|
||||
alert('登录成功');
|
||||
location.href = '/dnfs.html';
|
||||
} else {
|
||||
alert(result.message || '登录失败');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
console.error('登录请求异常:', err);
|
||||
alert(err.message || '接口请求失败');
|
||||
} finally {
|
||||
loginButton.disabled = false;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function checkCode() {
|
||||
codeTip.classList.add('tip-yellow');
|
||||
return false;
|
||||
}
|
||||
if (code !== sendCode) {
|
||||
if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) {
|
||||
codeTip.textContent = '验证码错误,请核对!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
|
||||
+12
-17
@@ -2,7 +2,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- 移动端适配核心标签 -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>登录</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
@@ -10,22 +9,14 @@
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>用户登录</h2>
|
||||
<form action="" id="form" method="post">
|
||||
<form id="form" novalidate>
|
||||
<div>
|
||||
<label>用户名/推荐邮箱:</label>
|
||||
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名">
|
||||
<span id="msgTip" ></span>
|
||||
<label>密码:</label>
|
||||
<input type="password" id="loginPassword" name="password" placeholder="请输入密码">
|
||||
<span id="pwdTip"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label>验证邮箱:</label>
|
||||
<input type="email" id="loginEmail" name="email" placeholder="请输入验证邮箱">
|
||||
<span id="emlSub">
|
||||
<input type="submit" value="发送验证码">
|
||||
</span>
|
||||
<span id="emlTip"></span>
|
||||
<label>用户名/推荐邮箱:</label>
|
||||
<input type="text" id="loginUsername" placeholder="请输入用户名">
|
||||
<span id="msgTip"></span>
|
||||
<label>密码:</label>
|
||||
<input type="password" id="loginPassword" placeholder="请输入密码">
|
||||
<span id="pwdTip"></span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" id="registerBtn">注册</button>
|
||||
@@ -38,6 +29,10 @@
|
||||
<span id="agreeTip"></span>
|
||||
</div>
|
||||
</form>
|
||||
<a href="forgot.html" style="display:block;text-align:center;color:#999;font-size:13px;margin:8px 0">忘记密码?</a>
|
||||
<div style="text-align:center;margin:12px 0;position:relative"><div style="position:absolute;top:50%;left:0;right:0;height:1px;background:#e0d6cc"></div><span style="background:#fff;padding:0 12px;position:relative;color:#999;font-size:13px">或</span></div>
|
||||
<a href="https://yhdet.top/yhdet_auth?callback=https://qsont.me/yhdet_callback" class="yhdet-login-btn">开源社区 快捷登录</a>
|
||||
<style>.yhdet-login-btn{display:block;width:100%;padding:12px;border:1.5px solid #1a73e8;border-radius:8px;background:white;color:#1a73e8;font-size:15px;font-weight:600;text-decoration:none;text-align:center;box-sizing:border-box;margin-bottom:12px}.yhdet-login-btn:hover{background:#e8f0fe}</style>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
@@ -45,4 +40,4 @@
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,3 +1,4 @@
|
||||
../pymsg
|
||||
nuitka
|
||||
Flask>=2.3.0,<3.0.0
|
||||
Flask-CORS>=4.0.0,<5.0.0
|
||||
@@ -1,5 +1,6 @@
|
||||
# 编译产物
|
||||
waf.bin
|
||||
waf
|
||||
*.exe
|
||||
|
||||
# 证书与密钥(不入库)
|
||||
|
||||
+91
-21
@@ -28,17 +28,34 @@
|
||||
|
||||
```
|
||||
rules/
|
||||
├── waf.conf # 主入口,Include 各子文件
|
||||
├── waf.conf # 主入口:基础配置(SecRuleEngine / SecRequestBodyLimit 等)
|
||||
├── local/ # 本地自定义规则(ID 段 100000-199999)
|
||||
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
|
||||
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
|
||||
└── crs/ # OWASP CRS(git clone,不入库,ID 段 900000-999999)
|
||||
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取)
|
||||
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
|
||||
├── LICENSE # Apache-2.0
|
||||
└── rules/*.conf # CRS 规则文件
|
||||
```
|
||||
|
||||
> **注意**:engine 不使用 coraza 的 `Include` 指令(其路径相对 cwd 解析易出错),改为在 [engine.Reload](internal/engine/engine.go) 中自行扫描目录、逐个 `WithDirectivesFromFile` 加载。
|
||||
|
||||
### 规则加载顺序
|
||||
|
||||
由 [engine.go:50-89](internal/engine/engine.go#L50) `Reload` 控制,固定顺序:
|
||||
|
||||
1. `waf.conf` — 基础配置
|
||||
2. `local/*.conf` — 本地自定义规则(按文件名字母序)
|
||||
3. `crs/coraza.conf` — OWASP CRS 入口(**仅当文件存在时加载**)
|
||||
4. `crs/rules/*.conf` — OWASP CRS 规则(按文件名字母序)
|
||||
|
||||
### 规则 ID 分段
|
||||
|
||||
- `100000-199999` 本地自定义
|
||||
- `900000-999999` OWASP CRS
|
||||
| ID 段 | 用途 |
|
||||
|-------|------|
|
||||
| 100000-199999 | 本地自定义规则 |
|
||||
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) |
|
||||
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) |
|
||||
|
||||
### 规则语法(SecLang)
|
||||
|
||||
@@ -70,10 +87,21 @@ WAF 必须终止 TLS 才能看到明文 body,因此证书需从 gateway 迁到
|
||||
listen: ":443"
|
||||
tls_cert: "./certs/full_chain.crt"
|
||||
tls_key: "./certs/server.key"
|
||||
|
||||
# gateway 内部 HTTP 端口(gateway 需改为监听 127.0.0.1:8443 不启用 TLS)
|
||||
upstream: "http://127.0.0.1:8443"
|
||||
|
||||
# 规则根目录,主入口为 <rules_dir>/waf.conf
|
||||
rules_dir: "./rules"
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `listen` | `:443` | 监听地址 |
|
||||
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
|
||||
| `upstream` | — | gateway 地址,**必填** |
|
||||
| `rules_dir` | `./rules` | 规则根目录 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
@@ -83,7 +111,7 @@ waf/
|
||||
├── go.mod
|
||||
├── internal/
|
||||
│ ├── engine/
|
||||
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测
|
||||
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
|
||||
│ └── proxy/
|
||||
│ └── proxy.go # ReverseProxy + WAF 事务接入
|
||||
├── rules/
|
||||
@@ -91,8 +119,10 @@ waf/
|
||||
│ └── local/
|
||||
│ ├── custom.conf
|
||||
│ └── exceptions.conf
|
||||
├── _set.sh # 编译
|
||||
├── _set.sh # 编译(含 go mod tidy + download + build)
|
||||
├── _run.sh # 运行
|
||||
├── _goproxy.sh # 设置 GOPROXY 国内镜像(由 _set.sh 调用)
|
||||
├── _fetch_crs.sh # 拉取 OWASP CRS(可选)
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
@@ -100,11 +130,11 @@ waf/
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 1. 安装 Go 1.21+
|
||||
# 1. 安装 Go 1.21+(实际要求 1.22+,见 go.mod)
|
||||
brew install go # macOS
|
||||
# 或 sudo apt install golang-go # Debian/Ubuntu
|
||||
|
||||
# 2. 编译
|
||||
# 2. 编译(_set.sh 会自动设置 GOPROXY 国内镜像 + GOTOOLCHAIN=local)
|
||||
bash _set.sh
|
||||
|
||||
# 3. 准备证书
|
||||
@@ -114,6 +144,8 @@ mkdir certs && cp /path/to/full_chain.crt /path/to/server.key certs/
|
||||
bash _run.sh
|
||||
```
|
||||
|
||||
> **国内环境**:[_set.sh](_set.sh) 会调用 [_goproxy.sh](_goproxy.sh) 自动设置 `GOPROXY=https://goproxy.cn,direct` 与 `GOSUMDB=sum.golang.google.cn`;并强制 `GOTOOLCHAIN=local` 避免 Go 1.21+ 自动从 `proxy.golang.org` 下载 toolchain 超时。用户已显式配置非默认代理时尊重不覆盖。
|
||||
|
||||
## 接入 core 自启动
|
||||
|
||||
在 `core/init.txt` 增加:
|
||||
@@ -126,13 +158,19 @@ bash /opt/micro_servers/waf/_run.sh
|
||||
|
||||
## 命中日志
|
||||
|
||||
命中规则时输出 JSON 一行,便于 `jq` 分析:
|
||||
命中规则时输出 JSON 一行,便于 `jq` 分析(见 [proxy.go:120-137](internal/proxy/proxy.go#L120)):
|
||||
|
||||
```json
|
||||
{"ts":"2026-07-25T10:00:00Z","action":"block","rule_id":100001,"ip":"1.2.3.4","method":"GET","path":"/","query":"id=1'+OR+'1'='1","ua":"curl/8.0","cost_ms":0}
|
||||
```
|
||||
|
||||
聚合统计:
|
||||
放行请求时输出一行 pass 日志(见 [proxy.go:115](internal/proxy/proxy.go#L115)):
|
||||
|
||||
```
|
||||
[proxy] pass ip=1.2.3.4:54321 method=GET path=/ status=200 rule=- cost=1.2ms
|
||||
```
|
||||
|
||||
聚合统计命中规则:
|
||||
|
||||
```bash
|
||||
journalctl -u Nidus -o cat | grep '"action":"block"' | \
|
||||
@@ -141,31 +179,63 @@ journalctl -u Nidus -o cat | grep '"action":"block"' | \
|
||||
|
||||
## 启用 OWASP CRS(可选)
|
||||
|
||||
通过 Go module proxy 拉取(不依赖 GitHub 直连,国内可用):
|
||||
|
||||
```bash
|
||||
cd rules
|
||||
git clone --depth 1 https://github.com/coreruleset/coreruleset.git crs
|
||||
bash _fetch_crs.sh # 拉取最新版
|
||||
# 或
|
||||
bash _fetch_crs.sh v4.25.0 # 拉取指定版本
|
||||
```
|
||||
|
||||
在 `rules/waf.conf` 取消注释:
|
||||
脚本会:
|
||||
|
||||
```apache
|
||||
Include crs/coraza.conf
|
||||
Include crs/rules/*.conf
|
||||
1. 通过 `GOPROXY=https://goproxy.cn,direct` 下载 `github.com/corazawaf/coraza-coreruleset/v4` 到模块缓存
|
||||
2. 复制规则文件(`*.conf` + `*.data`)到 `rules/crs/rules/`
|
||||
3. 自动生成 `rules/crs/coraza.conf`(含请求体处理器初始化规则 200000-200006 与 `tx.crs_setup_version`)
|
||||
4. 复制 Apache-2.0 LICENSE
|
||||
|
||||
完成后 `rules/crs/coraza.conf` 存在,[engine.Reload](internal/engine/engine.go) 会**自动检测并加载**,无需改 `waf.conf`。直接热加载即可生效:
|
||||
|
||||
```bash
|
||||
kill -HUP $(pgrep waf)
|
||||
```
|
||||
|
||||
然后 `kill -HUP $(pgrep waf)`。
|
||||
校验加载结果:
|
||||
|
||||
```bash
|
||||
./waf.bin -list | head
|
||||
```
|
||||
|
||||
> `rules/crs/` 整个目录已在 `.gitignore` 中,不入库。如需升级 CRS,重跑 `_fetch_crs.sh` 即可。
|
||||
|
||||
## 请求处理流程
|
||||
|
||||
[proxy.go:57-117](internal/proxy/proxy.go#L57) `ServeHTTP`:
|
||||
|
||||
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
||||
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
||||
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
||||
5. 捕获响应头 → `ProcessResponseHeaders`
|
||||
|
||||
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
||||
|
||||
ErrorHandler([proxy.go:48-51](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,不暴露内部堆栈,仅服务端 log。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
||||
- 响应体检测未启用(需缓冲整个响应,延迟成本高)。
|
||||
- **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
||||
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Go 1.21+
|
||||
- github.com/corazawaf/coraza/v3 v3.7.0
|
||||
- gopkg.in/yaml.v3
|
||||
- Go 1.22+(见 [go.mod](go.mod),锁 `go 1.22.0`)
|
||||
- `github.com/corazawaf/coraza/v3` **v3.3.0**(锁版本,避免 v3.7.0 要求 Go 1.25+)
|
||||
- `gopkg.in/yaml.v3` v3.0.1
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
"github.com/corazawaf/coraza/v3/types"
|
||||
|
||||
"github.com/micro_servers/waf/internal/logclient"
|
||||
)
|
||||
|
||||
// Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。
|
||||
@@ -257,8 +259,8 @@ func TestFile(file string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogError 默认错误回调,输出到 stderr。
|
||||
// LogError 默认错误回调,推送到日志库。
|
||||
func LogError(mr types.MatchedRule) {
|
||||
fmt.Fprintf(os.Stderr, "[coraza] rule_id=%d severity=%s msg=%s\n",
|
||||
mr.Rule().ID(), mr.Rule().Severity(), mr.ErrorLog())
|
||||
logclient.Error("waf", fmt.Sprintf("coraza rule_id=%d severity=%s", mr.Rule().ID(), mr.Rule().Severity()),
|
||||
map[string]any{"rule_id": mr.Rule().ID(), "msg": mr.ErrorLog()})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Package logclient 提供异步批量日志推送客户端。
|
||||
//
|
||||
// 通过 pymsg TCP 协议调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
//
|
||||
// 设计要点(与 Python example_log_client.py 对齐):
|
||||
// - 异步非阻塞:内存 channel + 后台 goroutine 批量推送,不拖慢业务
|
||||
// - fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
// - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
//
|
||||
// 协议:pymsg 长度前缀帧(4 字节 big-endian uint32 长度 + UTF-8 载荷)
|
||||
package logclient
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dbAddr = "127.0.0.1:8005"
|
||||
batchSize = 50
|
||||
flushInterval = 2 * time.Second
|
||||
socketTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// Entry 单条日志。
|
||||
type Entry struct {
|
||||
Service string `json:"service"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// dbV2Request execute 路由的 INSERT 请求体。
|
||||
type dbV2Request struct {
|
||||
Route string `json:"_route"`
|
||||
Action string `json:"action"`
|
||||
Table string `json:"table"`
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// dbV2Response execute 路由的通用响应。
|
||||
type dbV2Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Client 异步批量日志推送客户端。
|
||||
type Client struct {
|
||||
ch chan Entry
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// global 全局单例。
|
||||
var global *Client
|
||||
|
||||
// Init 初始化全局日志客户端并启动后台 flush goroutine。
|
||||
func Init() {
|
||||
global = New()
|
||||
}
|
||||
|
||||
// New 创建客户端实例。
|
||||
func New() *Client {
|
||||
c := &Client{
|
||||
ch: make(chan Entry, 256),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
c.wg.Add(1)
|
||||
go c.flushLoop()
|
||||
return c
|
||||
}
|
||||
|
||||
// Push 推送一条日志(非阻塞,队列满时丢弃)。
|
||||
func Push(service, level, message string, extra map[string]any) {
|
||||
if global == nil {
|
||||
return
|
||||
}
|
||||
global.push(Entry{
|
||||
Service: service,
|
||||
Level: level,
|
||||
Message: message,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
// Info 推送 INFO 级别日志。
|
||||
func Info(service, message string, extra map[string]any) {
|
||||
Push(service, "INFO", message, extra)
|
||||
}
|
||||
|
||||
// Warning 推送 WARNING 级别日志。
|
||||
func Warning(service, message string, extra map[string]any) {
|
||||
Push(service, "WARNING", message, extra)
|
||||
}
|
||||
|
||||
// Error 推送 ERROR 级别日志。
|
||||
func Error(service, message string, extra map[string]any) {
|
||||
Push(service, "ERROR", message, extra)
|
||||
}
|
||||
|
||||
// Flush 同步 flush 队列中剩余日志(用于进程退出前)。
|
||||
func Flush() {
|
||||
if global == nil {
|
||||
return
|
||||
}
|
||||
global.flush()
|
||||
}
|
||||
|
||||
func (c *Client) push(e Entry) {
|
||||
select {
|
||||
case c.ch <- e:
|
||||
default:
|
||||
// 队列满,静默丢弃(fail-safe)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) flushLoop() {
|
||||
defer c.wg.Done()
|
||||
batch := make([]Entry, 0, batchSize)
|
||||
ticker := time.NewTicker(flushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case e := <-c.ch:
|
||||
batch = append(batch, e)
|
||||
// 非阻塞地继续取,直到满 batchSize
|
||||
for len(batch) < batchSize {
|
||||
select {
|
||||
case e2 := <-c.ch:
|
||||
batch = append(batch, e2)
|
||||
default:
|
||||
goto send
|
||||
}
|
||||
}
|
||||
send:
|
||||
if len(batch) >= batchSize {
|
||||
c.send(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-ticker.C:
|
||||
if len(batch) > 0 {
|
||||
c.send(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-c.stop:
|
||||
// 退出前 drain channel
|
||||
for {
|
||||
select {
|
||||
case e := <-c.ch:
|
||||
batch = append(batch, e)
|
||||
default:
|
||||
if len(batch) > 0 {
|
||||
c.send(batch)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) send(batch []Entry) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, entry := range batch {
|
||||
c.sendOne(entry, now)
|
||||
}
|
||||
}
|
||||
|
||||
// sendOne 通过 pymsg TCP 协议发送单条日志到 dbv2。
|
||||
func (c *Client) sendOne(entry Entry, ts string) {
|
||||
extraJSON := ""
|
||||
if entry.Extra != nil {
|
||||
b, err := json.Marshal(entry.Extra)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
extraJSON = string(b)
|
||||
}
|
||||
|
||||
values := map[string]any{
|
||||
"ts": ts,
|
||||
"service": entry.Service,
|
||||
"level": entry.Level,
|
||||
"message": entry.Message,
|
||||
}
|
||||
if entry.TraceID != "" {
|
||||
values["trace_id"] = entry.TraceID
|
||||
}
|
||||
if extraJSON != "" {
|
||||
values["extra"] = extraJSON
|
||||
}
|
||||
|
||||
req := dbV2Request{
|
||||
Route: "execute",
|
||||
Action: "insert",
|
||||
Table: "Logs",
|
||||
Values: values,
|
||||
}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// pymsg 协议:4 字节 big-endian 长度 + UTF-8 载荷
|
||||
resp, err := pymsgCall(data)
|
||||
if err != nil {
|
||||
return // fail-safe
|
||||
}
|
||||
|
||||
var r dbV2Response
|
||||
if json.Unmarshal(resp, &r) != nil || !r.Success {
|
||||
fmt.Printf("[logclient] INSERT failed: %s\n", r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// pymsgCall 发送一条 pymsg 请求并读取响应(短连接模式)。
|
||||
func pymsgCall(payload []byte) ([]byte, error) {
|
||||
conn, err := net.DialTimeout("tcp", dbAddr, socketTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetDeadline(time.Now().Add(socketTimeout))
|
||||
|
||||
// 发送:4 字节长度前缀 + 载荷
|
||||
header := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(header, uint32(len(payload)))
|
||||
if _, err := conn.Write(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Write(payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 接收:4 字节长度前缀 + 载荷
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respLen := binary.BigEndian.Uint32(header)
|
||||
resp := make([]byte, respLen)
|
||||
if _, err := io.ReadFull(conn, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) flush() {
|
||||
c.mu.Lock()
|
||||
if c.stopped {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.stopped = true
|
||||
c.mu.Unlock()
|
||||
|
||||
close(c.stop)
|
||||
c.wg.Wait()
|
||||
}
|
||||
+15
-21
@@ -3,9 +3,8 @@ package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/micro_servers/waf/internal/engine"
|
||||
"github.com/micro_servers/waf/internal/logclient"
|
||||
)
|
||||
|
||||
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
|
||||
@@ -46,7 +46,8 @@ func New(upstream string, waf *engine.Engine) (*Proxy, error) {
|
||||
}
|
||||
// 透传错误,不暴露内部堆栈
|
||||
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Printf("[proxy] upstream_error ip=%s path=%s err=%v", r.RemoteAddr, r.URL.Path, err)
|
||||
logclient.Error("waf", fmt.Sprintf("upstream_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
return p, nil
|
||||
@@ -111,27 +112,20 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
|
||||
|
||||
log.Printf("[proxy] pass ip=%s method=%s path=%s status=%d rule=- cost=%s",
|
||||
r.RemoteAddr, r.Method, r.URL.Path, rw.status, time.Since(start))
|
||||
}
|
||||
|
||||
// block 命中规则时统一返回 403,并打 JSON 日志便于 jq 分析。
|
||||
// block 命中规则时统一返回 403,并将关键输入推送到日志库。
|
||||
func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start time.Time) {
|
||||
rec := map[string]any{
|
||||
"ts": time.Now().UTC().Format(time.RFC3339),
|
||||
"action": "block",
|
||||
"rule_id": ruleID,
|
||||
"ip": r.RemoteAddr,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
"ua": r.UserAgent(),
|
||||
"cost_ms": time.Since(start).Milliseconds(),
|
||||
}
|
||||
if raw, err := json.Marshal(rec); err == nil {
|
||||
log.Println(string(raw))
|
||||
}
|
||||
logclient.Warning("waf", fmt.Sprintf("block rule_id=%d %s %s", ruleID, r.Method, r.URL.Path),
|
||||
map[string]any{
|
||||
"rule_id": ruleID,
|
||||
"ip": r.RemoteAddr,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
"ua": r.UserAgent(),
|
||||
"cost_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("blocked by WAF\n"))
|
||||
}
|
||||
|
||||
+15
-9
@@ -16,6 +16,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/micro_servers/waf/internal/engine"
|
||||
"github.com/micro_servers/waf/internal/logclient"
|
||||
"github.com/micro_servers/waf/internal/proxy"
|
||||
)
|
||||
|
||||
@@ -82,9 +84,14 @@ func main() {
|
||||
}
|
||||
|
||||
// 服务模式
|
||||
logclient.Init()
|
||||
log.SetOutput(io.Discard) // 静默标准 log
|
||||
|
||||
p, err := proxy.New(cfg.Upstream, eng)
|
||||
if err != nil {
|
||||
log.Fatalf("代理初始化失败: %v", err)
|
||||
logclient.Error("waf", fmt.Sprintf("代理初始化失败: %v", err), nil)
|
||||
logclient.Flush()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// SIGHUP 热加载
|
||||
@@ -92,11 +99,10 @@ func main() {
|
||||
signal.Notify(sigCh, syscall.SIGHUP)
|
||||
go func() {
|
||||
for range sigCh {
|
||||
log.Println("[reload] 收到 SIGHUP,重新加载规则")
|
||||
if err := eng.Reload(); err != nil {
|
||||
log.Printf("[reload] 失败,保留旧规则: %v", err)
|
||||
logclient.Error("waf", fmt.Sprintf("热加载失败,保留旧规则: %v", err), nil)
|
||||
} else {
|
||||
log.Println("[reload] 成功")
|
||||
logclient.Info("waf", "规则热加载成功", nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -109,13 +115,13 @@ func main() {
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("waf listening on %s, upstream=%s, rules=%s",
|
||||
cfg.Listen, cfg.Upstream, cfg.RulesDir)
|
||||
log.Printf(" - 热加载: kill -HUP %d", os.Getpid())
|
||||
log.Printf(" - 列规则: %s -c %s -list", os.Args[0], *configFile)
|
||||
logclient.Info("waf", fmt.Sprintf("服务启动 listen=%s upstream=%s rules=%s pid=%d",
|
||||
cfg.Listen, cfg.Upstream, cfg.RulesDir, os.Getpid()), nil)
|
||||
|
||||
if err := server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil {
|
||||
log.Fatal(err)
|
||||
logclient.Error("waf", fmt.Sprintf("TLS 监听失败: %v", err), nil)
|
||||
logclient.Flush()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user