scense/modules/scene/wwwroot/api/get_search_status.dspy
ymq 0cfeb45a3c fix(dspy): 修复 5 个 dspy 的 await-outside-async 语法错误 + 归档死文件
背景:移除 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 保持正常
2026-10-28 15:45:30 +08:00

403 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# [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)}