Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad749f9aa | ||
|
|
577386bff2 | ||
|
|
6150c60441 | ||
|
|
1970f3f824 | ||
|
|
49ed3f738f | ||
|
|
ea2717c400 | ||
|
|
a4611ec737 | ||
|
|
e92ef05ed0 |
No files matched your search
+15
@@ -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/
|
||||||
@@ -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`。
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+68
@@ -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,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
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec ./build/db2 "$@"
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
make
|
||||||
Executable
BIN
Binary file not shown.
Executable
+48
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),无可观测性 |
|
||||||
+43
-16
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string message;
|
|
||||||
try {
|
|
||||||
// deadline 模式:header+payload 共享总超时,防 Slowloris
|
|
||||||
message = pmsg_recv(fd, _config.recv_timeout);
|
|
||||||
} catch (const MsgException&) {
|
|
||||||
_remove_client(fd);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_config.workers == 0) {
|
if (_config.workers == 0) {
|
||||||
// 同步模式:在主线程执行回调
|
// 同步模式:在主线程接收并执行回调
|
||||||
|
std::string message;
|
||||||
|
try {
|
||||||
|
message = pmsg_recv(fd, _config.recv_timeout);
|
||||||
|
} catch (const MsgException&) {
|
||||||
|
_remove_client(fd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
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::lock_guard<std::mutex> clk(_clients_mtx);
|
std::shared_ptr<std::mutex> send_lock;
|
||||||
if (_clients.erase(fd) == 0) return; // 已被移除,防止 double-close
|
{
|
||||||
_send_locks.erase(fd);
|
std::lock_guard<std::mutex> clk(_clients_mtx);
|
||||||
::close(fd);
|
if (_clients.erase(fd) == 0) return; // 已被移除,防止 double-close
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
::close(fd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _close_all_clients() {
|
void _close_all_clients() {
|
||||||
|
|||||||
@@ -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; // 正则异常时返回原文
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -29,15 +29,46 @@
|
|||||||
|
|
||||||
<main id="main-page">
|
<main id="main-page">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h2 class="hero-title">让知识像树一样<br><span class="accent-text">自然生长</span></h2>
|
<!-- 品牌装饰:节点树(生长动画见 index.css,纯装饰不影响交互) -->
|
||||||
<p class="hero-sub">每个节点都可以生长出子节点,像树枝一样层层延展</p>
|
<svg class="hero-tree" viewBox="0 0 640 360" fill="none" aria-hidden="true">
|
||||||
<div class="hero-actions">
|
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
<button id="ctaBtn" class="btn btn-primary btn-lg">开始记录</button>
|
<path pathLength="100" d="M320 344 L320 262"/>
|
||||||
<a class="btn btn-ghost btn-lg" href="#features">了解更多</a>
|
<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>
|
||||||
|
<p class="hero-sub">每个节点都可以生长出子节点,像树枝一样层层延展</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button id="ctaBtn" class="btn btn-primary btn-lg">开始记录</button>
|
||||||
|
<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 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user