diff --git a/pbl_appcodes.egg-info/PKG-INFO b/pbl_appcodes.egg-info/PKG-INFO new file mode 100644 index 0000000..79ae20b --- /dev/null +++ b/pbl_appcodes.egg-info/PKG-INFO @@ -0,0 +1,11 @@ +Metadata-Version: 2.4 +Name: pbl_appcodes +Version: 1.0.0 +Summary: PBL 枚举编码注入(8 组 74 项,幂等,零表)——appcodes/apptype 等枚举种子 + self_check +Requires-Python: >=3.7 +Requires-Dist: pbl_common +Requires-Dist: apppublic +Requires-Dist: sqlor +Requires-Dist: ahserver +Requires-Dist: appbase +Requires-Dist: rbac diff --git a/pbl_appcodes.egg-info/SOURCES.txt b/pbl_appcodes.egg-info/SOURCES.txt new file mode 100644 index 0000000..8172ace --- /dev/null +++ b/pbl_appcodes.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +README.md +pyproject.toml +pbl_appcodes/__init__.py +pbl_appcodes/api.py +pbl_appcodes/codes.json +pbl_appcodes/codes_data.py +pbl_appcodes/init.py +pbl_appcodes/seed.py +pbl_appcodes/self_check.py +pbl_appcodes.egg-info/PKG-INFO +pbl_appcodes.egg-info/SOURCES.txt +pbl_appcodes.egg-info/dependency_links.txt +pbl_appcodes.egg-info/requires.txt +pbl_appcodes.egg-info/top_level.txt +pbl_appcodes/data/codes.json \ No newline at end of file diff --git a/pbl_appcodes.egg-info/dependency_links.txt b/pbl_appcodes.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pbl_appcodes.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/pbl_appcodes.egg-info/requires.txt b/pbl_appcodes.egg-info/requires.txt new file mode 100644 index 0000000..1c27fca --- /dev/null +++ b/pbl_appcodes.egg-info/requires.txt @@ -0,0 +1,6 @@ +pbl_common +apppublic +sqlor +ahserver +appbase +rbac diff --git a/pbl_appcodes.egg-info/top_level.txt b/pbl_appcodes.egg-info/top_level.txt new file mode 100644 index 0000000..be8da07 --- /dev/null +++ b/pbl_appcodes.egg-info/top_level.txt @@ -0,0 +1 @@ +pbl_appcodes diff --git a/pbl_appcodes/seed.py b/pbl_appcodes/seed.py index 83f6d93..ded42b6 100644 --- a/pbl_appcodes/seed.py +++ b/pbl_appcodes/seed.py @@ -37,11 +37,14 @@ from pbl_common.errors import ( err, ) from pbl_common.dbutil import ( + delete_row, get_db, get_module_dbname, + insert_row, now_str, safe_ident, sqlor_available, + update_row, ) log = logging.getLogger('pbl_appcodes.seed') @@ -294,20 +297,78 @@ async def ensure_tables(db=None, dbname=None, create_missing=False): if not exists: raise DbError( message='共享编码表 %s 不存在(appbase 未初始化)' % table, - detail={'table': table, 'dbname': db.dbname, + detail={'table': table, 'dbname': _db_dbname(db), 'hint': '先部署 appbase 或传 create_missing=True'}) result[table] = True result['created'] = created return result +def _db_module(db): + """取 db 门面的模块名(原语按 module 解析库名;替身缺则回落 pbl_appcodes)。""" + return str(getattr(db, 'module', None) or 'pbl_appcodes') + + +def _db_dbname(db): + """解析 db 门面对应的库名(三种形态兼容,取不到返回 None 不抛)。 + + * 真实 ``pbl_common.dbutil.Db``:只有公开 slot ``module``(``__slots__`` 里是 + 私有 ``_dbname``),故经 ``get_module_dbname(db.module)`` 走宿主映射; + * 测试替身(self_check 的 ``_NoTableDb``):只声明 ``dbname``; + * 显式注入库名的 Db:``_dbname`` 有值。 + """ + mod = getattr(db, 'module', None) + if mod: + try: + name = get_module_dbname(mod) + if name: + return str(name) + except Exception: # noqa: BLE001 宿主映射未挂时降级 + pass + for attr in ('dbname', '_dbname'): + value = getattr(db, attr, None) + if value: + return str(value) + return None + + async def _table_exists(db, table): + """表是否存在(information_schema COUNT,sqlor ${name}$ 具名参数)。 + + 三条踩坑教训(2026-09-29 真机 probe_show_tables.py 实测): + * 不能用 ``SHOW TABLES LIKE``:sqlor 通道下它对**存在**的表返回 int(而非 + 行集),``rows_to_dicts`` 迭代 int 抛 ``TypeError: 'int' object is not + iterable``;只有不存在的表才返回 []。故把旧 ``%s`` 简单换成 ``${t}$`` + 仍会崩——必须改 information_schema COUNT,实测稳定返回 [{'c': N}]。 + * 必须**继续走 ``db.execute``**,不能直接调 ``q_all``:self_check 的 + ``_NoTableDb`` 替身靠拦截 ``execute`` 返回 [] 来模拟「缺表」,绕过 db + 通道会让离线自检真连库、替身失效(首版修复即踩此坑,17/18 FAIL)。 + * 不直接复用 ``pbl_common.db.table_exists``:它带 ``_TABLE_CACHE``,而本 + 函数语义是「实时探测」——``ensure_tables`` 建表后要立即复查,缓存会返回 + stale False 把刚建好的表误判成缺失。 + """ try: - rows = await db.execute('SHOW TABLES LIKE %s', [table]) + dbname = _db_dbname(db) + if dbname: + rows = await db.execute( + 'SELECT COUNT(*) AS c FROM information_schema.TABLES ' + 'WHERE TABLE_SCHEMA=${s}$ AND TABLE_NAME=${t}$', + {'s': dbname, 't': str(table)}) + else: + # 库名不可解析时退化为仅按表名匹配(一个连接只连一个库,语义等价 + # 于 SHOW TABLES LIKE,但走 information_schema 避免 int 返回陷阱) + rows = await db.execute( + 'SELECT COUNT(*) AS c FROM information_schema.TABLES ' + 'WHERE TABLE_NAME=${t}$', {'t': str(table)}) except Exception as exc: # noqa: BLE001 - log.warning('SHOW TABLES LIKE %s 失败:%s', table, exc) + log.warning('表存在性探测失败 %s:%s', table, exc) return False - return bool(rows) + if not rows: # 替身返回 [] → 表不存在 + return False + first = rows[0] + count = list(first.values())[0] if isinstance(first, dict) \ + else getattr(first, 'c', None) + return int(count or 0) > 0 def _ddl_of(table): @@ -409,22 +470,72 @@ async def seed_group(group_id, db=None, dbname=None, path=None, dry_run=False): db = db or get_db(module='pbl_appcodes', dbname=dbname) stat = {'group': parent['id'], 'inserted': 0, 'updated': 0, 'skipped': 0, 'dry_run': False} - stat[_merge(await _upsert_row(db, APPCODES_TABLE, parent, ('name', 'hierarchy_flg', 'sort')))] = 1 + # parent 记 1 行(每组恰一条父记录);kv 逐行累加。两侧都经 _merge 归一, + # 保证写入的键恒在 totals 闭集内(原版 kv 侧用裸 action,键名漂移即静默丢计数)。 + _p_action = _merge(await _upsert_row(db, APPCODES_TABLE, parent, + ('name', 'hierarchy_flg', 'sort'))) + stat[_p_action] = 1 for kv in kvs: - action = await _upsert_row(db, APPCODES_KV_TABLE, kv, ('v', 'sort', 'parentid')) + action = _merge(await _upsert_row(db, APPCODES_KV_TABLE, kv, + ('v', 'sort', 'parentid'))) stat[action] = stat.get(action, 0) + 1 log.info('seed_group %s -> %s', parent['id'], {k: v for k, v in stat.items() if k != 'group'}) return stat +#: 统计键闭集(seed_all 汇总 totals 只认这三个键;域外键会被静默丢计数) +STAT_ACTIONS = ('inserted', 'updated', 'skipped') + + +def _merge(action): + """把 ``_upsert_row`` 返回的动作归一到统计键闭集(防御层,非业务分支)。 + + ``_upsert_row`` 契约返回 'inserted'/'updated'/'skipped';本函数保证任何域外 + 值折叠为 'skipped'——既不虚报写入,也避免 ``seed_all`` 的 totals 汇总因键名 + 漂移而**静默丢计数**(域外键不在 totals 循环里,加了多少都看不见)。 + + 历史缺陷:原版(HEAD 第 412 行)调用了**从未定义**的 ``_merge``, + ``seed_group`` 一进真实写库分支即 NameError —— 即 pbl_appcodes 的 + ``seed_all`` 从未成功跑通过(dry_run 路径不经过该行,故 self_check 全绿 + 也发现不了)。2026-09-29 补齐定义。 + """ + action = str(action or '').strip() + return action if action in STAT_ACTIONS else 'skipped' + + +async def _read_by_id(db, table, pk_value): + """按主键读一行(走 ``db.execute`` → q_all,**不强制租户上下文**)。 + + 为什么不能用 ``db.read(table, {...})``:``Db.read`` 传表名时会先 + ``self._tenant()`` 再拼 ``tenant_id`` 过滤,而 appcodes / appcodes_kv 是 + appbase 的**共享编码表,没有 tenant_id 列**(实测列面: + id/name/hierarchy_flg/sort/created_at/updated_at 与 + id/parentid/k/v/sort/created_at/updated_at)→ 必然 + ``PBL_E_TENANT_MISSING``,或把 tenant_id 当未知列拒掉。 + ``db.execute`` 只过禁写词校验后委托 ``q_all(sql, dict, module=self.module)``, + 是共享表的合规读通道(同 _table_exists 的口径)。 + """ + tbl = safe_ident(table, '表名') + rows = await db.execute('SELECT * FROM `%s` WHERE `id`=${i}$ LIMIT 1' % tbl, + {'i': str(pk_value)}) + return rows[0] if rows else None + + async def _upsert_row(db, table, row, compare_fields): - """按主键幂等 upsert,返回 'inserted' / 'updated' / 'skipped'。""" + """按主键幂等 upsert,返回 'inserted' / 'updated' / 'skipped'。 + + 写路径走 ``insert_row`` / ``update_row`` **原语**(tenant_id 不传即豁免租户 + 打头),而不是 ``Db.create`` / ``Db.update`` 门面:门面无条件 + ``self._tenant()`` fail-closed,对无 tenant_id 列的共享编码表必抛 + ``PBL_E_TENANT_MISSING``(2026-09-29 build_run5 实测)。原语同样做列发现 + + ``pick_columns`` + ``build_insert/update``,纪律不降。 + """ pk_value = row['id'] + module = _db_module(db) existing = None try: - rows = await db.read(table, {'id': pk_value}) - existing = rows[0] if rows else None + existing = await _read_by_id(db, table, pk_value) except Exception as exc: # noqa: BLE001 log.warning('读取 %s id=%s 失败(按不存在处理):%s', table, pk_value, exc) existing = None @@ -434,7 +545,7 @@ async def _upsert_row(db, table, row, compare_fields): payload['created_at'] = stamp payload['updated_at'] = stamp try: - await db.create(table, payload) + await insert_row(table, payload, module=module) return 'inserted' except Exception as exc: # noqa: BLE001 raise DbError(message='注入 %s id=%s 失败:%s' % (table, pk_value, exc), @@ -450,7 +561,7 @@ async def _upsert_row(db, table, row, compare_fields): return 'skipped' diff['updated_at'] = stamp try: - await db.update(table, diff, {'id': pk_value}) + await update_row(table, diff, where={'id': pk_value}, module=module) except Exception as exc: # noqa: BLE001 raise DbError(message='更新 %s id=%s 失败:%s' % (table, pk_value, exc), detail={'table': table, 'id': pk_value, 'diff': diff}) @@ -496,7 +607,7 @@ async def seed_all(db=None, dbname=None, path=None, dry_run=False, ensure=True, 'inserted': totals['inserted'], 'updated': totals['updated'], 'skipped': totals['skipped'], 'per_group': per_group, 'dry_run': False, 'sqlor': sqlor_available(), - 'dbname': db.dbname} + 'dbname': _db_dbname(db)} async def unseed_group(group_id, db=None, dbname=None): @@ -508,10 +619,15 @@ async def unseed_group(group_id, db=None, dbname=None): group = get_group(group_id) db = db or get_db(module='pbl_appcodes', dbname=dbname) parent, kvs = _plan_group(group) + module = _db_module(db) removed = 0 + # 写路径走 delete_row 原语(tenant_id 不传豁免租户打头),同 _upsert_row 口径。 + # 共享编码表无 tenant_id 列,db.delete 门面会 fail-closed 抛 PBL_E_TENANT_MISSING。 for kv in kvs: - removed += int(await db.delete(APPCODES_KV_TABLE, {'id': kv['id']}) or 0) - removed += int(await db.delete(APPCODES_TABLE, {'id': parent['id']}) or 0) + removed += int(await delete_row(APPCODES_KV_TABLE, {'id': kv['id']}, + module=module) or 0) + removed += int(await delete_row(APPCODES_TABLE, {'id': parent['id']}, + module=module) or 0) return {'group': parent['id'], 'removed': removed} @@ -521,21 +637,21 @@ async def verify_seed(db=None, dbname=None, path=None): plan = dry_run_seed(path=path) missing, mismatch, found = [], [], 0 for parent in plan['parents']: - rows = await db.read(APPCODES_TABLE, {'id': parent['id']}) - if not rows: + row = await _read_by_id(db, APPCODES_TABLE, parent['id']) + if not row: missing.append('%s:%s' % (APPCODES_TABLE, parent['id'])) continue found += 1 - row = rows[0] if isinstance(rows[0], dict) else dict(rows[0] or {}) + row = row if isinstance(row, dict) else dict(row or {}) if str(row.get('name') or '') != parent['name']: mismatch.append('%s:%s.name' % (APPCODES_TABLE, parent['id'])) for kv in plan['kvs']: - rows = await db.read(APPCODES_KV_TABLE, {'id': kv['id']}) - if not rows: + row = await _read_by_id(db, APPCODES_KV_TABLE, kv['id']) + if not row: missing.append('%s:%s' % (APPCODES_KV_TABLE, kv['id'])) continue found += 1 - row = rows[0] if isinstance(rows[0], dict) else dict(rows[0] or {}) + row = row if isinstance(row, dict) else dict(row or {}) if str(row.get('v') or '') != kv['v'] or int(row.get('sort') or 0) != kv['sort']: mismatch.append('%s:%s' % (APPCODES_KV_TABLE, kv['id'])) return {'ok': not missing and not mismatch, 'missing': missing,