fix(deploy): create_tables.py rewrite - all DDL via json2ddl from models/*.json, never DROP (2026-09-07 wipe incident), generic column sync, idempotent indexes by column-set; build.sh remove 8b DROP section, uapi seed after tables

This commit is contained in:
yumoqing 2026-09-08 15:28:50 +08:00
parent 86bd282be8
commit 72a17f7b84
2 changed files with 252 additions and 421 deletions

View File

@ -123,53 +123,10 @@ done
# pipeline_task 兜底:模块缺失时保留空目录防菜单 404正常路径已由上方软链覆盖
[ -e wwwroot/pipeline_task ] || mkdir -p wwwroot/pipeline_task
# 8b. uapi + rag 模块建表 + 导入外部服务配置种子
# uapi 表(upapp/uapi/uapiio...)必须先建rag 的 uapi_seed.sql 才有表可插
# rag 表统一 rag_ 前缀,与产线业务表隔离;数据独立于 ragserver同代码不同宿主各自的数据
for m in uapi rag dingdingflow account_resource storage_resource filemgr supplychain; do
if [ -d "pkgs/$m/models" ] && ls pkgs/$m/models/*.json >/dev/null 2>&1; then
cd "$cdir/pkgs/$m/models"
"$cdir/py3/bin/json2ddl" mysql . > "/tmp/pipeline_${m}_ddl.sql" || {
echo "ERROR: $m json2ddl failed" >&2; cd "$cdir"; exit 1; }
if [ ! -s "/tmp/pipeline_${m}_ddl.sql" ]; then
echo "ERROR: $m DDL empty — check models/*.json (need summary+fields)" >&2
cd "$cdir"; exit 1
fi
cd "$cdir"
fi
done
if [ -f /tmp/pipeline_uapi_ddl.sql ] || [ -f /tmp/pipeline_rag_ddl.sql ]; then
"$cdir/py3/bin/python" - <<'PYEOF'
import sys, os, subprocess
sys.path.insert(0, os.getcwd())
from appPublic.jsonConfig import getConfig
from appPublic.aes import aes_decode_b64
cfg = getConfig('.', {'workdir': '.'})
kw = cfg.databases['pipeline'].kwargs
pwd = aes_decode_b64(cfg.password_key, kw.password)
# 顺序关键uapi 建表 → rag 建表 → rag 的 uapi 配置种子
for sqlfile in ['/tmp/pipeline_uapi_ddl.sql', '/tmp/pipeline_rag_ddl.sql',
'/tmp/pipeline_dingdingflow_ddl.sql',
'/tmp/pipeline_account_resource_ddl.sql',
'/tmp/pipeline_storage_resource_ddl.sql',
'/tmp/pipeline_filemgr_ddl.sql',
'/tmp/pipeline_supplychain_ddl.sql',
'pkgs/rag/init/uapi_seed.sql']:
if not os.path.exists(sqlfile):
print(' SKIP (missing): %s' % sqlfile)
continue
with open(sqlfile, 'rb') as f:
r = subprocess.run(['mysql', '-h', str(kw.host), '-P', str(kw.port),
'-u', str(kw.user), '-p%s' % pwd, str(kw.db)],
stdin=f, capture_output=True)
tag = os.path.basename(sqlfile)
if r.returncode == 0:
print(' rag/uapi: %s applied' % tag)
else:
err = r.stderr.decode('utf-8', 'replace').strip().split(chr(10))[0]
print(' WARN %s: %s' % (tag, err[:160]))
PYEOF
fi
# 8b. uapi/rag 等模块建表已统一交给 create_tables.py步骤 10.6,动态扫描 pkgs/*/models幂等不 DROP
# 历史事故2026-09-07本段曾对这 7 个模块跑含 `drop table if exists` 的 json2ddl DDL
# 每次部署清空 upapp/uapi/storres_spec/supplychain 等表——与 TABLE_MODULES 清库同源隐患。
# rag 的 uapi_seed.sql 数据种子INSERT IGNORE依赖 uapi 表先建)移到步骤 10.65 执行。
# 9. Sync password_key from Sage (not databases — those are app-specific)
if [ -f /d/apitest/sage/conf/config.json ]; then
@ -246,6 +203,30 @@ else
echo " WARN: scripts/create_tables.py not found"
fi
# 10.65 rag 模块 uapi 外部服务配置种子INSERT IGNORE 幂等;依赖 uapi 表已由 10.6 建好)
echo "=== rag uapi seed ==="
if [ -f "$cdir/pkgs/rag/init/uapi_seed.sql" ]; then
"$cdir/py3/bin/python" - <<'PYEOF'
import sys, os, subprocess
sys.path.insert(0, os.getcwd())
from appPublic.jsonConfig import getConfig
from appPublic.aes import aes_decode_b64
cfg = getConfig('.', {'workdir': '.'})
kw = cfg.databases['pipeline'].kwargs
pwd = aes_decode_b64(cfg.password_key, kw.password)
sqlfile = 'pkgs/rag/init/uapi_seed.sql'
with open(sqlfile, 'rb') as f:
r = subprocess.run(['mysql', '-h', str(kw.host), '-P', str(kw.port),
'-u', str(kw.user), '-p%s' % pwd, str(kw.db)],
stdin=f, capture_output=True)
if r.returncode == 0:
print(' rag uapi_seed applied')
else:
err = r.stderr.decode('utf-8', 'replace').strip().split(chr(10))[0]
print(' WARN uapi_seed: %s' % err[:160])
PYEOF
fi
# 10.7 Import module init data (appcodes dictionaries)
echo "=== Module init data import ==="
if [ -f "$cdir/scripts/import_init.py" ]; then

