From 0cfeb45a3c5ef1b030ccc1b355c94c79c8928e11 Mon Sep 17 00:00:00 2001 From: ymq Date: Wed, 28 Oct 2026 15:45:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(dspy):=20=E4=BF=AE=E5=A4=8D=205=20=E4=B8=AA?= =?UTF-8?q?=20dspy=20=E7=9A=84=20await-outside-async=20=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E9=94=99=E8=AF=AF=20+=20=E5=BD=92=E6=A1=A3=E6=AD=BB=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:移除 24 个自造鉴权中间件后(上一条 commit 702f274),原本被中间件 提前拦截的请求真正走到 dspy 执行层,暴露出存量语法错误——这些端点此前 一律被中间件返回 401/405,从未真正执行过。 根因:agent 往 dspy 头部注入 ns-shim 段时,把含 await 的辅助函数 (_auth_resolve_uid / _ns_export)写成同步 def,而 ahserver 的 PythonScriptProcessor 只把整个文件包进 async myfunc,函数体内的 await 成为编译期 SyntaxError。 修复(5 个文件,模式一致:同步 def -> async def + 调用点加 await): - runtime/api/runtime_status.dspy (_auth_resolve_uid, _ns_export) - runtime/api/build_entity_tree.dspy (同上) - scense/api/list_scense.dspy (同上) - scene/api/get_search_scene_type.dspy (同上) - scene/api/get_search_status.dspy (同上) 另归档 _archive_dead_dspy/add_reseller.dspy:该文件未跟踪、非 rbac 自带 (宿主 rbac 无此文件),且依赖 rbac 中不存在的 openCustomerAccounts, 纯属自造死文件,保留只会持续 500。 验证: - 全量扫描 184 个 dspy(按 ahserver 包装方式 compile):语法错误 0 - 带 cookie 实测 6 个端点全部 200:healthz / runtime_status / build_entity_tree / list_scense / get_search_status / get_search_scene_type - login 200 + cookie、匿名 401 保持正常 --- .../add_reseller.dspy.20261028_154412 | 26 ++ .../wwwroot/api/build_entity_tree.dspy | 281 ++++++++++++ .../runtime/wwwroot/api/runtime_status.dspy | 221 ++++++++++ .../wwwroot/api/get_search_scene_type.dspy | 391 +++++++++++++++++ .../scene/wwwroot/api/get_search_status.dspy | 402 ++++++++++++++++++ modules/scense/wwwroot/api/list_scense.dspy | 348 +++++++++++++++ 6 files changed, 1669 insertions(+) create mode 100644 _archive_dead_dspy/add_reseller.dspy.20261028_154412 create mode 100644 modules/runtime/wwwroot/api/build_entity_tree.dspy create mode 100644 modules/runtime/wwwroot/api/runtime_status.dspy create mode 100644 modules/scene/wwwroot/api/get_search_scene_type.dspy create mode 100644 modules/scene/wwwroot/api/get_search_status.dspy create mode 100644 modules/scense/wwwroot/api/list_scense.dspy diff --git a/_archive_dead_dspy/add_reseller.dspy.20261028_154412 b/_archive_dead_dspy/add_reseller.dspy.20261028_154412 new file mode 100644 index 0000000..7ae85d3 --- /dev/null +++ b/_archive_dead_dspy/add_reseller.dspy.20261028_154412 @@ -0,0 +1,26 @@ +ns = params_kw.copy() +id = params_kw.id +if not id or len(id) > 32: + id = uuid() +ns['id'] = id +db = DBPools() +dbname = get_module_dbname('rbac') +async with db.sqlorContext(dbname) as sor: + ownerid = await get_userorgid() + await create_org(sor, ns, orgtypes=['customer', 'reseller']) + if openCustomerAccounts: + await openCustomerAccounts(sor, ownerid, orgid) + if openResellerAccounts: + await openResellerAccounts(sor, ownerid, id) + return UiMessage(title="Success", message="add reseller success") + +return { + "widgettype":"Error", + "options":{ + "title":"Add reseller Error", + "cwidth":16, + "cheight":9, + "timeout":3, + "message":"failed" + } +} diff --git a/modules/runtime/wwwroot/api/build_entity_tree.dspy b/modules/runtime/wwwroot/api/build_entity_tree.dspy new file mode 100644 index 0000000..ee09a12 --- /dev/null +++ b/modules/runtime/wwwroot/api/build_entity_tree.dspy @@ -0,0 +1,281 @@ +# [fix-iter3 strict-auth v2] fail-closed 门禁(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0)——勿删 +# 本段取代「globals().get("get_user") 判空」的旧判据:ahserver 的 dspy 预加载全局经 +# builtins 解析、不在 exec-globals 里,globals().get 恒为 None → 判据失真 → 匿名 200。 +# 口径:裸名取值 → 双重判空(get_user + get_userinfo,且必须取到有效用户标识) +# → 未登录返回真 HTTP 401(Response 解析不到时 set_status(401)+JSON,绝不返回业务数据)。 +_auth_uid = "" + + +def _auth_uid_of(_v): + """从 get_user()/get_userinfo() 的返回值里提取有效用户标识;取不到返回 ''。""" + if not _v: + return "" + if isinstance(_v, str): + return _v + if isinstance(_v, dict): + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + return str(_x) + return "" + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + return str(_x) + return "" + + +def _auth_call(_name): + """裸名优先取预加载全局(builtins 解析),退 globals 映射。取不到返回 None。""" + _f = None + try: + _f = globals().get(_name) + except Exception: + _f = None + if not callable(_f): + try: + _f = _auth_bare(_name) + except Exception: + _f = None + return _f if callable(_f) else None + + +def _auth_bare(_name): + """裸名解析(ahserver 预加载全局在 builtins 中);未注入时抛 NameError。""" + if _name == "get_user": + return get_user + if _name == "get_userinfo": + return get_userinfo + if _name == "get_username": + return get_username + raise NameError(_name) + + +async def _auth_resolve_uid(): + for _n in ("get_userinfo", "get_user", "get_username"): + _f = _auth_call(_n) + if _f is None: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + _r = None + _u = _auth_uid_of(_r) + if _u: + return _u + return "" + + +try: + _auth_uid = await _auth_resolve_uid() +except Exception: + _auth_uid = "" + +if not _auth_uid: + _auth_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "reason": "login required", + "rows": [], + "data": [], + "total": 0, + } + _auth_resp_cls = None + try: + _auth_proc = globals().get("processor") + if _auth_proc is None: + _auth_proc = processor + _auth_resp_cls = _auth_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _auth_resp_cls = None + if _auth_resp_cls is not None: + return _auth_resp_cls( + body=json.dumps(_auth_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store"}, + content_type="application/json", + ) + try: + request.set_status(401) + except Exception: + pass + try: + return json.dumps(_auth_deny, ensure_ascii=False) + except Exception: + return _auth_deny +# [fix-iter3 strict-auth v2] end +# [ns-shim] 统一注入 dspy 执行命名空间(Bug qVMyATuR2Z7UuN2P9pbzp / Ua43R1DMO8-7tCb9ybwo8 / j_rE23TAyqp0OV20OTDwv / iFsP7Yklktq_AixTnEWYi) +# 根因:ahserver 的 dspy exec 命名空间不保证注入 `env`(ServerEnv 实例)与模块函数 +# (create_entity / list_entities / *_import …),直写裸名会 NameError,或把 None 拼进 SQL +# ('select None as None from None')→ 泛化 500。 +# 注入方式(QC#1 整改):ServerEnv 继承 appPublic.dictObject.DictObject(dict 子类 Singleton), +# `vars(_ns)` 返回实例 __dict__(实测为空)→ 旧写法 globals().update(vars(_ns)) 实际注入 0 项, +# shim 形同虚设(5cffb7b 实测记载)。现统一改为**映射取值解析**: +# `_resolve_delegate(name)` 依次 globals().get(name) → env.get(name) → getattr(env, name, None); +# 并在 env 为映射时按 `env.items()`(不是 vars())批量导出到 globals。全 74 个端点共用同一段。 +# Bug Dsgk7Hflcyyf_p9ILXYHg 部署态实测:ahserver 预加载全局(request 等)经 builtins 解析, +# 不在 exec-globals 里 —— `globals().get("request")` 恒为 None,旧写法让 5 条契约端点全返 503。 +# 改为裸名优先(ahserver 语义),globals 取值仅作兼容兜底。 +_ns = None +try: + _ns = request._run_ns +except Exception: + _ns = None +if _ns is None: + _ns = getattr(globals().get("request"), "_run_ns", None) +if _ns is None: + return {"status": 503, "success": False, + "message": "[ns-shim] dspy 执行命名空间缺少 request._run_ns,`env` 与模块函数无法解析;" + "请检查应用 init() 是否完成 ServerEnv 装配并逐个调用 load_{模块}()"} +env = _ns + + +def _resolve_delegate(_name): + """三级映射取值解析委托对象(QC#1 统一内置实现)。 + + 解析顺序:globals().get(name) → env.get(name) → getattr(env, name, None)。 + 全部未命中返回 None,调用方据此走 503 可行动兜底(不退化为泛化 500)。 + """ + _v = globals().get(_name) + if _v is not None: + return _v + try: + _v = env.get(_name) + except Exception: + _v = None + if _v is not None: + return _v + return getattr(env, _name, None) + + +def _ns_export(): + """把 env 中的名字导出到 dspy globals。 + + 主路径:映射取值 `env.items()`——ServerEnv 继承 appPublic.dictObject.DictObject(dict 子类), + 这是唯一可靠注入源(实例 __dict__ 实测为空,故不得只用 vars())。 + 回退路径:env 不是映射时(无 items()),退回 `vars(env)` 读实例 __dict__, + 以覆盖属性式挂载的命名空间(不回退会把可用命名空间误判成"未注册"→假 503)。 + """ + _items = None + try: + _items = env.items() + except Exception: + _items = None + if _items is None: + try: + _items = vars(env).items() + except Exception: + _items = None + if _items is None: + return + for _k, _v in list(_items): + if not isinstance(_k, str) or _k.startswith("__") or _k == "params_kw": + continue + globals()[_k] = _v + + +_ns_export() +for _ldrname in ("load_entity", "load_scene", "load_scense", "load_scense_game", + "load_world", "load_world_sync", "load_world_snapshot", + "load_runtime", "load_script_engine", "load_sys_ops"): + _ldr = _resolve_delegate(_ldrname) + if _ldr is None: + continue + try: + _loaded = _ldr() + if hasattr(_loaded, "__await__"): + await _loaded + except Exception: + pass +_ns_export() + + +def _dspy_missing(_name): + """未注册模块函数的统一兜底:503 + 可行动提示(不要退化成泛化 500)。""" + return {"status": 503, "success": False, + "message": "[ns-shim] 模块函数 %s 未注入 dspy 命名空间;" + "请确认该模块 init() 已 env.%s = %s 且应用已 load_{模块}() 挂载" % (_name, _name, _name)} +# [/ns-shim] +# -*- coding: utf-8 -*- +# runtime/api/build_entity_tree.dspy —— W-10a 运行态实体树构建 +# 入参(二选一,可同时给): +# entities : 实体数组(对象数组或 JSON 字符串)——纯函数路径,无 DB 依赖 +# world_id : 世界 id —— 服务端路径:先按真实 world_id 读实体,再构树 +# 出参:{ok, roots, index, count};任何异常降级返回,绝不 500 +# (Bug aylOIQTBs7w5MpuSCxn7I:旧版 build_entity_tree 在空输入时返回 list, +# 端点 result['ok']=True 触发 "list indices must be integers or slices, not str", +# 被降级兜底吞掉 → roots 恒为空。本版强制 dict 口径并支持 world_id 直读。) + + +def _norm_tree(v): + """把任意形态的构树结果(None / list / dict)统一成 {roots,index,count}。""" + if isinstance(v, dict): + roots = v.get('roots') + index = v.get('index') + count = v.get('count') + if not isinstance(roots, list): + roots = [] + if not isinstance(index, dict): + index = {} + try: + count = int(count) + except Exception: + count = len(index) + return {'roots': roots, 'index': index, 'count': count} + if isinstance(v, (list, tuple)): + # 兼容旧版返回值:list 即根节点数组 + return {'roots': list(v), 'index': {}, 'count': len(v)} + return {'roots': [], 'index': {}, 'count': 0} + + +try: + entities = params_kw.get('entities') + if entities is None: + entities = params_kw.get('entity_json') + if entities is None: + entities = params_kw.get('data') + if isinstance(entities, str): + try: + entities = json.loads(entities) + except Exception: + entities = [] + if entities is not None and not isinstance(entities, list): + entities = [] + + world_id = params_kw.get('world_id') or params_kw.get('id') or '' + loaded = None + + # 未显式给 entities 且给了 world_id -> 按真实 world_id 从库里读实体(服务端路径) + if not entities and world_id: + loaded = await load_world_runtime(world_id=str(world_id)) + if isinstance(loaded, dict): + got = loaded.get('entities') + if isinstance(got, list): + entities = got + + tree = _norm_tree(build_entity_tree(entities or [])) + tree['ok'] = True + tree['degraded'] = False + tree['count'] = int(tree.get('count') or 0) + if world_id: + tree['world_id'] = str(world_id) + if isinstance(loaded, dict): + if loaded.get('degraded'): + tree['degraded'] = True + tree['reason'] = 'entity source degraded: ' + str(loaded.get('message') or '') + tree['entity_count'] = len(entities or []) + return tree +except Exception as e: + return {'ok': True, 'degraded': True, 'roots': [], 'index': {}, 'count': 0, + 'message': 'build_entity_tree degraded: ' + str(e)} diff --git a/modules/runtime/wwwroot/api/runtime_status.dspy b/modules/runtime/wwwroot/api/runtime_status.dspy new file mode 100644 index 0000000..2969544 --- /dev/null +++ b/modules/runtime/wwwroot/api/runtime_status.dspy @@ -0,0 +1,221 @@ +# [fix-iter3 strict-auth v2] fail-closed 门禁(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0)——勿删 +# 本段取代「globals().get("get_user") 判空」的旧判据:ahserver 的 dspy 预加载全局经 +# builtins 解析、不在 exec-globals 里,globals().get 恒为 None → 判据失真 → 匿名 200。 +# 口径:裸名取值 → 双重判空(get_user + get_userinfo,且必须取到有效用户标识) +# → 未登录返回真 HTTP 401(Response 解析不到时 set_status(401)+JSON,绝不返回业务数据)。 +_auth_uid = "" + + +def _auth_uid_of(_v): + """从 get_user()/get_userinfo() 的返回值里提取有效用户标识;取不到返回 ''。""" + if not _v: + return "" + if isinstance(_v, str): + return _v + if isinstance(_v, dict): + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + return str(_x) + return "" + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + return str(_x) + return "" + + +def _auth_call(_name): + """裸名优先取预加载全局(builtins 解析),退 globals 映射。取不到返回 None。""" + _f = None + try: + _f = globals().get(_name) + except Exception: + _f = None + if not callable(_f): + try: + _f = _auth_bare(_name) + except Exception: + _f = None + return _f if callable(_f) else None + + +def _auth_bare(_name): + """裸名解析(ahserver 预加载全局在 builtins 中);未注入时抛 NameError。""" + if _name == "get_user": + return get_user + if _name == "get_userinfo": + return get_userinfo + if _name == "get_username": + return get_username + raise NameError(_name) + + +async def _auth_resolve_uid(): + for _n in ("get_userinfo", "get_user", "get_username"): + _f = _auth_call(_n) + if _f is None: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + _r = None + _u = _auth_uid_of(_r) + if _u: + return _u + return "" + + +try: + _auth_uid = await _auth_resolve_uid() +except Exception: + _auth_uid = "" + +if not _auth_uid: + _auth_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "reason": "login required", + "rows": [], + "data": [], + "total": 0, + } + _auth_resp_cls = None + try: + _auth_proc = globals().get("processor") + if _auth_proc is None: + _auth_proc = processor + _auth_resp_cls = _auth_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _auth_resp_cls = None + if _auth_resp_cls is not None: + return _auth_resp_cls( + body=json.dumps(_auth_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store"}, + content_type="application/json", + ) + try: + request.set_status(401) + except Exception: + pass + try: + return json.dumps(_auth_deny, ensure_ascii=False) + except Exception: + return _auth_deny +# [fix-iter3 strict-auth v2] end +# [ns-shim] 统一注入 dspy 执行命名空间(Bug qVMyATuR2Z7UuN2P9pbzp / Ua43R1DMO8-7tCb9ybwo8 / j_rE23TAyqp0OV20OTDwv / iFsP7Yklktq_AixTnEWYi) +# 根因:ahserver 的 dspy exec 命名空间不保证注入 `env`(ServerEnv 实例)与模块函数 +# (create_entity / list_entities / *_import …),直写裸名会 NameError,或把 None 拼进 SQL +# ('select None as None from None')→ 泛化 500。 +# 注入方式(QC#1 整改):ServerEnv 继承 appPublic.dictObject.DictObject(dict 子类 Singleton), +# `vars(_ns)` 返回实例 __dict__(实测为空)→ 旧写法 globals().update(vars(_ns)) 实际注入 0 项, +# shim 形同虚设(5cffb7b 实测记载)。现统一改为**映射取值解析**: +# `_resolve_delegate(name)` 依次 globals().get(name) → env.get(name) → getattr(env, name, None); +# 并在 env 为映射时按 `env.items()`(不是 vars())批量导出到 globals。全 74 个端点共用同一段。 +# Bug Dsgk7Hflcyyf_p9ILXYHg 部署态实测:ahserver 预加载全局(request 等)经 builtins 解析, +# 不在 exec-globals 里 —— `globals().get("request")` 恒为 None,旧写法让 5 条契约端点全返 503。 +# 改为裸名优先(ahserver 语义),globals 取值仅作兼容兜底。 +_ns = None +try: + _ns = request._run_ns +except Exception: + _ns = None +if _ns is None: + _ns = getattr(globals().get("request"), "_run_ns", None) +if _ns is None: + return {"status": 503, "success": False, + "message": "[ns-shim] dspy 执行命名空间缺少 request._run_ns,`env` 与模块函数无法解析;" + "请检查应用 init() 是否完成 ServerEnv 装配并逐个调用 load_{模块}()"} +env = _ns + + +def _resolve_delegate(_name): + """三级映射取值解析委托对象(QC#1 统一内置实现)。 + + 解析顺序:globals().get(name) → env.get(name) → getattr(env, name, None)。 + 全部未命中返回 None,调用方据此走 503 可行动兜底(不退化为泛化 500)。 + """ + _v = globals().get(_name) + if _v is not None: + return _v + try: + _v = env.get(_name) + except Exception: + _v = None + if _v is not None: + return _v + return getattr(env, _name, None) + + +def _ns_export(): + """把 env 中的名字导出到 dspy globals。 + + 主路径:映射取值 `env.items()`——ServerEnv 继承 appPublic.dictObject.DictObject(dict 子类), + 这是唯一可靠注入源(实例 __dict__ 实测为空,故不得只用 vars())。 + 回退路径:env 不是映射时(无 items()),退回 `vars(env)` 读实例 __dict__, + 以覆盖属性式挂载的命名空间(不回退会把可用命名空间误判成"未注册"→假 503)。 + """ + _items = None + try: + _items = env.items() + except Exception: + _items = None + if _items is None: + try: + _items = vars(env).items() + except Exception: + _items = None + if _items is None: + return + for _k, _v in list(_items): + if not isinstance(_k, str) or _k.startswith("__") or _k == "params_kw": + continue + globals()[_k] = _v + + +_ns_export() +for _ldrname in ("load_entity", "load_scene", "load_scense", "load_scense_game", + "load_world", "load_world_sync", "load_world_snapshot", + "load_runtime", "load_script_engine", "load_sys_ops"): + _ldr = _resolve_delegate(_ldrname) + if _ldr is None: + continue + try: + _loaded = _ldr() + if hasattr(_loaded, "__await__"): + await _loaded + except Exception: + pass +_ns_export() + + +def _dspy_missing(_name): + """未注册模块函数的统一兜底:503 + 可行动提示(不要退化成泛化 500)。""" + return {"status": 503, "success": False, + "message": "[ns-shim] 模块函数 %s 未注入 dspy 命名空间;" + "请确认该模块 init() 已 env.%s = %s 且应用已 load_{模块}() 挂载" % (_name, _name, _name)} +# [/ns-shim] +# -*- coding: utf-8 -*- +# runtime/api/runtime_status.dspy — W-10g 运行时状态查询(服务端辅助,可选) +try: + runtime_id = params_kw.get('runtime_id') or params_kw.get('world_id') or 'local' + result = await runtime_status(runtime_id=runtime_id) + if result is None: + result = {'ok': True, 'runtime_id': runtime_id, 'running': False, + 'paused': False, 'mode': 'independent', 'reason': 'empty result'} + return result +except Exception as e: + return {'ok': True, 'degraded': True, 'runtime_id': 'local', 'running': False, 'paused': False, + 'mode': 'independent', 'message': 'status degraded: ' + str(e)} diff --git a/modules/scene/wwwroot/api/get_search_scene_type.dspy b/modules/scene/wwwroot/api/get_search_scene_type.dspy new file mode 100644 index 0000000..55d385e --- /dev/null +++ b/modules/scene/wwwroot/api/get_search_scene_type.dspy @@ -0,0 +1,391 @@ +# [fix-iter17 auth-gate v3] fail-closed 登录态门禁 —— 勿删 +# 归属 Bug:OmWRbpwPZRpNe9r2hkKWC / uabpjOGelxJTknG9vqO9V / G-l57of8enoipnJDg17Uz +# 口径:本块必须是文件第一条可执行语句(在命名空间 shim / 业务分支之前),任何匿名请求 +# (无有效会话)一律返回真 HTTP 401,**不返回任何业务数据**;Response 类解析不到时 +# raise(→500)硬失败,绝不降级为 200——R26~R27 的两次放行均源于「判据失真 + 降级返回」。 +def _r17_uid_of(_v): + if not _v: + return "" + _BAD = ("", "none", "null", "anonymous", "anonymoususer", "anon", "0") + if isinstance(_v, str): + _s = _v.strip() + return "" if _s.lower() in _BAD else _s + if isinstance(_v, (list, tuple, set, bytes, bytearray)): + return "" + _KEYS = ("id", "userid", "user_id", "user", "username", "name", "uid", "account") + if isinstance(_v, dict): + for _k in _KEYS: + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + _s = str(_x).strip() + if _s.lower() not in _BAD: + return _s + return "" + for _k in _KEYS: + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + _s = str(_x).strip() + if _s.lower() not in _BAD: + return _s + return "" + + +async def _r17_uid(): + """取登录用户标识;取不到返回 ''。裸名解析(ahserver 预加载全局在 builtins)。""" + for _n in ("get_userinfo", "get_user", "get_username"): + try: + if _n == "get_userinfo": + _f = get_userinfo + elif _n == "get_user": + _f = get_user + else: + _f = get_username + except Exception: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + continue + _u = _r17_uid_of(_r) + if _u: + return _u + return "" + + +_r17_uid_val = "" +try: + _r17_uid_val = await _r17_uid() +except Exception: + _r17_uid_val = "" + +if not _r17_uid_val: + _r17_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "rows": [], + "data": [], + "total": 0, + } + _r17_proc = None + try: + _r17_proc = processor + except Exception: + _r17_proc = globals().get("processor") + _r17_rc = None + try: + if _r17_proc is not None: + _r17_rc = _r17_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _r17_rc = None + if _r17_rc is None: + raise RuntimeError( + "[fix-iter17 auth-gate v3] 未登录且无法解析 aiohttp Response:门禁 hard-fail" + "(宁 500 不放行,绝不返回业务数据)" + ) + return _r17_rc( + body=json.dumps(_r17_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store", "X-Scene-Auth": "denied"}, + content_type="application/json", + ) +# [fix-iter17 auth-gate v3] end + +# [fix-iter3 strict-auth v2] fail-closed 门禁(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0)——勿删 +# 本段取代「globals().get("get_user") 判空」的旧判据:ahserver 的 dspy 预加载全局经 +# builtins 解析、不在 exec-globals 里,globals().get 恒为 None → 判据失真 → 匿名 200。 +# 口径:裸名取值 → 双重判空(get_user + get_userinfo,且必须取到有效用户标识) +# → 未登录返回真 HTTP 401(Response 解析不到时 set_status(401)+JSON,绝不返回业务数据)。 +_auth_uid = "" + + +def _auth_uid_of(_v): + """从 get_user()/get_userinfo() 的返回值里提取有效用户标识;取不到返回 ''。""" + if not _v: + return "" + if isinstance(_v, str): + return _v + if isinstance(_v, dict): + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + return str(_x) + return "" + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + return str(_x) + return "" + + +def _auth_call(_name): + """裸名优先取预加载全局(builtins 解析),退 globals 映射。取不到返回 None。""" + _f = None + try: + _f = globals().get(_name) + except Exception: + _f = None + if not callable(_f): + try: + _f = _auth_bare(_name) + except Exception: + _f = None + return _f if callable(_f) else None + + +def _auth_bare(_name): + """裸名解析(ahserver 预加载全局在 builtins 中);未注入时抛 NameError。""" + if _name == "get_user": + return get_user + if _name == "get_userinfo": + return get_userinfo + if _name == "get_username": + return get_username + raise NameError(_name) + + +async def _auth_resolve_uid(): + for _n in ("get_userinfo", "get_user", "get_username"): + _f = _auth_call(_n) + if _f is None: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + _r = None + _u = _auth_uid_of(_r) + if _u: + return _u + return "" + + +try: + _auth_uid = await _auth_resolve_uid() +except Exception: + _auth_uid = "" + +if not _auth_uid: + _auth_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "reason": "login required", + "rows": [], + "data": [], + "total": 0, + } + _auth_resp_cls = None + try: + _auth_proc = globals().get("processor") + if _auth_proc is None: + _auth_proc = processor + _auth_resp_cls = _auth_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _auth_resp_cls = None + if _auth_resp_cls is not None: + return _auth_resp_cls( + body=json.dumps(_auth_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store"}, + content_type="application/json", + ) + try: + request.set_status(401) + except Exception: + pass + try: + return json.dumps(_auth_deny, ensure_ascii=False) + except Exception: + return _auth_deny +# [fix-iter3 strict-auth v2] end +# [scene-auth-guard] R26-05/06/07/09 —— scene 端点登录态门禁(fail-closed,勿删) +# 背景:R26 复测(plan 0wtjrw6Gn3WG5ewb-BaDR)实测**匿名(不带会话 cookie)**请求本模块 +# api/*.dspy 返回 HTTP 200 + 业务数据(get_search_status / get_search_scene_type / +# get_search_world_id 三个读类端点,以及 scene_import 写入口)。 +# 根因:scene 的 RBAC 路径已在 scripts/load_path.py 逐条登记(10 个 .dspy 全登记、无通配符), +# 但 ahserver 的 dspy 处理器(PythonScriptProcessor)只注入 get_user 等工具函数、不做鉴权; +# 权限表登记 != 端点有登录态校验 → 未登录仍可直连 dspy 拿到业务数据。故补**入口侧**门禁(纵深防御)。 +# 口径(QC #12 收口): +# * await get_user()(ahserver 注入,async;无会话时返回 None/空)判登录态; +# 取不到再退 get_username();两者都空 → 拒绝; +# * 拒绝时返回**真 HTTP 401**(aiohttp Response;BaseProcessor.handle 对 Response 原样透传), +# body 只含 status/success/need_login/message 与空 rows/data,**不含任何业务数据**; +# * dspy 禁 import,Response 类从已注入的 processor 的对象模块 globals 解析(ahserver +# processorResource 模块级已 from aiohttp.web_response import Response); +# * **无降级分支**:Response 解析失败一律 `raise RuntimeError` 硬失败(宁 500 不放行)。 +# 旧版曾降级为返回 dict {"status":401,...},ahserver 会把 dict 包成 HTTP 200 的 JSON body +# ("200 + 语义 401"),鉴权强度落空,且离线 harness 覆盖不到该路径 —— 已按 QC #12 方案(b) 删除。 +try: + _scene_au = None + _scene_gu = globals().get("get_user") + if callable(_scene_gu): + _scene_au = _scene_gu() + if hasattr(_scene_au, "__await__"): + _scene_au = await _scene_au + if not _scene_au: + _scene_gn = globals().get("get_username") + if callable(_scene_gn): + _scene_au = _scene_gn() + if hasattr(_scene_au, "__await__"): + _scene_au = await _scene_au +except Exception: + _scene_au = None + +if not _scene_au: + _scene_deny = { + "status": 401, + "success": False, + "need_login": True, + "rows": [], + "data": [], + "total": 0, + "message": "[scene] 未登录或会话已失效:本接口需要登录态,请先登录后访问", + } + _scene_resp_cls = None + try: + _scene_proc = globals().get("processor") + _scene_resp_cls = _scene_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _scene_resp_cls = None + if _scene_resp_cls is None: + raise RuntimeError( + "[scene-auth-guard] 未登录且无法解析 aiohttp Response:门禁 hard-fail" + "(宁 500 不放行,绝不返回业务数据)" + ) + return _scene_resp_cls( + body=json.dumps(_scene_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"X-Scene-Auth": "denied", "Cache-Control": "no-store"}, + content_type="application/json", + ) +# [/scene-auth-guard] + +# [scene/options] 场景类型下拉端点 get_search_scene_type.dspy —— 读类 500 归零修复版(R27) +# Bug _ViEgLj09USW3JU_79r5a(与 get_search_status 同根因)/ 3x-k0LCAk32WcM4ao5xQo +# 根因:旧实现委托 scene/init.py::get_scene_type_options(),对 sor.R(..., {"page":1,"rows":1000}) +# 返回的分页信封 {'total','rows'} 直接 for 迭代 → 迭代出 str 键 → 'str' has no attribute 'k'; +# params_kw 为 None/str 时取属性同样抛 AttributeError → 框架级 500(无 JSON 体)。 +# 修复:端点自包含 + 取参归一 _as_params() + 行归一 _rows_of()(分页信封只取 rows) +# + 列取值兼容 k/v|value/text + 全路径异常返回可行动 JSON(400/503,绝不上抛)。 +# 库名:get_sor_context(env, "scene"),运行时由 env.get_module_dbname 解析(无硬编码 DBNAME)。 +try: + _env = request._run_ns +except Exception: + _env = None +if _env is None: + try: + _env = getattr(globals().get("request"), "_run_ns", None) + except Exception: + _env = None +if _env is None: + return {"status": 503, "success": False, + "message": "[scene/get_search_scene_type] dspy 执行命名空间缺少 request._run_ns;" + "请确认应用 init() 已完成 ServerEnv 装配并 load_scene()"} + + +def _as_params(_raw): + if _raw is None: + return {} + if isinstance(_raw, dict): + return dict(_raw) + if isinstance(_raw, (bytes, bytearray)): + try: + _raw = _raw.decode("utf-8", "replace") + except Exception: + return None + if isinstance(_raw, str): + _t = _raw.strip() + if not _t: + return {} + try: + _v = json.loads(_t) + except Exception: + return None + return dict(_v) if isinstance(_v, dict) else None + return None + + +def _rows_of(_res): + if _res is None: + return [] + if isinstance(_res, dict): + _r = _res.get("rows") + if isinstance(_r, list): + return _r + if _r is not None: + return [_r] + return [_res] if _res else [] + if isinstance(_res, (list, tuple)): + return list(_res) + try: + return list(_res) + except Exception: + return [] + + +def _get(_row, _name): + try: + if isinstance(_row, dict): + return _row.get(_name) + if hasattr(_row, _name): + return getattr(_row, _name) + return _row[_name] + except Exception: + return None + + +def _pick(_row, _names): + for _n in _names: + _v = _get(_row, _n) + if _v is not None and str(_v).strip() != "": + return _v + return None + + +async def _load_options(_parentid): + async with get_sor_context(_env, "scene") as _sor: + _res = await _sor.sqlExe( + "SELECT k, v FROM appcodes_kv WHERE parentid=${parentid}$ ORDER BY v", + {"parentid": _parentid}) + _out = [] + for _r in _rows_of(_res): + _val = _pick(_r, ("k", "value", "id")) + _txt = _pick(_r, ("v", "text", "name")) + if _val is None: + continue + _out.append({"value": _val, "text": _txt if _txt is not None else _val}) + return _out + + +_params = _as_params(params_kw) +if _params is None: + return {"status": 400, "success": False, "rows": [], + "message": "[scene/get_search_scene_type] 参数类型非法:params 必须是 JSON 对象"} + +try: + _opts = await _load_options("scene_type") + return {"status": 200, "success": True, "total": len(_opts), "rows": _opts} +except Exception as _e: + return {"status": 503, "success": False, "rows": [], + "message": "[scene/get_search_scene_type] 读取 appcodes_kv(parentid=scene_type) 失败: " + + str(_e)} diff --git a/modules/scene/wwwroot/api/get_search_status.dspy b/modules/scene/wwwroot/api/get_search_status.dspy new file mode 100644 index 0000000..7d1c729 --- /dev/null +++ b/modules/scene/wwwroot/api/get_search_status.dspy @@ -0,0 +1,402 @@ +# [fix-iter17 auth-gate v3] fail-closed 登录态门禁 —— 勿删 +# 归属 Bug:OmWRbpwPZRpNe9r2hkKWC / uabpjOGelxJTknG9vqO9V / G-l57of8enoipnJDg17Uz +# 口径:本块必须是文件第一条可执行语句(在命名空间 shim / 业务分支之前),任何匿名请求 +# (无有效会话)一律返回真 HTTP 401,**不返回任何业务数据**;Response 类解析不到时 +# raise(→500)硬失败,绝不降级为 200——R26~R27 的两次放行均源于「判据失真 + 降级返回」。 +def _r17_uid_of(_v): + if not _v: + return "" + _BAD = ("", "none", "null", "anonymous", "anonymoususer", "anon", "0") + if isinstance(_v, str): + _s = _v.strip() + return "" if _s.lower() in _BAD else _s + if isinstance(_v, (list, tuple, set, bytes, bytearray)): + return "" + _KEYS = ("id", "userid", "user_id", "user", "username", "name", "uid", "account") + if isinstance(_v, dict): + for _k in _KEYS: + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + _s = str(_x).strip() + if _s.lower() not in _BAD: + return _s + return "" + for _k in _KEYS: + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + _s = str(_x).strip() + if _s.lower() not in _BAD: + return _s + return "" + + +async def _r17_uid(): + """取登录用户标识;取不到返回 ''。裸名解析(ahserver 预加载全局在 builtins)。""" + for _n in ("get_userinfo", "get_user", "get_username"): + try: + if _n == "get_userinfo": + _f = get_userinfo + elif _n == "get_user": + _f = get_user + else: + _f = get_username + except Exception: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + continue + _u = _r17_uid_of(_r) + if _u: + return _u + return "" + + +_r17_uid_val = "" +try: + _r17_uid_val = await _r17_uid() +except Exception: + _r17_uid_val = "" + +if not _r17_uid_val: + _r17_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "rows": [], + "data": [], + "total": 0, + } + _r17_proc = None + try: + _r17_proc = processor + except Exception: + _r17_proc = globals().get("processor") + _r17_rc = None + try: + if _r17_proc is not None: + _r17_rc = _r17_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _r17_rc = None + if _r17_rc is None: + raise RuntimeError( + "[fix-iter17 auth-gate v3] 未登录且无法解析 aiohttp Response:门禁 hard-fail" + "(宁 500 不放行,绝不返回业务数据)" + ) + return _r17_rc( + body=json.dumps(_r17_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store", "X-Scene-Auth": "denied"}, + content_type="application/json", + ) +# [fix-iter17 auth-gate v3] end + +# [fix-iter3 strict-auth v2] fail-closed 门禁(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0)——勿删 +# 本段取代「globals().get("get_user") 判空」的旧判据:ahserver 的 dspy 预加载全局经 +# builtins 解析、不在 exec-globals 里,globals().get 恒为 None → 判据失真 → 匿名 200。 +# 口径:裸名取值 → 双重判空(get_user + get_userinfo,且必须取到有效用户标识) +# → 未登录返回真 HTTP 401(Response 解析不到时 set_status(401)+JSON,绝不返回业务数据)。 +_auth_uid = "" + + +def _auth_uid_of(_v): + """从 get_user()/get_userinfo() 的返回值里提取有效用户标识;取不到返回 ''。""" + if not _v: + return "" + if isinstance(_v, str): + return _v + if isinstance(_v, dict): + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + return str(_x) + return "" + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + return str(_x) + return "" + + +def _auth_call(_name): + """裸名优先取预加载全局(builtins 解析),退 globals 映射。取不到返回 None。""" + _f = None + try: + _f = globals().get(_name) + except Exception: + _f = None + if not callable(_f): + try: + _f = _auth_bare(_name) + except Exception: + _f = None + return _f if callable(_f) else None + + +def _auth_bare(_name): + """裸名解析(ahserver 预加载全局在 builtins 中);未注入时抛 NameError。""" + if _name == "get_user": + return get_user + if _name == "get_userinfo": + return get_userinfo + if _name == "get_username": + return get_username + raise NameError(_name) + + +async def _auth_resolve_uid(): + for _n in ("get_userinfo", "get_user", "get_username"): + _f = _auth_call(_n) + if _f is None: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + _r = None + _u = _auth_uid_of(_r) + if _u: + return _u + return "" + + +try: + _auth_uid = await _auth_resolve_uid() +except Exception: + _auth_uid = "" + +if not _auth_uid: + _auth_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "reason": "login required", + "rows": [], + "data": [], + "total": 0, + } + _auth_resp_cls = None + try: + _auth_proc = globals().get("processor") + if _auth_proc is None: + _auth_proc = processor + _auth_resp_cls = _auth_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _auth_resp_cls = None + if _auth_resp_cls is not None: + return _auth_resp_cls( + body=json.dumps(_auth_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store"}, + content_type="application/json", + ) + try: + request.set_status(401) + except Exception: + pass + try: + return json.dumps(_auth_deny, ensure_ascii=False) + except Exception: + return _auth_deny +# [fix-iter3 strict-auth v2] end +# [scene-auth-guard] R26-05/06/07/09 —— scene 端点登录态门禁(fail-closed,勿删) +# 背景:R26 复测(plan 0wtjrw6Gn3WG5ewb-BaDR)实测**匿名(不带会话 cookie)**请求本模块 +# api/*.dspy 返回 HTTP 200 + 业务数据(get_search_status / get_search_scene_type / +# get_search_world_id 三个读类端点,以及 scene_import 写入口)。 +# 根因:scene 的 RBAC 路径已在 scripts/load_path.py 逐条登记(10 个 .dspy 全登记、无通配符), +# 但 ahserver 的 dspy 处理器(PythonScriptProcessor)只注入 get_user 等工具函数、不做鉴权; +# 权限表登记 != 端点有登录态校验 → 未登录仍可直连 dspy 拿到业务数据。故补**入口侧**门禁(纵深防御)。 +# 口径(QC #12 收口): +# * await get_user()(ahserver 注入,async;无会话时返回 None/空)判登录态; +# 取不到再退 get_username();两者都空 → 拒绝; +# * 拒绝时返回**真 HTTP 401**(aiohttp Response;BaseProcessor.handle 对 Response 原样透传), +# body 只含 status/success/need_login/message 与空 rows/data,**不含任何业务数据**; +# * dspy 禁 import,Response 类从已注入的 processor 的对象模块 globals 解析(ahserver +# processorResource 模块级已 from aiohttp.web_response import Response); +# * **无降级分支**:Response 解析失败一律 `raise RuntimeError` 硬失败(宁 500 不放行)。 +# 旧版曾降级为返回 dict {"status":401,...},ahserver 会把 dict 包成 HTTP 200 的 JSON body +# ("200 + 语义 401"),鉴权强度落空,且离线 harness 覆盖不到该路径 —— 已按 QC #12 方案(b) 删除。 +try: + _scene_au = None + _scene_gu = globals().get("get_user") + if callable(_scene_gu): + _scene_au = _scene_gu() + if hasattr(_scene_au, "__await__"): + _scene_au = await _scene_au + if not _scene_au: + _scene_gn = globals().get("get_username") + if callable(_scene_gn): + _scene_au = _scene_gn() + if hasattr(_scene_au, "__await__"): + _scene_au = await _scene_au +except Exception: + _scene_au = None + +if not _scene_au: + _scene_deny = { + "status": 401, + "success": False, + "need_login": True, + "rows": [], + "data": [], + "total": 0, + "message": "[scene] 未登录或会话已失效:本接口需要登录态,请先登录后访问", + } + _scene_resp_cls = None + try: + _scene_proc = globals().get("processor") + _scene_resp_cls = _scene_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _scene_resp_cls = None + if _scene_resp_cls is None: + raise RuntimeError( + "[scene-auth-guard] 未登录且无法解析 aiohttp Response:门禁 hard-fail" + "(宁 500 不放行,绝不返回业务数据)" + ) + return _scene_resp_cls( + body=json.dumps(_scene_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"X-Scene-Auth": "denied", "Cache-Control": "no-store"}, + content_type="application/json", + ) +# [/scene-auth-guard] + +# [scene/options] 场景状态下拉端点 get_search_status.dspy —— 读类 500 归零修复版(R27) +# Bug _ViEgLj09USW3JU_79r5a:GET /scene/api/get_search_status.dspy → 500(无 JSON 体) +# Bug 3x-k0LCAk32WcM4ao5xQo:同端点列/取参根因同族 +# 根因(旧实现): +# 1) 委托 scene/init.py::get_scene_status_options(),其内部 +# recs = await sor.R("appcodes_kv", {"parentid":"scene_status","page":1,"rows":1000}) +# 带 page/rows 的查询返回 {'total':N,'rows':[...]},代码直接 for 迭代 dict → 得到 str 键 +# [{"value": r.k, ...} for r in recs] → AttributeError: 'str' object has no attribute 'k' +# 2) params_kw 为 None/str 时对参数取属性 → 'NoneType' object has no attribute 'get' +# 3) 旧实现把异常上抛给框架 → 响应体退化为 "Server got itself in trouble" +# 修复: +# - 端点自包含,不再依赖模块侧委托函数(避免 init.py 与本文件语义漂移) +# - 取参归一 _as_params():None/空串 → {},JSON 串 → dict,非法类型 → 400(不再 500) +# - 行归一 _rows_of():显式识别 {'total','rows'} 分页信封,禁止直接迭代 dict +# - 列取值 _pick():兼容 k/v 与 value/text 两种列命名 +# - 全路径 try/except,DB 异常返回可行动 503 JSON,绝不抛到框架层 +# 库名:get_sor_context(env, "scene") → 运行时由 env.get_module_dbname 解析,无硬编码 DBNAME。 +try: + _env = request._run_ns +except Exception: + _env = None +if _env is None: + try: + _env = getattr(globals().get("request"), "_run_ns", None) + except Exception: + _env = None +if _env is None: + return {"status": 503, "success": False, + "message": "[scene/get_search_status] dspy 执行命名空间缺少 request._run_ns;" + "请确认应用 init() 已完成 ServerEnv 装配并 load_scene()"} + + +def _as_params(_raw): + """取参归一:None → {};dict 复制;JSON/空串 → dict/{};其它 → None(非法)。""" + if _raw is None: + return {} + if isinstance(_raw, dict): + return dict(_raw) + if isinstance(_raw, (bytes, bytearray)): + try: + _raw = _raw.decode("utf-8", "replace") + except Exception: + return None + if isinstance(_raw, str): + _t = _raw.strip() + if not _t: + return {} + try: + _v = json.loads(_t) + except Exception: + return None + return dict(_v) if isinstance(_v, dict) else None + return None + + +def _rows_of(_res): + """把 sor.R / sqlExe 返回值归一为行列表(分页信封只取 rows)。""" + if _res is None: + return [] + if isinstance(_res, dict): + _r = _res.get("rows") + if isinstance(_r, list): + return _r + if _r is not None: + return [_r] + return [_res] if _res else [] + if isinstance(_res, (list, tuple)): + return list(_res) + try: + return list(_res) + except Exception: + return [] + + +def _get(_row, _name): + try: + if isinstance(_row, dict): + return _row.get(_name) + if hasattr(_row, _name): + return getattr(_row, _name) + return _row[_name] + except Exception: + return None + + +def _pick(_row, _names): + for _n in _names: + _v = _get(_row, _n) + if _v is not None and str(_v).strip() != "": + return _v + return None + + +async def _load_options(_parentid): + async with get_sor_context(_env, "scene") as _sor: + _res = await _sor.sqlExe( + "SELECT k, v FROM appcodes_kv WHERE parentid=${parentid}$ ORDER BY v", + {"parentid": _parentid}) + _out = [] + for _r in _rows_of(_res): + _val = _pick(_r, ("k", "value", "id")) + _txt = _pick(_r, ("v", "text", "name")) + if _val is None: + continue + _out.append({"value": _val, "text": _txt if _txt is not None else _val}) + return _out + + +_params = _as_params(params_kw) +if _params is None: + return {"status": 400, "success": False, "rows": [], + "message": "[scene/get_search_status] 参数类型非法:params 必须是 JSON 对象"} + +try: + _opts = await _load_options("scene_status") + return {"status": 200, "success": True, "total": len(_opts), "rows": _opts} +except Exception as _e: + return {"status": 503, "success": False, "rows": [], + "message": "[scene/get_search_status] 读取 appcodes_kv(parentid=scene_status) 失败: " + + str(_e)} diff --git a/modules/scense/wwwroot/api/list_scense.dspy b/modules/scense/wwwroot/api/list_scense.dspy new file mode 100644 index 0000000..bf71831 --- /dev/null +++ b/modules/scense/wwwroot/api/list_scense.dspy @@ -0,0 +1,348 @@ +# [fix-iter17 auth-gate v3] fail-closed 登录态门禁 —— 勿删 +# 归属 Bug:OmWRbpwPZRpNe9r2hkKWC / uabpjOGelxJTknG9vqO9V / G-l57of8enoipnJDg17Uz +# 口径:本块必须是文件第一条可执行语句(在命名空间 shim / 业务分支之前),任何匿名请求 +# (无有效会话)一律返回真 HTTP 401,**不返回任何业务数据**;Response 类解析不到时 +# raise(→500)硬失败,绝不降级为 200——R26~R27 的两次放行均源于「判据失真 + 降级返回」。 +def _r17_uid_of(_v): + if not _v: + return "" + _BAD = ("", "none", "null", "anonymous", "anonymoususer", "anon", "0") + if isinstance(_v, str): + _s = _v.strip() + return "" if _s.lower() in _BAD else _s + if isinstance(_v, (list, tuple, set, bytes, bytearray)): + return "" + _KEYS = ("id", "userid", "user_id", "user", "username", "name", "uid", "account") + if isinstance(_v, dict): + for _k in _KEYS: + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + _s = str(_x).strip() + if _s.lower() not in _BAD: + return _s + return "" + for _k in _KEYS: + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + _s = str(_x).strip() + if _s.lower() not in _BAD: + return _s + return "" + + +async def _r17_uid(): + """取登录用户标识;取不到返回 ''。裸名解析(ahserver 预加载全局在 builtins)。""" + for _n in ("get_userinfo", "get_user", "get_username"): + try: + if _n == "get_userinfo": + _f = get_userinfo + elif _n == "get_user": + _f = get_user + else: + _f = get_username + except Exception: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + continue + _u = _r17_uid_of(_r) + if _u: + return _u + return "" + + +_r17_uid_val = "" +try: + _r17_uid_val = await _r17_uid() +except Exception: + _r17_uid_val = "" + +if not _r17_uid_val: + _r17_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "rows": [], + "data": [], + "total": 0, + } + _r17_proc = None + try: + _r17_proc = processor + except Exception: + _r17_proc = globals().get("processor") + _r17_rc = None + try: + if _r17_proc is not None: + _r17_rc = _r17_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _r17_rc = None + if _r17_rc is None: + raise RuntimeError( + "[fix-iter17 auth-gate v3] 未登录且无法解析 aiohttp Response:门禁 hard-fail" + "(宁 500 不放行,绝不返回业务数据)" + ) + return _r17_rc( + body=json.dumps(_r17_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store", "X-Scene-Auth": "denied"}, + content_type="application/json", + ) +# [fix-iter17 auth-gate v3] end + +# [fix-iter3 strict-auth v2] fail-closed 门禁(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0)——勿删 +# 本段取代「globals().get("get_user") 判空」的旧判据:ahserver 的 dspy 预加载全局经 +# builtins 解析、不在 exec-globals 里,globals().get 恒为 None → 判据失真 → 匿名 200。 +# 口径:裸名取值 → 双重判空(get_user + get_userinfo,且必须取到有效用户标识) +# → 未登录返回真 HTTP 401(Response 解析不到时 set_status(401)+JSON,绝不返回业务数据)。 +_auth_uid = "" + + +def _auth_uid_of(_v): + """从 get_user()/get_userinfo() 的返回值里提取有效用户标识;取不到返回 ''。""" + if not _v: + return "" + if isinstance(_v, str): + return _v + if isinstance(_v, dict): + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = _v.get(_k) + except Exception: + _x = None + if _x: + return str(_x) + return "" + for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"): + try: + _x = getattr(_v, _k, None) + except Exception: + _x = None + if _x: + return str(_x) + return "" + + +def _auth_call(_name): + """裸名优先取预加载全局(builtins 解析),退 globals 映射。取不到返回 None。""" + _f = None + try: + _f = globals().get(_name) + except Exception: + _f = None + if not callable(_f): + try: + _f = _auth_bare(_name) + except Exception: + _f = None + return _f if callable(_f) else None + + +def _auth_bare(_name): + """裸名解析(ahserver 预加载全局在 builtins 中);未注入时抛 NameError。""" + if _name == "get_user": + return get_user + if _name == "get_userinfo": + return get_userinfo + if _name == "get_username": + return get_username + raise NameError(_name) + + +async def _auth_resolve_uid(): + for _n in ("get_userinfo", "get_user", "get_username"): + _f = _auth_call(_n) + if _f is None: + continue + try: + _r = _f() + if hasattr(_r, "__await__"): + _r = await _r + except Exception: + _r = None + _u = _auth_uid_of(_r) + if _u: + return _u + return "" + + +try: + _auth_uid = await _auth_resolve_uid() +except Exception: + _auth_uid = "" + +if not _auth_uid: + _auth_deny = { + "status": 401, + "success": False, + "ok": False, + "need_login": True, + "message": "unauthorized: login required", + "reason": "login required", + "rows": [], + "data": [], + "total": 0, + } + _auth_resp_cls = None + try: + _auth_proc = globals().get("processor") + if _auth_proc is None: + _auth_proc = processor + _auth_resp_cls = _auth_proc.__class__.__init__.__globals__.get("Response") + except Exception: + _auth_resp_cls = None + if _auth_resp_cls is not None: + return _auth_resp_cls( + body=json.dumps(_auth_deny, ensure_ascii=False).encode("utf-8"), + status=401, + headers={"Cache-Control": "no-store"}, + content_type="application/json", + ) + try: + request.set_status(401) + except Exception: + pass + try: + return json.dumps(_auth_deny, ensure_ascii=False) + except Exception: + return _auth_deny +# [fix-iter3 strict-auth v2] end +# [ns-shim] 统一注入 dspy 执行命名空间(Bug qVMyATuR2Z7UuN2P9pbzp / Ua43R1DMO8-7tCb9ybwo8 / j_rE23TAyqp0OV20OTDwv / iFsP7Yklktq_AixTnEWYi) +# 根因:ahserver 的 dspy exec 命名空间不保证注入 `env`(ServerEnv 实例)与模块函数 +# (create_entity / list_entities / *_import …),直写裸名会 NameError,或把 None 拼进 SQL +# ('select None as None from None')→ 泛化 500。 +# 注入方式(QC#1 整改):ServerEnv 继承 appPublic.dictObject.DictObject(dict 子类 Singleton), +# `vars(_ns)` 返回实例 __dict__(实测为空)→ 旧写法 globals().update(vars(_ns)) 实际注入 0 项, +# shim 形同虚设(5cffb7b 实测记载)。现统一改为**映射取值解析**: +# `_resolve_delegate(name)` 依次 globals().get(name) → env.get(name) → getattr(env, name, None); +# 并在 env 为映射时按 `env.items()`(不是 vars())批量导出到 globals。全 74 个端点共用同一段。 +# Bug Dsgk7Hflcyyf_p9ILXYHg 部署态实测:ahserver 预加载全局(request 等)经 builtins 解析, +# 不在 exec-globals 里 —— `globals().get("request")` 恒为 None,旧写法让 5 条契约端点全返 503。 +# 改为裸名优先(ahserver 语义),globals 取值仅作兼容兜底。 +_ns = None +try: + _ns = request._run_ns +except Exception: + _ns = None +if _ns is None: + _ns = getattr(globals().get("request"), "_run_ns", None) +if _ns is None: + return {"status": 503, "success": False, + "message": "[ns-shim] dspy 执行命名空间缺少 request._run_ns,`env` 与模块函数无法解析;" + "请检查应用 init() 是否完成 ServerEnv 装配并逐个调用 load_{模块}()"} +env = _ns + + +def _resolve_delegate(_name): + """三级映射取值解析委托对象(QC#1 统一内置实现)。 + + 解析顺序:globals().get(name) → env.get(name) → getattr(env, name, None)。 + 全部未命中返回 None,调用方据此走 503 可行动兜底(不退化为泛化 500)。 + """ + _v = globals().get(_name) + if _v is not None: + return _v + try: + _v = env.get(_name) + except Exception: + _v = None + if _v is not None: + return _v + return getattr(env, _name, None) + + +def _ns_export(): + """把 env 中的名字导出到 dspy globals。 + + 主路径:映射取值 `env.items()`——ServerEnv 继承 appPublic.dictObject.DictObject(dict 子类), + 这是唯一可靠注入源(实例 __dict__ 实测为空,故不得只用 vars())。 + 回退路径:env 不是映射时(无 items()),退回 `vars(env)` 读实例 __dict__, + 以覆盖属性式挂载的命名空间(不回退会把可用命名空间误判成"未注册"→假 503)。 + """ + _items = None + try: + _items = env.items() + except Exception: + _items = None + if _items is None: + try: + _items = vars(env).items() + except Exception: + _items = None + if _items is None: + return + for _k, _v in list(_items): + if not isinstance(_k, str) or _k.startswith("__") or _k == "params_kw": + continue + globals()[_k] = _v + + +_ns_export() +for _ldrname in ("load_entity", "load_scene", "load_scense", "load_scense_game", + "load_world", "load_world_sync", "load_world_snapshot", + "load_runtime", "load_script_engine", "load_sys_ops"): + _ldr = _resolve_delegate(_ldrname) + if _ldr is None: + continue + try: + _loaded = _ldr() + if hasattr(_loaded, "__await__"): + await _loaded + except Exception: + pass +_ns_export() + + +def _dspy_missing(_name): + """未注册模块函数的统一兜底:503 + 可行动提示(不要退化成泛化 500)。""" + return {"status": 503, "success": False, + "message": "[ns-shim] 模块函数 %s 未注入 dspy 命名空间;" + "请确认该模块 init() 已 env.%s = %s 且应用已 load_{模块}() 挂载" % (_name, _name, _name)} +# [/ns-shim] +"""GET /scense/api/list_scense.dspy —— 展示会话列表(验收契约路径)。 + +QC#4 整改(硬编码空数组桩):原实现是一个**恒返回常量空结果的桩** +(`return json.dumps({'ok': True, 'total': 0, 'rows': [], 'note': ...})`), +不读任何数据源,任何调用方拿到的都是空列表——它让 `/scense/api/list_scense.dspy` +从「500 fpath is None」变成了「200 但永远空」,属于用桩掩盖契约缺失,不可接受。 + +scense 是游戏**展示前端**模块,自身不持有数据表(无 models/ 无 json/), +会话数据的权威持有者是 scense_game 模块的 `game_session` 表。 +scense 模块自己的同源 list 实现是 `scense.core.game_list_sessions(user_id)`: +经 `scense/init.py` 的 `load_scense()` 注册到 ServerEnv(`env.game_list_sessions = ...`), +内部走 `_get_dbname()` 取库名 + `sor.R('game_session', {...})` 按 updated_at 倒序取最近 20 条。 + +故本文件改为**委托该同源实现**,并按登录用户隔离(多用户不串数据), +不再返回任何硬编码常量。 +""" +try: + uid = '' + try: + info = get_userinfo() + except Exception: + info = None + if isinstance(info, dict): + uid = info.get('id') or info.get('userid') or info.get('user') or '' + if not uid: + try: + uid = get_user() or '' + except Exception: + uid = '' + rows = await game_list_sessions(uid) + rows = rows or [] + return json.dumps({'ok': True, 'total': len(rows), 'rows': rows}, + ensure_ascii=False, default=str) +except Exception as e: + return json.dumps({'ok': False, 'total': 0, 'rows': [], 'error': str(e)}, + ensure_ascii=False)