Compare commits

..
8 Commits
Author SHA1 Message Date
qing fad749f9aa docs(db2): 添加 README 2026-08-06 17:42:50 +00:00
qing 577386bff2 feat(static): 同步美化注册/找回密码/树状笔记页
- 新增 auth.css:表单页共享美化(点阵背景、品牌渐变顶条卡片、eyebrow 标题、
  渐变提交按钮、入场动画),register/forgot 各加一行 link 与 eyebrow
- 新增 dnfs.css:笔记页美化(顶部底纹暖光、卡片逐张入场、编辑卡聚焦光环);
  顺带修复节点编辑卡裸 <button> 无主题底色的问题
- 动效均遵循 prefers-reduced-motion,颜色全部消费 theme.css 变量
2026-08-06 17:42:38 +00:00
qing 6150c60441 chore: .gitignore 忽略 venv/.env 虚拟环境、build 产物与 waf 证书 2026-08-06 17:42:17 +00:00
qing 1970f3f824 feat(db2): C++20 数据库 RPC 服务,对齐 Python db 模块 execute 路由
- 完整实现 execute 路由(select/insert/update/delete)
- 相同 where 语法(eq/ne/gt/lt/ge/le/like/in)及响应格式
- 启动时从 confs 拉取 db.ini,支持环境变量和命令行覆盖
- per-thread SQLite 连接 + WAL/mmap/busy_timeout PRAGMA
- PRAGMA table_info 驱动的列类型反射与缓存
- $now 哨兵值自动替换为 UTC 时间
- 内置 Logs 表自动建表 + 后台清理线程(retention_days + max_records)
- 表名白名单校验防 SQL 注入
- gateway DbC::call_msg() 零改动可直接切换
2026-08-06 17:40:07 +00:00
qing 49ed3f738f fix(deploy): _inst.sh 补全 ai_review/aibox/redirect80; ai_review 模型走 ModelScope 镜像
- _inst.sh: 补入遗漏的 3 个模块(confs 之后、core 之前)
- ai_review/_fetch_onnx_model.sh: 新增 chromadb onnx 模型预下载脚本
  (默认 ModelScope 镜像,支持 ONNX_MODEL_BASE_URL/CHROMA_CACHE_DIR 覆盖,
  断点续传+幂等,绕过 chromadb 写死的 AWS S3 下载)
- ai_review/_set.sh: 安装后自动调用模型下载
- README.md: 更新部署说明
- docs/reviews/DEPLOY_DEFECT_AUDIT_20260806.md: 新增部署缺陷审查报告
- docs/INDEX.md: 登记审查报告
2026-08-06 17:39:42 +00:00
qing ea2717c400 feat(static): 优化社区首页美观度与高级感
- index.html: hero 新增品牌徽章、节点树装饰 SVG、下滑提示;章节增加 eyebrow 小标题
- index.css: 主按钮渐变+扫光、卡片序号水印+图标点亮、标题渐变流光、点阵底纹、
  品牌树描绘动画、关于卡装饰、胶囊快捷链接;全部基于 theme.css 变量,深浅色自适应
- index.js: 追加滚动渐显(IntersectionObserver)与特性卡片光标光斑,与登录逻辑解耦
2026-08-06 17:31:40 +00:00
qing a4611ec737 docs(security): 新增 libmsg 安全审计报告,更新文档索引
新增 docs/security/libmsg_SECURITY_AUDIT_20260806.md,记录 SAST
扫描发现的 3 个漏洞(CWE-362/400/209)及对应修复方案,更新
docs/INDEX.md 加入 libmsg 审计文档入口。
2026-08-06 17:18:27 +00:00
qing e92ef05ed0 fix(libmsg): 修复安全审计发现的三个漏洞
- CWE-362: _remove_client() 关闭 fd 前获取 per-connection send_lock,
  防止 broadcast/reply 向 OS 复用的 fd 发送数据到错误客户端
- CWE-400: workers>0 模式下消息接收移至 worker 线程,
  事件循环不再阻塞 pmsg_recv,防 Slowloris DoS
- CWE-209: 扩展 _sanitize_error 路径正则为 /\S+,
  覆盖含空格/括号/@/~ 等特殊字符的内部路径,防信息泄露
2026-08-06 17:16:33 +00:00
25 changed files with 2203 additions and 91 deletions

No files matched your search