View File

@ -1,12 +1,21 @@
#!/usr/bin/env python3
"""建表脚本:从各模块 models/*.json 生成 DDL 并在 pipeline 库执行
"""建表脚本:从各模块 models/*.json 经 json2ddl 生成 DDL幂等落库
对每个模块调用 json2ddl 生成 DDL按分号分割后逐条执行幂等DDL DROP TABLE IF EXISTS
铁律2026-09-07 用户定夺
1. DDL 一律由 json2ddl models/*.json 生成models JSON 是表结构唯一事实源
禁止手写 CREATE TABLE历史手写段已全部移除 pipeline-llm mysql.ddl.sql 依赖
2. 全程幂等CREATE TABLE IF NOT EXISTS**绝不 DROP**2026-09-07 14:08 事故
TABLE_MODULES DROP 重建一次部署清空 pipeline product/pricing/discount/sms
全部数据13 个模型定价丢失27 个产品映射丢失全平台记账 failed
3. 存量表 schema 演进靠通用列同步器解析 DDL 的期望列 vs information_schema 实际列
缺列 ALTER TABLE ADD COLUMN 补齐加字段只改 models JSON部署自动补列
4. 索引幂等 (, 列集合, 唯一性) 判重后 CREATE不按索引名手写时代的旧索引名
json2ddl 生成名不同按名判重会重复建同字段索引
用法
py3/bin/python scripts/create_tables.py
"""
import sys, os, re, asyncio, subprocess
import sys, os, re, json, asyncio, subprocess, glob
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(SCRIPT_DIR)
@ -19,190 +28,28 @@ from appPublic.folderUtils import ProgramPath
from ahserver.serverenv import ServerEnv
from ahserver.globalEnv import initEnv
TABLE_MODULES = ['product_management', 'discount', 'pricing', 'unipay', 'smssend']
# 幂等建表模块(不 DROP用 IF NOT EXISTS避免清空运行时数据如 llm/pipelines/skill_proposals/tasks 等)
# appbase 也放这里svgicon/appcodes/appcodes_kv/params 是系统级配置表含种子数据幂等建params 另在下方手动 ensure 默认参数)
# dapi 放这里downapp/downapikey 是下位系统接入表,幂等建(不 DROP避免误删已注册的下位系统
# accounting 放这里subject/account_config/accounting_config 是记账配置account/ledger 是运行时数据,幂等建(不 DROP
# 产线模块 pipeline-bidding(bid_*) / pipeline-opportunity(opp_*) 放这里:走 json2ddl 从 models/*.json 生成
# (已验证无 DEFAULT ''N'' bug项目里含运行时数据标书章节/商机报告),必须幂等不 DROP
IDEMPOTENT_MODULES = ['pipeline_core', 'pipeline-service', 'appbase', 'dapi', 'accounting',
'pipeline-bidding', 'pipeline-opportunity']
# appbase 系统级配置表(幂等建表 + 默认参数,不 DROP 以免清空运行时数据)
_PARAMS_DDL = """
CREATE TABLE IF NOT EXISTS params (
`id` VARCHAR(32) comment 'id',
`params_name` VARCHAR(255) comment '参数名称',
`params_value` VARCHAR(4000) comment '参数值',
primary key(id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci engine=innodb comment '系统参数表';
"""
# 不建表的模块(无 models/ 或表由宿主/其他机制管理)
SKIP_MODULES = set()
# appbase params 默认参数(数据种子,非 schemaparams 表结构走 models JSON
_PARAMS_INIT = [
("workspace_base", "/d/pipeline/workspaces"),
("task_max_retry", "3"),
("register_open", "1"),
]
# sd_features 功能/需求表 DDL手写避开 json2ddl 的 DEFAULT ''N'' bug索引内联避免重复建 Duplicate key name
_SD_FEATURES_DDL = """
CREATE TABLE IF NOT EXISTS sd_features (
`id` VARCHAR(32) NOT NULL comment '主键ID',
`project_id` VARCHAR(32) NOT NULL comment '项目ID',
`iteration_id` VARCHAR(32) comment '迭代ID',
`feature_name` VARCHAR(200) NOT NULL comment '功能名称',
`description` text comment '功能描述',
`feature_type` VARCHAR(20) NOT NULL DEFAULT 'new_feature' comment '功能类型',
`priority` VARCHAR(10) NOT NULL DEFAULT 'P2' comment '优先级',
`status` VARCHAR(20) NOT NULL DEFAULT 'proposed' comment '功能状态',
`acceptance_criteria` text comment '验收标准',
`task_id` VARCHAR(32) comment '关联Pipeline任务ID',
`created_by` VARCHAR(32) comment '创建人',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL comment '创建时间',
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP comment '更新时间',
primary key(id),
KEY idx_sd_features_project(project_id),
KEY idx_sd_features_iteration(iteration_id),
KEY idx_sd_features_status(status)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci engine=innodb comment '功能/需求表';
"""
# pipeline-service v2 运行时表(原 init.py::_init_v2_tables 启动时建,现迁到部署期)。
# 手写幂等 DDL避开 json2ddl 的 DEFAULT ''N'' bug索引内联避免重复建 Duplicate key name。
# 铁律:运行期不做任何 schema 变更DB 全部变化必须在部署期build.sh → create_tables.py完成。
_V2_RUNTIME_TABLES = [
"""
CREATE TABLE IF NOT EXISTS pipeline_user_memory (
`id` varchar(32) NOT NULL,
`memory_key` varchar(64) NOT NULL,
`content` text NOT NULL,
`category` varchar(32) NOT NULL DEFAULT 'memory',
`scope` varchar(32) NOT NULL DEFAULT 'global',
`scope_id` varchar(64) NOT NULL DEFAULT '',
`priority` int(11) NOT NULL DEFAULT 0,
`access_count` int(11) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_memory` (`memory_key`,`category`),
KEY `idx_category_priority` (`category`,`priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
""",
"""
CREATE TABLE IF NOT EXISTS sd_deploy_accounts (
`id` varchar(32) NOT NULL,
`user_id` varchar(32) NOT NULL,
`account_name` varchar(64) NOT NULL,
`deploy_dir` varchar(500) NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'active',
`sandbox_config` text,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account_name` (`account_name`),
UNIQUE KEY `uk_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS sd_work_envs (
`id` varchar(32) NOT NULL,
`owner_type` varchar(10) NOT NULL,
`owner_id` varchar(32) NOT NULL,
`mode` varchar(10) NOT NULL DEFAULT 'local',
`remote_host` varchar(200),
`remote_port` int(11) DEFAULT 22,
`remote_user` varchar(100),
`remote_key_path` varchar(500),
`remote_dir` varchar(500),
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_owner` (`owner_type`,`owner_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS pipeline_session_settings (
`id` varchar(32) NOT NULL,
`session_id` varchar(64) NOT NULL,
`user_id` varchar(32) NOT NULL,
`current_project_id` varchar(32) NOT NULL DEFAULT '',
`current_iteration_id` varchar(32) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_session` (`user_id`,`session_id`),
KEY `idx_session` (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS pipeline_llm_tokens (
`id` varchar(32) NOT NULL,
`token` varchar(80) NOT NULL,
`org_id` varchar(32) NOT NULL DEFAULT '0',
`project_id` varchar(32) NOT NULL DEFAULT '',
`task_id` varchar(32) NOT NULL DEFAULT '',
`model_name` varchar(100) NOT NULL DEFAULT '',
`purpose` varchar(100) NOT NULL DEFAULT '',
`status` varchar(20) NOT NULL DEFAULT 'active',
`expires_at` datetime NOT NULL,
`max_calls` int(11) NOT NULL DEFAULT 500,
`call_count` int(11) NOT NULL DEFAULT 0,
`prompt_tokens` bigint(20) NOT NULL DEFAULT 0,
`completion_tokens` bigint(20) NOT NULL DEFAULT 0,
`created_by` varchar(64) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`last_used_at` datetime NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_token` (`token`),
KEY `idx_org` (`org_id`),
KEY `idx_project` (`project_id`),
KEY `idx_status_exp` (`status`,`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
# sd_iterations 存量数据修复(数据层,非 schemaseq_no 列由通用列同步器补)
_DATA_FIXUPS = [
# 「当前迭代」改为显式 status='in_progress',迭代按 seq_no 顺序编号推进
("sd_iterations seq_no 回填",
"UPDATE sd_iterations t JOIN ("
"SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at ASC, id ASC) AS rn "
"FROM sd_iterations WHERE seq_no = 0) x ON x.id = t.id "
"SET t.seq_no = x.rn"),
("sd_iterations 脏状态修复 active→in_progress",
"UPDATE sd_iterations SET status='in_progress', updated_at=NOW() WHERE status='active'"),
]
# v2 引擎列迁移(原 init.py::_init_v2_tables 启动时 ALTER 迁移现迁到部署期information_schema 幂等)
_V2_COLUMNS = {
"pipelines": {"agent_config": "text"},
"sd_org_settings": {"agent_config": "text"},
"pipeline_tasks": {"retry_count": "int NOT NULL DEFAULT 0", "last_error": "text",
"parent_id": "varchar(32) NULL", "depends_on": "text NULL"},
}
# app_audit 审计日志表(原 app_audit/init.py::_init_audit 启动时建,现迁到部署期)
_AUDIT_LOGS_DDL = """
CREATE TABLE IF NOT EXISTS sd_audit_logs (
`id` varchar(32) NOT NULL,
`user_id` varchar(32),
`username` varchar(100),
`action` varchar(50) NOT NULL,
`target` varchar(200),
`detail` text,
`result` varchar(10),
`client_ip` varchar(64),
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_action` (`action`),
KEY `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
# git 级串行锁表:多 worker含跨主机对同一 repo 的 git 操作互斥,带 TTL 自动释放
# (expires_at 过期可被下一个申请者原子接管)。lock_key = sha256(repo_abs_path)。
_GIT_LOCKS_DDL = """
CREATE TABLE IF NOT EXISTS pipeline_git_locks (
`lock_key` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`lock_key`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
def split_ddl(ddl):
"""按分号分割 DDL跳过注释行和空语句。
@ -221,15 +68,13 @@ def split_ddl(ddl):
if in_str:
cur.append(ch)
if ch == "'":
# '' 是转义的单引号,仍在串内
if i + 1 < n and ddl[i + 1] == "'":
if i + 1 < n and ddl[i + 1] == "'": # '' 转义,仍在串内
cur.append("'")
i += 1
else:
in_str = False
elif ch == '\\' and i + 1 < n:
# 反斜杠转义MySQL 风格)
cur.append(ddl[i + 1])
cur.append(ddl[i + 1]) # 反斜杠转义MySQL 风格)
i += 1
elif ch == "'":
in_str = True
@ -252,6 +97,171 @@ def split_ddl(ddl):
return stmts
def parse_create_table(stmt):
"""从 CREATE TABLE 语句解析 (表名, {列名: 列定义DDL})。
列定义直接取自 json2ddl 输出类型映射逻辑单一来源sqlor 模板不在此重复实现
"""
m = re.search(r'(?is)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?\s*\(', stmt)
if not m:
return None, {}
table = m.group(1)
body = stmt[m.end():]
# 截到最后一个 ) 之前(表选项在括号外)
depth = 1
end = -1
in_str = False
for i, ch in enumerate(body):
if ch == "'" and (i == 0 or body[i - 1] != '\\'):
in_str = not in_str
elif not in_str:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if depth == 0:
end = i
break
if end < 0:
return table, {}
cols = {}
for line in body[:end].split('\n'):
line = line.strip().rstrip(',')
cm = re.match(r'`(\w+)`\s+(.+)$', line)
if cm:
cols[cm.group(1)] = cm.group(2).strip()
return table, cols
def parse_create_index(stmt):
"""解析 CREATE [UNIQUE] INDEX 语句 → (索引名, 表名, 唯一?, 列列表)。"""
m = re.match(r'(?is)CREATE\s+(UNIQUE\s+)?INDEX\s+`?(\w+)`?\s+ON\s+`?(\w+)`?\s*\(([^)]+)\)', stmt)
if not m:
return None
unique = bool(m.group(1))
name, table = m.group(2), m.group(3)
cols = [c.strip().strip('`') for c in m.group(4).split(',')]
return name, table, unique, cols
async def get_existing_indexes(sor, table):
"""现网索引集合:{(唯一?, (列...)): 索引名}——按列集合判重,免疫索引改名。"""
recs = await sor.sqlExe(
"SELECT index_name, non_unique, column_name, seq_in_index "
"FROM information_schema.statistics "
"WHERE table_schema=DATABASE() AND table_name=${t}$ "
"ORDER BY index_name, seq_in_index", {"t": table})
await sor.sqlExe("COMMIT", {})
acc = {}
for r in (recs or []):
name = getattr(r, 'index_name', '')
acc.setdefault((name, str(getattr(r, 'non_unique', ''))), []).append(
getattr(r, 'column_name', ''))
return {(nu == '0', tuple(cols)): name for (name, nu), cols in acc.items()}
async def get_existing_columns(sor, table):
recs = await sor.sqlExe(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema=DATABASE() AND table_name=${t}$", {"t": table})
await sor.sqlExe("COMMIT", {})
return {getattr(r, 'column_name', '') for r in (recs or [])}
async def table_exists(sor, table):
recs = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM information_schema.tables "
"WHERE table_schema=DATABASE() AND table_name=${t}$", {"t": table})
await sor.sqlExe("COMMIT", {})
return bool(recs) and int(getattr(recs[0], 'c', 0) or 0) > 0
async def apply_module_ddl(sor, mod, models_dir, stats):
"""单模块json2ddl → 幂等建表 → 列同步 → 索引幂等。"""
json2ddl = os.path.join(ROOT_DIR, 'py3', 'bin', 'json2ddl')
r = subprocess.run([json2ddl, 'mysql', models_dir], capture_output=True, text=True)
if r.returncode != 0 or not r.stdout.strip():
print(f' skip {mod}: json2ddl failed ({(r.stderr or "").strip()[:120]})')
return
creates, indexes = [], []
for stmt in split_ddl(r.stdout):
head = stmt.lstrip('(').lstrip().split(None, 2)
kw = (head[0] + ' ' + (head[1] if len(head) > 1 else '')).upper()
if re.match(r'(?i)^\s*DROP\s+TABLE', stmt):
continue # 铁律:绝不 DROP2026-09-07 清库事故根治)
if re.match(r'(?i)^\s*CREATE\s+TABLE', stmt):
creates.append(stmt)
elif re.match(r'(?i)^\s*CREATE\s+(UNIQUE\s+)?INDEX', stmt):
indexes.append(stmt)
elif re.match(r'(?i)^\s*ALTER\s+TABLE', stmt):
pass # json2ddl 不产 ALTER存量演进走列同步器
else:
stats['other'] += 1
# 1) 建表IF NOT EXISTS+ 收集期望列
expected_cols = {}
for stmt in creates:
table, cols = parse_create_table(stmt)
if not table:
continue
expected_cols[table] = cols
s = re.sub(r'(?i)CREATE\s+TABLE\s+', 'CREATE TABLE IF NOT EXISTS ', stmt, count=1)
existed = await table_exists(sor, table)
try:
await sor.execute(s, {})
if not existed:
stats['created'] += 1
print(f' + table {table} (新建)')
except Exception as e:
print(f' ! table {table} 建表失败: {str(e)[:160]}')
stats['errors'] += 1
# 2) 列同步存量表补新列——models JSON 是唯一事实源)
for table, cols in expected_cols.items():
if not await table_exists(sor, table):
continue
have = await get_existing_columns(sor, table)
for col, colddl in cols.items():
if col in have:
continue
try:
await sor.execute(
f'ALTER TABLE `{table}` ADD COLUMN `{col}` {colddl}', {})
stats['cols_added'] += 1
print(f' + column {table}.{col} (存量表补列)')
except Exception as e:
print(f' ! column {table}.{col} 补列失败: {str(e)[:160]}')
stats['errors'] += 1
# 3) 索引幂等(按 列集合+唯一性 判重,免疫手写时代的旧索引名)
idx_cache = {}
for stmt in indexes:
parsed = parse_create_index(stmt)
if not parsed:
continue
name, table, unique, cols = parsed
if not await table_exists(sor, table):
continue
if table not in idx_cache:
idx_cache[table] = await get_existing_indexes(sor, table)
if (unique, tuple(cols)) in idx_cache[table]:
stats['idx_skipped'] += 1
continue
try:
await sor.execute(stmt, {})
idx_cache[table][(unique, tuple(cols))] = name
stats['idx_created'] += 1
print(f' + index {table}({",".join(cols)}){" UNIQUE" if unique else ""}')
except Exception as e:
msg = str(e)[:160]
if 'Duplicate key name' in msg:
stats['idx_skipped'] += 1
else:
print(f' ! index {name} 失败: {msg}')
stats['errors'] += 1
async def main():
config = getConfig(ROOT_DIR, NS={'workdir': ROOT_DIR, 'ProgramPath': ProgramPath()})
DBPools(config.databases)
@ -259,210 +269,50 @@ async def main():
env = ServerEnv()
env.get_module_dbname = lambda m: 'pipeline' if 'pipeline' in m else 'sage'
json2ddl = os.path.join(ROOT_DIR, 'py3', 'bin', 'json2ddl')
# 动态扫描 pkgs/*/models——不再维护静态清单
#(历史教训:清单遗漏 → pricing 定价管理 500、product/pricing 走 DROP 清库)
mods = []
for d in sorted(glob.glob(os.path.join(ROOT_DIR, 'pkgs', '*', 'models'))):
mod = os.path.basename(os.path.dirname(d))
if mod in SKIP_MODULES:
continue
if not glob.glob(os.path.join(d, '*.json')):
continue
mods.append((mod, d))
print(f' modules with models/: {len(mods)}')
stats = {'created': 0, 'cols_added': 0, 'idx_created': 0, 'idx_skipped': 0,
'errors': 0, 'other': 0}
async with DBPools().sqlorContext('pipeline') as sor:
for mod in TABLE_MODULES:
models_dir = os.path.join(ROOT_DIR, 'pkgs', mod, 'models')
if not os.path.isdir(models_dir):
print(f' skip {mod}: no models dir')
continue
r = subprocess.run([json2ddl, 'mysql', models_dir], capture_output=True, text=True)
if r.returncode != 0 or not r.stdout.strip():
print(f' skip {mod}: json2ddl failed')
continue
n = 0
for stmt in split_ddl(r.stdout):
await sor.execute(stmt, {})
n += 1
print(f' tables: {mod} created ({n} statements)')
for mod, models_dir in mods:
print(f' === {mod} ===')
await apply_module_ddl(sor, mod, models_dir, stats)
# 幂等建表模块(去 DROP + IF NOT EXISTS不清理运行时数据
for mod in IDEMPOTENT_MODULES:
models_dir = os.path.join(ROOT_DIR, 'pkgs', mod, 'models')
if not os.path.isdir(models_dir):
print(f' skip {mod}: no models dir')
continue
r = subprocess.run([json2ddl, 'mysql', models_dir], capture_output=True, text=True)
if r.returncode != 0 or not r.stdout.strip():
print(f' skip {mod}: json2ddl failed')
continue
n = 0
for stmt in split_ddl(r.stdout):
s = re.sub(r'(?i)drop\s+table\s+if\s+exists\s+[\w`]+\s*;?', '', stmt)
s = re.sub(r'(?i)CREATE\s+TABLE\s+(`?\w+`?)', r'CREATE TABLE IF NOT EXISTS \1', s)
if not s.strip():
continue
# 跳过索引/约束语句(表已存在时索引也已存在,重复建会 Duplicate key name
if re.match(r'(?i)\s*(CREATE\s+(UNIQUE\s+)?INDEX|ALTER\s+TABLE)', s):
continue
await sor.execute(s, {})
n += 1
print(f' tables: {mod} ensured ({n} statements, idempotent)')
# appbase params 表幂等建表 + 默认参数
# appbase params 默认参数(数据种子)
try:
await sor.execute(_PARAMS_DDL, {})
for pname, pval in _PARAMS_INIT:
await sor.execute(
"INSERT INTO params (id, params_name, params_value) "
"VALUES (${id}$, ${n}$, ${v}$) "
"ON DUPLICATE KEY UPDATE params_name=params_name",
{"id": pname, "n": pname, "v": pval})
print(' tables: appbase params ensured (workspace_base)')
print(' seed: appbase params ensured (workspace_base/task_max_retry/register_open)')
except Exception as e:
print(f' WARN: appbase params ensure failed: {e}')
print(f' WARN: appbase params seed failed: {e}')
# pipeline_agent_questions 团队沟通列幂等迁移显式路由版current_handler 两列替代 escalation 路径快照)
try:
_add_cols = {
"from_agentid": "VARCHAR(64) NULL COMMENT '提问Agent标识'",
"problem_type": "VARCHAR(50) NULL COMMENT '问题类型'",
"current_handler_role": "VARCHAR(32) NULL COMMENT '当前处理角色'",
"current_handler_agentid": "VARCHAR(64) NULL COMMENT '当前处理Agent标识(空=该角色任意)'",
}
for col, ddl in _add_cols.items():
r = await sor.sqlExe(
"SELECT COUNT(*) as c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pipeline_agent_questions' "
"AND COLUMN_NAME=${col}$", {"col": col})
await sor.sqlExe("COMMIT", {})
if not r or getattr(r[0], 'c', 0) == 0:
await sor.execute(
f"ALTER TABLE pipeline_agent_questions ADD COLUMN {col} {ddl}", {})
# 废弃列清理(早期路径快照方案,已改为显式 handler
for col in ["escalation_path", "escalation_pos"]:
r = await sor.sqlExe(
"SELECT COUNT(*) as c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pipeline_agent_questions' "
"AND COLUMN_NAME=${col}$", {"col": col})
await sor.sqlExe("COMMIT", {})
if r and getattr(r[0], 'c', 0) > 0:
await sor.execute(
f"ALTER TABLE pipeline_agent_questions DROP COLUMN {col}", {})
print(' migrate: pipeline_agent_questions team-communication columns ensured')
except Exception as e:
print(f' WARN: pipeline_agent_questions migrate failed: {e}')
# 存量数据修复(非 schema
for label, sql in _DATA_FIXUPS:
try:
await sor.execute(sql, {})
print(f' fixup: {label}')
except Exception as e:
print(f' WARN: {label} failed: {str(e)[:120]}')
# pipeline_human_tasks SDLC 项目级人类任务列迁移(项目/迭代/bug 归属 + QC + bug 验收)
try:
_pht_cols = {
"project_id": "VARCHAR(32) NULL COMMENT '项目ID'",
"iteration_id": "VARCHAR(32) NULL COMMENT '迭代ID'",
"bug_id": "VARCHAR(32) NULL COMMENT '关联BugID'",
"qc_status": "VARCHAR(20) NULL DEFAULT 'pending' COMMENT 'QC状态'",
"qc_comment": "VARCHAR(500) NULL COMMENT 'QC意见'",
"title": "VARCHAR(500) NULL COMMENT '任务标题'",
"description": "TEXT NULL COMMENT '任务描述'",
}
for col, ddl in _pht_cols.items():
r = await sor.sqlExe(
"SELECT COUNT(*) as c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pipeline_human_tasks' "
"AND COLUMN_NAME=${col}$", {"col": col})
await sor.sqlExe("COMMIT", {})
if not r or getattr(r[0], 'c', 0) == 0:
await sor.execute(
f"ALTER TABLE pipeline_human_tasks ADD COLUMN {col} {ddl}", {})
for idx_name, idx_col in [("pipeline_human_tasks_idx_pht_project", "project_id"),
("pipeline_human_tasks_idx_pht_iteration", "iteration_id")]:
r = await sor.sqlExe(
"SELECT COUNT(*) as c FROM information_schema.STATISTICS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pipeline_human_tasks' "
"AND INDEX_NAME=${idx}$", {"idx": idx_name})
await sor.sqlExe("COMMIT", {})
if not r or getattr(r[0], 'c', 0) == 0:
await sor.execute(
f"CREATE INDEX {idx_name} ON pipeline_human_tasks({idx_col})", {})
print(' migrate: pipeline_human_tasks project/iteration/bug/qc columns ensured')
except Exception as e:
print(f' WARN: pipeline_human_tasks migrate failed: {e}')
# sd_features 功能/需求表pipeline-sdlc 业务表)幂等建表——手写 DDL 避开 json2ddl 的 DEFAULT ''N'' bug
try:
await sor.execute(_SD_FEATURES_DDL, {})
print(' tables: sd_features ensured (idempotent)')
except Exception as e:
print(f' WARN: sd_features ensure failed: {e}')
# pipeline-llm 模型治理表8 张llm_ 前缀)——模块自带手写幂等 DDLmysql.ddl.sql
# 不走 json2ddl模型定义含 'active' 等带引号默认值,会触发 DEFAULT ''N'' bug
try:
_llm_ddl_path = os.path.join(ROOT_DIR, 'pkgs', 'pipeline-llm', 'mysql.ddl.sql')
if os.path.isfile(_llm_ddl_path):
with open(_llm_ddl_path, 'r', encoding='utf-8') as _f:
# 先剔除注释行(-- 开头),避免头部注释与首条 CREATE 粘连被整段跳过
_llm_ddl = '\n'.join(
_l for _l in _f.read().splitlines() if not _l.strip().startswith('--'))
_n = 0
for _stmt in split_ddl(_llm_ddl):
await sor.execute(_stmt, {})
_n += 1
print(f' tables: pipeline-llm ensured ({_n} statements, idempotent)')
else:
print(' WARN: pkgs/pipeline-llm/mysql.ddl.sql not found (skip llm_ tables)')
except Exception as e:
print(f' WARN: pipeline-llm tables ensure failed: {e}')
# pipeline-service v2 运行时表(原启动时建,现迁到部署期幂等建表)
try:
for ddl in _V2_RUNTIME_TABLES:
await sor.execute(ddl, {})
print(' tables: pipeline-service v2 runtime tables ensured (pipeline_user_memory/sd_deploy_accounts/sd_work_envs)')
except Exception as e:
print(f' WARN: v2 runtime tables ensure failed: {e}')
# v2 引擎列迁移(原启动时 ALTER现迁到部署期information_schema 查列存在性幂等)
try:
for tbl, cols in _V2_COLUMNS.items():
for col, ddl in cols.items():
r = await sor.sqlExe(
"SELECT COUNT(*) as c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${tbl}$ "
"AND COLUMN_NAME=${col}$", {"tbl": tbl, "col": col})
await sor.sqlExe("COMMIT", {})
if not r or getattr(r[0], 'c', 0) == 0:
await sor.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} {ddl}", {})
print(' migrate: v2 engine columns ensured (agent_config/retry_count/last_error)')
except Exception as e:
print(f' WARN: v2 engine column migrate failed: {e}')
# app_audit 审计日志表(原启动时建,现迁到部署期幂等建表)
try:
await sor.execute(_AUDIT_LOGS_DDL, {})
print(' tables: sd_audit_logs ensured (idempotent)')
except Exception as e:
print(f' WARN: sd_audit_logs ensure failed: {e}')
# git 级串行锁表(跨主机多 worker 对同 repo git 操作互斥)
try:
await sor.execute(_GIT_LOCKS_DDL, {})
print(' tables: pipeline_git_locks ensured (idempotent)')
except Exception as e:
print(f' WARN: pipeline_git_locks ensure failed: {e}')
# sd_iterations 迭代序号 seq_no 列 + 存量回填 + 脏状态修复active→in_progress
# 「当前迭代」改为显式 status='in_progress' 标记,迭代按 seq_no 顺序编号推进,
# 不再靠 created_at 推断当前迭代。
try:
r = await sor.sqlExe(
"SELECT COUNT(*) as c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sd_iterations' "
"AND COLUMN_NAME='seq_no'", {})
await sor.sqlExe("COMMIT", {})
if not r or getattr(r[0], 'c', 0) == 0:
await sor.execute("ALTER TABLE sd_iterations ADD COLUMN seq_no INT NOT NULL DEFAULT 0", {})
# 存量回填seq_no=0 的迭代按每项目 created_at ASC 顺序赋 1,2,3...
await sor.sqlExe(
"UPDATE sd_iterations t JOIN ("
"SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at ASC, id ASC) AS rn "
"FROM sd_iterations WHERE seq_no = 0) x ON x.id = t.id "
"SET t.seq_no = x.rn", {})
# 脏状态修复:迭代状态合法值 planning/in_progress/completed/cancelled无 active
await sor.sqlExe(
"UPDATE sd_iterations SET status='in_progress', updated_at=NOW() WHERE status='active'", {})
print(' migrate: sd_iterations seq_no column + backfill + active→in_progress ensured')
except Exception as e:
print(f' WARN: sd_iterations migrate failed: {e}')
print(f' DONE: 新建表={stats["created"]} 补列={stats["cols_added"]} '
f'新建索引={stats["idx_created"]} 索引已存在跳过={stats["idx_skipped"]} '
f'错误={stats["errors"]}')
if stats['errors']:
sys.exit(1)
if __name__ == '__main__':