Files
Nexus/standards/system-development-standard.md
2026-07-08 22:31:31 +08:00

500 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 系统开发完整标准 v1.0
> **版本**: 1.0 · 2026-05-23
> **适用**: Python/FastAPI 后端 + 前端 全栈项目
> **来源**: perfect-implementation.mdc + nexus-*规则 + Phase 14 审计实践提炼
> **性质**: 强制规范,不得降级或妥协
---
## 零、实现铁律
```
❌ 不允许为了实现而实现 — 每行代码必须有明确目的
❌ 不允许降级实现 — 不以「先用着」为由降低安全/架构/质量
❌ 不允许妥协实现 — 发现问题必须根治,不得绕过
❌ 不允许留技术债 — TODO / FIXME / 临时方案禁止遗留
✅ 无法完美实现时必须停 — 告知用户确认方案后再换路,不擅自从权
```
---
## 一、开发执行顺序(每次必走)
```
需求确认 → 设计文档 → 技术文档 → 实现 → 测试验证 → changelog → 更新项目记忆文档
```
| 阶段 | 产出路径 | 必含内容 |
|------|---------|---------|
| 设计文档 | `docs/design/specs/YYYY-MM-DD-<topic>-design.md` | 背景与目标、方案对比、选定方案及理由、接口/数据模型、安全约束、验收标准 |
| 技术文档 | `docs/design/plans/YYYY-MM-DD-<topic>.md` | 涉及文件清单、实现步骤、依赖/配置变更、测试要点、回滚方式 |
| Changelog | `docs/changelog/YYYY-MM-DD-<主题>.md` | 日期、变更摘要、动机、涉及文件、是否需迁移/重启、验证方式 |
**新功能/架构变更/非平凡重构**:先设计文档,再写代码。
**改动时已有模块缺文档**:一并补全,不得只改代码不留档。
**禁止**:把 changelog 只写进 commit message。
---
## 二、架构分层(禁止跨层直连)
```
HTTP 路由层 → Service 层 → Repository 层 → Domain 模型
(api/) (services/) (*_repo.py) (models/)
基础设施层(database / redis / ssh / telegram)仅被 Service 层或 Repository 层调用
前端(web/app/)仅通过 HTTP API 与后端通信,不直接操作 DB/Redis
```
| 层 | 职责 | 禁止 |
|----|------|------|
| API 路由层 | 请求校验、鉴权 Depends、调用 Service、返回响应 | 写 SQL、直接操作 Redis |
| Service 层 | 业务编排、事务边界、审计写入 | 直接 import FastAPI 依赖 |
| Repository 层 | DB 读写、分页、批量操作 | 业务逻辑 |
| Domain 模型 | ORM 字段定义、关系 | 任何业务逻辑、FastAPI 依赖 |
| 基础设施层 | DB 连接池、Redis 客户端、SSH 池、外部 HTTP | 业务逻辑 |
---
## 三、后端编码规范(Python / FastAPI
### 3.1 API 路由
```python
# ✅ 正确:Pydantic 校验 + JWT 鉴权 + 结构化错误
@router.post("/resource", response_model=dict, status_code=201)
async def create_resource(
payload: ResourceCreate, # Pydantic 自动校验
admin: Admin = Depends(get_current_admin), # JWT 鉴权
service: ResourceService = Depends(...),
db: AsyncSession = Depends(get_db),
):
...
raise HTTPException(status_code=422, detail="具体原因") # 结构化错误
# ❌ 禁止:裸 except、无鉴权、无 Pydantic
@router.post("/resource")
async def create(data: dict): # 无 Pydantic,无鉴权
try:
...
except:
pass # 禁止静默吞错
```
### 3.2 鉴权矩阵(强制)
| 调用方 | 机制 | 备注 |
|--------|------|------|
| 管理端 API | `Depends(get_current_admin)` JWT Bearer | 所有业务路由必须 |
| Agent | `X-API-Key` header + `secrets.compare_digest` | 防时序攻击 |
| WebSocket | JWT query param `?token=` | ADR-011 |
| 安装向导 | 无 JWT | 仅安装模式且安装后 403 |
### 3.3 数据库
```python
# ✅ 时间戳:必须带 timezone
datetime.now(timezone.utc) # ✅
datetime.now() # ❌ naive,与 aware 比较 → TypeError
# ✅ 分页在 Repository 层
async def get_paginated(offset, limit) -> tuple[list, int]:
count = await session.execute(select(func.count(Model.id)))
data = await session.execute(select(Model).offset(offset).limit(limit))
# ✅ 字段长度:加密后的值须 Text 而非 String(N)
ssh_key_private = Column(Text) # RSA 4096 Fernet 后 ~4300 chars
encrypted_pw = Column(String(500)) # 短密码 Fernet 后 ~100 chars,可 String
# ✅ 外键删除策略:明确声明
ForeignKey("servers.id", ondelete="CASCADE") # 主记录删 → 关联删
ForeignKey("scripts.id", ondelete="SET NULL") # 主记录删 → 关联置 null
```
### 3.4 Redis
```python
# ✅ 使用 pipeline 保证 INCR + EXPIRE 原子性
pipe = redis.pipeline()
pipe.incr(key)
pipe.expire(key, window_seconds)
count, _ = await pipe.execute()
# ❌ 非原子(崩溃在中间 → key 无 TTL 永久存在)
count = await redis.incr(key)
await redis.expire(key, window_seconds) # 若崩溃 → 永久存在
```
### 3.5 SSH / Shell
```python
# ✅ 整段 key=value 必须整体 quote
cmd = f"echo {shlex.quote(f'{key}={value}')}" # ✅
cmd = f"echo {shlex.quote(key)}={shlex.quote(value)}" # ❌ 等号在引号外
# ✅ shlex.quote 结果不放进双引号(引号嵌套变字面量)
f"{shlex.quote(path)}.pid" # ✅ 产生 '/path/to/file'.pid
f'"{shlex.quote(path)}.pid"' # ❌ 产生 "'/path/to/file'.pid"(单引号字面量)
# ✅ 多行注入防御:剥离换行
safe_value = str(value).replace('\n',' ').replace('\r',' ').replace('\x00','')
```
### 3.6 凭据与加密
```python
# ✅ 存储:Fernet 加密
password_col = encrypt_value(plaintext)
# ✅ 响应:只返回布尔标记
{"password_set": bool(server.password)} # ✅
{"password": server.password} # ❌ 禁止返回密文或明文
# ✅ 变量替换:两轮替换防嵌套
SENTINELS = {"$DB_PASS": "\x00PASS\x00", "$DB_USER": "\x00USER\x00"}
VALUES = {"\x00PASS\x00": real_password, "\x00USER\x00": real_user}
for placeholder, sentinel in SENTINELS.items():
cmd = cmd.replace(placeholder, sentinel)
for sentinel, value in VALUES.items():
cmd = cmd.replace(sentinel, value)
# ✅ 日志:脱敏后记录
logger.info(f"Redis: {_mask_url(settings.REDIS_URL)}") # 非 settings.REDIS_URL 原文
```
### 3.7 错误处理
```python
# ✅ 向上抛,让调用方决定
def decrypt_value(ciphertext):
try:
return fernet.decrypt(...)
except Exception as e:
raise ValueError("解密失败") from e # ✅ 不返回密文,不静默
# ❌ 静默吞错(禁止)
except Exception:
return ciphertext # ❌ 返回密文 = 信息泄露
return None # ❌ 调用方看不到错误
# ✅ gather 异常必须检查
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
logger.error("Task failed: %s", r)
```
### 3.8 Datetime 规范
```python
# ✅ 所有 datetime 带 timezone
from datetime import datetime, timezone
now = datetime.now(timezone.utc) # ✅
# ✅ MySQL 返回 naive,比较前统一
expires = record.expires_at # naiveMySQL 存储无时区)
now_cmp = datetime.now(timezone.utc)
if expires.tzinfo is None:
now_cmp = now_cmp.replace(tzinfo=None) # 统一为 naive 再比较
if expires < now_cmp: ...
# ✅ Cron 解析防除零
step = int(part[2:])
if step == 0:
return False # 无效 step,跳过
```
---
## 四、前端编码规范
### 4.1 API 调用
```javascript
// ✅ 统一封装函数(含 Authorization + 错误处理)
const data = await apiFetch("/api/resource/");
// ❌ 禁止裸 fetch(漏 Authorization
const data = await fetch("/api/resource/");
```
### 4.2 XSS 防御(强制)
```javascript
// ✅ 文本内容用 textContent 或 Alpine 绑定
el.textContent = serverName;
// ✅ 必须插入 HTML 时用 esc()
el.innerHTML = `<b>${esc(serverName)}</b>`;
// ❌ 禁止
el.innerHTML = `<b>${serverName}</b>`; // 未 esc
el.innerHTML = `<button onclick="fn('${val}')">`; // onclick 注入
// ✅ data 属性安全传值
`<button data-id="${esc(id)}" @click="handle($el.dataset.id)">`
```
### 4.3 静态资源
```html
<!-- ✅ 所有第三方 JS/CSS 自托管(/app/vendor/)或带 SRI -->
<script src="/app/vendor/alpinejs.min.js"></script>
<!-- ❌ 无 SRI 的 CDN -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3"></script>
```
### 4.4 会话管理
- JWT 存 `localStorage`,到期前 2 分钟自动 refresh
- 401 响应:retry 一次,失败则 `_logout()` 跳登录页
- 8 小时无活动强制退出(与后端 `updated_at` 校验对齐)
---
## 五、安全规范
### 5.1 密钥管理
| 密钥 | 规则 |
|------|------|
| `SECRET_KEY` / `API_KEY` / `ENCRYPTION_KEY` | 只在 `.env`,不进 git,不可从 DB settings 覆盖,UI 不展示 |
| Agent `api_key` | `secrets.token_urlsafe(32)``compare_digest` 校验;启动时非空强制退出 |
| SSH 私钥 | Fernet 加密后存 `Text` 列;响应只返回 `ssh_key_private_set: bool` |
| JWT | `HS256`,含 `exp`/`sub`/`updated` claims;密码改后 `token_version` 递增使旧 token 失效 |
### 5.2 审计日志(所有 CUD 操作必须写)
```python
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_server",
target_type="server",
target_id=created.id,
detail=f"name={created.name}",
ip_address=request.client.host if request.client else "",
))
```
### 5.3 外部通知脱敏
```python
# 发 Telegram / Slack / 邮件前必须脱敏
safe_msg = sanitize_external_message(str(exception)) # 去除 URL / password / key
await send_telegram_system_alert(safe_msg)
# 原始详情只写本地日志
logger.error("Full error: %s", exception, exc_info=True)
```
### 5.4 登录防暴破
- 失败计数(同 username + IP)→ 15 分钟锁定 → HTTP 429
- Refresh token 重用检测:旧 token 作废 + `token_version` 递增 + 审计日志
### 5.5 危险命令策略
- 中心管控面(server/api/):**阻断**危险命令,返回 400
- 子机 Agentweb/agent/):**记录**日志,架构上无法拦截(terminal 字符流)
---
## 六、数据模型规范
### 6.1 列类型选择
| 数据 | 类型 | 理由 |
|------|------|------|
| 短文本(<255 chars | `String(N)` | 索引高效 |
| 加密后的短密码 | `String(500)` | Fernet 加密后 ~100-300 chars |
| SSH 私钥(加密后) | `Text` | RSA 4096 Fernet 后 ~4300 chars |
| JSON 数组/对象 | `JSON``Text`(存 json.dumps | 视 DB 版本 |
| 命令输出 / 详情 | `Text` | 无长度限制 |
### 6.2 时间戳规范
```python
# ✅ 所有 DateTime 列用 aware callable(不用 onupdate=func.now()
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
```
### 6.3 外键删除策略
```python
ondelete="CASCADE" # 主记录删 → 关联记录也删(日志、会话)
ondelete="SET NULL" # 主记录删 → 关联置 null(软依赖)
```
### 6.4 索引规范
- 高频过滤字段必须加索引:`WHERE status = X AND created_at >= Y`
- 复合索引列顺序:高选择性列在前
- 迁移幂等:`CREATE INDEX IF NOT EXISTS` 或 try/except duplicate
---
## 七、测试规范
### 7.1 覆盖要求
| 新 endpoint | 测试路径 |
|------------|---------|
| 认证失败 | 无 token → 401 |
| 参数校验失败 | 必填字段缺失 → 422 |
| 成功路径 | 正常入参 → 正确响应 |
| 边界情况 | 空列表 / 极值 |
### 7.2 命名规范
```python
# tests/test_<模块名>.py
def test_<函数名>_<场景>_<预期结果>():
...
# 示例
def test_create_server_missing_domain_returns_422(): ...
def test_decrypt_value_fernet_fails_raises_valueerror(): ...
```
### 7.3 禁止
- 测试中硬编码生产密钥
- 跳过鉴权测试(`skip` 除非有充分理由)
- 测试依赖真实数据库(使用 mock 或 test fixture
---
## 八、数据库迁移规范
```python
# ✅ 幂等迁移(每次启动安全重跑)
try:
await conn.execute(text("ALTER TABLE t ADD COLUMN c VARCHAR(100)"))
except Exception as e:
if "duplicate" in str(e).lower() or "already exists" in str(e).lower():
pass # 列已存在,跳过
else:
raise # 真正的错误必须抛出
# ✅ 索引幂等
try:
await conn.execute(text("CREATE INDEX idx_name ON table (col)"))
except Exception as e:
if "duplicate" in str(e).lower():
pass
else:
raise
```
---
## 九、后台任务规范
### 9.1 多 Worker 防重复
```python
# ✅ Redis 分布式锁:只有 primary worker 运行后台任务
acquired = await redis.set(LOCK_KEY, worker_id, nx=True, ex=TTL)
if acquired:
task = asyncio.create_task(my_loop())
# ✅ 锁丢失时取消任务
if lock_lost:
for task in _background_tasks:
task.cancel()
```
### 9.2 后台任务结构
```python
async def my_background_loop():
logger.info("Loop started (interval=%ss)", INTERVAL)
while True:
await asyncio.sleep(INTERVAL)
try:
async with AsyncSessionLocal() as session:
await do_work(session)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Loop error: %s", e)
# 继续下一个周期,不终止整个 loop
```
### 9.3 Datetime 防区时
```python
# ✅ MySQL 返回 naive,统一处理
last_run = record.last_run_at # naive
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
if last_run.tzinfo is not None:
last_run = last_run.replace(tzinfo=None)
elapsed = (now_naive - last_run).total_seconds()
```
---
## 十、文档交付标准
### 每次 PR / 功能完成必须包含
```
[ ] docs/changelog/YYYY-MM-DD-<主题>.md 已创建
[ ] 涉及新 APIschemas.py 有 Pydantic 模型
[ ] 涉及新 DB 表/列:domain/models/__init__.py 已更新 + 迁移脚本幂等
[ ] 涉及安全变更:audit-log 写入已验证
[ ] 项目记忆文档(CLAUDE.md / AGENTS.md)已更新实施状态
```
### Changelog 必含字段
```markdown
# YYYY-MM-DD 主题
**日期**: YYYY-MM-DD
**动机**: 为什么要做这个变更
**变更摘要**: 做了什么
**涉及文件**: 列出主要文件
**是否需迁移**: 是/否(若是,说明步骤)
**验证方式**: 如何确认变更生效
```
---
## 十一、禁止代码示例
```python
# ❌ 技术债(禁止提交)
# TODO: fix later
result = "workaround"
# ❌ 静默失败
except Exception:
return None
# ❌ 无文档大改
# (新增整模块却无 docs/design 或 docs/changelog 记录)
# ❌ tz-aware/naive 混用
datetime.now() - record.created_at # 若一个 aware 一个 naive → TypeError
# ❌ SQL 注入
await session.execute(text(f"SELECT * FROM t WHERE id = {user_input}"))
# ❌ 命令注入
cmd = f"rsync {source} {target}" # source/target 未 shlex.quote
# ❌ 密钥返回 API
{"api_key": settings.API_KEY} # 禁止返回完整密钥
# ❌ 日志泄密
logger.info(f"DB: {settings.DATABASE_URL}") # URL 含密码
```
---
*本规范可直接发给 AI,用于约束代码生成、代码审查、架构评审任务。*