+15
View File
@@ -6,3 +6,18 @@ confs/__pycache__/
libhttp/test_server libhttp/test_server
*.pyc *.pyc
__pycache__/ __pycache__/
# Python 虚拟环境(各模块 _set.sh 创建的 venv/.env)
venv/
.env/
**/venv/
**/.env/
# 构建产物
build/
*.out
*.bin
waf/certs/
# 本地 IDE / 工具
.qoder/
+1 -1
View File
@@ -242,7 +242,7 @@ bash _run.sh # 运行
部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。 部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。
> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需进入 `ai_review/` 目录执行 `_set.sh` 安装,并额外配置 `.env` 文件(DeepSeek API 密钥等)。 > **注意**:`ai_review` / `aibox` / `redirect80` 已纳入 `_inst.sh`(分别依赖 chromadb ONNX 模型下载、`~/.cert/deepseek.key`、本地 `pymsg` 包)。`ai_review` 额外需配置 `.env` 文件(DeepSeek API 密钥等,参考 [ai_review/README.md](ai_review/README.md))。
> **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。 > **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。
+3
View File
@@ -38,4 +38,7 @@ inst_module "db"
inst_module "smtp" inst_module "smtp"
inst_module "captcha" inst_module "captcha"
inst_module "confs" inst_module "confs"
inst_module "ai_review" # 需联网拉 chromadb onnx 模型(_fetch_onnx_model.sh 走 ModelScope 镜像)
inst_module "aibox" # 需 ~/.cert/deepseek.key(见 _setenv.sh)
inst_module "redirect80" # 80 端口重定向,依赖 ../pymsg
inst_module "core" # core last inst_module "core" # core last
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# ============================================================
# _fetch_onnx_model.sh — 预下载 chromadb ONNX 嵌入模型
# ------------------------------------------------------------
# chromadb 默认从 AWS S3 拉取 all-MiniLM-L6-v2 (onnx.tar.gz),
# 国内直连极慢(~18KB/s)且失败会导致 ai_review 无法启动。
# 本脚本改为从 ModelScope 镜像逐个下载 chroma 所需的 6 个文件,
# 放到 chromadb 的缓存目录,跳过其内置下载逻辑。
#
# 可配置:
# ONNX_MODEL_BASE_URL 模型仓库 base URL(默认 ModelScope 镜像)
# CHROMA_CACHE_DIR chroma 缓存根目录(默认 $HOME/.cache/chroma)
# ============================================================
set -uo pipefail
CACHE_ROOT="${CHROMA_CACHE_DIR:-$HOME/.cache/chroma}"
DEST_DIR="$CACHE_ROOT/onnx_models/all-MiniLM-L6-v2/onnx"
BASE_URL="${ONNX_MODEL_BASE_URL:-https://modelscope.cn/models/AI-ModelScope/all-MiniLM-L6-v2/resolve/master}"
# chromadb ONNXMiniLM_L6_V2._download_model_if_not_exists 检查的文件
FILES=(
"config.json"
"model.onnx"
"special_tokens_map.json"
"tokenizer_config.json"
"tokenizer.json"
"vocab.txt"
)
echo "[fetch-onnx] 目标目录: $DEST_DIR"
mkdir -p "$DEST_DIR"
# 已有完整模型则跳过
need=0
for f in "${FILES[@]}"; do
[ -s "$DEST_DIR/$f" ] || need=1
done
if [ "$need" = "0" ]; then
echo "[fetch-onnx] 模型已存在,跳过下载"
exit 0
fi
fail=0
for f in "${FILES[@]}"; do
if [ -s "$DEST_DIR/$f" ]; then
echo "[fetch-onnx] $f 已存在,跳过"
continue
fi
# model.onnx 在仓库 onnx/ 子目录,其余在根目录
if [ "$f" = "model.onnx" ]; then
url="$BASE_URL/onnx/$f"
else
url="$BASE_URL/$f"
fi
echo "[fetch-onnx] 下载 $f <- $url"
if ! curl -fsSL --retry 3 --connect-timeout 15 -o "$DEST_DIR/$f" "$url"; then
echo "[fetch-onnx] ⚠ 下载 $f 失败" >&2
fail=1
fi
done
if [ "$fail" = "1" ]; then
echo "[fetch-onnx] ⚠ 部分文件下载失败,ai_review 启动时 chromadb 会尝试原 S3 下载" >&2
exit 1
fi
echo "[fetch-onnx] ✅ 完成,文件清单:"
ls -la "$DEST_DIR"
+4
View File
@@ -4,5 +4,9 @@ python3 -m venv venv
#sudo chmod +x .env/bin/activate #sudo chmod +x .env/bin/activate
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 预下载 chromadb ONNX 嵌入模型(默认 ModelScope 镜像,避免 AWS S3 国内直连超时)
# 可用 ONNX_MODEL_BASE_URL 覆盖镜像源;失败不阻塞安装,但 ai_review 启动时可能需手动处理
bash _fetch_onnx_model.sh || echo "⚠ onnx 模型下载失败,可稍后重跑 bash _fetch_onnx_model.sh"
#nuitka --onefile --jobs=1 dnfs.py #nuitka --onefile --jobs=1 dnfs.py
+22
View File
@@ -0,0 +1,22 @@
CPP = g++
CPPFLAGS = -std=c++20 -O3 -Wall -Werror -pipe
INCS = -I ../libmsg/inc
LIBS = -L ../libmsg/build -lus_msg -lboost_json -lsqlite3
RPATH = -Wl,-rpath,'$$ORIGIN/../../libmsg/build'
BUILD = build
TARGET = $(BUILD)/db2
SRC = src/db2.cpp
all: $(TARGET)
$(BUILD):
mkdir -p $(BUILD)
$(TARGET): $(SRC) | $(BUILD)
$(CPP) $(CPPFLAGS) $(INCS) -o $@ $< $(LIBS) $(RPATH)
clean:
rm -f $(TARGET)
.PHONY: all clean
+253
View File
@@ -0,0 +1,253 @@
# db2 — C++20 数据库 RPC 服务(libmsg + SQLite)
db 模块的 C++20 重实现,接口与 Python 版完全兼容:相同的 `execute` 路由、相同的请求/响应格式、相同的 `where` 语法。gateway 的 `DbC::call_msg()` 以及所有使用 pymsg `execute` 路由的调用方**零改动**即可切换。
## 与 Python db 的关系
| 特性 | db(Python) | db2(C++) |
|------|-------------|-----------|
| 传输协议 | pymsg TCP 帧 | pymsg TCP 帧(libmsg) |
| `execute` 路由接口 | ✅ | ✅ 完全对齐 |
| where 运算符 | ✅ | ✅ 完全对齐 |
| 响应格式 | ✅ | ✅ 完全对齐 |
| `$now` 哨兵值 | ✅ | ✅ |
| confs 配置拉取 | ✅ | ✅ |
| Logs 自动建表 + 清理 | ✅ | ✅ |
| 数据库后端 | SQLite / PG / MySQL | SQLite(直接 C API) |
| 依赖 | Python 3 + SQLAlchemy | libmsg + boost::json + libsqlite3 |
## 快速开始
### 编译
```bash
./_set.sh # 等同于 make,产物为 build/db2
```
需要系统已安装:`libboost-json-dev`、`libsqlite3-dev`,以及 `libmsg` 已构建(`../libmsg/build/libus_msg.so`)。
### 启动
```bash
./_run.sh # 读 confs 自动获取配置
./_run.sh --addr 127.0.0.1 --port 8005 --workers 4 # 覆盖监听参数
```
服务启动时强依赖 confs(拉取 `db.ini`)。confs 未就绪时自动重试,不要求启动顺序。
## 配置管理
与 Python db 完全一致:启动时从 confs 服务拉取 `db.ini`,失败则退出(exit code 1)。
### confs 配置文件(db.ini)
配置文件托管于 [confs/configs/db.ini](../confs/configs/db.ini):
```ini
[server]
host = 127.0.0.1
port = 8005
workers = 4
[database]
url = sqlite:///database.db
[logs]
retention_days = 30
max_records = 1000000
cleanup_interval = 3600
```
### 引导配置(confs 连接地址)
| 来源 | 优先级 |
|------|--------|
| 环境变量 `CONFS_HOST` / `CONFS_PORT` | 最高 |
| 默认值 `127.0.0.1:8010` | 兜底 |
### 环境变量(最高优先级,覆盖 confs 配置值)
| 环境变量 | 说明 | 对应配置项 |
|---------|------|-----------|
| `LISTEN_ADDR` | 监听地址 | `[server] host` |
| `LISTEN_PORT` | 监听端口 | `[server] port` |
| `WORKERS` | 工作线程数 | `[server] workers` |
| `DATABASE_URL` | 数据库文件路径 | `[database] url` |
| `CONFS_HOST` | confs 地址(引导) | — |
| `CONFS_PORT` | confs 端口(引导) | — |
### 命令行参数(最高优先级)
```
--addr / -a 监听地址
--port / -p 监听端口
--workers / -w 工作线程数
```
## 协议与 API
与 Python db 完全一致,可直接参考 [db/README.md](../db/README.md)。以下为要点摘要。
### 传输协议(pymsg)
帧格式:`[4 字节大端长度][UTF-8 JSON]`,TCP 长连接。
### execute 路由 — 通用 CRUD
```json
{
"_route": "execute",
"action": "select",
"table": "Users",
"columns": ["id", "username"],
"values": {"username": "bob", "password_hash": "..."},
"where": {
"is_active": 1,
"create_at": {"ge": "2026-01-01T00:00:00"}
},
"order_by": [{"column": "id", "desc": true}],
"limit": 10,
"offset": 0
}
```
### where 条件运算符
| 运算符 | SQL | 示例 |
|--------|-----|------|
| 直接值 | `col = ?` | `"name": "Alice"` |
| `eq` | `=` | `"age": {"eq": 30}` |
| `ne` | `!=` | `"status": {"ne": "deleted"}` |
| `gt` | `>` | `"score": {"gt": 80}` |
| `ge` | `>=` | `"score": {"ge": 60}` |
| `lt` | `<` | `"price": {"lt": 100}` |
| `le` | `<=` | `"price": {"le": 200}` |
| `like` | `LIKE` | `"name": {"like": "%Smith%"}` |
| `in` | `IN (...)` | `"id": {"in": [1,2,3]}` |
多条件自动 **AND** 连接。
### 响应格式
```json
// 成功
{"success": true, "data": [{"id": 1, "username": "Alice"}]}
// insert
{"success": true, "data": [{"lastrowid": 42}]}
// update / delete
{"success": true, "data": [{"rowcount": 3}]}
// 失败
{"success": false, "error": "table not found: Foo"}
```
### 日期时间列
列类型含 `DATETIME`/`DATE`/`TIME`,或列名含 `_at`/`time`/`date` 的列自动视为日期列:
| 写入值 | 行为 |
|--------|------|
| `"2026-07-28T22:30:00"` | 原样存储(UTC) |
| `"$now"` | 替换为服务端当前 UTC 时间 |
| 其他字符串 | 原样存储 |
## 使用示例(C++ gateway)
gateway 的 `DbC::call_msg()` 无需任何修改,直接指向 db2 端口即可:
```cpp
// DbC::init("127.0.0.1", 8005); // 不变
// 查询用户
auto result = DbC::call_msg({
{"action", "select"},
{"table", "Users"},
{"where", {{"username", "alice"}}},
{"limit", 1},
});
// 更新登录时间
DbC::call_msg({
{"action", "update"},
{"table", "Users"},
{"values", {{"last_login", "$now"}}},
{"where", {{"id", user_id}}},
});
```
## 使用示例(Python pymsg)
```python
from pymsg.rpc import RpcClient
client = RpcClient("127.0.0.1", 8005, "myservice")
# 查询
rows = client.call("execute", {
"action": "select",
"table": "Users",
"where": {"is_active": 1},
"order_by": [{"column": "id", "desc": True}],
"limit": 20,
})
# 插入
result = client.call("execute", {
"action": "insert",
"table": "Notes",
"values": {"pid": 0, "owner": "alice", "title": "hello", "content": "world"},
})
print(result[0]["lastrowid"])
```
## 内置日志能力
db2 内置 Logs 表(启动时自动建表 + 索引),设计与 Python db 完全一致。
### Logs 表结构
```sql
CREATE TABLE IF NOT EXISTS Logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- UTC ISO 8601
service TEXT NOT NULL,
level TEXT NOT NULL, -- DEBUG/INFO/WARNING/ERROR/CRITICAL
message TEXT NOT NULL, -- 写入时截断至 8192 字节
trace_id TEXT,
extra TEXT -- 可选 JSON 字符串,截断至 4096 字节
);
-- 索引: (service,ts) / (level,ts) / (ts) / (trace_id)
```
外部服务通过 `execute` 路由 INSERT 到 Logs 表即可。可复用 [db/example_log_client.py](../db/example_log_client.py)。
### 自动清理
后台线程每 `cleanup_interval` 秒(默认 3600)执行:
1. 删除超过 `retention_days` 天(默认 30)的记录
2. 若总条数超过 `max_records`(默认 100 万),删除最旧的超出部分
## 安全机制
- 表名仅允许字母、数字、下划线,防止 SQL 注入
- 所有 values / where 值通过 `sqlite3_bind_text` 参数绑定,无字符串拼接
- 仅操作已有表(`PRAGMA table_info` 反射),无法建表或删表
- RpcServer 异常自动脱敏(过滤内部路径),错误细节仅写 stderr
## 实现说明
- **per-thread 连接**:每个 worker 线程持有独立 `sqlite3*` 连接(`thread_local`),避免跨线程共享 prepared statement
- **列 schema 缓存**:`PRAGMA table_info` 结果加锁缓存,仅首次请求时反射
- **SQLite PRAGMA**:`journal_mode=WAL`、`synchronous=NORMAL`、`foreign_keys=ON`、`busy_timeout=5000`、`mmap_size=268435456`,对齐 Python 版配置
## 文件说明
| 文件 | 说明 |
|------|------|
| `src/db2.cpp` | 全部实现(配置/连接池/路由/日志/清理) |
| `Makefile` | 编译规则,链接 `libus_msg` + `boost_json` + `sqlite3` |
| `_set.sh` | 一键编译 |
| `_run.sh` | 启动服务 |
| `docker-entrypoint.sh` | 容器入口:初始化 schema + 启动 |
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
exec ./build/db2 "$@"
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
make
Executable
BIN
View File
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
set -e
# 从 DATABASE_URL 提取 SQLite 文件路径并确保目录存在
if [[ "${DATABASE_URL:-}" == sqlite:///* ]]; then
DB_PATH="${DATABASE_URL#sqlite:///}"
mkdir -p "$(dirname "$DB_PATH")"
fi
# 初始化 schema(幂等)
if [[ -n "${DATABASE_URL:-}" ]]; then
DB_FILE="${DATABASE_URL#sqlite:///}"
sqlite3 "$DB_FILE" <<'SQL'
CREATE TABLE IF NOT EXISTS Users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
create_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1
);
CREATE TABLE IF NOT EXISTS Notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pid INTEGER NOT NULL,
owner VARCHAR(128) DEFAULT 'Unknow',
title VARCHAR(128) NOT NULL,
content TEXT NOT NULL
);
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
);
CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts);
CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts);
CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts);
CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id);
SQL
fi
exec /app/db2 \
--addr "${LISTEN_ADDR:-0.0.0.0}" \
--port "${LISTEN_PORT:-8005}" \
--workers "${WORKERS:-4}"
+850
View File
@@ -0,0 +1,850 @@
/** db2 — C++20 数据库 RPC 服务(对齐 Python db 模块的 execute 路由)
*
* 协议约定(与 Python/Go 客户端字节级兼容):
* 请求: {"_route": "execute", "action": "select|insert|update|delete",
* "table": "T", "columns": [...], "values": {...},
* "where": {"col": val | {"op": val}}, "order_by": [...],
* "limit": N, "offset": N}
* 响应: {"success": true, "data": [...]}
* {"success": false, "error": "..."}
*
* 依赖: libmsg (libus_msg.so), boost::json (-lboost_json), sqlite3 (-lsqlite3)
*/
#include <us/RpcServer.hpp>
#include <us/RpcClient.hpp>
#include <boost/json.hpp>
#include <sqlite3.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <fstream>
#include <iostream>
#include <map>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include <cstdio>
#include <ctime>
namespace json = boost::json;
using namespace pmc::net;
// ============================================================================
// 全局常量
// ============================================================================
static constexpr size_t kMaxMessage = 8192;
static constexpr size_t kMaxExtra = 4096;
// ============================================================================
// 工具:当前 UTC 时间 ISO 8601
// ============================================================================
static std::string utc_now() {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t));
return buf;
}
// ============================================================================
// 工具:简单 INI 解析(白名单模式)
// ============================================================================
using IniMap = std::map<std::string, std::map<std::string, std::string>>;
static IniMap parse_ini(const std::string& text) {
IniMap result;
std::string section;
std::istringstream ss(text);
std::string line;
while (std::getline(ss, line)) {
// 去首尾空白
auto b = line.find_first_not_of(" \t\r\n");
if (b == std::string::npos) continue;
line = line.substr(b);
auto e = line.find_last_not_of(" \t\r\n");
if (e != std::string::npos) line = line.substr(0, e + 1);
if (line.empty() || line[0] == '#' || line[0] == ';') continue;
if (line.front() == '[' && line.back() == ']') {
section = line.substr(1, line.size() - 2);
continue;
}
auto eq = line.find('=');
if (eq == std::string::npos || section.empty()) continue;
std::string key = line.substr(0, eq);
std::string val = line.substr(eq + 1);
// 去 key/val 空白
auto trim = [](std::string& s) {
auto bb = s.find_first_not_of(" \t");
auto ee = s.find_last_not_of(" \t");
s = (bb == std::string::npos) ? "" : s.substr(bb, ee - bb + 1);
};
trim(key); trim(val);
result[section][key] = val;
}
return result;
}
// ============================================================================
// 配置
// ============================================================================
struct Config {
std::string listen_host = "127.0.0.1";
int listen_port = 8005;
int workers = 4;
std::string db_url = "sqlite:///database.db";
// 日志清理
int retention_days = 30;
int max_records = 1000000;
int cleanup_interval = 3600;
// confs 地址(引导配置,不从 confs 自身获取)
std::string confs_host = "127.0.0.1";
int confs_port = 8010;
};
// 从 sqlite:///path 或 sqlite:////abs/path 提取文件路径
static std::string db_url_to_path(const std::string& url) {
const std::string prefix = "sqlite:///";
if (url.rfind(prefix, 0) == 0) {
return url.substr(prefix.size());
}
return url; // 非 sqlite URL,原样返回(暂不支持其他后端)
}
static Config load_config(int argc, char* argv[]) {
Config cfg;
// 1. 从环境变量读取 confs 引导地址
if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h;
if (const char* p = std::getenv("CONFS_PORT")) cfg.confs_port = std::stoi(p);
// 2. 从 confs 服务拉取 db.ini(retry_on_refused=true,等待 confs 就绪)
std::string ini_text;
try {
RpcClient confs_client(cfg.confs_host, cfg.confs_port, "db2",
/*timeout=*/5.0, /*retry_on_refused=*/true);
json::value data = confs_client.call("get", {{"name", "db.ini"}});
if (data.is_object() && data.as_object().contains("content")) {
ini_text = std::string(data.as_object().at("content").as_string());
}
} catch (const std::exception& e) {
std::fprintf(stderr, "[db2] Failed to fetch db.ini from confs: %s\n", e.what());
std::exit(1);
}
// 3. 解析 INI(白名单字段)
IniMap ini = parse_ini(ini_text);
auto get = [&](const std::string& sec, const std::string& key,
const std::string& def = "") -> std::string {
auto si = ini.find(sec);
if (si == ini.end()) return def;
auto ki = si->second.find(key);
return (ki == si->second.end()) ? def : ki->second;
};
cfg.listen_host = get("server", "host", cfg.listen_host);
cfg.listen_port = std::stoi(get("server", "port", std::to_string(cfg.listen_port)));
cfg.workers = std::stoi(get("server", "workers",std::to_string(cfg.workers)));
cfg.db_url = get("database", "url", cfg.db_url);
cfg.retention_days = std::stoi(get("logs", "retention_days", std::to_string(cfg.retention_days)));
cfg.max_records = std::stoi(get("logs", "max_records", std::to_string(cfg.max_records)));
cfg.cleanup_interval = std::stoi(get("logs", "cleanup_interval", std::to_string(cfg.cleanup_interval)));
// 4. 环境变量最高优先级覆盖(Docker 兼容)
if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v;
if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = std::stoi(v);
if (const char* v = std::getenv("WORKERS")) cfg.workers = std::stoi(v);
if (const char* v = std::getenv("DATABASE_URL")) cfg.db_url = v;
// 5. 命令行参数(最高优先级)
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if ((arg == "--addr" || arg == "-a") && i + 1 < argc) cfg.listen_host = argv[++i];
else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = std::stoi(argv[++i]);
else if ((arg == "--workers" || arg == "-w") && i + 1 < argc) cfg.workers = std::stoi(argv[++i]);
}
return cfg;
}
// ============================================================================
// SQLite 连接(per-thread)
// ============================================================================
class DbConn {
public:
explicit DbConn(const std::string& path) {
if (sqlite3_open(path.c_str(), &_db) != SQLITE_OK) {
std::string err = sqlite3_errmsg(_db);
sqlite3_close(_db);
_db = nullptr;
throw std::runtime_error("sqlite3_open failed: " + err);
}
_exec_pragma("PRAGMA journal_mode=WAL");
_exec_pragma("PRAGMA synchronous=NORMAL");
_exec_pragma("PRAGMA foreign_keys=ON");
_exec_pragma("PRAGMA busy_timeout=5000");
_exec_pragma("PRAGMA mmap_size=268435456");
}
~DbConn() {
if (_db) { sqlite3_close(_db); _db = nullptr; }
}
sqlite3* get() const { return _db; }
// 执行无结果集 SQL(建表、PRAGMA 等)
void exec(const std::string& sql) {
char* errmsg = nullptr;
int rc = sqlite3_exec(_db, sql.c_str(), nullptr, nullptr, &errmsg);
if (rc != SQLITE_OK) {
std::string err = errmsg ? errmsg : "unknown";
sqlite3_free(errmsg);
throw std::runtime_error("sqlite3_exec failed: " + err);
}
}
DbConn(const DbConn&) = delete;
DbConn& operator=(const DbConn&) = delete;
private:
sqlite3* _db = nullptr;
void _exec_pragma(const std::string& sql) {
char* errmsg = nullptr;
sqlite3_exec(_db, sql.c_str(), nullptr, nullptr, &errmsg);
if (errmsg) sqlite3_free(errmsg);
}
};
// ============================================================================
// 列元信息(来自 PRAGMA table_info)
// ============================================================================
struct ColInfo {
std::string name;
std::string type; // 原始类型字符串,大写,如 "DATETIME" "TEXT" "INTEGER"
};
// ============================================================================
// DB2Server
// ============================================================================
class DB2Server {
public:
explicit DB2Server(const Config& cfg)
: _cfg(cfg), _db_path(db_url_to_path(cfg.db_url)) {}
void start() {
// 用主线程连接初始化 schema
{
DbConn conn(_db_path);
_ensure_logs_table(conn);
}
_log_internal("INFO", "db2", "db2 starting on "
+ _cfg.listen_host + ":" + std::to_string(_cfg.listen_port)
+ ", db=" + _db_path, "", "");
// 启动后台清理线程
_cleanup_running = true;
_cleanup_thread = std::thread(&DB2Server::_cleanup_loop, this);
// 启动 RPC 服务
MsgServerConfig srv_cfg;
srv_cfg.workers = _cfg.workers;
RpcServer rpc(_cfg.listen_host, _cfg.listen_port, srv_cfg,
[this](const std::string& route, const std::exception& e) {
std::fprintf(stderr, "[db2] handler error route=%s: %s\n",
route.c_str(), e.what());
});
rpc.add_route("execute", [this](const json::object& req) -> json::value {
return _handle_execute(req);
});
rpc.start();
// rpc.start() 返回后停止清理线程
_cleanup_running = false;
_cleanup_cv.notify_all();
if (_cleanup_thread.joinable()) _cleanup_thread.join();
}
private:
const Config& _cfg;
std::string _db_path;
// 表列信息缓存
std::mutex _schema_mtx;
std::unordered_map<std::string, std::vector<ColInfo>> _schema_cache;
// 后台清理线程
std::atomic<bool> _cleanup_running{false};
std::thread _cleanup_thread;
std::mutex _cleanup_mtx;
std::condition_variable _cleanup_cv;
// ---- per-thread SQLite 连接 -----------------------------------------------
DbConn& _get_conn() {
thread_local std::unique_ptr<DbConn> conn;
if (!conn) conn = std::make_unique<DbConn>(_db_path);
return *conn;
}
// ---- 列 schema 反射 --------------------------------------------------------
std::vector<ColInfo> _get_schema(const std::string& table) {
{
std::lock_guard<std::mutex> lk(_schema_mtx);
auto it = _schema_cache.find(table);
if (it != _schema_cache.end()) return it->second;
}
// PRAGMA table_info 返回: cid, name, type, notnull, dflt_value, pk
DbConn& conn = _get_conn();
std::string sql = "PRAGMA table_info(" + table + ")";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("PRAGMA table_info failed for: " + table);
}
std::vector<ColInfo> cols;
while (sqlite3_step(stmt) == SQLITE_ROW) {
ColInfo ci;
const char* name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
const char* type = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
ci.name = name ? name : "";
ci.type = type ? type : "";
// 统一大写便于比较
std::transform(ci.type.begin(), ci.type.end(), ci.type.begin(), ::toupper);
cols.push_back(ci);
}
sqlite3_finalize(stmt);
if (cols.empty()) {
throw std::runtime_error("table not found: " + table);
}
std::lock_guard<std::mutex> lk(_schema_mtx);
_schema_cache[table] = cols;
return cols;
}
// 判断列是否为日期时间类型
static bool _is_datetime_col(const ColInfo& ci) {
// 按类型字符串
if (ci.type.find("DATETIME") != std::string::npos) return true;
if (ci.type.find("DATE") != std::string::npos) return true;
if (ci.type.find("TIME") != std::string::npos) return true;
// 按列名约定
std::string n = ci.name;
std::transform(n.begin(), n.end(), n.begin(), ::tolower);
return n.find("_at") != std::string::npos
|| n.find("time") != std::string::npos
|| n.find("date") != std::string::npos;
}
// 将 JSON 值转换为 SQLite 绑定字符串($now 替换为 UTC 时间)
static std::string _coerce_value(const ColInfo& ci, const json::value& jv) {
if (jv.is_string()) {
std::string s(jv.as_string());
if (_is_datetime_col(ci) && s == "$now") return utc_now();
return s;
}
if (jv.is_int64()) return std::to_string(jv.as_int64());
if (jv.is_uint64()) return std::to_string(jv.as_uint64());
if (jv.is_double()) return std::to_string(jv.as_double());
if (jv.is_bool()) return jv.as_bool() ? "1" : "0";
if (jv.is_null()) return ""; // 绑定 NULL
return json::serialize(jv);
}
// ---- WHERE 子句构建 --------------------------------------------------------
struct WhereClause {
std::string sql; // "col1 = ? AND col2 > ?"
std::vector<std::string> params; // 绑定参数值(顺序对应 ?)
std::vector<bool> is_null; // 对应参数是否绑定 NULL
};
WhereClause _build_where(const json::object& where,
const std::vector<ColInfo>& cols) {
WhereClause wc;
std::vector<std::string> parts;
// 建立列名 -> ColInfo 映射
std::unordered_map<std::string, ColInfo> col_map;
for (auto& c : cols) col_map[c.name] = c;
for (auto& [key, val] : where) {
std::string col(key);
ColInfo ci;
auto it = col_map.find(col);
if (it != col_map.end()) ci = it->second;
else { ci.name = col; ci.type = "TEXT"; }
if (val.is_object()) {
// {"op": value} 形式
for (auto& [op_key, op_val] : val.as_object()) {
std::string op(op_key);
if (op == "eq") {
parts.push_back(col + " = ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(op_val.is_null());
} else if (op == "ne") {
parts.push_back(col + " != ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(op_val.is_null());
} else if (op == "gt") {
parts.push_back(col + " > ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(false);
} else if (op == "lt") {
parts.push_back(col + " < ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(false);
} else if (op == "ge") {
parts.push_back(col + " >= ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(false);
} else if (op == "le") {
parts.push_back(col + " <= ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(false);
} else if (op == "like") {
parts.push_back(col + " LIKE ?");
wc.params.push_back(_coerce_value(ci, op_val));
wc.is_null.push_back(false);
} else if (op == "in") {
if (!op_val.is_array() || op_val.as_array().empty()) continue;
std::string placeholders;
for (auto& item : op_val.as_array()) {
if (!placeholders.empty()) placeholders += ", ";
placeholders += "?";
wc.params.push_back(_coerce_value(ci, item));
wc.is_null.push_back(item.is_null());
}
parts.push_back(col + " IN (" + placeholders + ")");
}
}
} else {
// 直接等于
parts.push_back(col + " = ?");
wc.params.push_back(_coerce_value(ci, val));
wc.is_null.push_back(val.is_null());
}
}
for (size_t i = 0; i < parts.size(); ++i) {
if (i > 0) wc.sql += " AND ";
wc.sql += parts[i];
}
return wc;
}
// ---- 参数绑定辅助 ----------------------------------------------------------
static void _bind_params(sqlite3_stmt* stmt, int start_idx,
const std::vector<std::string>& params,
const std::vector<bool>& is_null) {
for (size_t i = 0; i < params.size(); ++i) {
int idx = start_idx + static_cast<int>(i);
if (is_null[i]) {
sqlite3_bind_null(stmt, idx);
} else {
sqlite3_bind_text(stmt, idx, params[i].c_str(), -1, SQLITE_TRANSIENT);
}
}
}
// ---- execute 路由 ----------------------------------------------------------
json::value _handle_execute(const json::object& req) {
// 提取公共字段
std::string action = "select";
if (auto it = req.find("action"); it != req.end() && it->value().is_string())
action = std::string(it->value().as_string());
if (!req.contains("table") || !req.at("table").is_string())
throw std::runtime_error("missing required field: table");
std::string table(req.at("table").as_string());
// 验证表名(仅允许字母数字下划线,防止 SQL 注入)
for (char c : table) {
if (!std::isalnum(static_cast<unsigned char>(c)) && c != '_')
throw std::runtime_error("invalid table name: " + table);
}
std::vector<ColInfo> cols = _get_schema(table);
if (action == "select") return _do_select(req, table, cols);
if (action == "insert") return _do_insert(req, table, cols);
if (action == "update") return _do_update(req, table, cols);
if (action == "delete") return _do_delete(req, table, cols);
throw std::runtime_error("unknown action: " + action);
}
// ---- SELECT ----------------------------------------------------------------
json::value _do_select(const json::object& req,
const std::string& table,
const std::vector<ColInfo>& cols) {
DbConn& conn = _get_conn();
// columns
std::string col_expr = "*";
if (auto it = req.find("columns");
it != req.end() && it->value().is_array() && !it->value().as_array().empty()) {
col_expr.clear();
for (auto& c : it->value().as_array()) {
if (!col_expr.empty()) col_expr += ", ";
col_expr += std::string(c.as_string());
}
}
std::string sql = "SELECT " + col_expr + " FROM " + table;
WhereClause wc;
if (auto it = req.find("where"); it != req.end() && it->value().is_object())
wc = _build_where(it->value().as_object(), cols);
if (!wc.sql.empty()) sql += " WHERE " + wc.sql;
// order_by
if (auto it = req.find("order_by");
it != req.end() && it->value().is_array()) {
std::string order;
for (auto& ob : it->value().as_array()) {
if (!ob.is_object()) continue;
auto& o = ob.as_object();
if (!o.contains("column")) continue;
if (!order.empty()) order += ", ";
order += std::string(o.at("column").as_string());
if (o.contains("desc") && o.at("desc").as_bool()) order += " DESC";
}
if (!order.empty()) sql += " ORDER BY " + order;
}
// limit / offset
if (auto it = req.find("limit"); it != req.end() && it->value().is_int64())
sql += " LIMIT " + std::to_string(it->value().as_int64());
if (auto it = req.find("offset"); it != req.end() && it->value().is_int64())
sql += " OFFSET " + std::to_string(it->value().as_int64());
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get()));
}
_bind_params(stmt, 1, wc.params, wc.is_null);
// 获取结果列名(可能是 * 展开后的实际列名)
int ncols = sqlite3_column_count(stmt);
std::vector<std::string> result_cols;
result_cols.reserve(ncols);
for (int i = 0; i < ncols; ++i) {
const char* n = sqlite3_column_name(stmt, i);
result_cols.push_back(n ? n : "");
}
json::array rows;
while (sqlite3_step(stmt) == SQLITE_ROW) {
json::object row;
for (int i = 0; i < ncols; ++i) {
int ctype = sqlite3_column_type(stmt, i);
if (ctype == SQLITE_NULL) {
row[result_cols[i]] = nullptr;
} else if (ctype == SQLITE_INTEGER) {
row[result_cols[i]] = sqlite3_column_int64(stmt, i);
} else if (ctype == SQLITE_FLOAT) {
row[result_cols[i]] = sqlite3_column_double(stmt, i);
} else {
const unsigned char* txt = sqlite3_column_text(stmt, i);
row[result_cols[i]] = txt ? reinterpret_cast<const char*>(txt) : "";
}
}
rows.push_back(row);
}
sqlite3_finalize(stmt);
return rows;
}
// ---- INSERT ----------------------------------------------------------------
json::value _do_insert(const json::object& req,
const std::string& table,
const std::vector<ColInfo>& cols) {
if (!req.contains("values") || !req.at("values").is_object())
throw std::runtime_error("insert requires 'values' object");
DbConn& conn = _get_conn();
const json::object& vals = req.at("values").as_object();
// 建列名 -> ColInfo 映射
std::unordered_map<std::string, ColInfo> col_map;
for (auto& c : cols) col_map[c.name] = c;
std::string col_list, ph_list;
std::vector<std::string> params;
std::vector<bool> is_null;
for (auto& [k, v] : vals) {
if (!col_list.empty()) { col_list += ", "; ph_list += ", "; }
col_list += std::string(k);
ph_list += "?";
ColInfo ci;
auto it = col_map.find(std::string(k));
if (it != col_map.end()) ci = it->second;
else { ci.name = std::string(k); ci.type = "TEXT"; }
params.push_back(_coerce_value(ci, v));
is_null.push_back(v.is_null());
}
std::string sql = "INSERT INTO " + table + " (" + col_list + ") VALUES (" + ph_list + ")";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get()));
_bind_params(stmt, 1, params, is_null);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE)
throw std::runtime_error(std::string("insert failed: ") + sqlite3_errmsg(conn.get()));
sqlite3_int64 rowid = sqlite3_last_insert_rowid(conn.get());
return json::array{json::object{{"lastrowid", rowid}}};
}
// ---- UPDATE ----------------------------------------------------------------
json::value _do_update(const json::object& req,
const std::string& table,
const std::vector<ColInfo>& cols) {
if (!req.contains("values") || !req.at("values").is_object())
throw std::runtime_error("update requires 'values' object");
DbConn& conn = _get_conn();
const json::object& vals = req.at("values").as_object();
std::unordered_map<std::string, ColInfo> col_map;
for (auto& c : cols) col_map[c.name] = c;
std::string set_clause;
std::vector<std::string> params;
std::vector<bool> is_null;
for (auto& [k, v] : vals) {
if (!set_clause.empty()) set_clause += ", ";
set_clause += std::string(k) + " = ?";
ColInfo ci;
auto it = col_map.find(std::string(k));
if (it != col_map.end()) ci = it->second;
else { ci.name = std::string(k); ci.type = "TEXT"; }
params.push_back(_coerce_value(ci, v));
is_null.push_back(v.is_null());
}
std::string sql = "UPDATE " + table + " SET " + set_clause;
WhereClause wc;
if (auto it = req.find("where"); it != req.end() && it->value().is_object())
wc = _build_where(it->value().as_object(), cols);
if (!wc.sql.empty()) {
sql += " WHERE " + wc.sql;
params.insert(params.end(), wc.params.begin(), wc.params.end());
is_null.insert(is_null.end(), wc.is_null.begin(), wc.is_null.end());
}
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get()));
_bind_params(stmt, 1, params, is_null);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE)
throw std::runtime_error(std::string("update failed: ") + sqlite3_errmsg(conn.get()));
int rowcount = sqlite3_changes(conn.get());
return json::array{json::object{{"rowcount", rowcount}}};
}
// ---- DELETE ----------------------------------------------------------------
json::value _do_delete(const json::object& req,
const std::string& table,
const std::vector<ColInfo>& cols) {
DbConn& conn = _get_conn();
std::string sql = "DELETE FROM " + table;
WhereClause wc;
if (auto it = req.find("where"); it != req.end() && it->value().is_object())
wc = _build_where(it->value().as_object(), cols);
if (!wc.sql.empty()) sql += " WHERE " + wc.sql;
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
throw std::runtime_error(std::string("prepare failed: ") + sqlite3_errmsg(conn.get()));
_bind_params(stmt, 1, wc.params, wc.is_null);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE)
throw std::runtime_error(std::string("delete failed: ") + sqlite3_errmsg(conn.get()));
int rowcount = sqlite3_changes(conn.get());
return json::array{json::object{{"rowcount", rowcount}}};
}
// ---- Logs 表初始化 ---------------------------------------------------------
void _ensure_logs_table(DbConn& conn) {
conn.exec(R"(
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("CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)");
conn.exec("CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)");
conn.exec("CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)");
conn.exec("CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)");
}
// ---- 内部日志直写(绕过 RPC,防递归) --------------------------------------
void _log_internal(const std::string& level, const std::string& service,
std::string message, const std::string& trace_id,
const std::string& extra) {
if (message.size() > kMaxMessage) message.resize(kMaxMessage);
std::string ex = extra;
if (ex.size() > kMaxExtra) ex.resize(kMaxExtra);
try {
DbConn& conn = _get_conn();
std::string sql =
"INSERT INTO Logs (ts, service, level, message, trace_id, extra) "
"VALUES (?, ?, ?, ?, ?, ?)";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(conn.get(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return;
std::string ts = utc_now();
sqlite3_bind_text(stmt, 1, ts.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, service.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, level.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, message.c_str(), -1, SQLITE_TRANSIENT);
if (trace_id.empty()) sqlite3_bind_null(stmt, 5);
else sqlite3_bind_text(stmt, 5, trace_id.c_str(), -1, SQLITE_TRANSIENT);
if (ex.empty()) sqlite3_bind_null(stmt, 6);
else sqlite3_bind_text(stmt, 6, ex.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
} catch (...) {}
}
// ---- 后台日志清理线程 -------------------------------------------------------
void _cleanup_loop() {
while (_cleanup_running) {
std::unique_lock<std::mutex> lk(_cleanup_mtx);
_cleanup_cv.wait_for(lk,
std::chrono::seconds(_cfg.cleanup_interval),
[this] { return !_cleanup_running.load(); });
if (!_cleanup_running) break;
_do_cleanup();
}
}
void _do_cleanup() {
try {
DbConn& conn = _get_conn();
// 1. 删除超过 retention_days 的记录
std::string cutoff_sql =
"DELETE FROM Logs WHERE ts < datetime('now', '-"
+ std::to_string(_cfg.retention_days) + " days')";
conn.exec(cutoff_sql);
int expired = sqlite3_changes(conn.get());
// 2. 若总条数超过 max_records,删除最旧的超出部分
int trimmed = 0;
{
sqlite3_stmt* stmt = nullptr;
std::string cnt_sql = "SELECT COUNT(*) FROM Logs";
if (sqlite3_prepare_v2(conn.get(), cnt_sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
int total = sqlite3_column_int(stmt, 0);
if (total > _cfg.max_records) {
int to_delete = total - _cfg.max_records;
sqlite3_finalize(stmt);
std::string trim_sql =
"DELETE FROM Logs WHERE id IN "
"(SELECT id FROM Logs ORDER BY id ASC LIMIT "
+ std::to_string(to_delete) + ")";
conn.exec(trim_sql);
trimmed = sqlite3_changes(conn.get());
} else {
sqlite3_finalize(stmt);
}
} else {
sqlite3_finalize(stmt);
}
}
}
if (expired > 0 || trimmed > 0) {
_log_internal("INFO", "db2",
"cleanup: expired=" + std::to_string(expired)
+ " trimmed=" + std::to_string(trimmed), "", "");
}
} catch (const std::exception& e) {
std::fprintf(stderr, "[db2] cleanup error: %s\n", e.what());
}
}
};
// ============================================================================
// main
// ============================================================================
int main(int argc, char* argv[]) {
try {
Config cfg = load_config(argc, argv);
std::printf("[db2] listen=%s:%d workers=%d db=%s\n",
cfg.listen_host.c_str(), cfg.listen_port,
cfg.workers, cfg.db_url.c_str());
std::fflush(stdout);
DB2Server server(cfg);
server.start();
} catch (const std::exception& e) {
std::fprintf(stderr, "[db2] fatal: %s\n", e.what());
return 1;
}
return 0;
}
+2
View File
@@ -47,6 +47,7 @@ docs/
| smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验) | | smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验) |
| captcha | [captcha_SECURITY_AUDIT_20260801.md](security/captcha_SECURITY_AUDIT_20260801.md) | Python 验证码服务 | | captcha | [captcha_SECURITY_AUDIT_20260801.md](security/captcha_SECURITY_AUDIT_20260801.md) | Python 验证码服务 |
| confs | [confs_SECURITY_AUDIT_20260801.md](security/confs_SECURITY_AUDIT_20260801.md) | Python 统一配置服务 | | confs | [confs_SECURITY_AUDIT_20260801.md](security/confs_SECURITY_AUDIT_20260801.md) | Python 统一配置服务 |
| libmsg | [libmsg_SECURITY_AUDIT_20260806.md](security/libmsg_SECURITY_AUDIT_20260806.md) | C++ RPC 基础库(pymsg 协议帧、MsgClient/Server、RpcClient/Server) |
| libhttp | [libhttp_SECURITY_AUDIT_20260803.md](security/libhttp_SECURITY_AUDIT_20260803.md) | C++ HTTP/WebSocket 库(请求/响应/WS/静态文件) | | libhttp | [libhttp_SECURITY_AUDIT_20260803.md](security/libhttp_SECURITY_AUDIT_20260803.md) | C++ HTTP/WebSocket 库(请求/响应/WS/静态文件) |
| aibox | [aibox_SECURITY_AUDIT_20260801.md](security/aibox_SECURITY_AUDIT_20260801.md) | Python AI 智能体系统(WebSocket) | | aibox | [aibox_SECURITY_AUDIT_20260801.md](security/aibox_SECURITY_AUDIT_20260801.md) | Python AI 智能体系统(WebSocket) |
| BotOps | [BotOps_SECURITY_AUDIT_20260801.md](security/BotOps_SECURITY_AUDIT_20260801.md) | BotOps 运维控制面板 | | BotOps | [BotOps_SECURITY_AUDIT_20260801.md](security/BotOps_SECURITY_AUDIT_20260801.md) | BotOps 运维控制面板 |
@@ -58,6 +59,7 @@ docs/
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [core_CODE_REVIEW_LOG_20260730.md](reviews/core_CODE_REVIEW_LOG_20260730.md) | Core 模块代码审查日志(2026-07-30,自动化审查) | | [core_CODE_REVIEW_LOG_20260730.md](reviews/core_CODE_REVIEW_LOG_20260730.md) | Core 模块代码审查日志(2026-07-30,自动化审查) |
| [DEPLOY_DEFECT_AUDIT_20260806.md](reviews/DEPLOY_DEFECT_AUDIT_20260806.md) | 部署缺陷审查(2026-08-06,首次原生部署实录,含修复记录) |
--- ---
@@ -0,0 +1,96 @@
# blackbird 部署缺陷审查报告(2026-08-06)
> 来源:2026-08-06 首次原生编译部署全流程观察(qsont 运维实录)
> 范围:`_inst.sh` 构建链、systemd 单元、chromadb 模型下载、key 路径约定等部署/运维相关代码缺陷
> 状态标记:🔴 高(影响可靠性) 🟡 中(坑人/影响开发) 🟢 低(文档/非功能)
---
## 🔴 高(影响可靠性)
### 1. Nidus.service 没有 `Restart=` 配置
- **位置**:`core/Nidus.service`
- **问题**:README 声称"崩溃守护由 systemd 负责",但 service 文件只有 `ExecStart`,systemd 默认 `Restart=no`。
- **影响**:nidus 一旦崩溃不会被拉起,12 个服务全部停摆,且无告警。
- **建议**:加 `Restart=on-failure` + `RestartSec=5`。
### 2. `_inst.sh` 遗漏 3 个模块(已修复 ✅)
- **位置**:`_inst.sh`(本次已修复)
- **问题**:ai_review、aibox、redirect80 不在构建列表,README 只提到 ai_review 需单独安装。
- **影响**:按 README 部署的人会发现 init.txt 引用的服务有 3 个没装。
- **修复**:已补入 `_inst.sh`(confs 之后、core 之前),并更新 README 说明。
### 3. chromadb 模型从 AWS S3 硬编码下载(已修复 ✅)
- **位置**:chromadb 第三方库 `onnx_mini_lm_l6_v2.py`(`MODEL_DOWNLOAD_URL` 写死 S3)
- **问题**:国内直连 18KB/s,90MB 模型要 1 小时+,下载失败直接抛异常导致服务起不来。
- **影响**:ai_review 在国内环境首次启动必挂。
- **修复**:新增 `ai_review/_fetch_onnx_model.sh`,默认从 ModelScope 镜像下载(560KB/s),支持 `ONNX_MODEL_BASE_URL` / `CHROMA_CACHE_DIR` 环境变量覆盖,断点续传 + 幂等;`ai_review/_set.sh` 自动调用。
### 4. key 路径硬编码 + 运行身份不一致
- **位置**:`aibox/_setenv.sh`(读 `~/.cert/deepseek.key`)
- **问题**:Nidus 以 root 运行,root 的 `~` 与 qing 的 `~` 不是同一路径,部署时缺 key 服务启动即崩(RuntimeError),日志难排查。
- **影响**:aibox / ai_review 缺 DeepSeek key 时静默崩溃。
- **建议**:改为环境变量优先,fallback 到 `/root/.cert/` 与 `/home/<user>/.cert/` 多路径探测。
---
## 🟡 中(坑人/影响开发)
### 5. `waf/_gen.sh` 产物与配置不匹配
- **位置**:`waf/_gen.sh`、`waf/config.yaml`
- **问题**:脚本生成 `server.crt`,配置要求 `certs/full_chain.crt`,需手动改名/移动。
- **建议**:脚本直接输出到 `certs/` 目录并命名 `full_chain.crt`。
### 6. pymsg 构建产物属主污染
- **位置**:`pymsg/`(egg-info / build 目录)
- **问题**:`_inst.sh` 以 root 运行,构建产物变成 root 属主,之后 qing 用户再构建报 "Cannot update time stamp"。
- **建议**:构建脚本里 `chown` 回原用户,或明确要求统一以 root 构建。
### 7. core Makefile install 注释掉 `mkdir -p`
- **位置**:`core/Makefile`(`#@mkdir -p $(INSTL_DIRS)`)
- **问题**:依赖目标目录预存在,`/usr/local/bin` 恰好存在所以未炸,属隐藏脆弱点。
- **建议**:取消注释。
### 8. `captcha/_set.sh` 复制粘贴错误
- **位置**:`captcha/_set.sh`
- **问题**:nuitka 注释行写的是 `smtp.py` 而非 `captcha.py`(照抄 smtp)。
- **建议**:修正为 `captcha_service.py`。
---
## 🟢 低(文档/非功能)
### 9. 部署树创建无文档
- **位置**:README 部署章节
- **问题**:`make install` 只装二进制和 systemd 单元,`/opt/micro_servers` 需自行创建,首次部署必踩 127。
- **建议**:README 补充 `mkdir -p /opt/micro_servers` 与同步命令。
### 10. docker-compose 与原生部署并存
- **位置**:`docker-compose.yml`
- **问题**:compose 中 db/ai_review/dnfs 端口与原生部署冲突,切换方式未文档化。
- **建议**:README 说明两种模式互斥,或提供 `docker.sh` 切换脚本说明。
### 11. `.env` 无模板
- **位置**:`ai_review/`
- **问题**:依赖 README 贴示例,无 `.env.example`,漏配一个 key 就是启动崩溃。
- **建议**:提供 `.env.example` 并纳入 git。
---
## 优先级建议
1. **#1 Restart 策略** — 一行 systemd 配置,消除生产事故级隐患
2. **#4 key 路径探测** — 消除部署时静默崩溃
3. **#5-#8** — 开发体验类,随手可改
4. **#9-#11** — 文档类,随 README 更新一起做
@@ -0,0 +1,151 @@
# libmsg 模块安全审计报告
**审计日期**: 2026-08-06
**审计范围**: `libmsg/inc/us/MsgServer.hpp`、`libmsg/inc/us/RpcServer.hpp`、`libmsg/inc/us/Msg.hpp`、`libmsg/inc/us/RpcClient.hpp`、`libmsg/inc/us/AsyncQueue.hpp`、`libmsg/inc/us/Msg_Basic.hpp`
**代码版本**: main 分支(e92ef05)
**审计工具**: Qoder Security SAST 云端扫描
---
## 问题严重等级
| 等级 | 说明 | 响应时间 |
|------|------|----------|
| P0 🔴 | 可导致服务崩溃、DoS 或远程接管 | 立即修复 |
| P1 🟠 | 可导致资源耗尽、信息泄露 | 72小时内 |
| P2 🟡 | 健壮性/配置问题 | 一周内 |
| P3 🟢 | 优化建议 | 版本迭代 |
---
## 修复进度汇总
| 等级 | 总数 | 已修复 | 待修复 | 修复率 |
|------|------|--------|--------|--------|
| P0 | 1 | 1 | 0 | 100% |
| P1 | 1 | 1 | 0 | 100% |
| P2 | 1 | 1 | 0 | 100% |
| **合计** | **3** | **3** | **0** | **100%** |
---
## 🔴 P0 — 严重问题
### P0-1: broadcast/reply 竞态条件导致数据发送到错误客户端 ✅ 已修复
- **CWE**: CWE-362 (Race Condition)
- **OWASP**: A01:2021
- **位置**: `MsgServer.hpp:228`(原始行号)
- **置信度**: 80%
**问题描述**:
`broadcast()` 在 `_clients_mtx` 保护下拷贝 fd + send_lock 列表后释放锁,再无锁执行 `pmsg_send(fd, msg)`。此窗口期内 `_remove_client()` 可关闭该 fd,OS 可将其分配给新连接。`broadcast()`/`reply()` 随后向复用的 fd 发送数据,导致消息到达错误的客户端。`_remove_client()` 原实现关闭 fd 前不获取 per-connection `send_lock`,因此 `send_lock` 无法阻止此竞态。
**数据流**:
1. `broadcast()` 在 `_clients_mtx` 下拷贝 fd 和 send_lock 列表
2. `_remove_client()` 关闭 fd 但不持 send_lock,OS 复用该 fd
3. `broadcast()` 向已被复用的 fd 调用 `pmsg_send()`,数据到达新连接
**修复方案**(commit `e92ef05`):
`_remove_client()` 先从 `_send_locks` 取出 `send_lock` 并从 map 删除,持有 `send_lock` 后再执行 `::close(fd)`,确保关闭与正在进行的发送操作互斥:
```cpp
void _remove_client(int fd) {
std::shared_ptr<std::mutex> send_lock;
{
std::lock_guard<std::mutex> clk(_clients_mtx);
if (_clients.erase(fd) == 0) return;
auto it = _send_locks.find(fd);
if (it != _send_locks.end()) {
send_lock = it->second;
_send_locks.erase(it);
}
}
if (send_lock) {
std::lock_guard<std::mutex> slk(*send_lock);
::close(fd);
} else {
::close(fd);
}
}
```
---
## 🟠 P1 — 高危问题
### P1-1: 事件循环顺序阻塞接收导致 DoS(Slowloris 变体)✅ 已修复
- **CWE**: CWE-400 (Uncontrolled Resource Consumption)
- **OWASP**: A05:2021
- **位置**: `MsgServer.hpp:424`(原始行号)
- **置信度**: 80%
**问题描述**:
`_event_loop()` 对每个就绪 fd 顺序调用 `_handle_client()`,后者在**事件循环主线程**中阻塞调用 `pmsg_recv(fd, recv_timeout)`(默认 10 秒)。线程池仅处理回调执行,不处理消息接收,即使配置了 `workers > 0` 也无法缓解。攻击者用 `max_connections`(默认 1000)个连接各发送不完整帧头(如 3 字节),可使事件循环阻塞最长 `1000 × 10 = 10000` 秒(约 2.8 小时),期间服务完全无法处理任何新连接或已有消息。
**数据流**:
1. 事件循环顺序遍历所有就绪的客户端 fd
2. `_handle_client()` 在主线程调用 `pmsg_recv(fd, recv_timeout)`,阻塞最长 `recv_timeout` 秒
**修复方案**(commit `e92ef05`):
在 `workers > 0` 模式下,`_handle_client()` 不再在事件循环线程调用 `pmsg_recv()`,而是提交 `needs_recv=true` 的任务给 worker 线程,由 worker 线程负责完整的 recv + callback 流程:
```cpp
// 线程池模式:直接提交,由 worker 负责 recv + callback
_pool_queue.push(Task{fd, std::move(addr), {}, true});
```
同步模式(`workers=0`)行为不变,仍在主线程顺序处理(无线程池时无法并发)。
---
## 🟡 P2 — 中危问题
### P2-1: 错误信息路径脱敏正则不完整,可能泄露内部路径 ✅ 已修复
- **CWE**: CWE-209 (Information Exposure Through an Error Message)
- **OWASP**: A04:2021
- **位置**: `RpcServer.hpp:184`(原始行号)
- **置信度**: 75%
**问题描述**:
`RpcServer::_sanitize_error()` 使用正则 `/[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)*` 替换异常消息中的绝对路径,但该正则仅匹配由 `[A-Za-z0-9_.\-]` 组成的路径段。含空格(`/home/user name/`)、括号(`/tmp/file(1).txt`)、`@`(`/opt/app@2.0/`)、`~` 等特殊字符的路径不被匹配,会原文返回给远程客户端,泄露内部服务器路径信息。
**修复方案**(commit `e92ef05`):
将正则扩展为 `/\S+(?:/\S*)*`,匹配以 `/` 开头、后接所有非空白字符的路径段,覆盖所有常见内部路径格式:
```cpp
static const std::regex path_re(R"(/\S+(?:/\S*)*)");
return std::regex_replace(msg, path_re, "<redacted>");
```
---
## ✅ 已有安全机制
| 机制 | 位置 | 说明 |
|------|------|------|
| 消息大小上限 64 MiB | `Msg_Basic.hpp` `kMaxMsgSize` | 防止超大消息耗尽内存 |
| deadline 模式 recv 超时 | `Msg.hpp` `pmsg_recv(fd, timeout)` | header + payload 共享总超时,防 Slowloris |
| 最大连接数限制 | `MsgServer.hpp` `max_connections=1000` | 超限拒绝新连接 |
| 有界任务队列 | `MsgServer.hpp` `max_queue_size=1000` | 队列满时断开连接,防 OOM |
| per-connection 发送锁 | `MsgServer.hpp` `_send_locks` | reply/broadcast 线程安全,防帧交错 |
| 同一连接消息串行 | `MsgServer.hpp` `_busy` 集合 | 回调执行中暂停该连接 select 监听 |
| JSON 解析深度限制 | `RpcServer.hpp` `opts.max_depth=32` | 防深度嵌套 JSON 导致栈溢出 |
| 异常信息脱敏 | `RpcServer.hpp` `_sanitize_error()` | 过滤绝对路径,完整异常由 `on_error` 钩子记录 |
| RpcClient 响应格式校验 | `RpcClient.hpp` | 校验 `success` 字段,非法响应抛 `RpcError` |
| worker 线程异常隔离 | `MsgServer.hpp` `_worker_loop()` | handler 异常仅断开该连接,不影响服务 |
| select FD_SETSIZE 检查 | `MsgServer.hpp` `_accept_client()` | fd >= FD_SETSIZE 时拒绝连接,防 select 越界 |
---
## 待修复 / 已知限制
| # | 问题 | 优先级 | 说明 |
|---|------|--------|------|
| L-1 | 无认证/鉴权机制 | P0(已缓解) | 仅依赖绑定 127.0.0.1 网络层隔离,任意本地进程可调用所有路由 |
| L-2 | 无 TLS 加密传输 | P1 | 内网明文 TCP,进程间通信可被本地抓包 |
| L-3 | 同步模式(workers=0)仍在主线程阻塞 recv | P2 | 无线程池时无法并发,Slowloris 防护依赖 recv_timeout 截止时间 |
| L-4 | select 模型上限 FD_SETSIZE(通常 1024) | P2 | 高并发场景建议改用 epoll |
| L-5 | 安全事件无结构化日志 | P3 | 超时、超大消息、JSON 解析失败等仅 fprintf(stderr),无可观测性 |
+34 -7
View File
@@ -249,6 +249,7 @@ private:
int fd; int fd;
std::string addr; std::string addr;
std::string message; std::string message;
bool needs_recv = false; // true: worker 负责 pmsg_recv;false: 消息已就绪
}; };
void _worker_loop() { void _worker_loop() {
@@ -268,6 +269,19 @@ private:
} }
if (!has_task) continue; if (!has_task) continue;
// workers > 0 模式下,消息接收在 worker 线程中完成(防事件循环阻塞 CWE-400)
if (task.needs_recv) {
try {
task.message = pmsg_recv(task.fd, _config.recv_timeout);
} catch (const MsgException&) {
std::lock_guard<std::mutex> flk(_failed_mtx);
_failed.insert(task.fd);
std::lock_guard<std::mutex> blk(_busy_mtx);
_busy.erase(task.fd);
continue;
}
}
// 执行回调 // 执行回调
bool failed = false; bool failed = false;
try { try {
@@ -418,17 +432,15 @@ private:
addr = it->second; addr = it->second;
} }
if (_config.workers == 0) {
// 同步模式:在主线程接收并执行回调
std::string message; std::string message;
try { try {
// deadline 模式:header+payload 共享总超时,防 Slowloris
message = pmsg_recv(fd, _config.recv_timeout); message = pmsg_recv(fd, _config.recv_timeout);
} catch (const MsgException&) { } catch (const MsgException&) {
_remove_client(fd); _remove_client(fd);
return; return;
} }
if (_config.workers == 0) {
// 同步模式:在主线程执行回调
try { try {
_on_message(fd, addr, std::move(message)); _on_message(fd, addr, std::move(message));
} catch (const MsgConnectionException&) { } catch (const MsgConnectionException&) {
@@ -441,7 +453,8 @@ private:
return; return;
} }
// 线程池模式:标记 busy,提交任务 // 线程池模式:标记 busy,提交任务(由 worker 负责 recv + callback,
// 事件循环线程不阻塞接收,防 Slowloris DoS CWE-400)
{ {
std::lock_guard<std::mutex> blk(_busy_mtx); std::lock_guard<std::mutex> blk(_busy_mtx);
_busy.insert(fd); _busy.insert(fd);
@@ -455,7 +468,7 @@ private:
_remove_client(fd); _remove_client(fd);
return; return;
} }
_pool_queue.push(Task{fd, std::move(addr), std::move(message)}); _pool_queue.push(Task{fd, std::move(addr), {}, true});
} }
_pool_cv.notify_one(); _pool_cv.notify_one();
} }
@@ -476,10 +489,24 @@ private:
} }
void _remove_client(int fd) { void _remove_client(int fd) {
std::shared_ptr<std::mutex> send_lock;
{
std::lock_guard<std::mutex> clk(_clients_mtx); std::lock_guard<std::mutex> clk(_clients_mtx);
if (_clients.erase(fd) == 0) return; // 已被移除,防止 double-close if (_clients.erase(fd) == 0) return; // 已被移除,防止 double-close
_send_locks.erase(fd); auto it = _send_locks.find(fd);
if (it != _send_locks.end()) {
send_lock = it->second;
_send_locks.erase(it);
}
}
// 持有 send_lock 后再关闭 fd:防止 broadcast/reply 在 fd 关闭后向 OS
// 复用的新连接发送数据(CWE-362)。
if (send_lock) {
std::lock_guard<std::mutex> slk(*send_lock);
::close(fd); ::close(fd);
} else {
::close(fd);
}
} }
void _close_all_clients() { void _close_all_clients() {
+2 -3
View File
@@ -179,9 +179,8 @@ private:
static std::string _sanitize_error(const std::string& msg) { static std::string _sanitize_error(const std::string& msg) {
if (msg.empty()) return msg; if (msg.empty()) return msg;
try { try {
// 匹配以 / 开头的绝对路径段,替换为 <redacted> // 匹配以 / 开头的绝对路径(含特殊字符如空格、括号、@、~ 等),替换为 <redacted>
// 不返回函数堆栈、SQL、文件路径等内部细节给客户端 static const std::regex path_re(R"(/\S+(?:/\S*)*)");
static const std::regex path_re(R"(/[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)*)");
return std::regex_replace(msg, path_re, "<redacted>"); return std::regex_replace(msg, path_re, "<redacted>");
} catch (...) { } catch (...) {
return msg; // 正则异常时返回原文 return msg; // 正则异常时返回原文
+125
View File
@@ -0,0 +1,125 @@
/* auth.css — 注册/重置密码页专属美化(在 theme.css 之后加载,视觉语言与首页 index.css 对齐) */
::selection {
background: var(--accent-glow);
color: var(--fg);
}
/* ===== 页面背景:点阵底纹 + 暖色环境光(固定,不随滚动) ===== */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background-image: radial-gradient(var(--border) 1.2px, transparent 1.2px);
background-size: 26px 26px;
-webkit-mask-image: radial-gradient(ellipse 78% 68% at 50% 38%, #000 18%, transparent 72%);
mask-image: radial-gradient(ellipse 78% 68% at 50% 38%, #000 18%, transparent 72%);
pointer-events: none;
}
body::after {
content: "";
position: fixed;
left: 50%;
top: 32%;
width: 560px;
height: 560px;
transform: translate(-50%, -50%);
z-index: -1;
background: radial-gradient(circle, var(--accent-glow), transparent 62%);
opacity: 0.35;
pointer-events: none;
}
/* ===== 卡片:顶部品牌渐变条 + 加深阴影 ===== */
.card {
position: relative;
overflow: hidden;
box-shadow: 0 10px 30px rgba(74, 55, 40, 0.1);
}
.card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
}
@media (prefers-color-scheme: dark) {
.card { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); }
}
/* ===== 品牌 eyebrow + 居中标题(渐变短杠,对齐首页章节标题) ===== */
.auth-eyebrow {
text-align: center;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.26em;
text-indent: 0.26em; /* 视觉居中补偿字距 */
color: var(--accent);
margin-bottom: 0.4rem;
}
.card h2 {
text-align: center;
}
.card h2::after {
content: "";
display: block;
width: 40px;
height: 3px;
margin: 0.55rem auto 0;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
opacity: 0.9;
}
/* ===== 按钮:主按钮品牌渐变 + 上浮;次按钮描边反馈 ===== */
.card input[type="submit"] {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
color: #fff;
box-shadow: 0 2px 10px rgba(232, 133, 58, 0.32);
transition: transform 0.18s, box-shadow 0.22s, opacity 0.15s;
}
.card input[type="submit"]:not(:disabled):hover {
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(232, 133, 58, 0.4);
}
.card input[type="submit"]:not(:disabled):active { transform: scale(0.96); }
.card input[type="button"] {
border: 1px solid var(--accent-glow);
transition: transform 0.18s, border-color 0.18s, opacity 0.15s;
}
.card input[type="button"]:not(:disabled):hover {
opacity: 1;
transform: translateY(-1px);
border-color: var(--accent);
}
.card input[type="button"]:not(:disabled):active { transform: scale(0.96); }
.card input[type="reset"] { transition: transform 0.18s, color 0.18s, border-color 0.18s, opacity 0.15s; }
.card input[type="reset"]:hover {
opacity: 1;
color: var(--accent);
border-color: var(--accent);
}
/* ===== 图形验证码:hover 描边 + 阴影 ===== */
.code-box .captcha-img {
transition: border-color 0.18s, box-shadow 0.18s;
}
.code-box .captcha-img:hover {
border-color: var(--accent);
box-shadow: var(--shadow-md);
}
/* ===== 入场动画 ===== */
@keyframes auth-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: no-preference) {
.card { animation: auth-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
}
+118
View File
@@ -0,0 +1,118 @@
/* dnfs.css — 树状笔记页专属美化(在 theme.css / codeblock.css 之后加载,视觉语言与首页 index.css 对齐) */
::selection {
background: var(--accent-glow);
color: var(--fg);
}
/* ===== 页面底纹:极淡点阵 + 顶部暖光,增加层次不影响阅读 ===== */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background-image: radial-gradient(var(--border) 1px, transparent 1px);
background-size: 26px 26px;
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 55%);
mask-image: linear-gradient(180deg, #000 0%, transparent 55%);
pointer-events: none;
}
body::after {
content: "";
position: fixed;
left: 50%;
top: -160px;
width: 720px;
height: 480px;
transform: translateX(-50%);
z-index: -1;
background: radial-gradient(ellipse, var(--accent-glow), transparent 65%);
opacity: 0.3;
pointer-events: none;
}
/* ===== 节点卡片入场:逐张上浮(JS 动态插入时播放) ===== */
@keyframes card-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: no-preference) {
.recommend-list .card { animation: card-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
.recommend-list .card:nth-of-type(2) { animation-delay: 0.05s; }
.recommend-list .card:nth-of-type(3) { animation-delay: 0.1s; }
.recommend-list .card:nth-of-type(4) { animation-delay: 0.15s; }
.recommend-list .card:nth-of-type(5) { animation-delay: 0.2s; }
.recommend-list .card:nth-of-type(6) { animation-delay: 0.25s; }
.recommend-list .card:nth-of-type(7) { animation-delay: 0.3s; }
.recommend-list .card:nth-of-type(n+8) { animation-delay: 0.35s; }
/* 首屏骨架元素入场 */
.searchbox, .userpanel, .minimap, .node-form { animation: card-in 0.4s ease both; }
.node-form { animation-delay: 0.1s; }
}
/* ===== 搜索框:hover 升温(focus 高亮由 theme.css 提供) ===== */
.searchbox:hover {
border-color: var(--border);
box-shadow: var(--shadow-md);
}
/* ===== 节点编辑卡:品牌渐变顶条 + 聚焦光环 ===== */
.node-form {
position: relative;
overflow: hidden;
}
.node-form::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
opacity: 0.85;
}
.node-form:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md);
}
/* ===== 编辑卡按钮:提交渐变 / 重置柔色 / 删除警示 ===== */
#input-note-submit {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
color: #fff;
box-shadow: 0 2px 10px rgba(232, 133, 58, 0.3);
transition: transform 0.18s, box-shadow 0.22s, opacity 0.15s;
}
#input-note-submit:not(:disabled):hover {
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 5px 16px rgba(232, 133, 58, 0.38);
}
#input-note-submit:not(:disabled):active { transform: scale(0.96); }
#input-note-clear {
background: var(--accent-soft);
color: var(--accent-dark);
border: 1px solid var(--accent-glow);
transition: transform 0.18s, border-color 0.18s, opacity 0.15s;
}
#input-note-clear:not(:disabled):hover {
opacity: 1;
transform: translateY(-1px);
border-color: var(--accent);
}
#input-note-clear:not(:disabled):active { transform: scale(0.96); }
#delete-note {
background: var(--error-bg);
color: var(--error);
border: 1px solid transparent;
transition: transform 0.18s, border-color 0.18s, opacity 0.15s;
}
#delete-note:not(:disabled):hover {
opacity: 1;
transform: translateY(-1px);
border-color: var(--error);
}
#delete-note:not(:disabled):active { transform: scale(0.96); }
+325 -80
View File
@@ -4,6 +4,12 @@ body {
align-items: normal; align-items: normal;
} }
/* 选中文字着色,与品牌色一致 */
::selection {
background: var(--accent-glow);
color: var(--fg);
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
} }
@@ -22,14 +28,44 @@ body {
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
line-height: 1.4; line-height: 1.4;
transition: opacity 0.15s, transform 0.1s, background 0.15s, border-color 0.15s; transition: opacity 0.15s, transform 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.22s;
} }
.btn:active { transform: scale(0.96); } .btn:active { transform: scale(0.96); }
.btn-primary { background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(232, 133, 58, 0.28); }
.btn-primary:hover { background: var(--accent-dark); opacity: 1; box-shadow: 0 4px 14px rgba(232, 133, 58, 0.36); } /* 主按钮:品牌渐变 + hover 上浮与扫光 */
.btn-ghost { background: var(--accent-soft); color: var(--accent-dark); } .btn-primary {
.btn-ghost:hover { border-color: var(--accent); opacity: 1; } position: relative;
.btn-lg { padding: 0.7rem 1.9rem; font-size: 1rem; border-radius: var(--radius-md); } overflow: hidden;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
color: #fff;
box-shadow: 0 2px 10px rgba(232, 133, 58, 0.32);
}
.btn-primary::after {
content: "";
position: absolute;
top: 0;
left: -80%;
width: 50%;
height: 100%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.35), transparent);
transform: skewX(-20deg);
transition: left 0.5s ease;
pointer-events: none;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(232, 133, 58, 0.4);
}
.btn-primary:hover::after { left: 130%; }
.btn-primary:active { transform: scale(0.96); }
.btn-ghost {
background: var(--accent-soft);
color: var(--accent-dark);
border-color: var(--accent-glow);
}
.btn-ghost:hover { border-color: var(--accent); transform: translateY(-1px); }
.btn-lg { padding: 0.75rem 2rem; font-size: 1rem; border-radius: 999px; letter-spacing: 0.02em; }
/* ===== 头部:悬浮毛玻璃导航(镜像 minimap/userpanel 风格) ===== */ /* ===== 头部:悬浮毛玻璃导航(镜像 minimap/userpanel 风格) ===== */
.header { .header {
@@ -44,12 +80,12 @@ body {
width: 100%; width: 100%;
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: 0.5rem 1rem; padding: 0.55rem 1.1rem;
border-radius: var(--radius-md); border-radius: var(--radius-lg);
background: rgba(253, 246, 238, 0.72); background: rgba(253, 246, 238, 0.72);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
backdrop-filter: blur(12px); backdrop-filter: blur(16px) saturate(1.4);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(16px) saturate(1.4);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transition: background 0.2s, border-color 0.2s; transition: background 0.2s, border-color 0.2s;
} }
@@ -95,95 +131,180 @@ body {
white-space: nowrap; white-space: nowrap;
} }
#main-page { width: 100%; }
/* ===== Hero 首屏 ===== */ /* ===== Hero 首屏 ===== */
.hero { .hero {
position: relative; position: relative;
text-align: center; text-align: center;
padding: 6rem 1rem 5rem; padding: 4.5rem 1rem 5.5rem;
overflow: hidden;
} }
/* 点阵底纹:中心清晰、四周渐隐 */
.hero::before { .hero::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
background: radial-gradient(ellipse 60% 50% at 50% 30%, var(--accent-glow), transparent 70%); background-image: radial-gradient(var(--border) 1.2px, transparent 1.2px);
background-size: 26px 26px;
-webkit-mask-image: radial-gradient(ellipse 70% 62% at 50% 42%, #000 25%, transparent 72%);
mask-image: radial-gradient(ellipse 70% 62% at 50% 42%, #000 25%, transparent 72%);
pointer-events: none; pointer-events: none;
} }
/* 暖色环境光晕 */
.hero::after { .hero::after {
content: ""; content: "";
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 52%; top: 46%;
width: 640px; width: 680px;
height: 640px; height: 680px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: radial-gradient(circle, var(--accent-glow), transparent 62%); background:
opacity: 0.5; radial-gradient(circle, var(--accent-glow), transparent 60%),
radial-gradient(ellipse 55% 40% at 50% 62%, var(--accent-soft), transparent 70%);
opacity: 0.55;
pointer-events: none; pointer-events: none;
} }
.hero > * { position: relative; z-index: 1; } .hero-inner { position: relative; z-index: 1; }
.hero-title {
font-size: clamp(2rem, 6vw, 3rem); /* 品牌装饰:节点树(低透明度衬底,不干扰阅读) */
font-weight: 800; .hero-tree {
line-height: 1.28; position: absolute;
letter-spacing: 0.02em; left: 50%;
margin-bottom: 1rem; bottom: -8px;
transform: translateX(-50%);
width: min(600px, 96%);
height: auto;
color: var(--accent);
opacity: 0.16;
pointer-events: none;
} }
@media (prefers-color-scheme: dark) {
.hero-tree { opacity: 0.2; }
}
/* eyebrow 徽章:呼吸圆点 + 胶囊 */
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.95rem;
margin-bottom: 1.4rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--accent-dark);
background: var(--accent-soft);
border: 1px solid var(--accent-glow);
border-radius: 999px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
.hero-title {
font-size: clamp(2.1rem, 6.5vw, 3.3rem);
font-weight: 800;
line-height: 1.26;
letter-spacing: 0.04em;
text-wrap: balance;
margin-bottom: 1.1rem;
}
/* 渐变标题字:缓慢流光 */
.accent-text { .accent-text {
background: linear-gradient(120deg, var(--accent) 20%, var(--accent-dark) 80%); background: linear-gradient(120deg, var(--accent) 15%, var(--accent-dark) 45%, var(--accent) 85%);
background-size: 220% auto;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
} }
.hero-sub { .hero-sub {
font-size: 1.08rem; font-size: 1.08rem;
letter-spacing: 0.03em;
color: var(--fg-secondary); color: var(--fg-secondary);
max-width: 30rem; max-width: 32rem;
margin: 0 auto; margin: 0 auto;
line-height: 1.7; line-height: 1.75;
} }
.hero-actions { .hero-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.85rem; gap: 0.85rem;
margin-top: 2.1rem; margin-top: 2.2rem;
} }
/* ===== 特性卡片 ===== */ /* 下滑提示:浮动小箭头(窄屏隐藏) */
.scroll-hint {
position: absolute;
left: 50%;
bottom: 1.3rem;
transform: translateX(-50%);
z-index: 1;
display: flex;
color: var(--muted);
transition: color 0.2s;
}
.scroll-hint:hover { color: var(--accent); }
.scroll-hint svg { width: 22px; height: 22px; }
/* ===== 章节标题:eyebrow + 标题 + 渐变短杠 ===== */
.section-eyebrow {
text-align: center;
margin-top: 4.5rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.28em;
text-indent: 0.28em; /* 视觉居中补偿字距 */
color: var(--accent);
}
.section-title { .section-title {
text-align: center; text-align: center;
margin-top: 4rem; margin-top: 0.7rem;
font-size: 1.5rem; font-size: 1.55rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.01em; letter-spacing: 0.02em;
} }
.section-title::after { .section-title::after {
content: ""; content: "";
display: block; display: block;
width: 44px; width: 46px;
height: 3px; height: 3px;
margin: 0.6rem auto 0; margin: 0.65rem auto 0;
border-radius: 2px; border-radius: 2px;
background: var(--accent); background: linear-gradient(90deg, var(--accent), var(--accent-dark));
opacity: 0.85; opacity: 0.9;
} }
/* ===== 特性卡片:序号水印 + 光标光斑 + 图标点亮 ===== */
.features { .features {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.1rem; gap: 1.1rem;
width: 100%; width: 100%;
max-width: 960px; max-width: 960px;
margin: 1.75rem auto 0; margin: 2rem auto 0;
counter-reset: feat;
} }
.feature-card { .feature-card {
counter-increment: feat;
position: relative; position: relative;
background: var(--card); background: var(--card);
/* 光标光斑:JS 写入 --mx/--my,缺省停在顶部中央 */
background-image: radial-gradient(260px circle at var(--mx, 50%) var(--my, 0%), var(--accent-soft), transparent 75%);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
padding: 1.7rem 1.4rem; padding: 1.8rem 1.5rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
overflow: hidden; overflow: hidden;
transition: transform 0.18s, box-shadow 0.2s, border-color 0.2s; transition: transform 0.22s ease, box-shadow 0.25s ease, border-color 0.25s ease;
} }
.feature-card::before { .feature-card::before {
content: ""; content: "";
@@ -194,98 +315,217 @@ body {
height: 3px; height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-dark)); background: linear-gradient(90deg, var(--accent), var(--accent-dark));
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.25s;
}
/* 序号水印(编辑感装饰) */
.feature-card::after {
content: "0" counter(feat);
position: absolute;
top: 1rem;
right: 1.2rem;
font-size: 1.55rem;
font-weight: 800;
line-height: 1;
letter-spacing: 0.02em;
color: var(--border);
transition: color 0.25s;
pointer-events: none;
} }
.feature-card:hover { .feature-card:hover {
transform: translateY(-4px); transform: translateY(-5px);
border-color: var(--accent); border-color: var(--accent);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.feature-card:hover::before { opacity: 0.9; } .feature-card:hover::before { opacity: 0.9; }
.feature-card:hover::after { color: var(--accent-glow); }
.feature-icon { .feature-icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 46px;
height: 44px; height: 46px;
margin-bottom: 0.9rem; margin-bottom: 0.95rem;
border-radius: var(--radius-sm); border-radius: 13px;
background: linear-gradient(135deg, var(--accent-soft), rgba(232, 133, 58, 0.16)); background: linear-gradient(135deg, var(--accent-soft), rgba(232, 133, 58, 0.16));
color: var(--accent-dark); color: var(--accent-dark);
transition: transform 0.25s, background 0.25s, color 0.25s, box-shadow 0.25s;
} }
.feature-icon svg { width: 22px; height: 22px; } .feature-icon svg { width: 22px; height: 22px; }
.feature-card h3 { margin-bottom: 0.4rem; font-size: 1.05rem; } .feature-card:hover .feature-icon {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: #fff;
transform: translateY(-2px) scale(1.06);
box-shadow: 0 6px 14px rgba(232, 133, 58, 0.35);
}
.feature-card h3 { margin-bottom: 0.4rem; font-size: 1.05rem; letter-spacing: 0.01em; }
.feature-card p { .feature-card p {
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.7; line-height: 1.75;
color: var(--fg-secondary); color: var(--fg-secondary);
} }
/* ===== 关于本站 ===== */ /* ===== 关于本站 ===== */
.about { .about {
text-align: center; text-align: center;
padding: 1rem 1rem 3rem; padding: 1rem 1rem 3.5rem;
max-width: 760px; max-width: 780px;
margin: 0 auto; margin: 0 auto;
} }
.about .section-eyebrow, .about .section-title { margin-top: 0; }
.about .site-intro { .about .site-intro {
position: relative;
background: var(--card); background: var(--card);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
padding: 1.75rem 1.9rem; padding: 2rem 2.2rem;
margin-top: 2rem;
overflow: hidden;
}
/* 左侧品牌渐变条 + 右上角装饰引号 */
.about .site-intro::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--accent), var(--accent-dark));
opacity: 0.85;
}
.about .site-intro::after {
content: "”";
position: absolute;
top: -0.6rem;
right: 1.1rem;
font-family: Georgia, 'Times New Roman', serif;
font-size: 5rem;
line-height: 1;
color: var(--accent-soft);
pointer-events: none;
} }
.site-intro { .site-intro {
font-size: 0.96rem; font-size: 0.96rem;
line-height: 1.95; line-height: 1.95;
color: var(--fg-secondary); color: var(--fg-secondary);
} }
/* 快捷链接:胶囊按钮 */
.quick-links { .quick-links {
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1.5rem; gap: 0.9rem;
margin-top: 1.75rem; margin-top: 1.9rem;
} }
.quick-links a { .quick-links a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1.15rem;
font-size: 0.86rem; font-size: 0.86rem;
color: var(--muted); font-weight: 600;
color: var(--fg-secondary);
background: var(--card);
border: 1px solid var(--border);
border-radius: 999px;
text-decoration: none; text-decoration: none;
padding-bottom: 1px; box-shadow: var(--shadow-sm);
border-bottom: 1px solid transparent; transition: color 0.18s, border-color 0.18s, transform 0.18s, box-shadow 0.2s;
transition: color 0.15s, border-color 0.15s;
} }
.quick-links a:hover { color: var(--accent); border-bottom-color: var(--accent); } .quick-links a::after {
content: "→";
font-size: 0.85em;
transition: transform 0.18s;
}
.quick-links a:hover {
color: var(--accent);
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.quick-links a:hover::after { transform: translateX(3px); }
/* ===== 入场动画 ===== */ /* ===== 入场动画(首屏元素,页面加载即播) ===== */
@keyframes fade-up { @keyframes fade-up {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
@media (prefers-reduced-motion: no-preference) { /* 品牌树:枝干描绘 + 节点弹出 + 整体轻摆 */
.hero > *, .feature-card, .about { animation: fade-up 0.5s ease both; } @keyframes tree-draw { to { stroke-dashoffset: 0; } }
.hero-sub { animation-delay: 0.08s; } @keyframes node-in {
.hero-actions { animation-delay: 0.16s; } from { opacity: 0; transform: scale(0); }
.feature-card:nth-child(2) { animation-delay: 0.06s; } to { opacity: 1; transform: scale(1); }
.feature-card:nth-child(3) { animation-delay: 0.12s; }
.feature-card:nth-child(4) { animation-delay: 0.18s; }
} }
@keyframes tree-sway {
0%, 100% { transform: translateX(-50%) rotate(0deg); }
50% { transform: translateX(-50%) rotate(0.7deg); }
}
@keyframes text-shine { to { background-position: 220% center; } }
@keyframes badge-pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
50% { box-shadow: 0 0 0 5px transparent; }
}
@keyframes hint-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(5px); }
}
@media (prefers-reduced-motion: no-preference) {
.hero-inner > * { animation: fade-up 0.55s ease both; }
.hero-title { animation-delay: 0.06s; }
.hero-sub { animation-delay: 0.14s; }
.hero-actions { animation-delay: 0.22s; }
.accent-text { animation: text-shine 7s linear infinite; }
.badge-dot { animation: badge-pulse 2.4s ease-in-out infinite; }
.scroll-hint svg { animation: hint-bounce 2s ease-in-out infinite; }
.hero-tree { animation: tree-sway 10s ease-in-out 2.8s infinite; }
.hero-tree path {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: tree-draw 0.9s ease-out forwards;
}
.hero-tree path:nth-of-type(1) { animation-delay: 0.15s; }
.hero-tree path:nth-of-type(2),
.hero-tree path:nth-of-type(3) { animation-delay: 0.75s; }
.hero-tree path:nth-of-type(n+4) { animation-delay: 1.35s; }
.hero-tree circle {
opacity: 0;
transform-box: fill-box;
transform-origin: center;
animation: node-in 0.45s ease-out forwards;
}
.hero-tree circle:nth-of-type(1) { animation-delay: 0.2s; }
.hero-tree circle:nth-of-type(2) { animation-delay: 0.85s; }
.hero-tree circle:nth-of-type(3),
.hero-tree circle:nth-of-type(4) { animation-delay: 1.45s; }
.hero-tree circle:nth-of-type(n+5) { animation-delay: 2s; }
}
/* ===== 滚动渐显(JS 加 .reveal 隐藏、入场加 .reveal-in;无 JS 时天然可见) ===== */
.reveal {
opacity: 0;
transform: translateY(18px);
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.reveal-in { opacity: 1; transform: none; }
/* ===== 响应式 ===== */ /* ===== 响应式 ===== */
/* 触屏设备:加大可点目标 */ /* 触屏设备:加大可点目标 */
@media (hover: none) and (pointer: coarse) { @media (hover: none) and (pointer: coarse) {
.btn { min-height: 44px; } .btn { min-height: 44px; }
.quick-links a { padding: 0.5rem 0.25rem; } .quick-links a { padding: 0.6rem 1.1rem; }
} }
/* 平板及以下:收敛首屏光晕与留白 */ /* 平板及以下:收敛首屏光晕与留白 */
@media (max-width: 768px) { @media (max-width: 768px) {
body { padding: 1.25rem 0.75rem; } body { padding: 1.25rem 0.75rem; }
.hero { padding: 4rem 1.25rem 3.5rem; } .hero { padding: 3.5rem 1.25rem 4rem; }
.hero::after { width: 420px; height: 420px; opacity: 0.4; } .hero::after { width: 440px; height: 440px; opacity: 0.45; }
.hero-title { letter-spacing: 0.01em; } .hero-tree { width: 130%; opacity: 0.12; }
.hero-title { letter-spacing: 0.02em; }
.features { gap: 0.9rem; } .features { gap: 0.9rem; }
.feature-card { padding: 1.4rem 1.2rem; } .feature-card { padding: 1.5rem 1.25rem; }
} }
/* 手机:全宽布局、紧凑排版 */ /* 手机:全宽布局、紧凑排版 */
@@ -302,9 +542,11 @@ body {
.right-group .btn { padding: 0.5rem 0.95rem; font-size: 0.82rem; } .right-group .btn { padding: 0.5rem 0.95rem; font-size: 0.82rem; }
#name { max-width: 72px; } #name { max-width: 72px; }
.hero { padding: 3rem 0.75rem 2.5rem; } .hero { padding: 2.75rem 0.75rem 2.5rem; }
.hero::after { width: 300px; height: 300px; opacity: 0.32; } .hero::after { width: 310px; height: 310px; opacity: 0.35; }
.hero-title { margin-bottom: 0.75rem; } .hero-tree { width: 160%; opacity: 0.1; }
.hero-badge { font-size: 0.74rem; padding: 0.3rem 0.8rem; margin-bottom: 1.1rem; }
.hero-title { margin-bottom: 0.8rem; }
.hero-sub { font-size: 0.95rem; } .hero-sub { font-size: 0.95rem; }
.hero-actions { .hero-actions {
flex-direction: column; flex-direction: column;
@@ -312,19 +554,22 @@ body {
gap: 0.65rem; gap: 0.65rem;
} }
.hero-actions .btn-lg { width: 100%; padding: 0.85rem 1.5rem; } .hero-actions .btn-lg { width: 100%; padding: 0.85rem 1.5rem; }
.scroll-hint { display: none; }
.section-title { margin-top: 2.5rem; font-size: 1.3rem; } .section-eyebrow { margin-top: 2.75rem; }
.section-title { font-size: 1.3rem; }
.features { margin-top: 1.25rem; grid-template-columns: 1fr; } .features { margin-top: 1.25rem; grid-template-columns: 1fr; }
.feature-card:hover { transform: none; } /* 触屏取消悬浮位移 */ .feature-card:hover { transform: none; } /* 触屏取消悬浮位移 */
.about { padding: 0.5rem 0.5rem 2.5rem; } .about { padding: 0.5rem 0.5rem 2.5rem; }
.about .site-intro { padding: 1.4rem 1.3rem; text-align: left; } .about .site-intro { padding: 1.5rem 1.4rem; text-align: left; }
.quick-links { gap: 1rem 1.5rem; margin-top: 1.5rem; } .about .site-intro::after { font-size: 3.5rem; right: 0.8rem; }
.quick-links { gap: 0.6rem 0.8rem; margin-top: 1.5rem; }
} }
/* 超小屏 */ /* 超小屏 */
@media (max-width: 360px) { @media (max-width: 360px) {
.hero { padding: 2.5rem 0.5rem 2rem; } .hero { padding: 2.25rem 0.5rem 2rem; }
.header h1 { font-size: 0.95rem; } .header h1 { font-size: 0.95rem; }
.right-group .btn { padding: 0.45rem 0.8rem; font-size: 0.8rem; } .right-group .btn { padding: 0.45rem 0.8rem; font-size: 0.8rem; }
} }
+1
View File
@@ -7,6 +7,7 @@
<title>树状笔记</title> <title>树状笔记</title>
<link rel="stylesheet" href="css/theme.css"> <link rel="stylesheet" href="css/theme.css">
<link rel="stylesheet" href="css/codeblock.css"> <link rel="stylesheet" href="css/codeblock.css">
<link rel="stylesheet" href="css/dnfs.css">
</head> </head>
<body> <body>
<div class="box"> <div class="box">
+2
View File
@@ -5,9 +5,11 @@
<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">
<link rel="stylesheet" href="css/auth.css">
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<p class="auth-eyebrow">DNFS 树状笔记</p>
<h2>重置密码页面</h2> <h2>重置密码页面</h2>
<form id="form"> <form id="form">
<div> <div>
+32
View File
@@ -29,15 +29,46 @@
<main id="main-page"> <main id="main-page">
<section class="hero"> <section class="hero">
<!-- 品牌装饰:节点树(生长动画见 index.css,纯装饰不影响交互) -->
<svg class="hero-tree" viewBox="0 0 640 360" fill="none" aria-hidden="true">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path pathLength="100" d="M320 344 L320 262"/>
<path pathLength="100" d="M320 262 C 282 236, 238 214, 202 184"/>
<path pathLength="100" d="M320 262 C 358 236, 402 214, 438 184"/>
<path pathLength="100" d="M202 184 C 178 156, 156 134, 142 104"/>
<path pathLength="100" d="M202 184 C 222 156, 244 134, 258 104"/>
<path pathLength="100" d="M438 184 C 414 156, 396 134, 382 104"/>
<path pathLength="100" d="M438 184 C 462 156, 484 134, 498 104"/>
</g>
<g fill="currentColor">
<circle cx="320" cy="344" r="6"/>
<circle cx="320" cy="262" r="4.5"/>
<circle cx="202" cy="184" r="4.5"/>
<circle cx="438" cy="184" r="4.5"/>
<circle cx="142" cy="104" r="4"/>
<circle cx="258" cy="104" r="4"/>
<circle cx="382" cy="104" r="4"/>
<circle cx="498" cy="104" r="4"/>
</g>
</svg>
<div class="hero-inner">
<div class="hero-badge"><span class="badge-dot"></span>树状结构 · Markdown · 多媒体分享</div>
<h2 class="hero-title">让知识像树一样<br><span class="accent-text">自然生长</span></h2> <h2 class="hero-title">让知识像树一样<br><span class="accent-text">自然生长</span></h2>
<p class="hero-sub">每个节点都可以生长出子节点,像树枝一样层层延展</p> <p class="hero-sub">每个节点都可以生长出子节点,像树枝一样层层延展</p>
<div class="hero-actions"> <div class="hero-actions">
<button id="ctaBtn" class="btn btn-primary btn-lg">开始记录</button> <button id="ctaBtn" class="btn btn-primary btn-lg">开始记录</button>
<a class="btn btn-ghost btn-lg" href="#features">了解更多</a> <a class="btn btn-ghost btn-lg" href="#features">了解更多</a>
</div> </div>
</div>
<a class="scroll-hint" href="#features" aria-label="下滑了解特性">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</a>
</section> </section>
<section id="features"> <section id="features">
<p class="section-eyebrow">核心特性</p>
<h2 class="section-title">为什么选择树状笔记</h2> <h2 class="section-title">为什么选择树状笔记</h2>
<div class="features"> <div class="features">
<div class="feature-card"> <div class="feature-card">
@@ -72,6 +103,7 @@
</section> </section>
<section class="about"> <section class="about">
<p class="section-eyebrow">我们的理念</p>
<h2 class="section-title">关于本站</h2> <h2 class="section-title">关于本站</h2>
<p class="site-intro"> <p class="site-intro">
DNFS 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点, DNFS 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点,
+41
View File
@@ -58,3 +58,44 @@
checkLogin(); checkLogin();
})(); })();
/* ===== 视觉增强:滚动渐显 + 卡片光标光斑(装饰性,与登录逻辑解耦) ===== */
(function () {
'use strict';
const reduceMotion = !window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
/* 滚动进入视口渐显:由 JS 添加 .reveal 隐藏初始态,无 JS / 减弱动效时内容天然可见 */
if (!reduceMotion && 'IntersectionObserver' in window) {
const targets = document.querySelectorAll('.section-eyebrow, .section-title, .feature-card, .about .site-intro, .quick-links');
targets.forEach(function (el, i) {
el.classList.add('reveal');
el.style.transitionDelay = (i % 4) * 70 + 'ms';
});
const io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
const el = entry.target;
el.classList.add('reveal-in');
io.unobserve(el);
/* 动画结束后移除类与延迟,把 transition 控制权交还 hover 等交互样式 */
setTimeout(function () {
el.classList.remove('reveal', 'reveal-in');
el.style.transitionDelay = '';
}, 900);
});
}, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
targets.forEach(function (el) { io.observe(el); });
}
/* 特性卡片光标光斑:鼠标位置写入 CSS 变量,由 index.css 径向渐变消费(仅精确指针设备) */
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
document.querySelectorAll('.feature-card').forEach(function (card) {
card.addEventListener('mousemove', function (e) {
const rect = card.getBoundingClientRect();
card.style.setProperty('--mx', (e.clientX - rect.left) + 'px');
card.style.setProperty('--my', (e.clientY - rect.top) + 'px');
});
});
}
})();
+2
View File
@@ -5,9 +5,11 @@
<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">
<link rel="stylesheet" href="css/auth.css">
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<p class="auth-eyebrow">DNFS 树状笔记</p>
<h2>注册页面</h2> <h2>注册页面</h2>
<form id="form"> <form id="form">
<div> <div>