Merge pull request 'Dev qing' (#9) from dev_qing into main

Reviewed-on: qing/micro_servers#9
This commit is contained in:
qing committed 2026-07-27 21:47:16 +08:00
commit e923b32b70
50 files changed
+3680 -2883

No files matched your search

+23 -3
View File
@@ -1,7 +1,7 @@
# micro_servers # micro_servers
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python 与 Go 混合实现。 一套 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 │ │ gateway │ │ static │ │ dnfs │ │ db │ │ ai_review │
│ WAF 网关 │ │ 应用层网关 │ │前端托管│ │ 树状菜单 │ │ 通用数据库 │ │ AI内容审核 │ │ WAF 网关 │ │ 应用层网关 │ │前端托管│ │ 树状菜单 │ │ 通用数据库 │ │ AI内容审核 │
│ :443 (入口) │ │ :8443 (内部) │ │ :9200 │ │ :8006 │ │ :8005 │ │ :8007 │ │ :443 (入口) │ │ :8443 (内部) │ │ :9200 │ │ :8006 │ │ :8005 │ │ :8007 │
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL-over-HTTP│ │ DeepSeek+RAG │ │ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL+日志收集 │ │ DeepSeek+RAG │
└──────┬───────┘ └──────┬───────┘ └────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘ └──────┬───────┘ └──────┬───────┘ └────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
│ HTTPS终止 │ 反向代理 │ 调用 │ HTTPS终止 │ 反向代理 │ 调用
└──────────────────►│ ├──────────► db:8005 └──────────────────►│ ├──────────► db:8005
├────────────────► static:9200 ├────────────────► static:9200
└────────────────► dnfs:8006 ──► ai_review:8007 └────────────────► 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`)。 4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。 5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
6. `dnfs` 处理节点增删改查时,通过 HTTP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。 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 签发与校验、反向代理到内部服务 | | [gateway](gateway/) | C++20 | 8443 | 应用层网关(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、反向代理到内部服务 |
| [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 | | [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 |
| [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 | | [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,支持人工反馈纠偏 | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
### core(核心模块 · 进程托管服务) ### core(核心模块 · 进程托管服务)
@@ -98,6 +101,15 @@
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。 - 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
- 仅监听 127.0.0.1,单线程,自动回滚,不暴露 DDL 操作。 - 仅监听 127.0.0.1,单线程,自动回滚,不暴露 DDL 操作。
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。 - 默认使用 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 内容审核服务) ### ai_review(AI 内容审核服务)
@@ -116,6 +128,13 @@
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` | | [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) | | [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 通信。 - **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)混合,通过 HTTP 通信。
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 HTTP 接口,业务服务无需直连数据库。 - **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 HTTP 接口,业务服务无需直连数据库。
- **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。 - **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。
- **集中式可观测性**:`db` 内置日志收集服务(/log/* 路由),各服务通过 `log_client.py` SDK 异步推送日志,实现全链路汇聚、查询与统计,无需引入额外中间件。
- **安全分层**:`waf` 在边缘做规则匹配(OWASP CRS / 自定义 SecLang 规则),`gateway` 在应用层做鉴权,`db` 仅监听本机,多层防护。 - **安全分层**:`waf` 在边缘做规则匹配(OWASP CRS / 自定义 SecLang 规则),`gateway` 在应用层做鉴权,`db` 仅监听本机,多层防护。
与云原生微服务的差异:未引入服务注册发现、容器编排、消息总线,服务地址在 `waf/config.yaml` 与 `gateway/example.ini` 中静态配置。对于个人/中小规模的 DIY 场景,这种取舍在简洁性与可控性上是合理的。 与云原生微服务的差异:未引入服务注册发现、容器编排、消息总线,服务地址在 `waf/config.yaml` 与 `gateway/example.ini` 中静态配置。对于个人/中小规模的 DIY 场景,这种取舍在简洁性与可控性上是合理的。
+1
View File
@@ -34,4 +34,5 @@ inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 w
inst_module "static" inst_module "static"
inst_module "dnfs" inst_module "dnfs"
inst_module "db" inst_module "db"
inst_module "smtp"
inst_module "core" # core last inst_module "core" # core last
Regular → Executable
View File
File mode changed.
+5 -1
View File
@@ -1,7 +1,11 @@
bash /opt/micro_servers/ai_review/_run.sh
bash /opt/micro_servers/db/_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/dnfs/_run.sh
bash /opt/micro_servers/smtp/_run.sh
bash /opt/micro_servers/static/_run.sh bash /opt/micro_servers/static/_run.sh
bash /opt/micro_servers/waf/_run.sh bash /opt/micro_servers/waf/_run.sh
bash /opt/micro_servers/gateway/_run.sh bash /opt/micro_servers/gateway/_run.sh
bash /opt/micro_servers/captcha/_run.sh bash /opt/micro_servers/captcha/_run.sh
#
#ssh-copy-id user@your-server
#ssh -D 1080 -N user@your-server
+14 -13
View File
@@ -279,20 +279,21 @@ try {
return; return;
} }
else if (typ == "kill") { /* 20260727(qing): 在身份验证之前禁止操作 */
int trg = jv.at("kill").as_int64(); //else if (typ == "kill") {
auto flag = pmc_mtd::kill(trg); // int trg = jv.at("kill").as_int64();
std::string msg = flag ? "OK" : "Nope"; // auto flag = pmc_mtd::kill(trg);
pipe.writePipe(msg); // std::string msg = flag ? "OK" : "Nope";
return; // pipe.writePipe(msg);
} // return;
//}
else if (typ == "exec"){ //else if (typ == "exec"){
std::string cmd = jv.at("exec").as_string().c_str(); // std::string cmd = jv.at("exec").as_string().c_str();
std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR"; // std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
pipe.writePipe(msg); // pipe.writePipe(msg);
return; // return;
} //}
} }
/* 捕获异常后,仅仅只是打印出来? */ /* 捕获异常后,仅仅只是打印出来? */
+1 -1
View File
@@ -2,4 +2,4 @@
cd "$(dirname "$0")" || exit cd "$(dirname "$0")" || exit
source ./venv/bin/activate source ./venv/bin/activate
#exec ./main.dist/main.bin #exec ./main.dist/main.bin
exec python3 main.py exec python3 dbv2.py
+85
View File
@@ -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
View File
@@ -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()
+184
View File
@@ -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
View File
@@ -16,7 +16,7 @@ import threading
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, current_app
from sqlalchemy import ( from sqlalchemy import (
Table, insert, select, delete, func, and_, or_, MetaData Table, insert, select, delete, func, and_, or_, MetaData
) )
@@ -120,7 +120,6 @@ def init_log_routes(app, engine, metadata,
max_records=DEFAULT_MAX_RECORDS, max_records=DEFAULT_MAX_RECORDS,
cleanup_interval=DEFAULT_CLEANUP_INTERVAL): cleanup_interval=DEFAULT_CLEANUP_INTERVAL):
"""把日志蓝图注册到 Flask app,并注入 engine/metadata。""" """把日志蓝图注册到 Flask app,并注入 engine/metadata。"""
print('[logs] 正在初始化日志模块 ...')
app.config['LOG_ENGINE'] = engine app.config['LOG_ENGINE'] = engine
app.config['LOG_METADATA'] = metadata app.config['LOG_METADATA'] = metadata
@@ -130,37 +129,17 @@ def init_log_routes(app, engine, metadata,
# 建表 + 反射 # 建表 + 反射
try: try:
table = _ensure_logs_table(metadata, engine) _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} 条日志')
except Exception as e: except Exception as e:
print(f'[logs] 警告: Logs 表初始化失败: {e}') log_internal(engine, metadata, 'db', 'ERROR',
f'Logs 表初始化失败: {e}')
# 注册路由 # 注册路由
app.register_blueprint(bp) 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, _start_cleanup_thread(engine, metadata,
retention_days, max_records, cleanup_interval) retention_days, max_records, cleanup_interval)
print('[logs] 后台清理线程已启动')
print('[logs] 日志模块初始化完成')
# ── 5.1 推送日志 ────────────────────────────────────────────────────── # ── 5.1 推送日志 ──────────────────────────────────────────────────────
@@ -181,8 +160,8 @@ def ingest():
'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条' 'error': f'单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条'
}), 400 }), 400
engine = app.config['LOG_ENGINE'] engine = current_app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA'] metadata = current_app.config['LOG_METADATA']
try: try:
table = _ensure_logs_table(metadata, engine) table = _ensure_logs_table(metadata, engine)
@@ -252,8 +231,8 @@ def query():
"""结构化查询日志。""" """结构化查询日志。"""
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
engine = app.config['LOG_ENGINE'] engine = current_app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA'] metadata = current_app.config['LOG_METADATA']
try: try:
table = _ensure_logs_table(metadata, engine) table = _ensure_logs_table(metadata, engine)
@@ -353,8 +332,8 @@ def stats():
"""日志分类统计。""" """日志分类统计。"""
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
engine = app.config['LOG_ENGINE'] engine = current_app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA'] metadata = current_app.config['LOG_METADATA']
try: try:
table = _ensure_logs_table(metadata, engine) table = _ensure_logs_table(metadata, engine)
@@ -416,8 +395,8 @@ def purge():
if not before: if not before:
return jsonify({'success': False, 'error': '缺少 before 字段'}), 400 return jsonify({'success': False, 'error': '缺少 before 字段'}), 400
engine = app.config['LOG_ENGINE'] engine = current_app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA'] metadata = current_app.config['LOG_METADATA']
try: try:
table = _ensure_logs_table(metadata, engine) table = _ensure_logs_table(metadata, engine)
@@ -445,8 +424,8 @@ def purge():
@bp.route('/health', methods=['GET']) @bp.route('/health', methods=['GET'])
def health(): def health():
"""日志模块健康检查。""" """日志模块健康检查。"""
engine = app.config['LOG_ENGINE'] engine = current_app.config['LOG_ENGINE']
metadata = app.config['LOG_METADATA'] metadata = current_app.config['LOG_METADATA']
try: try:
table = _ensure_logs_table(metadata, engine) table = _ensure_logs_table(metadata, engine)
@@ -506,6 +485,6 @@ def _do_cleanup(engine, metadata, retention_days, max_records):
conn.commit() conn.commit()
trimmed = trim_result.rowcount trimmed = trim_result.rowcount
print(f'[logs] 自动清理完成: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条') if expired_deleted or trimmed:
log_internal(engine, metadata, 'db', 'INFO', log_internal(engine, metadata, 'db', 'INFO',
f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条') f'自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条')
-172
View File
@@ -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)
+1
View File
@@ -2,3 +2,4 @@ nuitka
flask flask
SQLAlchemy SQLAlchemy
waitress waitress
../pymsg
+115 -67
View File
@@ -1,38 +1,67 @@
# DNFS — 树状网络文件系统 # dnfs — 树状网络文件系统
Dendrachy Network File-System,基于父子关系(pid)组织的树状节点服务。 Dendrachy Network File-System,基于父子关系(pid)组织的树状节点服务。
监听 `127.0.0.1:8006`,提供节点的增删改查,支持文件以 Base64 编码形式存储在节点 content 中。 监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
## 架构 ## 架构
``` ```
客户端 (static/dnfs.html) 客户端 (static/dnfs.html)
│ │ Bearer Token
▼ ▼
dnfs :8006 (Flask + Waitress) dnfs :8006 (Flask + Waitress)
│ │
├──► db :8005 数据持久化(SQL-over-HTTP) ├──► db :8005 数据持久化(SQL-over-HTTP,/execute)
└──► ai_review :8007 内容审核(仅 add_note 时调用) └──► 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 ### POST /get_notes
获取指定父节点下的子节点列表。 获取指定父节点下的子节点列表。**无需鉴权**。
**Request** **Request**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| pid | int | 否 | 父节点 id,0 表示根节点,缺省默认 0 | | pid | int | 否 | 父节点 id,0 表示根,缺省默认 0 |
```bash ```bash
curl -X POST http://localhost:8006/get_notes \ curl -X POST http://localhost:8006/get_notes \
@@ -40,66 +69,58 @@ curl -X POST http://localhost:8006/get_notes \
-d '{"pid": 0}' -d '{"pid": 0}'
``` ```
**Response 200** **Response 200** — 直接返回节点数组:
直接返回节点数组:
```json ```json
[ [
{"id": 1, "pid": 0, "title": "根节点", "content": "hello"}, {"id": 1, "pid": 0, "owner": "admin", "title": "根节点", "content": "hello"},
{"id": 2, "pid": 0, "title": "另一个", "content": "world"} {"id": 2, "pid": 0, "owner": "alice", "title": "另一个", "content": "world"}
] ]
``` ```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 节点唯一标识 |
| pid | int | 父节点 id |
| title | str | 节点标题 |
| content | str | 节点内容(可能含 Base64 文件数据) |
空目录返回 `[]`。 空目录返回 `[]`。
--- ---
### POST /add_note ### POST /add_note
添加新节点。流程:校验参数 → 校验父节点存在 → AI 内容审核 → 写入数据库。 添加新节点。流程:校验参数 → 校验 Token → 校验父节点存在 → AI 内容审核 → 写入数据库(含 owner)。
**Request** **Request**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| pid | int | 是 | 父节点 id,0 为根节点,非 0 时校验父节点是否存在 | | pid | int | 是 | 父节点 id,0 为根,非 0 时校验父节点是否存在 |
| title | str | 是 | 节点标题,不可为空 | | title | str | 是 | 节点标题,不可为空 |
| content | str | 是 | 节点内容,不可为空(可含 Base64 文件数据) | | content | str | 是 | 节点内容,不可为空(可含 Base64 文件数据) |
请求头需带 `Authorization: Bearer <token>`。
```bash ```bash
curl -X POST http://localhost:8006/add_note \ curl -X POST http://localhost:8006/add_note \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"pid": 0, "title": "新节点", "content": "节点内容"}' -d '{"pid": 0, "title": "新节点", "content": "节点内容"}'
``` ```
**Response 200** — 成功 **响应**
```json | 状态码 | 场景 | 响应体 |
{"success": true} |--------|------|--------|
``` | 200 | 成功 | `{"success": true}` |
| 400 | 缺少必填字段 | `{"error": "Pid, title and content are required"}` |
**Response 400** — 失败 | 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"}` |
| 缺少必填字段 | `{"error": "Pid, title and content are required"}` |
| 父节点不存在(pid≠0) | `{"error": "the new node need a parent"}` |
| AI 内容审核不通过 | `{"error": "review failed"}` |
| 数据库写入失败 | `{"success": false}` |
--- ---
### POST /delete_note ### POST /delete_note
删除指定节点。存在子节点时拒绝删除(保护树结构完整性)。 删除指定节点。流程:校验参数 → 校验 Token → 检查子节点 → 校验所有权 → 删除。
存在子节点时拒绝删除(保护树结构完整性)。**admin 可删除任意节点**,普通用户仅能删除自己拥有的节点。
**Request** **Request**
@@ -107,31 +128,33 @@ curl -X POST http://localhost:8006/add_note \
|------|------|------|------| |------|------|------|------|
| id | int | 是 | 要删除的节点 id | | id | int | 是 | 要删除的节点 id |
请求头需带 `Authorization: Bearer <token>`。
```bash ```bash
curl -X POST http://localhost:8006/delete_note \ curl -X POST http://localhost:8006/delete_note \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"id": 1}' -d '{"id": 1}'
``` ```
**Response 200** — 成功 **响应**
```json | 状态码 | 场景 | 响应体 |
{"success": true} |--------|------|--------|
``` | 200 | 成功 | `{"success": true}` |
| 400 | 缺少 id | `{"error": "Note ID is required"}` |
**Response 400** — 失败 | 400 | 存在子节点 | `{"error": "this node has a child "}` |
| 400 | 数据库删除失败 | `{"success": false}` |
| 场景 | 响应体 | | 401 | 未携带 Token | `{"error": "Warning: no Authorization"}` |
|------|--------| | 403 | 非本人节点且非 admin | `{"error": "It's not your note"}` |
| 缺少 id 字段 | `{"error": "Note ID is required"}` |
| 存在子节点 | `{"error": "this node has a child"}` |
| 数据库删除失败 | `{"success": false}` |
--- ---
### POST /update_note ### POST /update_note
更新节点的标题、内容和父节点(可用于迁移节点到其他位置)。 更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
仅节点 owner 可更新;update 路径下 **admin 不享豁免**(与 delete 不同)。
**Request** **Request**
@@ -142,27 +165,47 @@ curl -X POST http://localhost:8006/delete_note \
| title | str | 是 | 新标题,不可为空 | | title | str | 是 | 新标题,不可为空 |
| content | str | 是 | 新内容,不可为空 | | content | str | 是 | 新内容,不可为空 |
请求头需带 `Authorization: Bearer <token>`。
```bash ```bash
curl -X POST http://localhost:8006/update_note \ curl -X POST http://localhost:8006/update_note \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"id": 1, "pid": 2, "title": "新标题", "content": "新内容"}' -d '{"id": 1, "pid": 2, "title": "新标题", "content": "新内容"}'
``` ```
**Response 200** — 成功 **响应**
```json | 状态码 | 场景 | 响应体 |
{"success": true} |--------|------|--------|
``` | 200 | 成功 | `{"success": true}` |
| 400 | 缺少必填字段 | `{"error": "Invalid param"}` |
**Response 400** — 失败 | 400 | AI 内容审核不通过 | `{"error": "review failed"}` |
| 400 | 数据库更新失败 | `{"success": false}` |
| 场景 | 响应体 | | 401 | 未携带 Token | `{"error": "Warning: no Authorization"}` |
|------|--------| | 403 | 非本人节点 | `{"error": "It's not your note"}` |
| 缺少必填字段 | `{"error": "Invalid param"}` |
| 数据库更新失败 | `{"success": false}` |
--- ---
## 安全特性
- **安全响应头**([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 上传与下载、节点迁移(移动到其他父节点下)。 功能:节点树浏览、新增/编辑/删除节点、文件 Base64 上传与下载、节点迁移(移动到其他父节点下)。
> 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 static 服务的 `dnfs.html` 承载。 > 注:首页路由 `@app.route('/')` 当前已注释,前端页面实际由 `static` 服务的 `dnfs.html` 承载。
## 依赖 ## 依赖
@@ -186,16 +229,21 @@ waitress
nuitka # 可选,编译为独立二进制 nuitka # 可选,编译为独立二进制
``` ```
运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动。 > `requirements.txt` 仍列有 `flask_sqlalchemy`,但代码已不使用(SQLAlchemy 直连代码已注释),属遗留依赖。
运行时需要 `db`(:8005)和 `ai_review`(:8007)服务已启动;对外访问需经 gateway 转发并验签。
## 运行 ## 运行
```bash ```bash
bash _set.sh # 创建 venv + 安装依赖 bash _set.sh # 创建 venv + 安装依赖
bash _run.sh # 启动服务 bash _run.sh # 启动服务(waitress)
``` ```
## 已知问题 ## 已知问题与风险
- `delete_note` 中子节点检查逻辑有误:查询条件为 `where id = note_id`(查节点自身),应为 `where pid = note_id`(查子节点),导致任何节点都无法删除。 - **`decode_token` 未验签**([dnfs.py:167](dnfs.py#L167)):仅解 Base64 取 payload,存在 `# FIXME: 验证数字签名` 标记。当前安全性依赖"必须经 gateway 转发"这一部署假设;若 dnfs 被直接访问(绕过 gateway),任意伪造的 Token 都能通过。建议补 RSA 验签或在部署上确保仅 gateway 可达。
- `review_content` 返回值类型不一致:正常返回 `bool`,HTTPError 时返回 `dict`。 - **`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
View File
@@ -27,47 +27,29 @@ import urllib.request
import urllib.error import urllib.error
from typing import Dict, Any from typing import Dict, Any
from security_middleware import apply_security_headers from security_middleware import apply_security_headers
from log_client import logs
DB_SERVER_URL = 'http://127.0.0.1:8005/execute' # pymsg 导入
def call_db_service(url: str, payload: dict, timeout: int = 10) -> dict: import sys as _sys
''' _sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
使用标准库向数据库服务发送 JSON 请求并返回解析后的响应。 from pymsg import MsgClient # noqa: E402
Args: _DBV2_ADDR = '127.0.0.1'
url: 服务端点 URL (例如 'http://127.0.0.1:8005/execute') _DBV2_PORT = 8005
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'
)
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
"""通过 pymsg 短连接调用 dbv2 数据库服务。
自动注入 ``_route: "execute"``。
"""
payload = {**payload, "_route": "execute"}
try: try:
with urllib.request.urlopen(req, timeout=timeout) as response: client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
raw = response.read().decode('utf-8') resp_text = client.call(json.dumps(payload), timeout=timeout)
return json.loads(raw) return json.loads(resp_text)
except urllib.error.HTTPError as e: except Exception as e:
# HTTP错误,尝试读取错误响应体 raise ConnectionError(
error_body = e.read().decode('utf-8', errors='replace') f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
raise urllib.error.HTTPError(
e.url, e.code, f'HTTP Error {e.code}: {error_body}', e.hdrs, e.fp
) from None ) 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' REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]: 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: 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 return jsonify({"error": "Pid, title and content are required"}), 400
info = None info = None
@@ -193,6 +176,7 @@ def add_note():
info = decode_token(token) info = decode_token(token)
if info is None: if info is None:
logs.warning('dnfs', 'add_note 未授权访问', extra={'ip': request.remote_addr})
return jsonify({"error": "Warning: no Authorization"}), 401 return jsonify({"error": "Warning: no Authorization"}), 401
if pid != 0: 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: 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 return jsonify({'error': 'the new node need a parent'}), 400
@@ -216,6 +201,7 @@ def add_note():
review = review_content(title + '\n' + content, 10) review = review_content(title + '\n' + content, 10)
if True != review: if True != review:
#return jsonify({'error': 'review failed', 'data': review}), 400 #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 return jsonify({'error': 'review failed'}), 400
insert_payload = { 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'] success = result['success']
if success is True: if success is True:
logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']})
return jsonify({'success': success}), 200 return jsonify({'success': success}), 200
else: else:
logs.error('dnfs', f'add_note 写入失败 title={title}', extra={'user': info['user']})
return jsonify({'success': success}), 400 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'] result = notes['data']
logs.info('dnfs', f'get_notes 查询 pid={pid} 返回{len(result)}条', extra={'ip': request.remote_addr})
return jsonify(result), 200 return jsonify(result), 200
@@ -276,6 +265,7 @@ def delete_note():
if not note_id: if not note_id:
logs.warning('dnfs', 'delete_note 参数缺失 id', extra={'ip': request.remote_addr})
return jsonify({"error": "Note ID is required"}), 400 return jsonify({"error": "Note ID is required"}), 400
info = None info = None
@@ -285,6 +275,7 @@ def delete_note():
info = decode_token(token) info = decode_token(token)
if info is None: if info is None:
logs.warning('dnfs', 'delete_note 未授权访问', extra={'ip': request.remote_addr})
return jsonify({"error": "Warning: no Authorization"}), 401 return jsonify({"error": "Warning: no Authorization"}), 401
#with app.app_context(): #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: 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 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'] notes = data['data']
if not info['user'] == 'admin': if not info['user'] == 'admin':
if not len(notes) == 1 or not notes[0]['owner'] == info['user']: 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 return jsonify({"error": 'It\'s not your note'}), 403
@@ -344,16 +337,18 @@ def delete_note():
'where': {'id': note_id} 'where': {'id': note_id}
} }
result = call_db_service(DB_SERVER_URL, delete_payload) result = _call_dbv2(delete_payload)
result = result['success'] result = result['success']
if result is True: if result is True:
logs.info('dnfs', f'delete_note 成功 id={note_id}', extra={'user': info['user']})
return jsonify({'success': True}), 200 return jsonify({'success': True}), 200
else: else:
logs.error('dnfs', f'delete_note 失败 id={note_id}', extra={'user': info['user']})
return jsonify({'success': False}), 400 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: 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 return jsonify({"error": "Invalid param"}), 400
# 获取用户名 # 获取用户名
@@ -389,6 +385,7 @@ def update_note():
info = decode_token(token) info = decode_token(token)
if info is None: if info is None:
logs.warning('dnfs', 'update_note 未授权访问', extra={'ip': request.remote_addr})
return jsonify({"error": "Warning: no Authorization"}), 401 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'] notes = data['data']
if not len(notes) == 1 or not notes[0]['owner'] == info['user']: 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 return jsonify({"error": 'It\'s not your note'}), 403
# 内容审查 # 内容审查
review = review_content(title + '\n' + content, 10) review = review_content(title + '\n' + content, 10)
if True != review: if True != review:
#return jsonify({'error': 'review failed', 'data': review}), 400 #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 return jsonify({'error': 'review failed'}), 400
#with app.app_context(): #with app.app_context():
@@ -437,18 +436,57 @@ def update_note():
'where': {'id': id} 'where': {'id': id}
} }
result = call_db_service(DB_SERVER_URL, update_payload) result = _call_dbv2(update_payload)
result = result['success'] result = result['success']
if result is True: if result is True:
logs.info('dnfs', f'update_note 成功 id={id} title={title}', extra={'user': info['user']})
return jsonify({'success': True}), 200 return jsonify({'success': True}), 200
else: else:
logs.error('dnfs', f'update_note 失败 id={id}', extra={'user': info['user']})
return jsonify({'success': False}), 400 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' ADDR='127.0.0.1'
PORT=8006 PORT=8006
if __name__ == "__main__": if __name__ == "__main__":
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
print(f'Listen at http://{ADDR}:{PORT}') print(f'Listen at http://{ADDR}:{PORT}')
#app.run(host="127.0.0.1", port=8006) #app.run(host="127.0.0.1", port=8006)
serve(app, host='127.0.0.1', port=8006) serve(app, host='127.0.0.1', port=8006)
-144
View File
@@ -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>
+50 -28
View File
@@ -2,7 +2,7 @@
统一日志客户端 — 各服务共用。 统一日志客户端 — 各服务共用。
设计要点: 设计要点:
- 使用 urllib.request(标准库,不引入 requests) - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - fail-safe:db 服务不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
@@ -17,32 +17,37 @@
""" """
import json import json
import os as _os
import sys as _sys
import threading import threading
import queue import queue
import time import time
import urllib.request
import urllib.error
import atexit 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 _BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # HTTP 请求超时(秒) _TIMEOUT = 5 # socket 超时(秒)
class LogClient: class LogClient:
"""异步批量日志推送客户端。""" """异步批量日志推送客户端。"""
def __init__(self, url=DB_LOG_URL, batch_size=_BATCH_SIZE, def __init__(self, batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL): flush_interval=_FLUSH_INTERVAL):
self._url = url
self._batch_size = batch_size self._batch_size = batch_size
self._flush_interval = flush_interval self._flush_interval = flush_interval
self._queue = queue.Queue() self._queue = queue.Queue()
self._stop = threading.Event() self._stop = threading.Event()
self._flush_signal = threading.Event()
self._thread = threading.Thread(target=self._flush_loop, self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client') daemon=True, name='log-client')
self._thread.start() self._thread.start()
@@ -76,7 +81,9 @@ class LogClient:
self.push(service, 'CRITICAL', message, **kw) self.push(service, 'CRITICAL', message, **kw)
def flush(self): def flush(self):
"""手动 flush 队列中剩余的日志。""" """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
time.sleep(0.6) # 等待后台线程响应
batch = [] batch = []
while True: while True:
try: try:
@@ -88,6 +95,7 @@ class LogClient:
break break
if batch: if batch:
self._send(batch) self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ──────────────────────────────────────────────────────── # ── 内部实现 ────────────────────────────────────────────────────────
@@ -97,25 +105,25 @@ class LogClient:
last_flush = time.monotonic() last_flush = time.monotonic()
while not self._stop.is_set(): 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: try:
item = self._queue.get(timeout=timeout) item = self._queue.get(timeout=chk_timeout)
batch.append(item) batch.append(item)
# 继续非阻塞地取,直到满 batch_size
while len(batch) < self._batch_size: while len(batch) < self._batch_size:
try: try:
batch.append(self._queue.get_nowait()) batch.append(self._queue.get_nowait())
except queue.Empty: except queue.Empty:
break break
except queue.Empty: except queue.Empty:
pass # 超时,没有新日志 pass
# 满了或超时了就发
should_flush = ( should_flush = (
len(batch) >= self._batch_size or len(batch) >= self._batch_size or
self._flush_signal.is_set() or
(batch and time.monotonic() - last_flush >= self._flush_interval) (batch and time.monotonic() - last_flush >= self._flush_interval)
) )
if should_flush: if should_flush and batch:
self._send(batch) self._send(batch)
batch = [] batch = []
last_flush = time.monotonic() last_flush = time.monotonic()
@@ -125,21 +133,35 @@ class LogClient:
self._send(batch) self._send(batch)
def _send(self, batch): def _send(self, batch):
"""发送一批日志到 db 服务。失败时静默丢弃。""" """通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
data = json.dumps({'logs': batch}).encode('utf-8') now = datetime.now(timezone.utc).isoformat()
req = urllib.request.Request(
self._url, data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
try: try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log")
resp.read() # 消费响应体 for entry in batch:
except (urllib.error.URLError, urllib.error.HTTPError, OSError): extra_val = entry.get("extra")
# fail-safe:日志服务不可用,静默丢弃,不影响业务 payload = {
pass "_route": "execute",
except Exception: "action": "insert",
pass "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): def _shutdown(self):
"""进程退出时 flush 剩余日志。""" """进程退出时 flush 剩余日志。"""
+1
View File
@@ -4,3 +4,4 @@ flask-cors
flask_sqlalchemy flask_sqlalchemy
#gunicorn #gunicorn
waitress waitress
../pymsg
-504
View File
@@ -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
-144
View File
@@ -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>
+40
View File
@@ -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";
};
}
+334
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+112
View File
@@ -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/
+370
View File
@@ -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()
+13
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.env
venv/
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
#exec ./smtp.bin
exec python3 smtp.py
Executable
+7
View File
@@ -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
+182
View File
@@ -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")
+8
View File
@@ -0,0 +1,8 @@
../pymsg
nuitka
flask
flask-cors
flask_sqlalchemy
#gunicorn
waitress
python-dotenv
+84
View File
@@ -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)
+283 -10
View File
@@ -9,7 +9,10 @@ from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
import configparser import configparser
import sys import sys
from security_middleware import apply_security_headers
# pymsg 日志客户端(集中式日志 → dbv2 Logs 表)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from log_client import logs # noqa: E402
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录 # 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题 # 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
@@ -34,8 +37,7 @@ parser.add_argument('--config', type=str, default='config.ini', help='Configurat
args = parser.parse_args() args = parser.parse_args()
app = Flask(__name__) app = Flask(__name__)
apply_security_headers(app) CORS(app, origins=["https://qsont.me", "https://yhdet.top", "http://localhost:9200"])
CORS(app)
# ==================== 配置文件加载 ==================== # ==================== 配置文件加载 ====================
CONFIG_FILE = args.config CONFIG_FILE = args.config
@@ -66,8 +68,11 @@ if FILESYSTEM_ENABLED:
try: try:
Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True) Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True)
logger.info(f"Created directory: {HTML_ROOT_DIR}") logger.info(f"Created directory: {HTML_ROOT_DIR}")
logs.info("static", f"目录已创建: {HTML_ROOT_DIR}")
except Exception as e: except Exception as e:
logger.error(f"Failed to create directory: {str(e)}") logger.error(f"Failed to create directory: {str(e)}")
logs.error("static", f"目录创建失败: {str(e)}",
extra={"path": HTML_ROOT_DIR})
FILESYSTEM_ENABLED = False FILESYSTEM_ENABLED = False
SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true' SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
@@ -170,6 +175,8 @@ def get_file_list():
}) })
except Exception as e: except Exception as e:
logger.error(f"Get file list error: {str(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 return jsonify({'error': str(e)}), 500
@app.route('/video/<path:file_path>', methods=['GET']) @app.route('/video/<path:file_path>', methods=['GET'])
@@ -214,15 +221,282 @@ def stream_video(file_path):
return send_file(full_path, mimetype=mime_type, as_attachment=False) return send_file(full_path, mimetype=mime_type, as_attachment=False)
except Exception as e: except Exception as e:
logger.error(f"Video streaming error: {str(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 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('/') @app.route('/')
def serve_view(): def serve_view():
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)""" """根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
if not FILESYSTEM_ENABLED: if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403 return "文件系统未启用", 403
index_path = os.path.join(HTML_ROOT_DIR, 'index.html') #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): if os.path.exists(index_path):
return send_file(index_path, mimetype='text/html') return send_file(index_path, mimetype='text/html')
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE) view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
@@ -271,18 +545,18 @@ def serve_file(path):
except Exception as e: except Exception as e:
logger.error(f"Error: {str(e)}") logger.error(f"Error: {str(e)}")
logs.error("static", f"静态文件服务错误: {str(e)}",
extra={"path": path})
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return f"服务器内部错误: {str(e)}", 500 return f"服务器内部错误: {str(e)}", 500
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health_check(): def health_check():
return jsonify({ return jsonify({"status": "ok"})
'status': 'healthy',
'filesystem': {'enabled': FILESYSTEM_ENABLED, 'root': HTML_ROOT_DIR if FILESYSTEM_ENABLED else None}
})
# ==================== 主程序 ====================
if __name__ == '__main__': if __name__ == '__main__':
host = file_config['server']['host'] host = file_config['server']['host']
port = int(file_config['server']['port']) port = int(file_config['server']['port'])
@@ -300,4 +574,3 @@ if __name__ == '__main__':
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False) app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
else: else:
app.run(host=host, port=port, debug=False) app.run(host=host, port=port, debug=False)
+177
View File
@@ -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")
+79
View File
@@ -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 h3 { transition: color 0.15s; }
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); } .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 (dnfs) ===== */
.node-form { margin-top: 1rem; } .node-form { margin-top: 1rem; }
.node-form .form-actions { margin-top: 0.75rem; } .node-form .form-actions { margin-top: 0.75rem; }
+1
View File
@@ -9,6 +9,7 @@
</head> </head>
<body> <body>
<div class="box"> <div class="box">
<div id="minimap" class="minimap"></div>
<div class="recommend-list" id="recommend-list"></div> <div class="recommend-list" id="recommend-list"></div>
<div class="card node-form"> <div class="card node-form">
<h3>节点操作</h3> <h3>节点操作</h3>
+86
View File
@@ -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>
+46 -56
View File
@@ -1,58 +1,48 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>树状菜单 · 开源社区</title> <link rel="stylesheet" href="css/theme.css">
<link rel="stylesheet" href="css/theme.css"> <style>
<style> body {
.hero { text-align: center; margin-bottom: 1.5rem; } align-items: normal;
.hero h1 { font-size: 1.6rem; margin-bottom: 0.4rem; } }
.hero p { font-size: 0.9rem; color: var(--fg-secondary); line-height: 1.6; } .header {
.btn-row { display: flex;
display: flex; justify-content: space-between;
gap: 0.75rem; align-items: center;
justify-content: center; width: 100%;
margin-top: 1.5rem; }
} .right-group {
.btn-row a { display: flex;
display: inline-block; align-items: center;
padding: 0.6rem 1.8rem; }
font-size: 0.9rem; .right-group > img,
font-weight: 600; .right-group > b,
border-radius: var(--radius-sm); .right-group > button {
text-decoration: none; margin-left: 5px;
transition: opacity 0.15s, transform 0.1s; margin-right: 5px;
} }
.btn-row a:active { transform: scale(0.96); } </style>
.btn-primary { background: var(--accent); color: #fff; } <title>DNFS 欢迎来到树状笔记</title>
.btn-primary:hover { opacity: 0.85; } </head>
.btn-ghost { background: transparent; color: var(--accent-dark); border: 1.5px solid var(--border); } <body>
.btn-ghost:hover { border-color: var(--accent); opacity: 0.85; } <header class="header">
.tagline { <h1 onclick="window.location.href='index.html'">DNFS 树状笔记</h1>
margin-top: 1.5rem; <div class="right-group">
padding-top: 1rem; <img src="favicon.ico" alt="头像加载失败" id="avatar" width="70px" height="70px" style="border-radius: 100px;">
border-top: 1px solid var(--border-light); <b id="name">DNFS</b>
font-size: 0.8rem; <button onclick="window.location.href='login.html'">登录</button>
color: var(--muted); <button onclick="window.location.href='register.html'">注册</button>
text-align: center; </div>
} </header>
</style> <div id="main-page">
</head> <h2>推荐节点</h2>
<body> <div id="recommended-node"></div>
<div class="card"> </div>
<div class="hero"> <footer class="footer">
<h1>树状菜单</h1> <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
<p>一个轻量的开源社区,用树的结构组织知识与文件。<br>自由生长,自由分享。</p> </footer>
</div> </body>
<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>
+60 -13
View File
@@ -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. /* by qing.
* *
@@ -88,6 +90,7 @@ async function addNode(token, pid, title, content) {
input_note_title.value = ""; input_note_title.value = "";
input_note_content.value = ""; input_note_content.value = "";
getNode(token, stack[stack.length - 1]).then(load_notes); getNode(token, stack[stack.length - 1]).then(load_notes);
getNode(token, stack[stack.length - 1]).then(loadMap)
//if (result.success) { //if (result.success) {
alert(`添加节点成功`, result); alert(`添加节点成功`, result);
@@ -140,6 +143,7 @@ async function deleteNode(token, id) {
stack.pop(); stack.pop();
getNode(token, stack[stack.length - 1]).then(load_notes); getNode(token, stack[stack.length - 1]).then(load_notes);
getNode(token, stack[stack.length - 1]).then(loadMap)
console.log(`删除节点成功`); console.log(`删除节点成功`);
return result 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 input_note_clear = document.getElementById('input-note-clear');
var recommend_list = document.getElementById('recommend-list'); var recommend_list = document.getElementById('recommend-list');
var delete_note = document.getElementById('delete-note'); var delete_note = document.getElementById('delete-note');
var minimap = document.getElementById("minimap");
var is_expand = true;
input_note_submit.addEventListener('click', function(event) { input_note_submit.addEventListener('click', function(event) {
var title = input_note_title.value var title = input_note_title.value
@@ -219,19 +225,6 @@ function load_notes(main_node) {
recommend_list.replaceChildren(); 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) { for (let i in main_node) {
let item = main_node[i] let item = main_node[i]
let card = document.createElement('div'); let card = document.createElement('div');
@@ -240,10 +233,12 @@ function load_notes(main_node) {
var id = Number(item.id) var id = Number(item.id)
stack.push(id) stack.push(id)
getNode(token, id).then(load_notes) getNode(token, id).then(load_notes)
getNode(token, id).then(loadMap)
}); });
let ttl = document.createElement('center'); let ttl = document.createElement('center');
let ttl1 =document.createElement('h3'); let ttl1 =document.createElement('h3');
ttl1.textContent = item.title; ttl1.textContent = item.title;
card.id = item.title;
ttl.appendChild(ttl1); ttl.appendChild(ttl1);
card.appendChild(ttl); card.appendChild(ttl);
let vit_div = document.createElement('p'); 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') var token = sessionStorage.getItem('q4_token')
getNode(token, 0).then(load_notes); getNode(token, 0).then(load_notes);
getNode(token, 0).then(loadMap)
View File
Whitespace-only changes.
+14 -86
View File
@@ -1,162 +1,90 @@
const msgTip = document.getElementById('msgTip'); const msgTip = document.getElementById('msgTip');
const pwdTip = document.getElementById('pwdTip'); const pwdTip = document.getElementById('pwdTip');
const emlTip = document.getElementById('emlTip');
const agreeTip = document.getElementById('agreeTip'); const agreeTip = document.getElementById('agreeTip');
const form = document.getElementById('form'); const form = document.getElementById('form');
const usernameInput = document.getElementById('loginUsername'); const usernameInput = document.getElementById('loginUsername');
const passwordInput = document.getElementById('loginPassword'); const passwordInput = document.getElementById('loginPassword');
const emailInput = document.getElementById('loginEmail');
const agreeBox = document.getElementById('agreeBox'); const agreeBox = document.getElementById('agreeBox');
const protocol = document.getElementById('agreement');
const resetUser = document.getElementById('resetUser'); const resetUser = document.getElementById('resetUser');
const registerBtn = document.getElementById('registerBtn'); const registerBtn = document.getElementById('registerBtn');
const loginButton = document.getElementById('loginBtn'); const loginButton = document.getElementById('loginBtn');
resetUser.addEventListener('click', function() { resetUser.addEventListener('click', function() { clearTipStyle(); });
clearTipStyle();
});
function clearTipStyle() { function clearTipStyle() {
msgTip.innerHTML = ''; msgTip.innerHTML = '';
pwdTip.innerHTML = ''; pwdTip.innerHTML = '';
agreeTip.innerHTML = ''; agreeTip.innerHTML = '';
emlTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.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'); agreeTip.classList.remove('tip-red');
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
protocol.classList.remove('tip-red');
protocol.classList.remove('tip-green');
} }
clearTipStyle(); clearTipStyle();
/**
* @returns {string|false}
*/
function checkUsername() { function checkUsername() {
const result = FormValidators.username(usernameInput.value); const result = FormValidators.username(usernameInput.value);
msgTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
msgTip.textContent = result.message; msgTip.textContent = result.message;
msgTip.classList.add(result.className); msgTip.className = result.className;
return result.valid ? result.value : false; return result.valid ? result.value : false;
} }
/**
* @returns {string|false}
*/
function checkPwd() { function checkPwd() {
const result = FormValidators.password(passwordInput.value); const result = FormValidators.password(passwordInput.value);
pwdTip.innerHTML = '';
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.textContent = result.message; pwdTip.textContent = result.message;
pwdTip.classList.add(result.className); pwdTip.className = result.className;
return result.valid ? result.value : false; 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() { function checkAgree() {
agreeTip.innerHTML = ''; agreeTip.innerHTML = '';
agreeTip.classList.remove('tip-red'); agreeTip.classList.remove('tip-red');
protocol.classList.remove('tip-green', 'tip-red');
if (!agreeBox.checked) { if (!agreeBox.checked) {
agreeTip.innerHTML = '请同意用户协议!'; agreeTip.innerHTML = '请同意用户协议!';
agreeTip.classList.add('tip-red'); agreeTip.classList.add('tip-red');
protocol.classList.add('tip-red');
return false; return false;
} }
protocol.classList.add('tip-green');
return true; return true;
} }
usernameInput.addEventListener('focus', checkUsername); usernameInput.addEventListener('focus', checkUsername);
passwordInput.addEventListener('focus', checkPwd); passwordInput.addEventListener('focus', checkPwd);
usernameInput.addEventListener('input', checkUsername); usernameInput.addEventListener('input', checkUsername);
passwordInput.addEventListener('input', checkPwd); passwordInput.addEventListener('input', checkPwd);
usernameInput.addEventListener('blur', checkUsername); usernameInput.addEventListener('blur', checkUsername);
passwordInput.addEventListener('blur', checkPwd); passwordInput.addEventListener('blur', checkPwd);
emailInput.addEventListener('focus', checkEml);
emailInput.addEventListener('input', checkEml);
emailInput.addEventListener('blur', checkEml);
agreeBox.addEventListener('change', checkAgree); 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) { form.addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
clearTipStyle(); clearTipStyle();
const userOk = checkUsername(); const userOk = checkUsername();
const pwdOk = checkPwd(); const pwdOk = checkPwd();
const emlOk = checkEml();
const agreeOk = checkAgree(); const agreeOk = checkAgree();
if (!userOk || !pwdOk || !agreeOk) return;
if (!(userOk && pwdOk && emlOk && agreeOk)) { const jsonBody = JSON.stringify({ username: userOk, password: pwdOk });
return;
}
const jsonBody = JSON.stringify({
username: userOk,
password: pwdOk
});
try { try {
loginButton.disabled = true; loginButton.disabled = true;
const res = await fetch('/login', { const res = await fetch('/login', {
method: 'POST', method: 'POST',
headers: { headers: {'Content-Type': 'application/json'},
'Content-Type': 'application/json' // 告诉服务器内容为json
},
body: jsonBody body: jsonBody
}); });
const responseText = await res.text(); const responseText = await res.text();
let result; let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
try { if (!res.ok) throw new Error(result.message || '登录失败');
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})`);
}
if (result.success) { if (result.success) {
alert(`登陆成功`); localStorage.setItem('q4_token', result.data.token);
sessionStorage.setItem('q4_token', result.data.token); sessionStorage.setItem('q4_token', result.data.token);
location.href = 'dnfs.html'; alert('登录成功');
location.href = '/dnfs.html';
} else { } else {
alert(result.message || '登录失败'); alert(result.message || '登录失败');
} }
} catch (err) { } catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动'); alert(err.message || '接口请求失败');
console.error('登录请求异常:', err);
} finally { } finally {
loginButton.disabled = false; loginButton.disabled = false;
} }
+1 -1
View File
@@ -92,7 +92,7 @@ function checkCode() {
codeTip.classList.add('tip-yellow'); codeTip.classList.add('tip-yellow');
return false; return false;
} }
if (code !== sendCode) { if (sendCode === 'email_sent') { /* Server-verified, skip client check */ } else if (code !== sendCode) {
codeTip.textContent = '验证码错误,请核对!'; codeTip.textContent = '验证码错误,请核对!';
codeTip.classList.add('tip-red'); codeTip.classList.add('tip-red');
return false; return false;
+11 -16
View File
@@ -2,7 +2,6 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- 移动端适配核心标签 -->
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<title>登录</title> <title>登录</title>
<link rel="stylesheet" href="css/theme.css"> <link rel="stylesheet" href="css/theme.css">
@@ -10,22 +9,14 @@
<body> <body>
<div class="card"> <div class="card">
<h2>用户登录</h2> <h2>用户登录</h2>
<form action="" id="form" method="post"> <form id="form" novalidate>
<div> <div>
<label>用户名/推荐邮箱:</label> <label>用户名/推荐邮箱:</label>
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名"> <input type="text" id="loginUsername" placeholder="请输入用户名">
<span id="msgTip" ></span> <span id="msgTip"></span>
<label>密码:</label> <label>密码:</label>
<input type="password" id="loginPassword" name="password" placeholder="请输入密码"> <input type="password" id="loginPassword" placeholder="请输入密码">
<span id="pwdTip"></span> <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>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" id="registerBtn">注册</button> <button type="button" id="registerBtn">注册</button>
@@ -38,6 +29,10 @@
<span id="agreeTip"></span> <span id="agreeTip"></span>
</div> </div>
</form> </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> </div>
<footer class="footer"> <footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a> <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
+1
View File
@@ -1,3 +1,4 @@
../pymsg
nuitka nuitka
Flask>=2.3.0,<3.0.0 Flask>=2.3.0,<3.0.0
Flask-CORS>=4.0.0,<5.0.0 Flask-CORS>=4.0.0,<5.0.0
+1
View File
@@ -1,5 +1,6 @@
# 编译产物 # 编译产物
waf.bin waf.bin
waf
*.exe *.exe
# 证书与密钥(不入库) # 证书与密钥(不入库)
+91 -21
View File
@@ -28,17 +28,34 @@
``` ```
rules/ rules/
├── waf.conf # 主入口,Include 各子文件 ├── waf.conf # 主入口:基础配置(SecRuleEngine / SecRequestBodyLimit 等)
├── local/ # 本地自定义规则(ID 段 100000-199999) ├── local/ # 本地自定义规则(ID 段 100000-199999)
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA │ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById) │ └── 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 分段 ### 规则 ID 分段
- `100000-199999` 本地自定义 | ID 段 | 用途 |
- `900000-999999` OWASP CRS |-------|------|
| 100000-199999 | 本地自定义规则 |
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) |
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) |
### 规则语法(SecLang) ### 规则语法(SecLang)
@@ -70,10 +87,21 @@ WAF 必须终止 TLS 才能看到明文 body,因此证书需从 gateway 迁到
listen: ":443" listen: ":443"
tls_cert: "./certs/full_chain.crt" tls_cert: "./certs/full_chain.crt"
tls_key: "./certs/server.key" tls_key: "./certs/server.key"
# gateway 内部 HTTP 端口(gateway 需改为监听 127.0.0.1:8443 不启用 TLS)
upstream: "http://127.0.0.1:8443" upstream: "http://127.0.0.1:8443"
# 规则根目录,主入口为 <rules_dir>/waf.conf
rules_dir: "./rules" rules_dir: "./rules"
``` ```
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `listen` | `:443` | 监听地址 |
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
| `upstream` | — | gateway 地址,**必填** |
| `rules_dir` | `./rules` | 规则根目录 |
## 目录结构 ## 目录结构
``` ```
@@ -83,7 +111,7 @@ waf/
├── go.mod ├── go.mod
├── internal/ ├── internal/
│ ├── engine/ │ ├── engine/
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测 │ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
│ └── proxy/ │ └── proxy/
│ └── proxy.go # ReverseProxy + WAF 事务接入 │ └── proxy.go # ReverseProxy + WAF 事务接入
├── rules/ ├── rules/
@@ -91,8 +119,10 @@ waf/
│ └── local/ │ └── local/
│ ├── custom.conf │ ├── custom.conf
│ └── exceptions.conf │ └── exceptions.conf
├── _set.sh # 编译 ├── _set.sh # 编译(含 go mod tidy + download + build)
├── _run.sh # 运行 ├── _run.sh # 运行
├── _goproxy.sh # 设置 GOPROXY 国内镜像(由 _set.sh 调用)
├── _fetch_crs.sh # 拉取 OWASP CRS(可选)
├── .gitignore ├── .gitignore
└── README.md └── README.md
``` ```
@@ -100,11 +130,11 @@ waf/
## 安装 ## 安装
```bash ```bash
# 1. 安装 Go 1.21+ # 1. 安装 Go 1.21+(实际要求 1.22+,见 go.mod)
brew install go # macOS brew install go # macOS
# 或 sudo apt install golang-go # Debian/Ubuntu # 或 sudo apt install golang-go # Debian/Ubuntu
# 2. 编译 # 2. 编译(_set.sh 会自动设置 GOPROXY 国内镜像 + GOTOOLCHAIN=local)
bash _set.sh bash _set.sh
# 3. 准备证书 # 3. 准备证书
@@ -114,6 +144,8 @@ mkdir certs && cp /path/to/full_chain.crt /path/to/server.key certs/
bash _run.sh 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 自启动
在 `core/init.txt` 增加: 在 `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 ```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} {"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 ```bash
journalctl -u Nidus -o cat | grep '"action":"block"' | \ journalctl -u Nidus -o cat | grep '"action":"block"' | \
@@ -141,31 +179,63 @@ journalctl -u Nidus -o cat | grep '"action":"block"' | \
## 启用 OWASP CRS(可选) ## 启用 OWASP CRS(可选)
通过 Go module proxy 拉取(不依赖 GitHub 直连,国内可用):
```bash ```bash
cd rules bash _fetch_crs.sh # 拉取最新版
git clone --depth 1 https://github.com/coreruleset/coreruleset.git crs # 或
bash _fetch_crs.sh v4.25.0 # 拉取指定版本
``` ```
在 `rules/waf.conf` 取消注释: 脚本会:
```apache 1. 通过 `GOPROXY=https://goproxy.cn,direct` 下载 `github.com/corazawaf/coraza-coreruleset/v4` 到模块缓存
Include crs/coraza.conf 2. 复制规则文件(`*.conf` + `*.data`)到 `rules/crs/rules/`
Include crs/rules/*.conf 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`)。大文件上传场景需调大或加白名单。 - 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
- 响应体检测未启用(需缓冲整个响应,延迟成本高)。 - **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
## 依赖 ## 依赖
- Go 1.21+ - Go 1.22+(见 [go.mod](go.mod),锁 `go 1.22.0`)
- github.com/corazawaf/coraza/v3 v3.7.0 - `github.com/corazawaf/coraza/v3` **v3.3.0**(锁版本,避免 v3.7.0 要求 Go 1.25+)
- gopkg.in/yaml.v3 - `gopkg.in/yaml.v3` v3.0.1
## 许可证 ## 许可证
+5 -3
View File
@@ -11,6 +11,8 @@ import (
"github.com/corazawaf/coraza/v3" "github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/types" "github.com/corazawaf/coraza/v3/types"
"github.com/micro_servers/waf/internal/logclient"
) )
// Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。 // Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。
@@ -257,8 +259,8 @@ func TestFile(file string) error {
return nil return nil
} }
// LogError 默认错误回调,输出到 stderr。 // LogError 默认错误回调,推送到日志库。
func LogError(mr types.MatchedRule) { func LogError(mr types.MatchedRule) {
fmt.Fprintf(os.Stderr, "[coraza] rule_id=%d severity=%s msg=%s\n", logclient.Error("waf", fmt.Sprintf("coraza rule_id=%d severity=%s", mr.Rule().ID(), mr.Rule().Severity()),
mr.Rule().ID(), mr.Rule().Severity(), mr.ErrorLog()) map[string]any{"rule_id": mr.Rule().ID(), "msg": mr.ErrorLog()})
} }
+269
View File
@@ -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
View File
@@ -3,9 +3,8 @@ package proxy
import ( import (
"bytes" "bytes"
"encoding/json" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -14,6 +13,7 @@ import (
"time" "time"
"github.com/micro_servers/waf/internal/engine" "github.com/micro_servers/waf/internal/engine"
"github.com/micro_servers/waf/internal/logclient"
) )
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。 // 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) { 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) http.Error(w, "upstream unavailable", http.StatusBadGateway)
} }
return p, nil return p, nil
@@ -111,27 +112,20 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") 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) { func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start time.Time) {
rec := map[string]any{ logclient.Warning("waf", fmt.Sprintf("block rule_id=%d %s %s", ruleID, r.Method, r.URL.Path),
"ts": time.Now().UTC().Format(time.RFC3339), map[string]any{
"action": "block", "rule_id": ruleID,
"rule_id": ruleID, "ip": r.RemoteAddr,
"ip": r.RemoteAddr, "method": r.Method,
"method": r.Method, "path": r.URL.Path,
"path": r.URL.Path, "query": r.URL.RawQuery,
"query": r.URL.RawQuery, "ua": r.UserAgent(),
"ua": r.UserAgent(), "cost_ms": time.Since(start).Milliseconds(),
"cost_ms": time.Since(start).Milliseconds(), })
}
if raw, err := json.Marshal(rec); err == nil {
log.Println(string(raw))
}
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("blocked by WAF\n")) _, _ = w.Write([]byte("blocked by WAF\n"))
} }
+15 -9
View File
@@ -16,6 +16,7 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -25,6 +26,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/micro_servers/waf/internal/engine" "github.com/micro_servers/waf/internal/engine"
"github.com/micro_servers/waf/internal/logclient"
"github.com/micro_servers/waf/internal/proxy" "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) p, err := proxy.New(cfg.Upstream, eng)
if err != nil { if err != nil {
log.Fatalf("代理初始化失败: %v", err) logclient.Error("waf", fmt.Sprintf("代理初始化失败: %v", err), nil)
logclient.Flush()
os.Exit(1)
} }
// SIGHUP 热加载 // SIGHUP 热加载
@@ -92,11 +99,10 @@ func main() {
signal.Notify(sigCh, syscall.SIGHUP) signal.Notify(sigCh, syscall.SIGHUP)
go func() { go func() {
for range sigCh { for range sigCh {
log.Println("[reload] 收到 SIGHUP,重新加载规则")
if err := eng.Reload(); err != nil { if err := eng.Reload(); err != nil {
log.Printf("[reload] 失败,保留旧规则: %v", err) logclient.Error("waf", fmt.Sprintf("热加载失败,保留旧规则: %v", err), nil)
} else { } else {
log.Println("[reload] 成功") logclient.Info("waf", "规则热加载成功", nil)
} }
} }
}() }()
@@ -109,13 +115,13 @@ func main() {
}, },
} }
log.Printf("waf listening on %s, upstream=%s, rules=%s", logclient.Info("waf", fmt.Sprintf("服务启动 listen=%s upstream=%s rules=%s pid=%d",
cfg.Listen, cfg.Upstream, cfg.RulesDir) cfg.Listen, cfg.Upstream, cfg.RulesDir, os.Getpid()), nil)
log.Printf(" - 热加载: kill -HUP %d", os.Getpid())
log.Printf(" - 列规则: %s -c %s -list", os.Args[0], *configFile)
if err := server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != 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)
} }
} }