cndb 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cndb/__init__.py +34 -0
- cndb/api/__init__.py +10 -0
- cndb/api/deps.py +111 -0
- cndb/app.py +163 -0
- cndb/backup.py +349 -0
- cndb/core/__init__.py +3 -0
- cndb/core/config.py +98 -0
- cndb/core/database.py +40 -0
- cndb/core/plugin_registry.py +145 -0
- cndb/core/security.py +121 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/204/342/225/235/320/221/321/204/342/225/225/320/252/321/211/320/244/320/220/321/205/320/244/320/276/321/207/320/276/320/261/321/207/320/240/320/226/views.json +618 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/204/342/225/235/320/221/321/204/342/225/225/320/252/321/211/320/244/320/220/321/205/320/244/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/204/342/225/221/320/267/321/205/320/243/320/221/321/205/342/225/235/320/220/321/205/320/237/320/241.csv +501 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/204/342/225/235/320/221/321/204/342/225/225/320/252/321/211/320/244/320/220/321/205/320/244/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/205/320/227/342/225/221/321/205/342/225/226/320/276/321/207/342/225/227/320/257/321/210/320/276/320/261.csv +163 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/204/342/225/235/320/221/321/204/342/225/225/320/252/321/211/320/244/320/220/321/205/320/244/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/205/320/276/320/262/321/206/320/230/342/225/226/321/206/342/225/241/320/221/321/205/320/264/342/226/222.csv +501 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/204/342/225/235/320/221/321/204/342/225/225/320/252/321/211/320/244/320/220/321/205/320/244/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/207/320/244/342/225/241/321/205/320/245/320/226/321/211/320/244/320/220/321/205/320/244/320/276.csv +501 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/205/320/254/342/226/221/321/205/320/234/342/225/221/321/206/320/245/342/226/221/321/206/320/235/320/276/views.json +18 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/205/320/254/342/226/221/321/205/320/234/342/225/221/321/206/320/245/342/226/221/321/206/320/235/320/276//321/206/320/230/342/224/220/321/204/342/225/227/342/225/226/321/211/320/262/320/224/321/206/342/225/241/320/233.csv +501 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/206/320/257/320/240/321/205/320/254/342/226/221/321/205/320/234/342/225/221/321/206/320/245/342/226/221/321/206/320/235/320/276//321/206/342/226/221/320/244/321/206/342/225/225/320/271/321/205/320/264/320/271/321/206/342/226/221/320/244.csv +501 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/207/320/267/320/241/321/207/320/260/320/244/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226/views.json +461 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/207/320/267/320/241/321/207/320/260/320/244/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/207/320/267/320/241/321/207/320/260/320/244/321/207/342/225/227/320/237/321/210/342/224/244/342/225/243.csv +33 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/207/320/267/320/241/321/207/320/260/320/244/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/207/320/267/320/241/321/207/320/260/320/244/321/211/320/261/342/225/243/321/207/320/253/320/276.csv +16 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/207/320/267/320/241/321/207/320/260/320/244/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/210/320/277/342/225/233/321/211/320/262/320/250/321/210/342/224/244/320/257/321/210/342/224/244/320/263/321/204/342/225/221/342/225/221.csv +19 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/207/320/267/320/241/321/207/320/260/320/244/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226//321/211/320/261/342/225/243/321/207/320/253/320/276/321/210/342/224/220/320/253/321/205/342/226/222/320/245.csv +32 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/210/320/222/320/261/321/207/320/265/320/270/321/204/342/224/220/320/261/321/206/320/221/320/277/321/207/320/276/320/261/321/207/320/240/320/226/api_config.json +71 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226/WBS/321/204/342/225/227/342/225/227/321/205/320/232/320/261/321/205/320/230/320/226/321/210/320/267/320/263.csv +24 -0
- cndb/datasets//321/205/342/225/226/320/265/321/204/342/225/234/320/254/321/205/320/234/342/225/221-/321/211/320/261/342/225/243/321/207/320/253/320/276/321/207/320/276/320/261/321/207/320/240/320/226/views.json +148 -0
- cndb/models/__init__.py +11 -0
- cndb/models/base.py +69 -0
- cndb/plugins/__init__.py +14 -0
- cndb/plugins/accounts/__init__.py +1 -0
- cndb/plugins/accounts/models.py +118 -0
- cndb/plugins/accounts/plugin.py +37 -0
- cndb/plugins/accounts/routers/__init__.py +1 -0
- cndb/plugins/accounts/routers/auth.py +226 -0
- cndb/plugins/accounts/routers/preferences.py +161 -0
- cndb/plugins/accounts/schemas/__init__.py +12 -0
- cndb/plugins/accounts/schemas/auth.py +61 -0
- cndb/plugins/base.py +111 -0
- cndb/plugins/health/__init__.py +3 -0
- cndb/plugins/health/plugin.py +43 -0
- cndb/plugins/reports/__init__.py +5 -0
- cndb/plugins/reports/models.py +54 -0
- cndb/plugins/reports/plugin.py +36 -0
- cndb/plugins/reports/routers/__init__.py +7 -0
- cndb/plugins/reports/routers/reports.py +265 -0
- cndb/plugins/reports/schemas/__init__.py +88 -0
- cndb/plugins/tables/__init__.py +5 -0
- cndb/plugins/tables/access.py +215 -0
- cndb/plugins/tables/api_config_loader.py +236 -0
- cndb/plugins/tables/api_fetch.py +580 -0
- cndb/plugins/tables/audit.py +82 -0
- cndb/plugins/tables/ddl.py +363 -0
- cndb/plugins/tables/diff_reporter.py +83 -0
- cndb/plugins/tables/failed_row_exporter.py +115 -0
- cndb/plugins/tables/field_ops.py +298 -0
- cndb/plugins/tables/field_types/__init__.py +833 -0
- cndb/plugins/tables/field_types/smart_color.py +439 -0
- cndb/plugins/tables/import_tasks.py +327 -0
- cndb/plugins/tables/importer.py +248 -0
- cndb/plugins/tables/links.py +315 -0
- cndb/plugins/tables/models.py +351 -0
- cndb/plugins/tables/plugin.py +47 -0
- cndb/plugins/tables/query.py +378 -0
- cndb/plugins/tables/records.py +524 -0
- cndb/plugins/tables/routers/__init__.py +37 -0
- cndb/plugins/tables/routers/audit.py +45 -0
- cndb/plugins/tables/routers/bulk.py +439 -0
- cndb/plugins/tables/routers/comments.py +124 -0
- cndb/plugins/tables/routers/fields.py +325 -0
- cndb/plugins/tables/routers/files.py +211 -0
- cndb/plugins/tables/routers/import_api.py +307 -0
- cndb/plugins/tables/routers/import_csv.py +114 -0
- cndb/plugins/tables/routers/members.py +316 -0
- cndb/plugins/tables/routers/permissions.py +85 -0
- cndb/plugins/tables/routers/public.py +155 -0
- cndb/plugins/tables/routers/records.py +212 -0
- cndb/plugins/tables/routers/tables.py +566 -0
- cndb/plugins/tables/routers/trash.py +315 -0
- cndb/plugins/tables/routers/views.py +419 -0
- cndb/plugins/tables/row_validator.py +202 -0
- cndb/plugins/tables/schemas/__init__.py +358 -0
- cndb/plugins/tables/schemas/comments.py +57 -0
- cndb/plugins/tables/transfer.py +644 -0
- cndb/plugins/wechat_auth/__init__.py +5 -0
- cndb/plugins/wechat_auth/client.py +57 -0
- cndb/plugins/wechat_auth/models.py +54 -0
- cndb/plugins/wechat_auth/plugin.py +29 -0
- cndb/plugins/wechat_auth/routers/__init__.py +1 -0
- cndb/plugins/wechat_auth/routers/login.py +140 -0
- cndb/plugins/wechat_auth/schemas/__init__.py +8 -0
- cndb/plugins/wechat_auth/schemas/auth.py +24 -0
- cndb/plugins/workspaces/__init__.py +12 -0
- cndb/plugins/workspaces/models.py +193 -0
- cndb/plugins/workspaces/permissions.py +35 -0
- cndb/plugins/workspaces/plugin.py +35 -0
- cndb/plugins/workspaces/routers/__init__.py +3 -0
- cndb/plugins/workspaces/routers/roles.py +227 -0
- cndb/plugins/workspaces/routers/workspaces.py +748 -0
- cndb/plugins/workspaces/schemas/__init__.py +234 -0
- cndb/py.typed +0 -0
- cndb/restore.py +383 -0
- cndb/runner.py +259 -0
- cndb/schemas/__init__.py +7 -0
- cndb/seed.py +549 -0
- cndb/services/__init__.py +7 -0
- cndb/static/.gitkeep +0 -0
- cndb/static/assets/ApiImportDialog-Ck3QpBew.js +1 -0
- cndb/static/assets/ArrowLeftOutlined-CfcYJquS.js +1 -0
- cndb/static/assets/FieldManager-D13Osy69.js +13 -0
- cndb/static/assets/GridCell-DzS2y21x.js +5 -0
- cndb/static/assets/GridPage-l1RzV0gK.js +16 -0
- cndb/static/assets/HomeOutlined-BrquOt1m.js +1 -0
- cndb/static/assets/ImportExportDialog-BHPGsZAb.js +1 -0
- cndb/static/assets/PublicFormPage-q9g0sBw9.js +1 -0
- cndb/static/assets/PublicSharePage-jqSsQ7lr.js +1 -0
- cndb/static/assets/ReloadOutlined-DaNpemW9.js +1 -0
- cndb/static/assets/ReportsPage-BUNKtZsn.js +61 -0
- cndb/static/assets/ReportsPage-Bwgzgxg9.css +1 -0
- cndb/static/assets/SettingsModal-CgFxRDWi.js +1 -0
- cndb/static/assets/TrashPanel-CP37espS.js +1 -0
- cndb/static/assets/WorkspaceSettingsPage-DyCr0ZXL.js +4 -0
- cndb/static/assets/index-CLkyLhC9.js +538 -0
- cndb/static/assets/index-DPuneDdh.css +1 -0
- cndb/static/assets/index-Dvb5hg8k.js +13 -0
- cndb/static/assets/index-qBRt_nHP.js +20 -0
- cndb/static/favicon.svg +1 -0
- cndb/static/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- cndb/static/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
- cndb/static/fonts/noto-sans-sc/noto-sans-sc-101-400-normal.woff2 +0 -0
- cndb/static/fonts/noto-sans-sc/noto-sans-sc-101-500-normal.woff2 +0 -0
- cndb/static/fonts/noto-sans-sc/noto-sans-sc-101-700-normal.woff2 +0 -0
- cndb/static/index.html +14 -0
- cndb-0.1.6.dist-info/METADATA +261 -0
- cndb-0.1.6.dist-info/RECORD +147 -0
- cndb-0.1.6.dist-info/WHEEL +4 -0
- cndb-0.1.6.dist-info/entry_points.txt +2 -0
- cndb-0.1.6.dist-info/licenses/LICENSE +21 -0
cndb/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""cndb - FastAPI + SQLAlchemy + Plugin 架构脚手架.
|
|
2
|
+
|
|
3
|
+
对外门面(facade),仅做 re-export,不承载业务实现。
|
|
4
|
+
使用者可直接:
|
|
5
|
+
|
|
6
|
+
from cndb import AppFactory, PluginBase, Settings
|
|
7
|
+
|
|
8
|
+
依赖完整 re-export 清单见 __all__。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from cndb import core, plugins
|
|
14
|
+
from cndb.app import app as _fastapi_app
|
|
15
|
+
from cndb.core.config import Settings
|
|
16
|
+
from cndb.plugins.base import AppItem, NavItem, PluginBase
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AppItem",
|
|
20
|
+
"NavItem",
|
|
21
|
+
"PluginBase",
|
|
22
|
+
"Settings",
|
|
23
|
+
"__version__",
|
|
24
|
+
"app",
|
|
25
|
+
"core",
|
|
26
|
+
"plugins",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# 版本号:与 pyproject.toml 保持同步(由 bump-my-version 自动维护)
|
|
31
|
+
__version__ = "0.1.6"
|
|
32
|
+
|
|
33
|
+
# 给调用者一个便捷入口:from cndb import app
|
|
34
|
+
app = _fastapi_app
|
cndb/api/__init__.py
ADDED
cndb/api/deps.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""FastAPI 依赖注入工具函数.
|
|
2
|
+
|
|
3
|
+
集中放置 Depends() 可调用对象,避免散落在各路由文件.
|
|
4
|
+
|
|
5
|
+
认证方式:JWT(Authorization: Bearer <jwt>),由前端登录流程签发.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import TYPE_CHECKING, Annotated
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, Header, HTTPException, Request, status
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from cndb.plugins.accounts.models import User
|
|
19
|
+
|
|
20
|
+
from cndb.core.config import settings
|
|
21
|
+
from cndb.core.database import get_db
|
|
22
|
+
from cndb.core.security import decode_access_token
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_request_id(x_request_id: str | None = Header(default=None)) -> str:
|
|
28
|
+
"""生成或透传请求 ID(链路追踪用).
|
|
29
|
+
|
|
30
|
+
若上游调用方已传入 X-Request-Id,则直接透传;否则生成一个新的 UUID.
|
|
31
|
+
下游服务可通过依赖注入在任意位置拿到同一个 request_id.
|
|
32
|
+
"""
|
|
33
|
+
if x_request_id:
|
|
34
|
+
return x_request_id
|
|
35
|
+
return uuid.uuid4().hex
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── 认证依赖 ──────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _authenticate_jwt(token: str, db: Session) -> User | None:
|
|
42
|
+
"""尝试用 JWT 解析令牌并加载用户."""
|
|
43
|
+
from cndb.plugins.accounts.models import User
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
payload = decode_access_token(token)
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
sub = payload.get("sub")
|
|
50
|
+
if not sub:
|
|
51
|
+
return None
|
|
52
|
+
return db.query(User).filter(User.id == int(sub), User.is_active.is_(True)).first()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_current_user(
|
|
56
|
+
request: Request,
|
|
57
|
+
db: Annotated[Session, Depends(get_db)],
|
|
58
|
+
) -> object | None:
|
|
59
|
+
"""JWT 认证:解析 Bearer 令牌并加载用户.
|
|
60
|
+
|
|
61
|
+
返回:
|
|
62
|
+
认证成功返回 User 对象;AUTH_ENABLED=False 或无认证头时返回 None;
|
|
63
|
+
AUTH_ENABLED=True 但认证失败时抛 401.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if not settings.AUTH_ENABLED:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
|
70
|
+
if not auth_header:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
# 格式: "Bearer <token>"
|
|
74
|
+
parts = auth_header.split(" ", 1)
|
|
75
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
token = parts[1].strip()
|
|
79
|
+
if not token:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
user: User | None = _authenticate_jwt(token, db)
|
|
83
|
+
|
|
84
|
+
if user is None:
|
|
85
|
+
raise HTTPException(
|
|
86
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
87
|
+
detail="无效或过期的令牌",
|
|
88
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
89
|
+
)
|
|
90
|
+
return user
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_optional_user(
|
|
94
|
+
request: Request,
|
|
95
|
+
db: Annotated[Session, Depends(get_db)],
|
|
96
|
+
) -> object | None:
|
|
97
|
+
"""可选认证:有令牌则解析,无令牌或无效也不报错(返回 None).
|
|
98
|
+
|
|
99
|
+
适用于公开表单/共享视图等"登录更好、匿名也行"的场景.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
return get_current_user(request, db)
|
|
103
|
+
except HTTPException:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"get_current_user",
|
|
109
|
+
"get_optional_user",
|
|
110
|
+
"get_request_id",
|
|
111
|
+
]
|
cndb/app.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""cndb FastAPI 应用入口.
|
|
2
|
+
|
|
3
|
+
职责:
|
|
4
|
+
- 组装 FastAPI app(配置/中间件/lifespan)
|
|
5
|
+
- 启动时自动发现并挂载所有插件
|
|
6
|
+
- 暴露框架级元路由(/api/health /api/plugins /api/navigation)
|
|
7
|
+
- 不承载业务逻辑,业务由 plugins 按需挂载
|
|
8
|
+
- 使用 FastAPIOffline,Swagger UI / ReDoc 静态资源从本地加载,避免外网依赖
|
|
9
|
+
|
|
10
|
+
启动方式:
|
|
11
|
+
- cndb serve(CLI 入口,见 runner.py)
|
|
12
|
+
- uvicorn cndb.app:app --reload
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import AsyncGenerator
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
+
from fastapi.responses import FileResponse
|
|
24
|
+
from fastapi.staticfiles import StaticFiles
|
|
25
|
+
from fastapi_offline import FastAPIOffline
|
|
26
|
+
|
|
27
|
+
from cndb.core.config import settings
|
|
28
|
+
from cndb.core.plugin_registry import plugin_registry
|
|
29
|
+
from cndb.plugins.tables.routers.public import router as public_router
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|
34
|
+
"""应用生命周期管理."""
|
|
35
|
+
|
|
36
|
+
yield
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
app = FastAPIOffline(
|
|
40
|
+
title=settings.APP_NAME,
|
|
41
|
+
version=settings.APP_VERSION,
|
|
42
|
+
description="cndb - FastAPI + SQLAlchemy + Plugin 架构脚手架",
|
|
43
|
+
lifespan=lifespan,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── 启动时自动发现并挂载插件(模块加载时执行,避免依赖 TestClient lifespan)──
|
|
48
|
+
if settings.PLUGINS_AUTO_DISCOVER: # pragma: no cover - 配置开关
|
|
49
|
+
plugin_registry.discover_and_load()
|
|
50
|
+
plugin_registry.mount_routes(app)
|
|
51
|
+
|
|
52
|
+
# tables 插件全局公开路由
|
|
53
|
+
app.include_router(public_router)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
app.add_middleware(
|
|
57
|
+
CORSMiddleware,
|
|
58
|
+
allow_origins=settings.CORS_ORIGINS,
|
|
59
|
+
allow_credentials=True,
|
|
60
|
+
allow_methods=["*"],
|
|
61
|
+
allow_headers=["*"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.get("/api/health", tags=["framework"])
|
|
66
|
+
def health_check() -> dict[str, object]:
|
|
67
|
+
"""框架级健康检查(最简版)."""
|
|
68
|
+
return {"status": "ok", "version": settings.APP_VERSION, "app": settings.APP_NAME}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.get("/api/plugins", tags=["framework"])
|
|
72
|
+
def list_plugins() -> dict[str, object]:
|
|
73
|
+
"""获取已加载插件列表."""
|
|
74
|
+
return {"plugins": plugin_registry.get_plugin_info_list()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.get("/api/navigation", tags=["framework"])
|
|
78
|
+
def get_navigation() -> dict[str, object]:
|
|
79
|
+
"""汇总所有插件注册的侧边栏导航项."""
|
|
80
|
+
return {"navigation": plugin_registry.get_all_navigation()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── 前端静态资源(Vite build 产物挂 src/cndb/static/)─────────────
|
|
84
|
+
from collections.abc import Awaitable, Callable # noqa: E402
|
|
85
|
+
|
|
86
|
+
from starlette.requests import Request # noqa: E402
|
|
87
|
+
from starlette.responses import Response # noqa: E402
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def should_spa_fallback(path: str, accept_header: str) -> bool:
|
|
91
|
+
"""判断是否应该对 404 请求回退 index.html.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
path: 请求路径(如 ``"/w/1/tables"``).
|
|
95
|
+
accept_header: HTTP Accept 头的值.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``True`` 表示应该回退到 index.html.
|
|
99
|
+
"""
|
|
100
|
+
# API 路由不走前端
|
|
101
|
+
if path.startswith("/api/"):
|
|
102
|
+
return False
|
|
103
|
+
# /assets/ 下的静态资源直接返回 404,避免把不存在的 JS chunk 回成 HTML
|
|
104
|
+
if path.startswith("/assets/"):
|
|
105
|
+
return False
|
|
106
|
+
# 仅对浏览器页面请求回退(Accept 包含 text/html)
|
|
107
|
+
return "text/html" in accept_header
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_STATIC = Path(__file__).resolve().parent / "static"
|
|
111
|
+
_ASSETS_DIR = _STATIC / "assets"
|
|
112
|
+
_INDEX_HTML = _STATIC / "index.html"
|
|
113
|
+
# 仅当下目录结构完整时才挂载 SPA 静态资源;前端未 build 时(如 CI、纯后端测试)跳过,
|
|
114
|
+
# 避免 StaticFiles 构造器因目录不存在抛 RuntimeError
|
|
115
|
+
_SPA_READY = _STATIC.is_dir() and _ASSETS_DIR.is_dir() and _INDEX_HTML.is_file()
|
|
116
|
+
|
|
117
|
+
if _SPA_READY: # pragma: no cover - 需要前端构建产物
|
|
118
|
+
# 挂载 /assets 为独立静态目录(带 hash 的产物,可长缓存)
|
|
119
|
+
app.mount("/assets", StaticFiles(directory=str(_ASSETS_DIR)), name="assets")
|
|
120
|
+
|
|
121
|
+
@app.middleware("http")
|
|
122
|
+
async def _spa_fallback(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
123
|
+
"""SPA 路由 fallback + 缓存策略中间件.
|
|
124
|
+
|
|
125
|
+
缓存策略:
|
|
126
|
+
- index.html 及其它顶层 HTML:Cache-Control: no-cache(每次协商获取最新版本)
|
|
127
|
+
原因:Vite 构建产物文件名带 hash,index.html 是唯一引用入口,必须及时刷新
|
|
128
|
+
- /assets/*:Cache-Control: public, max-age=31536000, immutable
|
|
129
|
+
原因:Vite 输出带内容 hash,内容永不变化,可长缓存
|
|
130
|
+
|
|
131
|
+
fallback 逻辑:
|
|
132
|
+
仅对以下条件同时满足的 404 请求回退到 index.html:
|
|
133
|
+
1. 非 /api/ 开头(API 路由不走前端)
|
|
134
|
+
2. 非 /assets/ 开头(不存在的 chunk 文件直接返回 404,不要回 HTML)
|
|
135
|
+
3. Accept 头包含 text/html(浏览器请求页面才 fallback,JS/CSS/XHR 请求不回)
|
|
136
|
+
"""
|
|
137
|
+
response = await call_next(request)
|
|
138
|
+
path = request.url.path
|
|
139
|
+
|
|
140
|
+
# /assets/ 下带 hash 的产物 —— 一年长缓存 + immutable
|
|
141
|
+
if path.startswith("/assets/") and response.status_code == 200:
|
|
142
|
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
# HTML 页面 —— 禁止缓存,确保每次都拿到最新 index.html
|
|
146
|
+
is_html_path = path == "/" or path.endswith(".html")
|
|
147
|
+
if is_html_path and response.status_code == 200:
|
|
148
|
+
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
|
149
|
+
|
|
150
|
+
if response.status_code != 404:
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
# 404 路径 —— 优先尝试直接命中静态文件(favicon.svg 等顶层资源)
|
|
154
|
+
fp = _STATIC / path.lstrip("/")
|
|
155
|
+
if fp.is_file():
|
|
156
|
+
return FileResponse(str(fp))
|
|
157
|
+
# 用纯函数判断是否需要 fallback
|
|
158
|
+
if should_spa_fallback(path, request.headers.get("accept", "")):
|
|
159
|
+
return FileResponse(
|
|
160
|
+
str(_STATIC / "index.html"),
|
|
161
|
+
headers={"Cache-Control": "no-cache, must-revalidate"},
|
|
162
|
+
)
|
|
163
|
+
return response
|
cndb/backup.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""数据备份模块.
|
|
2
|
+
|
|
3
|
+
提供完整的数据备份能力,覆盖:
|
|
4
|
+
1. SQLite 数据库文件(使用 sqlite3.Connection.backup 热备份,无需停服)
|
|
5
|
+
2. uploads 目录下的所有附件文件
|
|
6
|
+
|
|
7
|
+
输出格式为 ``.tar.gz`` 归档,包含 ``manifest.json`` 元信息供恢复端校验。
|
|
8
|
+
|
|
9
|
+
备份模式:
|
|
10
|
+
- ``native``(默认):直接备份数据库文件,速度最快,仅 SQLite 可用
|
|
11
|
+
- ``sqlalchemy``:通过 SQLAlchemy 序列化所有表数据,跨数据库兼容
|
|
12
|
+
|
|
13
|
+
归档结构::
|
|
14
|
+
|
|
15
|
+
backup-<timestamp>.tar.gz
|
|
16
|
+
├── manifest.json # 备份元信息(版本、时间、表清单、文件统计)
|
|
17
|
+
├── database/ # 数据库备份
|
|
18
|
+
│ ├── cndb.db # native 模式下的原始文件(SQLite)
|
|
19
|
+
│ └── dump.json # sqlalchemy 模式下的 JSON 导出
|
|
20
|
+
└── uploads/ # 附件文件目录(按 workspace_id 隔离)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import datetime as dt
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import sqlite3
|
|
31
|
+
import tarfile
|
|
32
|
+
import tempfile
|
|
33
|
+
from dataclasses import asdict, dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
__all__ = ["BackupError", "BackupManifest", "create_backup"]
|
|
39
|
+
|
|
40
|
+
MANIFEST_VERSION = "1" # 当前 manifest 协议版本,restore 端检查此值做兼容性判断
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BackupError(RuntimeError):
|
|
44
|
+
"""备份过程中的业务错误基类."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class UploadsInfo:
|
|
49
|
+
"""uploads 目录统计信息."""
|
|
50
|
+
|
|
51
|
+
included: bool = False
|
|
52
|
+
file_count: int = 0
|
|
53
|
+
total_size: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DatabaseInfo:
|
|
58
|
+
"""数据库备份统计."""
|
|
59
|
+
|
|
60
|
+
path: str = ""
|
|
61
|
+
db_type: str = "sqlite"
|
|
62
|
+
backup_mode: str = "native"
|
|
63
|
+
tables: list[str] = field(default_factory=list)
|
|
64
|
+
row_counts: dict[str, int] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class BackupManifest:
|
|
69
|
+
"""备份元信息(序列化写入 manifest.json)."""
|
|
70
|
+
|
|
71
|
+
version: str = MANIFEST_VERSION
|
|
72
|
+
app_version: str = ""
|
|
73
|
+
created_at: str = ""
|
|
74
|
+
database: DatabaseInfo = field(default_factory=DatabaseInfo)
|
|
75
|
+
uploads: UploadsInfo = field(default_factory=UploadsInfo)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_sqlite_url(database_url: str) -> bool:
|
|
79
|
+
"""判断 DATABASE_URL 是否为 SQLite."""
|
|
80
|
+
parsed = urlparse(database_url)
|
|
81
|
+
return parsed.scheme.startswith("sqlite")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_sqlite_path(database_url: str) -> Path:
|
|
85
|
+
"""从 SQLite DATABASE_URL 解析出实际文件路径."""
|
|
86
|
+
# sqlite:///absolute/path/to/db → absolute/path/to/db
|
|
87
|
+
# sqlite:///./relative/path → 处理相对路径
|
|
88
|
+
parsed = urlparse(database_url)
|
|
89
|
+
# netloc 为空时,path 开头已有斜杠
|
|
90
|
+
path_str = parsed.path
|
|
91
|
+
# 去掉前导斜杠(sqlite:/// 产生三个斜杠)
|
|
92
|
+
if path_str.startswith("/"):
|
|
93
|
+
path_str = path_str[1:]
|
|
94
|
+
return Path(path_str).resolve()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _backup_sqlite_native(db_path: Path, target_dir: Path) -> tuple[str, dict[str, int]]:
|
|
98
|
+
"""使用 sqlite3 的 backup() API 热备份数据库文件.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
(备份文件名, 表名 → 行数 映射)
|
|
102
|
+
"""
|
|
103
|
+
dest = target_dir / "cndb.db"
|
|
104
|
+
# 使用 sqlite3.Connection.backup() 做联机备份
|
|
105
|
+
src_conn = sqlite3.connect(str(db_path))
|
|
106
|
+
try:
|
|
107
|
+
dest_conn = sqlite3.connect(str(dest))
|
|
108
|
+
try:
|
|
109
|
+
src_conn.backup(dest_conn)
|
|
110
|
+
finally:
|
|
111
|
+
dest_conn.close()
|
|
112
|
+
finally:
|
|
113
|
+
src_conn.close()
|
|
114
|
+
|
|
115
|
+
# 统计表行数
|
|
116
|
+
row_counts: dict[str, int] = {}
|
|
117
|
+
conn = sqlite3.connect(str(dest))
|
|
118
|
+
try:
|
|
119
|
+
cursor = conn.execute(
|
|
120
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
121
|
+
)
|
|
122
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
123
|
+
for table in tables:
|
|
124
|
+
count = conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
|
125
|
+
row_counts[table] = count
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
return "cndb.db", row_counts
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _backup_sqlalchemy(database_url: str, target_dir: Path) -> tuple[str, dict[str, int], list[str]]:
|
|
132
|
+
"""通过 SQLAlchemy 序列化所有表数据(跨数据库兼容).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
(备份文件名, 表名 → 行数 映射, 表清单)
|
|
136
|
+
"""
|
|
137
|
+
from sqlalchemy import MetaData, create_engine, text
|
|
138
|
+
|
|
139
|
+
from cndb.core.plugin_registry import plugin_registry
|
|
140
|
+
|
|
141
|
+
plugin_registry.discover_and_load()
|
|
142
|
+
|
|
143
|
+
engine = create_engine(database_url)
|
|
144
|
+
try:
|
|
145
|
+
metadata = MetaData()
|
|
146
|
+
metadata.reflect(bind=engine)
|
|
147
|
+
tables_info: list[dict[str, Any]] = []
|
|
148
|
+
row_counts: dict[str, int] = {}
|
|
149
|
+
|
|
150
|
+
with engine.connect() as conn:
|
|
151
|
+
for table_name in sorted(metadata.tables.keys()):
|
|
152
|
+
sa_table = metadata.tables[table_name]
|
|
153
|
+
# 反射列名(按顺序)
|
|
154
|
+
columns = [c.name for c in sa_table.columns]
|
|
155
|
+
# 统计行数
|
|
156
|
+
count = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).scalar() or 0
|
|
157
|
+
row_counts[table_name] = count
|
|
158
|
+
# 导出所有行(limit 保护:单表超 100 万行报警)
|
|
159
|
+
rows = conn.execute(sa_table.select()).mappings().all()
|
|
160
|
+
if len(rows) > 1_000_000:
|
|
161
|
+
print(f"[backup] 警告:表 {table_name} 行数 {len(rows)} 超过一百万,序列化体积较大")
|
|
162
|
+
# 转成普通 dict,处理 datetime 等不可序列化类型
|
|
163
|
+
clean_rows = [{k: _to_json_safe(v) for k, v in row.items()} for row in rows]
|
|
164
|
+
tables_info.append({"table": table_name, "columns": columns, "rows": clean_rows})
|
|
165
|
+
|
|
166
|
+
dump = {"tables": tables_info}
|
|
167
|
+
finally:
|
|
168
|
+
engine.dispose()
|
|
169
|
+
|
|
170
|
+
dest = target_dir / "dump.json"
|
|
171
|
+
dest.write_text(json.dumps(dump, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
172
|
+
return "dump.json", row_counts, sorted(metadata.tables.keys())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _to_json_safe(value: Any) -> Any:
|
|
176
|
+
"""将 datetime/bytes/Decimal 等不可 JSON 序列化的类型转为安全值."""
|
|
177
|
+
if value is None:
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(value, (dt.datetime, dt.date, dt.time)):
|
|
180
|
+
return value.isoformat()
|
|
181
|
+
if isinstance(value, bytes):
|
|
182
|
+
import base64
|
|
183
|
+
|
|
184
|
+
return {"__base64__": base64.b64encode(value).decode("ascii")}
|
|
185
|
+
# Decimal / UUID 等有 __str__ 的类型
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _collect_uploads(upload_dir: Path, target_dir: Path) -> UploadsInfo:
|
|
190
|
+
"""收集 uploads 目录并复制到目标临时目录.
|
|
191
|
+
|
|
192
|
+
保留子目录结构(按 workspace_id 隔离)。空目录自动跳过。
|
|
193
|
+
"""
|
|
194
|
+
info = UploadsInfo(included=False, file_count=0, total_size=0)
|
|
195
|
+
if not upload_dir.is_dir():
|
|
196
|
+
return info
|
|
197
|
+
|
|
198
|
+
target_uploads = target_dir / "uploads"
|
|
199
|
+
target_uploads.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
for root, dirs, files in os.walk(upload_dir):
|
|
202
|
+
# 跳过隐藏目录
|
|
203
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
204
|
+
for fname in files:
|
|
205
|
+
if fname.startswith("."):
|
|
206
|
+
continue
|
|
207
|
+
src_file = Path(root) / fname
|
|
208
|
+
rel_path = src_file.relative_to(upload_dir)
|
|
209
|
+
dst_file = target_uploads / rel_path
|
|
210
|
+
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
shutil.copy2(src_file, dst_file)
|
|
212
|
+
info.file_count += 1
|
|
213
|
+
info.total_size += src_file.stat().st_size
|
|
214
|
+
|
|
215
|
+
if info.file_count > 0:
|
|
216
|
+
info.included = True
|
|
217
|
+
return info
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _make_tar_archive(source_dir: Path, output_path: Path) -> None:
|
|
221
|
+
"""将 source_dir 打包为 output_path(.tar.gz).
|
|
222
|
+
|
|
223
|
+
使用 GNU 长文件格式以支持 Windows 下的长路径。
|
|
224
|
+
"""
|
|
225
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
with tarfile.open(output_path, "w:gz", format=tarfile.GNU_FORMAT) as tar:
|
|
227
|
+
tar.add(source_dir, arcname="backup")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _total_size(path: Path) -> int:
|
|
231
|
+
"""计算目录或文件总字节数."""
|
|
232
|
+
if path.is_file():
|
|
233
|
+
return path.stat().st_size
|
|
234
|
+
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def create_backup(
|
|
238
|
+
output: Path | None = None,
|
|
239
|
+
mode: str = "auto",
|
|
240
|
+
include_uploads: bool = True,
|
|
241
|
+
database_url: str | None = None,
|
|
242
|
+
upload_dir: Path | None = None,
|
|
243
|
+
) -> Path:
|
|
244
|
+
"""创建完整数据备份.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
output: 输出归档路径(默认 ``backup-<timestamp>.tar.gz``).
|
|
248
|
+
mode: 备份模式 — ``auto``(SQLite 用 native,其它 sqlalchemy)/ ``native`` / ``sqlalchemy``.
|
|
249
|
+
include_uploads: 是否包含 uploads 目录附件.
|
|
250
|
+
database_url: 覆盖 settings.DATABASE_URL(测试用).
|
|
251
|
+
upload_dir: 覆盖 settings.UPLOAD_DIR(测试用).
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
备份归档的绝对路径.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
BackupError: 备份过程中的业务错误(DB 不可用、路径不存在等).
|
|
258
|
+
"""
|
|
259
|
+
import cndb
|
|
260
|
+
from cndb.core.config import settings
|
|
261
|
+
|
|
262
|
+
db_url = database_url or settings.DATABASE_URL
|
|
263
|
+
up_dir = upload_dir or settings.UPLOAD_DIR
|
|
264
|
+
is_sqlite = _is_sqlite_url(db_url)
|
|
265
|
+
|
|
266
|
+
# 解析最终使用的模式
|
|
267
|
+
resolved_mode = mode
|
|
268
|
+
if mode == "auto":
|
|
269
|
+
resolved_mode = "native" if is_sqlite else "sqlalchemy"
|
|
270
|
+
if resolved_mode == "native" and not is_sqlite:
|
|
271
|
+
raise BackupError("native 备份模式仅支持 SQLite,当前数据库类型不支持,请使用 --mode sqlalchemy")
|
|
272
|
+
|
|
273
|
+
# 设置默认输出文件名
|
|
274
|
+
if output is None:
|
|
275
|
+
ts = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
276
|
+
output = Path(f"backup-{ts}.tar.gz").resolve()
|
|
277
|
+
else:
|
|
278
|
+
output = Path(output).resolve()
|
|
279
|
+
|
|
280
|
+
# 临时目录:拼装 manifest / database / uploads 后一次性打包
|
|
281
|
+
temp_root = Path(tempfile.mkdtemp(prefix="cndb-backup-"))
|
|
282
|
+
try:
|
|
283
|
+
db_dir = temp_root / "database"
|
|
284
|
+
db_dir.mkdir()
|
|
285
|
+
|
|
286
|
+
manifest = BackupManifest(
|
|
287
|
+
app_version=cndb.__version__,
|
|
288
|
+
created_at=dt.datetime.now(dt.UTC).isoformat(),
|
|
289
|
+
database=DatabaseInfo(db_type="sqlite" if is_sqlite else "other", backup_mode=resolved_mode),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
print(f"[backup] 模式: {resolved_mode},数据库: {db_url}")
|
|
293
|
+
|
|
294
|
+
# 1) 备份数据库
|
|
295
|
+
if resolved_mode == "native":
|
|
296
|
+
db_path = _resolve_sqlite_path(db_url)
|
|
297
|
+
if not db_path.is_file():
|
|
298
|
+
raise BackupError(f"SQLite 数据库文件不存在: {db_path}")
|
|
299
|
+
db_file, row_counts = _backup_sqlite_native(db_path, db_dir)
|
|
300
|
+
manifest.database.path = db_file
|
|
301
|
+
manifest.database.tables = sorted(row_counts.keys())
|
|
302
|
+
manifest.database.row_counts = row_counts
|
|
303
|
+
else:
|
|
304
|
+
db_file, row_counts, tables = _backup_sqlalchemy(db_url, db_dir)
|
|
305
|
+
manifest.database.path = db_file
|
|
306
|
+
manifest.database.tables = tables
|
|
307
|
+
manifest.database.row_counts = row_counts
|
|
308
|
+
|
|
309
|
+
total_rows = sum(row_counts.values())
|
|
310
|
+
print(f"[backup] 数据库备份完成: {len(row_counts)} 张表, 共 {total_rows} 行")
|
|
311
|
+
|
|
312
|
+
# 2) 收集 uploads
|
|
313
|
+
if include_uploads:
|
|
314
|
+
print(f"[backup] 收集附件目录: {up_dir}")
|
|
315
|
+
manifest.uploads = _collect_uploads(up_dir, temp_root)
|
|
316
|
+
if manifest.uploads.included:
|
|
317
|
+
size_kb = manifest.uploads.total_size / 1024
|
|
318
|
+
print(f"[backup] 附件: {manifest.uploads.file_count} 个文件, {size_kb:.1f} KB")
|
|
319
|
+
else:
|
|
320
|
+
print("[backup] uploads 目录为空或不存在,跳过附件备份")
|
|
321
|
+
|
|
322
|
+
# 3) 写入 manifest
|
|
323
|
+
manifest_path = temp_root / "manifest.json"
|
|
324
|
+
manifest_path.write_text(json.dumps(asdict(manifest), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
325
|
+
|
|
326
|
+
# 4) 打包
|
|
327
|
+
print(f"[backup] 打包归档 → {output}")
|
|
328
|
+
_make_tar_archive(temp_root, output)
|
|
329
|
+
size_mb = output.stat().st_size / 1024 / 1024
|
|
330
|
+
print(f"[backup] 完成!归档大小: {size_mb:.2f} MB")
|
|
331
|
+
return output
|
|
332
|
+
|
|
333
|
+
finally:
|
|
334
|
+
shutil.rmtree(temp_root, ignore_errors=True)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def backup_command(args: argparse.Namespace) -> None:
|
|
338
|
+
"""CLI 命令:``cndb backup``."""
|
|
339
|
+
try:
|
|
340
|
+
output = Path(args.output).resolve() if args.output else None
|
|
341
|
+
include_uploads = not args.no_uploads
|
|
342
|
+
result = create_backup(output=output, mode=args.mode, include_uploads=include_uploads)
|
|
343
|
+
print(f"[ok] 备份成功: {result}")
|
|
344
|
+
except BackupError as exc:
|
|
345
|
+
print(f"[error] {exc}", file=os.sys.stderr)
|
|
346
|
+
os.sys.exit(1)
|
|
347
|
+
except Exception as exc: # 兜底:捕获并格式化未预期异常
|
|
348
|
+
print(f"[error] 备份失败: {exc}", file=os.sys.stderr)
|
|
349
|
+
os.sys.exit(1)
|
cndb/core/__init__.py
ADDED