approve: 修复当前迭代 2 个功能 Bug(pbls-初始迭代)

This commit is contained in:
agent.develop 2026-09-30 18:15:28 +08:00
parent 27fd7addee
commit 66fcd7af90
6 changed files with 170 additions and 20 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,6 @@
pbl_common
apppublic
sqlor
ahserver
appbase
rbac

View File

@ -0,0 +1 @@
pbl_appcodes

View File

@ -37,11 +37,14 @@ from pbl_common.errors import (
err, err,
) )
from pbl_common.dbutil import ( from pbl_common.dbutil import (
delete_row,
get_db, get_db,
get_module_dbname, get_module_dbname,
insert_row,
now_str, now_str,
safe_ident, safe_ident,
sqlor_available, sqlor_available,
update_row,
) )
log = logging.getLogger('pbl_appcodes.seed') log = logging.getLogger('pbl_appcodes.seed')
@ -294,20 +297,78 @@ async def ensure_tables(db=None, dbname=None, create_missing=False):
if not exists: if not exists:
raise DbError( raise DbError(
message='共享编码表 %s 不存在(appbase 未初始化)' % table, message='共享编码表 %s 不存在(appbase 未初始化)' % table,
detail={'table': table, 'dbname': db.dbname, detail={'table': table, 'dbname': _db_dbname(db),
'hint': '先部署 appbase 或传 create_missing=True'}) 'hint': '先部署 appbase 或传 create_missing=True'})
result[table] = True result[table] = True
result['created'] = created result['created'] = created
return result return result
async def _table_exists(db, table): 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: try:
rows = await db.execute('SHOW TABLES LIKE %s', [table]) 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:
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 except Exception as exc: # noqa: BLE001
log.warning('SHOW TABLES LIKE %s 失败:%s', table, exc) log.warning('表存在性探测失败 %s:%s', table, exc)
return False 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): 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) db = db or get_db(module='pbl_appcodes', dbname=dbname)
stat = {'group': parent['id'], 'inserted': 0, 'updated': 0, 'skipped': 0, stat = {'group': parent['id'], 'inserted': 0, 'updated': 0, 'skipped': 0,
'dry_run': False} '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: 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 stat[action] = stat.get(action, 0) + 1
log.info('seed_group %s -> %s', parent['id'], log.info('seed_group %s -> %s', parent['id'],
{k: v for k, v in stat.items() if k != 'group'}) {k: v for k, v in stat.items() if k != 'group'})
return stat 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): 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'] pk_value = row['id']
module = _db_module(db)
existing = None existing = None
try: try:
rows = await db.read(table, {'id': pk_value}) existing = await _read_by_id(db, table, pk_value)
existing = rows[0] if rows else None
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.warning('读取 %s id=%s 失败(按不存在处理):%s', table, pk_value, exc) log.warning('读取 %s id=%s 失败(按不存在处理):%s', table, pk_value, exc)
existing = None existing = None
@ -434,7 +545,7 @@ async def _upsert_row(db, table, row, compare_fields):
payload['created_at'] = stamp payload['created_at'] = stamp
payload['updated_at'] = stamp payload['updated_at'] = stamp
try: try:
await db.create(table, payload) await insert_row(table, payload, module=module)
return 'inserted' return 'inserted'
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise DbError(message='注入 %s id=%s 失败:%s' % (table, pk_value, exc), 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' return 'skipped'
diff['updated_at'] = stamp diff['updated_at'] = stamp
try: 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 except Exception as exc: # noqa: BLE001
raise DbError(message='更新 %s id=%s 失败:%s' % (table, pk_value, exc), raise DbError(message='更新 %s id=%s 失败:%s' % (table, pk_value, exc),
detail={'table': table, 'id': pk_value, 'diff': diff}) 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'], 'inserted': totals['inserted'], 'updated': totals['updated'],
'skipped': totals['skipped'], 'per_group': per_group, 'skipped': totals['skipped'], 'per_group': per_group,
'dry_run': False, 'sqlor': sqlor_available(), 'dry_run': False, 'sqlor': sqlor_available(),
'dbname': db.dbname} 'dbname': _db_dbname(db)}
async def unseed_group(group_id, db=None, dbname=None): 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) group = get_group(group_id)
db = db or get_db(module='pbl_appcodes', dbname=dbname) db = db or get_db(module='pbl_appcodes', dbname=dbname)
parent, kvs = _plan_group(group) parent, kvs = _plan_group(group)
module = _db_module(db)
removed = 0 removed = 0
# 写路径走 delete_row 原语(tenant_id 不传豁免租户打头),同 _upsert_row 口径。
# 共享编码表无 tenant_id 列,db.delete 门面会 fail-closed 抛 PBL_E_TENANT_MISSING。
for kv in kvs: for kv in kvs:
removed += int(await db.delete(APPCODES_KV_TABLE, {'id': kv['id']}) or 0) removed += int(await delete_row(APPCODES_KV_TABLE, {'id': kv['id']},
removed += int(await db.delete(APPCODES_TABLE, {'id': parent['id']}) or 0) module=module) or 0)
removed += int(await delete_row(APPCODES_TABLE, {'id': parent['id']},
module=module) or 0)
return {'group': parent['id'], 'removed': removed} 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) plan = dry_run_seed(path=path)
missing, mismatch, found = [], [], 0 missing, mismatch, found = [], [], 0
for parent in plan['parents']: for parent in plan['parents']:
rows = await db.read(APPCODES_TABLE, {'id': parent['id']}) row = await _read_by_id(db, APPCODES_TABLE, parent['id'])
if not rows: if not row:
missing.append('%s:%s' % (APPCODES_TABLE, parent['id'])) missing.append('%s:%s' % (APPCODES_TABLE, parent['id']))
continue continue
found += 1 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']: if str(row.get('name') or '') != parent['name']:
mismatch.append('%s:%s.name' % (APPCODES_TABLE, parent['id'])) mismatch.append('%s:%s.name' % (APPCODES_TABLE, parent['id']))
for kv in plan['kvs']: for kv in plan['kvs']:
rows = await db.read(APPCODES_KV_TABLE, {'id': kv['id']}) row = await _read_by_id(db, APPCODES_KV_TABLE, kv['id'])
if not rows: if not row:
missing.append('%s:%s' % (APPCODES_KV_TABLE, kv['id'])) missing.append('%s:%s' % (APPCODES_KV_TABLE, kv['id']))
continue continue
found += 1 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']: 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'])) mismatch.append('%s:%s' % (APPCODES_KV_TABLE, kv['id']))
return {'ok': not missing and not mismatch, 'missing': missing, return {'ok': not missing and not mismatch, 'missing': missing,