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.
Files changed (147) hide show
  1. cndb/__init__.py +34 -0
  2. cndb/api/__init__.py +10 -0
  3. cndb/api/deps.py +111 -0
  4. cndb/app.py +163 -0
  5. cndb/backup.py +349 -0
  6. cndb/core/__init__.py +3 -0
  7. cndb/core/config.py +98 -0
  8. cndb/core/database.py +40 -0
  9. cndb/core/plugin_registry.py +145 -0
  10. cndb/core/security.py +121 -0
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. cndb/models/__init__.py +11 -0
  28. cndb/models/base.py +69 -0
  29. cndb/plugins/__init__.py +14 -0
  30. cndb/plugins/accounts/__init__.py +1 -0
  31. cndb/plugins/accounts/models.py +118 -0
  32. cndb/plugins/accounts/plugin.py +37 -0
  33. cndb/plugins/accounts/routers/__init__.py +1 -0
  34. cndb/plugins/accounts/routers/auth.py +226 -0
  35. cndb/plugins/accounts/routers/preferences.py +161 -0
  36. cndb/plugins/accounts/schemas/__init__.py +12 -0
  37. cndb/plugins/accounts/schemas/auth.py +61 -0
  38. cndb/plugins/base.py +111 -0
  39. cndb/plugins/health/__init__.py +3 -0
  40. cndb/plugins/health/plugin.py +43 -0
  41. cndb/plugins/reports/__init__.py +5 -0
  42. cndb/plugins/reports/models.py +54 -0
  43. cndb/plugins/reports/plugin.py +36 -0
  44. cndb/plugins/reports/routers/__init__.py +7 -0
  45. cndb/plugins/reports/routers/reports.py +265 -0
  46. cndb/plugins/reports/schemas/__init__.py +88 -0
  47. cndb/plugins/tables/__init__.py +5 -0
  48. cndb/plugins/tables/access.py +215 -0
  49. cndb/plugins/tables/api_config_loader.py +236 -0
  50. cndb/plugins/tables/api_fetch.py +580 -0
  51. cndb/plugins/tables/audit.py +82 -0
  52. cndb/plugins/tables/ddl.py +363 -0
  53. cndb/plugins/tables/diff_reporter.py +83 -0
  54. cndb/plugins/tables/failed_row_exporter.py +115 -0
  55. cndb/plugins/tables/field_ops.py +298 -0
  56. cndb/plugins/tables/field_types/__init__.py +833 -0
  57. cndb/plugins/tables/field_types/smart_color.py +439 -0
  58. cndb/plugins/tables/import_tasks.py +327 -0
  59. cndb/plugins/tables/importer.py +248 -0
  60. cndb/plugins/tables/links.py +315 -0
  61. cndb/plugins/tables/models.py +351 -0
  62. cndb/plugins/tables/plugin.py +47 -0
  63. cndb/plugins/tables/query.py +378 -0
  64. cndb/plugins/tables/records.py +524 -0
  65. cndb/plugins/tables/routers/__init__.py +37 -0
  66. cndb/plugins/tables/routers/audit.py +45 -0
  67. cndb/plugins/tables/routers/bulk.py +439 -0
  68. cndb/plugins/tables/routers/comments.py +124 -0
  69. cndb/plugins/tables/routers/fields.py +325 -0
  70. cndb/plugins/tables/routers/files.py +211 -0
  71. cndb/plugins/tables/routers/import_api.py +307 -0
  72. cndb/plugins/tables/routers/import_csv.py +114 -0
  73. cndb/plugins/tables/routers/members.py +316 -0
  74. cndb/plugins/tables/routers/permissions.py +85 -0
  75. cndb/plugins/tables/routers/public.py +155 -0
  76. cndb/plugins/tables/routers/records.py +212 -0
  77. cndb/plugins/tables/routers/tables.py +566 -0
  78. cndb/plugins/tables/routers/trash.py +315 -0
  79. cndb/plugins/tables/routers/views.py +419 -0
  80. cndb/plugins/tables/row_validator.py +202 -0
  81. cndb/plugins/tables/schemas/__init__.py +358 -0
  82. cndb/plugins/tables/schemas/comments.py +57 -0
  83. cndb/plugins/tables/transfer.py +644 -0
  84. cndb/plugins/wechat_auth/__init__.py +5 -0
  85. cndb/plugins/wechat_auth/client.py +57 -0
  86. cndb/plugins/wechat_auth/models.py +54 -0
  87. cndb/plugins/wechat_auth/plugin.py +29 -0
  88. cndb/plugins/wechat_auth/routers/__init__.py +1 -0
  89. cndb/plugins/wechat_auth/routers/login.py +140 -0
  90. cndb/plugins/wechat_auth/schemas/__init__.py +8 -0
  91. cndb/plugins/wechat_auth/schemas/auth.py +24 -0
  92. cndb/plugins/workspaces/__init__.py +12 -0
  93. cndb/plugins/workspaces/models.py +193 -0
  94. cndb/plugins/workspaces/permissions.py +35 -0
  95. cndb/plugins/workspaces/plugin.py +35 -0
  96. cndb/plugins/workspaces/routers/__init__.py +3 -0
  97. cndb/plugins/workspaces/routers/roles.py +227 -0
  98. cndb/plugins/workspaces/routers/workspaces.py +748 -0
  99. cndb/plugins/workspaces/schemas/__init__.py +234 -0
  100. cndb/py.typed +0 -0
  101. cndb/restore.py +383 -0
  102. cndb/runner.py +259 -0
  103. cndb/schemas/__init__.py +7 -0
  104. cndb/seed.py +549 -0
  105. cndb/services/__init__.py +7 -0
  106. cndb/static/.gitkeep +0 -0
  107. cndb/static/assets/ApiImportDialog-Ck3QpBew.js +1 -0
  108. cndb/static/assets/ArrowLeftOutlined-CfcYJquS.js +1 -0
  109. cndb/static/assets/FieldManager-D13Osy69.js +13 -0
  110. cndb/static/assets/GridCell-DzS2y21x.js +5 -0
  111. cndb/static/assets/GridPage-l1RzV0gK.js +16 -0
  112. cndb/static/assets/HomeOutlined-BrquOt1m.js +1 -0
  113. cndb/static/assets/ImportExportDialog-BHPGsZAb.js +1 -0
  114. cndb/static/assets/PublicFormPage-q9g0sBw9.js +1 -0
  115. cndb/static/assets/PublicSharePage-jqSsQ7lr.js +1 -0
  116. cndb/static/assets/ReloadOutlined-DaNpemW9.js +1 -0
  117. cndb/static/assets/ReportsPage-BUNKtZsn.js +61 -0
  118. cndb/static/assets/ReportsPage-Bwgzgxg9.css +1 -0
  119. cndb/static/assets/SettingsModal-CgFxRDWi.js +1 -0
  120. cndb/static/assets/TrashPanel-CP37espS.js +1 -0
  121. cndb/static/assets/WorkspaceSettingsPage-DyCr0ZXL.js +4 -0
  122. cndb/static/assets/index-CLkyLhC9.js +538 -0
  123. cndb/static/assets/index-DPuneDdh.css +1 -0
  124. cndb/static/assets/index-Dvb5hg8k.js +13 -0
  125. cndb/static/assets/index-qBRt_nHP.js +20 -0
  126. cndb/static/favicon.svg +1 -0
  127. cndb/static/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  128. cndb/static/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  129. cndb/static/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  130. cndb/static/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  131. cndb/static/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  132. cndb/static/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  133. cndb/static/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  134. cndb/static/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  135. cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
  136. cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
  137. cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  138. cndb/static/fonts/jetbrains-mono/jetbrains-mono-latin-ext-700-normal.woff2 +0 -0
  139. cndb/static/fonts/noto-sans-sc/noto-sans-sc-101-400-normal.woff2 +0 -0
  140. cndb/static/fonts/noto-sans-sc/noto-sans-sc-101-500-normal.woff2 +0 -0
  141. cndb/static/fonts/noto-sans-sc/noto-sans-sc-101-700-normal.woff2 +0 -0
  142. cndb/static/index.html +14 -0
  143. cndb-0.1.6.dist-info/METADATA +261 -0
  144. cndb-0.1.6.dist-info/RECORD +147 -0
  145. cndb-0.1.6.dist-info/WHEEL +4 -0
  146. cndb-0.1.6.dist-info/entry_points.txt +2 -0
  147. 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
@@ -0,0 +1,10 @@
1
+ """FastAPI 依赖注入包.
2
+
3
+ 集中定义 Depends() 里用到的可调用对象,避免散落在各路由文件里。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from cndb.api.deps import get_request_id
9
+
10
+ __all__ = ["get_request_id"]
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
@@ -0,0 +1,3 @@
1
+ """cndb 核心模块(配置、数据库、安全、插件注册中心)."""
2
+
3
+ from __future__ import annotations