diff --git a/deploy/migrations/m0014_llm_usage_status_failed_dict.json b/deploy/migrations/m0014_llm_usage_status_failed_dict.json new file mode 100644 index 0000000..3ff47a7 --- /dev/null +++ b/deploy/migrations/m0014_llm_usage_status_failed_dict.json @@ -0,0 +1,11 @@ +{"id": "m0014", + "title": "补 m0012 静默丢失的 llm_usage_status FAILED 字典行——m0012 的 INSERT IGNORE 先于 legacy 挪移 UPDATE 执行,CI 排序规则下 k='FAILED' 与旧行 k='failed' 撞 (parentid,k) 唯一索引被 IGNORE 跳过,随后旧行挪去 legacy 导致新枚举行缺失;import_init 补插又因计算 id 与 legacy 行 CI 撞 PRIMARY 而中止", + "backup_tables": ["appcodes_kv"], + "up": [ + {"op": "sql", + "sql": "INSERT IGNORE INTO appcodes_kv (id,parentid,k,v) VALUES ('llumg_FAILED','llm_usage_status','FAILED','失败')"} + ], + "down": [ + {"op": "sql", + "sql": "DELETE FROM appcodes_kv WHERE id='llumg_FAILED' AND parentid='llm_usage_status'"} + ]} diff --git a/scripts/import_init.py b/scripts/import_init.py index dd16bcb..b076ee0 100644 --- a/scripts/import_init.py +++ b/scripts/import_init.py @@ -14,6 +14,8 @@ ROOT_DIR = os.path.dirname(SCRIPT_DIR) sys.path.insert(0, os.path.join(ROOT_DIR, 'py3', 'lib', 'python3.10', 'site-packages')) sys.path.insert(0, ROOT_DIR) +from pymysql.err import IntegrityError + from sqlor.dbpools import DBPools from appPublic.jsonConfig import getConfig from appPublic.folderUtils import ProgramPath @@ -64,6 +66,24 @@ def normalize(data): return raw_ac, data.get('appcodes_kv', []), data.get('organization', []) +async def _safe_insert(sor, tbl, row, total, tkey): + """插入一行,主键/唯一键冲突时告警跳过而非中止全量导入。 + + 实测事故(2026-09-07):appcodes_kv 的 CI 排序规则下,计算 id + 'llm_usage_status_FAILED' 与 legacy 行 id 'llm_usage_status_failed' PRIMARY 撞车, + (parentid,k) 检查通过但 INSERT 抛 1062 → 后续所有模块的导入整段中止。 + 单行冲突属数据态问题(迁移/历史数据),不应阻断其余行导入。 + """ + try: + await sor.C(tbl, row) + total[tkey] = total.get(tkey, 0) + 1 + except IntegrityError as e: + if e.args[0] in (1062,): + print(f' WARN: {tbl} 行冲突跳过(已存在等值行): id={row.get("id")} {e.args[1][:80]}') + else: + raise + + async def main(): config = getConfig(ROOT_DIR, NS={'workdir': ROOT_DIR, 'ProgramPath': ProgramPath()}) DBPools(config.databases) @@ -83,12 +103,11 @@ async def main(): for ac in appcodes: recs = await sor.R('appcodes', {'id': ac['id']}) if not recs: - await sor.C('appcodes', { + await _safe_insert(sor, 'appcodes', { 'id': ac['id'], 'name': ac.get('name', ''), 'hierarchy_flg': ac.get('hierarchy_flg', '0'), - }) - total['appcodes'] += 1 + }, total, 'appcodes') for kv in appcodes_kv: # 幂等检查用 (parentid, k) 组合(appcodes_kv 有 parentid+k 唯一索引, # 且历史数据 id 是 UUID 非 parentid_k 组合键,用 id 检查会漏判 → 唯一索引冲突) @@ -96,31 +115,28 @@ async def main(): "SELECT id FROM appcodes_kv WHERE parentid=${p}$ AND k=${k}$", {"p": kv.get('parentid', ''), "k": kv.get('k', '')}) if not recs: - await sor.C('appcodes_kv', { + await _safe_insert(sor, 'appcodes_kv', { 'id': kv['id'], 'parentid': kv.get('parentid', ''), 'k': kv.get('k', ''), 'v': kv.get('v', ''), - }) - total['appcodes_kv'] += 1 + }, total, 'appcodes_kv') for org in organization: recs = await sor.R('organization', {'id': org['id']}) if not recs: - await sor.C('organization', { + await _safe_insert(sor, 'organization', { 'id': org['id'], 'orgname': org.get('orgname', ''), - }) - total['organization'] += 1 + }, total, 'organization') # RBAC 角色(如 app_audit 的 owner.audit 审计独立角色),幂等按主键 id 检查 for r in data.get('roles', []): recs = await sor.R('role', {'id': r['id']}) if not recs: - await sor.C('role', { + await _safe_insert(sor, 'role', { 'id': r['id'], 'orgtypeid': r.get('orgtypeid', '0'), 'name': r.get('name', ''), - }) - total['roles'] += 1 + }, total, 'roles') # 记账配置表(accounting 模块:subject/account_config/accounting_config/currency/exchange_rate) # 幂等按主键 id 检查,字段名与表结构一致 for tbl, recs_data in [ @@ -135,8 +151,7 @@ async def main(): if not existing: # 去掉 None 值字段(DB 用 NULL 而非 None) clean = {k: v for k, v in rec.items() if v is not None} - await sor.C(tbl, clean) - total[tbl] = total.get(tbl, 0) + 1 + await _safe_insert(sor, tbl, clean, total, tbl) summary = ' '.join(f'{k} {v}' for k, v in total.items()) print(f'import_init: {summary}')