ci(#211): ruff 门禁扩 app/ + 新增空库 alembic upgrade head + gitleaks 收窄
- ruff: tests/ -> app/ tests/;清零 app/ 存量 6 处(isort×4 + unused import×2), 并修复合并扫描才暴露的 tests/test_llm_outage_event 多余空行 - CI(test.yml): 新增独立空库 ci-pg-mig(POSTGRES_DB=csctalon_dev) 上的 alembic upgrade head + heads 门禁;连接参数用命令级 env 前缀,不污染 conftest 以 setdefault 从 TEST_POSTGRES_* 回填的 pytest 连接 - 修复真实迁移链断裂: a1c2e3f4d5b6 无条件 DROP CONSTRAINT uq_cs_record_business 在全新空库报 UndefinedObject(该约束建表迁移从未创建,仅由 create_all 带出)。 改为先查 pg_constraint 存在才 DROP,空库/存量两路径最终 schema 一致 - gitleaks 收窄: workflows 由 .* 通配收窄为仅精确放行 test.yml;backend/tests 由"整目录全规则免疫"收窄为仅 hardcoded-phone-number/hardcoded-strong-password 两条夹具启发式的规则级豁免,内置密钥规则与 scrm/shanmao 业务凭证规则对 tests 仍全量生效(正/负向实测验证) - 文档: alembic-migration 规范升 v1.2,增空库可重放门禁(§八-6)与"修复可重放性 缺陷可改已提交迁移"例外(§八-7);README 记录本地门禁命令 Refs #211
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
# 原 `uses:` 的 GitHub action(actions/checkout/setup-python/setup-node/cache)
|
||||
# 无法下载, job 卡死在 "Set up job"。改为纯本地 docker 执行:
|
||||
# - checkout: runner(:host) 已自动 clone 代码到工作目录
|
||||
# - 后端: docker run postgres:15(测试库) + docker run python:3.11-slim(pytest)
|
||||
# - 后端: postgres:15 ×2(pytest 测试库 + 空库迁移门禁库) + python:3.11-slim
|
||||
# (ruff app/+tests/ → 空库 alembic upgrade head → pytest)
|
||||
# - 前端: docker run node:20(npm ci + lint + vitest)
|
||||
# 前提: 群晖可访问 docker hub(已验证 python:3.11-slim/gitleaks 可拉取)
|
||||
|
||||
@@ -27,21 +28,34 @@ jobs:
|
||||
git clone --quiet http://127.0.0.1:3000/tanzk/csctalon.git /workspace/ci-repo
|
||||
cd /workspace/ci-repo && git log --oneline -1
|
||||
|
||||
- name: 启动 postgres 测试库容器
|
||||
- name: 启动 postgres 容器 (测试库 + 空库迁移门禁库)
|
||||
run: |
|
||||
# 清理残留(失败时)
|
||||
docker rm -f ci-pg 2>/dev/null || true
|
||||
docker rm -f ci-pg ci-pg-mig 2>/dev/null || true
|
||||
docker network create ci-net 2>/dev/null || true
|
||||
docker run -d --name ci-pg --network ci-net \
|
||||
-e POSTGRES_USER=csctalon \
|
||||
-e POSTGRES_PASSWORD=csctalon123 \
|
||||
-e POSTGRES_DB=csctalon_test \
|
||||
postgres:15
|
||||
# 空库迁移门禁专用库(#211): POSTGRES_DB 在容器首次初始化时建成一个不含任何
|
||||
# 表的**空库**, 专供 `alembic upgrade head` 从基线重放整条迁移链。它与 pytest 的
|
||||
# create_all + stamp head 路径相互独立 —— 后者会掩盖"某迁移 DROP 了链上从未
|
||||
# CREATE 的对象/迁移链断裂/多 head"。库名 _dev 后缀以通过启动期模式守卫。
|
||||
docker run -d --name ci-pg-mig --network ci-net \
|
||||
-e POSTGRES_USER=csctalon \
|
||||
-e POSTGRES_PASSWORD=csctalon123 \
|
||||
-e POSTGRES_DB=csctalon_dev \
|
||||
postgres:15
|
||||
# 等待 PG 就绪(最多 30s)
|
||||
for i in $(seq 1 30); do
|
||||
docker exec ci-pg pg_isready -U csctalon && break
|
||||
sleep 1
|
||||
done
|
||||
for i in $(seq 1 30); do
|
||||
docker exec ci-pg-mig pg_isready -U csctalon && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: 后端测试 (docker python:3.11-slim)
|
||||
id: backend-test
|
||||
@@ -59,8 +73,20 @@ jobs:
|
||||
apt-get update -qq && apt-get install -y -qq libzbar0 libpq-dev gcc >/dev/null 2>&1
|
||||
pip install -q --upgrade pip >/dev/null 2>&1
|
||||
pip install -q -e '.[dev]' >/dev/null 2>&1
|
||||
echo '== ruff (tests/) =='
|
||||
ruff check tests/
|
||||
echo '== ruff (app/ + tests/) =='
|
||||
ruff check app/ tests/
|
||||
echo '== alembic upgrade head (空库迁移链门禁, #211) =='
|
||||
# 命令级 env 仅作用于本条 alembic 进程(切勿 export 到全局): tests/conftest.py
|
||||
# 用 setdefault 从 TEST_POSTGRES_* 回填 POSTGRES_*, 若全局已存在 POSTGRES_HOST
|
||||
# 会遮蔽回填, 反令 pytest 连到空库主机上不存在的 csctalon_test。
|
||||
# 多 head / 链断裂时 upgrade head 直接非零退出(set -e 即门禁失败)。
|
||||
APP_MODE=dev POSTGRES_HOST=ci-pg-mig POSTGRES_PORT=5432 \
|
||||
POSTGRES_USER=csctalon POSTGRES_PASSWORD=csctalon123 \
|
||||
POSTGRES_DB=csctalon_dev python -m alembic upgrade head
|
||||
echo '== alembic heads (应为单一 head) =='
|
||||
APP_MODE=dev POSTGRES_HOST=ci-pg-mig POSTGRES_PORT=5432 \
|
||||
POSTGRES_USER=csctalon POSTGRES_PASSWORD=csctalon123 \
|
||||
POSTGRES_DB=csctalon_dev python -m alembic heads
|
||||
echo '== pytest =='
|
||||
pytest
|
||||
"
|
||||
@@ -96,7 +122,7 @@ jobs:
|
||||
|
||||
- name: 清理 postgres 容器
|
||||
run: |
|
||||
docker rm -f ci-pg 2>/dev/null || true
|
||||
docker rm -f ci-pg ci-pg-mig 2>/dev/null || true
|
||||
docker network rm ci-net 2>/dev/null || true
|
||||
echo "已清理测试容器"
|
||||
|
||||
|
||||
@@ -20,12 +20,16 @@ paths = [
|
||||
'''^\.gitleaks\.toml$''',
|
||||
'''package-lock\.json$''',
|
||||
'''yarn\.lock$''',
|
||||
# CI 工作流: postgres service 测试密码(非业务凭证, 与 config.py 默认一致)。
|
||||
# 仅用于 CI 隔离测试库, 生产走 .env 注入; 属于测试基础设施豁免, 不违反 OPS-11。
|
||||
'''^\.gitea/workflows/.*\.yml$''',
|
||||
# 后端测试: 测试用的假手机号/假数据(如 13812345678), 非真实凭证。
|
||||
# 测试文件可能构造各种"像凭证"的样例数据, 豁免避免误报拖慢开发。
|
||||
'''^backend/tests/.*\.py$''',
|
||||
# CI 工作流: 仅 test.yml 含 postgres service 测试口令(非业务凭证, 与 config.py
|
||||
# 默认一致), 只用于 CI 隔离库, 生产走 .env 注入; 属测试基础设施豁免, 不违反 OPS-11。
|
||||
# #211 收窄: 不再用 .*\.yml 整体放行整个 workflows 目录 —— 其余 workflow 及未来
|
||||
# 新增工作流一律纳入扫描, 防凭证借 CI 文件绕过。
|
||||
'''^\.gitea/workflows/test\.yml$''',
|
||||
# #211 收窄: 不再整体放行 backend/tests(原放行会让真实密钥借测试文件绕过**所有**规则)。
|
||||
# 测试夹具天然高发的「假手机号 / 假强密码串」改在对应**规则级** allowlist 精确豁免
|
||||
# (见文末 hardcoded-phone-number / hardcoded-strong-password 两条规则);
|
||||
# 而 scrm-* / shanmao-* 业务凭证规则与 gitleaks 内置密钥规则(AKIA/openai/jwt/
|
||||
# generic-api-key/private-key 等)对 backend/tests 仍全量生效。
|
||||
]
|
||||
# 占位符/示例值不报警
|
||||
# 注意: admin123/csctalon123 已从全局 allowlist 移除(2026-08-20 审计 OPS-11):
|
||||
@@ -77,6 +81,12 @@ regexes = [
|
||||
'''REPLACE_''',
|
||||
'''\$\{2:?''',
|
||||
]
|
||||
# #211: 测试夹具的默认假密码/密文样例(如 pwd="P@ssw0rd123"、
|
||||
# "unit-test-...-secret"、"sk-...-never-leak")是本条启发式规则的高假阳性来源,
|
||||
# 故仅此一条规则对 tests 豁免; 真实密钥形态仍由 gitleaks 内置规则在 tests 中扫描。
|
||||
paths = [
|
||||
'''^backend/tests/.*\.py$''',
|
||||
]
|
||||
tags = ["password", "hardcoded"]
|
||||
|
||||
# 规则3: 硬编码管理员手机号(11位连续数字,出现在赋值/字典字面量中)
|
||||
@@ -85,6 +95,13 @@ id = "hardcoded-phone-number"
|
||||
description = "疑似硬编码手机号(11位 1开头)"
|
||||
regex = '''["']?(1[3-9]\d{9})["']?\s*[,\)#]'''
|
||||
tags = ["phone", "pii"]
|
||||
# #211: 测试夹具大量构造 11 位手机号(连续假号 13700137xxx、号码分割逻辑样例等),
|
||||
# 是本条 PII 启发式规则的高假阳性来源, 仅此规则对 tests 豁免; 带账号登录语义的
|
||||
# SCRM 手机号仍由 scrm-baiyin-credentials 规则(对 tests 生效)兜底, 不会漏。
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''^backend/tests/.*\.py$''',
|
||||
]
|
||||
|
||||
# 规则4: 杉猫(BMS)平台凭证(2026-08-20 审计 C-1 新增)
|
||||
# 覆盖两种形态: SHANMAO_PASS = "值" 直接赋值, 与 os.environ.get("SHANMAO_PASS", "值") 回退默认值
|
||||
|
||||
@@ -161,7 +161,7 @@ csctalon/
|
||||
| 规范 | `docs/standards/` | 错误码 / 审查清单 / 任务派工单模板 / 工单驱动工作流 |
|
||||
| 文档组织 | `docs/README.md` | docs 目录职责 / 文件命名规范 / 运维手册分发 |
|
||||
| 测试 | `backend/tests/` | pytest 基建, 独立 `csctalon_test` 库, 隔离开发/生产 |
|
||||
| CI | `.gitea/workflows/` | gitleaks 凭证扫描 + backend-test(ruff + pytest 强制) |
|
||||
| CI | `.gitea/workflows/` | gitleaks 凭证扫描 + backend-test(ruff `app/`+`tests/` · **空库 alembic upgrade head** · pytest,强制) |
|
||||
|
||||
**协作流程**: 需求/事件 → **建 Gitea 工单**(见 [docs/standards/issue-workflow.md](docs/standards/issue-workflow.md),事故拆单:incident + governance)→ 架构师出设计 → 人工锁定(`docs/arch/` + git tag) → 开发者实现(提交/PR 关联 `#issue号`)→ 审查员审查 → 人工终审合并。测试与 lint 由 CI 机器验证, 不依赖 AI 口头声称。
|
||||
|
||||
@@ -175,6 +175,10 @@ pip install -e ".[dev]"
|
||||
pytest
|
||||
# 静态检查
|
||||
ruff check app/ tests/
|
||||
# 空库迁移链自检(CI 门禁 #211; 需先建一个空库, 完整命令见
|
||||
# docs/standards/alembic-migration管理规范.md §二/§八)
|
||||
APP_MODE=dev POSTGRES_DB=<空库名_dev> python -m alembic upgrade head
|
||||
python -m alembic heads # 必须只输出一个 (head)
|
||||
```
|
||||
|
||||
## 核心功能模块
|
||||
|
||||
@@ -57,7 +57,26 @@ def upgrade() -> None:
|
||||
)
|
||||
|
||||
# 1) 删除旧约束(NULLS DISTINCT 语义,对含 NULL 的记录不起去重作用)
|
||||
op.drop_constraint(_CONSTRAINT_NAME, "cs_records", type_="unique")
|
||||
# ⚠️ 空库可重放(#211):建表迁移 b4c9d1f02e5a 从未创建过该唯一约束
|
||||
# (旧约束历史上仅由 create_all 依模型带出,链上无独立建约束迁移)。
|
||||
# 全新环境顺序迁移到此处时约束并不存在,无条件 drop_constraint 会以
|
||||
# UndefinedObject 中断整条迁移链(create_all + stamp head 的盲区)。
|
||||
# 故先查 pg_constraint:存在(存量库)才 DROP;不存在(全新空库)跳过,
|
||||
# 由下一步直接建立 NULLS NOT DISTINCT 约束。两种路径最终 schema 一致。
|
||||
op.execute(
|
||||
f"""
|
||||
DO $do$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'cs_records'::regclass
|
||||
AND conname = '{_CONSTRAINT_NAME}'
|
||||
) THEN
|
||||
ALTER TABLE cs_records DROP CONSTRAINT {_CONSTRAINT_NAME};
|
||||
END IF;
|
||||
END $do$;
|
||||
"""
|
||||
)
|
||||
|
||||
# 2) 重建为 NULLS NOT DISTINCT:NULL 视为相等值,参与去重
|
||||
# 这样即使 problem_create_time 为 NULL,重复行也会被数据库拒绝。
|
||||
|
||||
@@ -13,9 +13,9 @@ from app.api.deps import AdminUser, DBSession
|
||||
from app.models.llm_provider import LlmCallLog
|
||||
from app.schemas.common import R
|
||||
from app.schemas.llm_provider import (
|
||||
LlmCloudPolicyUpdate,
|
||||
LlmProviderCreate,
|
||||
LlmProviderUpdate,
|
||||
LlmCloudPolicyUpdate,
|
||||
LlmRoleMemberAdd,
|
||||
LlmRoleMemberOrder,
|
||||
LlmRoleUpdate,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
auto = 本地失败自动降级云端(⚠️ 可能静默出域)
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Integer, String
|
||||
from sqlalchemy import Boolean, CheckConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- 本表 role_key = 任务**能力需求**(该找哪个模型)
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -587,7 +587,7 @@ def get_cloud_policy(db: Session | None = None) -> dict:
|
||||
def _policy_row_exists(db: Session | None) -> bool:
|
||||
"""DB 单行表是否已有策略行(决定界面显示"来源: 数据库/环境变量")。"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.llm_cloud_policy import LlmCloudPolicy, SINGLETON_ID
|
||||
from app.models.llm_cloud_policy import SINGLETON_ID, LlmCloudPolicy
|
||||
|
||||
own = db is None
|
||||
d = db or SessionLocal()
|
||||
@@ -603,7 +603,7 @@ def _policy_row_exists(db: Session | None) -> bool:
|
||||
def _policy_updated_by(db: Session | None) -> str | None:
|
||||
"""读策略行的最后修改人(界面展示审计线索)。"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.llm_cloud_policy import LlmCloudPolicy, SINGLETON_ID
|
||||
from app.models.llm_cloud_policy import SINGLETON_ID, LlmCloudPolicy
|
||||
|
||||
own = db is None
|
||||
d = db or SessionLocal()
|
||||
|
||||
@@ -96,7 +96,7 @@ def _read_policy_from_db() -> str | None:
|
||||
|
||||
db = None
|
||||
try:
|
||||
from app.models.llm_cloud_policy import LlmCloudPolicy, SINGLETON_ID
|
||||
from app.models.llm_cloud_policy import SINGLETON_ID, LlmCloudPolicy
|
||||
db = SessionLocal()
|
||||
row = db.query(LlmCloudPolicy).filter(LlmCloudPolicy.id == SINGLETON_ID).first()
|
||||
val = (row.policy or "").strip().lower() if row else None
|
||||
|
||||
@@ -19,7 +19,6 @@ import pytest
|
||||
from app.services import llm_provider_service, system_event_service
|
||||
from app.services.llm_client import LLMError, _call_multi_provider
|
||||
|
||||
|
||||
# ── 轻量 fake, 不依赖真实 DB ─────────────────────────────────────────
|
||||
|
||||
class _FakeQuery:
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
**性质**:元需求(META) —— 约束**所有**表结构变更
|
||||
|
||||
|
||||
> 版本:v1.1 · 日期:2026-08-26
|
||||
> 版本:v1.2 · 日期:2026-10-07(#211 增补:空库可重放 CI 门禁 +「修改已提交 migration」的例外,见 §二 / §八 第 6、7 条)
|
||||
> 关联代码:[backend/alembic/](../backend/alembic/)
|
||||
> 当前 head:`73a7922ec598`(单一 head)
|
||||
> 当前 head:以 `alembic heads` 实测为准 —— 2026-10-07 实测为 `t0u1v2w3x4y5`(单一 head)。
|
||||
> 下方 §一 版本链图为 v1.1(2026-08-26)时点快照,版本号可能落后,**请勿据该图判断 head**。
|
||||
|
||||
## 一、版本链现状
|
||||
|
||||
@@ -70,6 +71,14 @@ alembic stamp <revision>
|
||||
|
||||
# 自动生成 migration(需连接数据库)
|
||||
alembic revision --autogenerate -m "description"
|
||||
|
||||
# ★空库可重放自检(CI 强制门禁,#211;规范依据见 §八 第 6、7 条)
|
||||
# 在一个全新空库上从基线重放整条迁移链 —— create_all + stamp 覆盖不到的
|
||||
# 「链断裂 / 多 head / DROP 了链上从未 CREATE 的对象」只有它能暴露。
|
||||
createdb csctalon_migchk_dev # 库名须 _dev 后缀,通过启动期模式守卫
|
||||
APP_MODE=dev POSTGRES_DB=csctalon_migchk_dev python -m alembic upgrade head
|
||||
python -m alembic heads # 必须只输出一行 (head)
|
||||
dropdb csctalon_migchk_dev # 校验后删除临时库
|
||||
```
|
||||
|
||||
> **注意**:在 Docker 环境中,需在 backend 容器内执行:`docker exec csctalon-backend alembic <command>`
|
||||
@@ -209,7 +218,9 @@ docker exec csctalon-backend alembic stamp <revision>
|
||||
## 八、注意事项
|
||||
|
||||
1. **不要删除已提交的 migration 文件**:会导致版本链断裂
|
||||
2. **不要修改已提交的 migration 文件**:已应用的环境不会重新执行
|
||||
2. **原则上不要修改已提交的 migration 文件**:已应用的环境不会重新执行,改动对存量库无效、还会造成认知不一致。**唯一例外见第 7 条**(修复空库可重放性缺陷)。
|
||||
3. **downgrade 必须可实现**:以便回滚
|
||||
4. **单 worker 模式**:docker-compose.yml 中 `gunicorn -w 1` 保证互斥锁有效
|
||||
5. **alembic_version 表应只有 1 行**:正常状态为当前 head 版本号;多行表示异常(可用 `alembic stamp` 修复)
|
||||
6. **★迁移必须能在全新空库顺序 `upgrade head`(空库可重放,CI 强制门禁,#211)**:CI(`.gitea/workflows/test.yml`)在一个独立空库上执行 `alembic upgrade head` 并校验单一 head。测试库走 `create_all + stamp head`(见 [tests/conftest.py](../../backend/tests/conftest.py)),会**跳过全部迁移**——因此凡是"假设某约束/索引/列已被 create_all 带出"就直接 `DROP`/`ALTER` 的迁移,在存量库正常、在全新环境却会中断(典型报错 `UndefinedObject: constraint "xxx" does not exist`)。编写要求:对"可能不存在"的对象做 `DROP`/`ALTER` 前,先用 `pg_constraint` / `information_schema` 做存在性判断(或用 `IF EXISTS`),保证「全新空库」与「存量库」两条路径最终 schema 一致;提交前必须按 §二 的空库自检命令本地实测一遍。
|
||||
7. **第 2 条的唯一例外——为修复空库可重放性缺陷可改已提交迁移**,但须同时满足:(a) **不改变**已应用环境的最终 schema 与数据(只加存在性判断/分支,让两条路径殊途同归);(b) 不增删版本、不改 `revision` / `down_revision`;(c) 本地全新空库 `upgrade head` 实测通过。已应用该迁移的库不会重跑,故对生产无影响。首例:`a1c2e3f4d5b6`(#211)——其无条件 `DROP CONSTRAINT uq_cs_record_business` 在空库断裂(该约束链上从未 CREATE,历史上仅由 create_all 依模型带出),已改为先查 `pg_constraint`、存在才 DROP。
|
||||
|
||||
Reference in New Issue
Block a user