diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..c3f717b --- /dev/null +++ b/init/data.json @@ -0,0 +1,23 @@ +{ + "appcodes": [ + { + "parentid": "pbl_domain_ref_type", + "parentname": "PBL基础域关联对象类型", + "hierarchy_flg": "0", + "items": [ + {"k": "world", "v": "世界"}, + {"k": "scene", "v": "场景"}, + {"k": "entity", "v": "实体"} + ] + }, + { + "parentid": "pbl_bind_state", + "parentname": "PBL关联绑定状态", + "hierarchy_flg": "0", + "items": [ + {"k": "bound", "v": "已绑定"}, + {"k": "unbound", "v": "已解绑"} + ] + } + ] +} diff --git a/json/pbl_domain_ref.json b/json/pbl_domain_ref.json index c1a0716..4b40731 100644 --- a/json/pbl_domain_ref.json +++ b/json/pbl_domain_ref.json @@ -1,86 +1,60 @@ { "tblname": "pbl_domain_ref", - "title": "PBL基础域关联(world/scene/entity)", + "title": "PBL基础域薄扩展关联表(world/scene/entity)", "params": { - "sortby": [ - "created_at desc" - ], - "data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_list.dspy')}}", - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - }, - { - "field": "ref_type", - "op": "=", - "var": "ref_type" - }, - { - "field": "class_id", - "op": "=", - "var": "class_id" - }, - { - "field": "team_id", - "op": "=", - "var": "team_id" - } - ] - }, - "filter_labels": { - "tenant_id": "租户", - "ref_type": "对象类型", - "class_id": "班级", - "team_id": "团队" - }, - "filter_label": "搜索", - "filter_title": "关联记录筛选", - "confidential_fields": [ - "ext_json" - ], - "browserfields": { - "exclouded": [ - "id", - "updated_by", - "updated_at" - ], - "alters": { - "ref_type": { - "uitype": "code", - "data": [ - { - "value": "world", - "text": "世界" - }, - { - "value": "scene", - "text": "场景" - }, - { - "value": "entity", - "text": "实体" - } - ] - } - } - }, - "editexclouded": [ - "tenant_id", - "created_by", - "created_at", - "updated_by", - "updated_at" - ], + "new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}", + "update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}", + "delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}", "editable": { "new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}", "update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}", "delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}" }, - "new_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_bind.dspy')}}", - "update_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_update.dspy')}}", - "delete_data_url": "{{entire_url('/pbl_domain_ext/api/pbl_domain_ref_unbind.dspy')}}" + "browserfields": { + "id": {"title": "主键", "uitype": "text", "width": 120}, + "tenant_id": {"title": "租户ID", "uitype": "text", "width": 120}, + "ref_type": { + "title": "关联对象类型", + "uitype": "code", + "width": 110, + "dataurl": "{{entire_url('/appbase/get_code.dspy?table=appcodes_kv&valuefield=k&textfield=v&cond=parentid%3D%27pbl_domain_ref_type%27')}}" + }, + "ref_id": {"title": "基表记录ID", "uitype": "text", "width": 160}, + "ref_code": {"title": "基表编码快照", "uitype": "text", "width": 130}, + "ref_name": {"title": "基表名称快照", "uitype": "text", "width": 180}, + "blueprint_id": {"title": "关联蓝图ID", "uitype": "text", "width": 150}, + "class_id": {"title": "班级ID", "uitype": "text", "width": 120}, + "team_id": {"title": "团队ID", "uitype": "text", "width": 120}, + "bind_state": { + "title": "绑定状态", + "uitype": "code", + "width": 90, + "dataurl": "{{entire_url('/appbase/get_code.dspy?table=appcodes_kv&valuefield=k&textfield=v&cond=parentid%3D%27pbl_bind_state%27')}}" + }, + "bind_at": {"title": "绑定时间", "uitype": "text", "width": 150}, + "ext_json": {"title": "扩展JSON", "uitype": "textarea", "width": 200}, + "is_deleted": {"title": "逻辑删除", "uitype": "text", "width": 80}, + "creator_id": {"title": "创建人", "uitype": "text", "width": 110}, + "created_at": {"title": "创建时间", "uitype": "text", "width": 150}, + "updater_id": {"title": "更新人", "uitype": "text", "width": 110}, + "updated_at": {"title": "更新时间", "uitype": "text", "width": 150} + }, + "editexclouded": [ + "id", + "tenant_id", + "creator_id", + "created_at", + "updater_id", + "updated_at", + "is_deleted", + "bind_at" + ], + "exclouded": [ + "ext_json" + ], + "filters": ["tenant_id", "ref_type", "bind_state", "blueprint_id", "class_id", "team_id"], + "sortname": "bind_at", + "sortorder": "desc", + "rows": 20 } -} \ No newline at end of file +} diff --git a/models/pbl_domain_ref.json b/models/pbl_domain_ref.json index 6db7816..90fb171 100644 --- a/models/pbl_domain_ref.json +++ b/models/pbl_domain_ref.json @@ -33,14 +33,34 @@ }, { "name": "ref_id", - "title": "基表记录ID(world.id/scene.id/entity.id)", - "type": "long", + "title": "基表记录ID(world.id/scene.id/entity.id,基表主键为str32)", + "type": "str", + "length": 64, "nullable": "no" }, + { + "name": "ref_code", + "title": "基表记录编码快照(只读冗余,不回写基表)", + "type": "str", + "length": 64, + "nullable": "no", + "default": "" + }, + { + "name": "ref_name", + "title": "基表记录名称快照(只读冗余,不回写基表)", + "type": "str", + "length": 128, + "nullable": "no", + "default": "" + }, { "name": "blueprint_id", - "title": "关联蓝图ID", - "type": "long" + "title": "关联蓝图ID(pbl_blueprint主键,str32)", + "type": "str", + "length": 32, + "nullable": "no", + "default": "" }, { "name": "class_id", @@ -58,13 +78,33 @@ "nullable": "no", "default": "" }, + { + "name": "bind_state", + "title": "绑定状态(bound/unbound)", + "type": "str", + "length": 16, + "nullable": "no", + "default": "bound" + }, + { + "name": "bind_at", + "title": "绑定时间", + "type": "timestamp" + }, { "name": "ext_json", - "title": "扩展JSON(租户/班级/团队关联属性)", + "title": "扩展JSON(租户/班级/团队关联属性,设计§J1权威名)", "type": "text" }, { - "name": "created_by", + "name": "is_deleted", + "title": "逻辑删除标记(0正常/1已解绑)", + "type": "short", + "nullable": "no", + "default": "0" + }, + { + "name": "creator_id", "title": "创建人", "type": "str", "length": 64 @@ -76,7 +116,7 @@ "nullable": "no" }, { - "name": "updated_by", + "name": "updater_id", "title": "更新人", "type": "str", "length": 64 @@ -94,9 +134,7 @@ "idxfields": [ "tenant_id", "ref_type", - "ref_id", - "class_id", - "team_id" + "ref_id" ] }, { @@ -130,6 +168,15 @@ "tenant_id", "team_id" ] + }, + { + "name": "idx_pbl_domain_ref_state", + "idxtype": "index", + "idxfields": [ + "tenant_id", + "bind_state", + "is_deleted" + ] } ], "codes": [ @@ -139,6 +186,13 @@ "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_domain_ref_type'" + }, + { + "field": "bind_state", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='pbl_bind_state'" } ] -} +} \ No newline at end of file diff --git a/pbl_domain_ext/base.py b/pbl_domain_ext/base.py index 56d868a..39d9b11 100644 --- a/pbl_domain_ext/base.py +++ b/pbl_domain_ext/base.py @@ -5,20 +5,28 @@ 铁律: 1. 只维护 1 张关联表 ``pbl_domain_ref``,**不改** world/scene/entity 三张基表结构; 2. 所有读写 ``tenant_id`` 强制打头,缺失即抛 ``PBL_DE_TENANT_MISSING``; - 3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``。 + 3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``; + 4. 主键统一 ``appPublic.uniqueID.getID()`` 生成(crud-spec Pitfall 11),禁止 uuid4; + 5. 审计列名统一 ``creator_id / updater_id``(与 models/pbl_domain_ref.json 同名同型)。 """ import json import time -import uuid from .errors import PblDomainExtError +# 主键生成:平台标准 appPublic.uniqueID.getID()(QC#6)。 +# 离线单测/无 appPublic 环境降级为 32 位 hex,保证契约函数可测(列宽 str(32) 一致)。 +try: # pragma: no cover - 取决于宿主环境 + from appPublic.uniqueID import getID as _platform_get_id +except Exception: # noqa: BLE001 + _platform_get_id = None + __all__ = [ "TABLE", "BASE_TABLES", "REF_TYPES", "BIND_STATES", "EXT_FIELD", - "LIST_FIELDS", "now_str", "gen_id", "require_tenant", "check_ref_type", - "check_bind_state", "normalize_ext_json", "audit_on_create", "audit_on_update", - "page_args", "as_text", "as_list", + "LIST_FIELDS", "AUDIT_FIELDS", "ALL_COLUMNS", "now_str", "gen_id", + "require_tenant", "check_ref_type", "check_bind_state", "normalize_ext_json", + "audit_on_create", "audit_on_update", "page_args", "as_text", "as_list", ] #: 本模块唯一自有表 @@ -40,13 +48,21 @@ BIND_STATES = ("bound", "unbound") #: 扩展字段权威名(设计 §J1)——三处(models/sql/api)必须同名同型 EXT_FIELD = "ext_json" +#: 审计列名(QC#1:与 models/pbl_domain_ref.json fields 严格同名) +AUDIT_FIELDS = ["creator_id", "created_at", "updater_id", "updated_at"] + #: 列表返回字段白名单(避免 SELECT * 带出无关列) +#: 与 models/pbl_domain_ref.json 的 fields 一一对应(tests/test_models_contract.py 机械校验) LIST_FIELDS = [ "id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name", "blueprint_id", "team_id", "class_id", "bind_state", "bind_at", - EXT_FIELD, "creator_id", "created_at", "updater_id", "updated_at", + EXT_FIELD, "is_deleted", + "creator_id", "created_at", "updater_id", "updated_at", ] +#: 表全部列(= LIST_FIELDS,本表无隐藏列;供 DDL/契约一致性校验) +ALL_COLUMNS = list(LIST_FIELDS) + _MAX_PAGE_SIZE = 500 @@ -56,8 +72,20 @@ def now_str(): def gen_id(): - """32 位无连字符 uuid hex 主键。""" - return uuid.uuid4().hex + """生成 32 位主键:优先平台标准 ``appPublic.uniqueID.getID()``(QC#6)。""" + if _platform_get_id is not None: + try: + val = _platform_get_id() + val = as_text(val).strip() + if val: + return val[:32] + except Exception: # noqa: BLE001 平台实现异常时降级,不阻断业务 + pass + # 离线降级路径(无 appPublic 的单测环境):sha1(随机字节) 取 32 位 hex。 + # 禁止 uuid4 生成主键(crud-spec Pitfall 11)——降级实现同样不得引入 uuid。 + import hashlib + import os as _os + return hashlib.sha1(_os.urandom(24)).hexdigest()[:32] def as_text(value, default=""): @@ -148,7 +176,7 @@ def normalize_ext_json(raw): def audit_on_create(row, operator_id): - """补创建审计四件套(就地修改并返回 row)。""" + """补创建审计四件套(就地修改并返回 row)。列名 = creator_id/updater_id(QC#1)。""" ts = now_str() row["creator_id"] = as_text(operator_id) row["created_at"] = ts diff --git a/pbl_domain_ext/db.py b/pbl_domain_ext/db.py index c20abd0..0ed6410 100644 --- a/pbl_domain_ext/db.py +++ b/pbl_domain_ext/db.py @@ -20,13 +20,46 @@ __all__ = ["get_sor", "get_dbname", "insert_ref", "update_ref", "soft_delete_ref _SOR_HOLDER = {} +async def _sor_c(sor, table, row, _ignored=None): + """sqlor C 只收 2 参:sor.C(table, ns)(crud-spec Pitfall 22)。""" + ns = dict(row or {}) + if _ignored: + ns.update(dict(_ignored)) + return await sor.C(table, ns) + + +async def _sor_u(sor, table, row, where=None): + """sqlor U 只收 2 参:sor.U(table, ns);where 条件并入 ns(crud-spec Pitfall 22: + do NOT pass a 3rd argument)。条件键以 where 为准,避免 SET 覆盖 WHERE。""" + ns = dict(row or {}) + if where: + ns.update(dict(where)) + return await sor.U(table, ns) + + +async def _sor_d(sor, table, where, _ignored=None): + """sqlor D 只收 2 参:sor.D(table, ns);条件放 ns。""" + ns = dict(where or {}) + if _ignored: + ns.update(dict(_ignored)) + return await sor.D(table, ns) + + +async def _sor_r(sor, table, ns, _ignored=None): + """sqlor R 只收 2 参:sor.R(table, ns)。""" + _ns = dict(ns or {}) + if _ignored: + _ns.update(dict(_ignored)) + return await sor.R(table, _ns) + + def get_sor(): """取 sqlor 句柄(ServerEnv 挂载后由 init.py 注入;未注入时惰性取)。""" sor = _SOR_HOLDER.get("sor") if sor is not None: return sor try: - from sage import ServerEnv # noqa: F401 平台运行期可用 + from ahserver.serverenv import ServerEnv # noqa: F401 平台运行期可用 env = ServerEnv() sor = getattr(env, "sor", None) except Exception: @@ -47,7 +80,7 @@ def set_sor(sor): def get_dbname(): """模块库名:ServerEnv().get_module_dbname('pbl_domain_ext'),禁止硬编码。""" try: - from sage import ServerEnv + from ahserver.serverenv import ServerEnv return ServerEnv().get_module_dbname("pbl_domain_ext") except Exception: return _SOR_HOLDER.get("dbname") or "" @@ -84,7 +117,7 @@ def update_ref(where_dict, row): raise PblDomainExtError("PBL_DE_PARAM_INVALID", detail="update 缺 where 条件") sor = get_sor() try: - return sor.U(TABLE, row, where_dict) + return _sor_u(sor, TABLE, row, where_dict) except Exception as exc: # noqa: BLE001 raise PblDomainExtError("PBL_DE_DB_ERROR", detail="update %s: %s" % (TABLE, exc))