From 37e018e0e0932aee9d5e751b7a4306dbf5cecb4e Mon Sep 17 00:00:00 2001 From: qiangayi Date: Fri, 14 Aug 2026 15:35:50 +0800 Subject: [PATCH] chore: publish secure public mirror --- .gitignore | 5 + AGENTS.md | 15 + CHANGELOG.md | 382 ++ README.md | 345 ++ SKILL.md | 197 + VERSION | 1 + config.json | 6 + docs/操作示例.md | 482 +++ references/bos-metadata.md | 254 ++ references/commands-and-auth.md | 175 + references/legacy-skill-v1.5.8.md | 692 ++++ references/oracle-ops-and-checkup.md | 203 + references/query-and-business-rules.md | 152 + references/sys-functions/README.md | 18 + references/sys-functions/henlo_awr_export.sql | 23 + .../sys-functions/henlo_ora_monitor.sql | 134 + scripts/config.template.json | 10 + scripts/oracle_skill.py | 3501 +++++++++++++++++ scripts/oracle_skill.py.backup | 289 ++ tests/test_awr.py | 124 + 20 files changed, 7008 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 VERSION create mode 100644 config.json create mode 100644 docs/操作示例.md create mode 100644 references/bos-metadata.md create mode 100644 references/commands-and-auth.md create mode 100644 references/legacy-skill-v1.5.8.md create mode 100644 references/oracle-ops-and-checkup.md create mode 100644 references/query-and-business-rules.md create mode 100644 references/sys-functions/README.md create mode 100644 references/sys-functions/henlo_awr_export.sql create mode 100644 references/sys-functions/henlo_ora_monitor.sql create mode 100644 scripts/config.template.json create mode 100644 scripts/oracle_skill.py create mode 100644 scripts/oracle_skill.py.backup create mode 100644 tests/test_awr.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9293b4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +outputs/ +scripts/config.json +scripts/*.token diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0418cd2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# oracle-jump-query Skill Release Standard + +A published `VERSION` is frozen. Increment it for every later change, update `CHANGELOG.md`, commit and push to Gitea. Skill updates use Git only: pull the commit into the Codex Skill directory with `git pull --ff-only origin main`, then run `capabilities --json`. Do not publish or update this Skill through OSS. + +`inspection_report` and `inspection_latest --refresh` must run `sys_function_check` before Agent collection. Missing SYS/current-schema functions stop generation and return the complete SQL bundle. Historical report reads do not run this preflight. + +Record version, commit, push time and verification result for every release. + +Release 1.5.32: added authenticated `awr_status`, `awr_list`, and byte-for-byte `awr_download` commands. AWR remains disabled until the customer DBA confirms Diagnostics Pack authorization and Agent configuration explicitly enables it. The release commit, push time, and installed-skill verification are recorded in the project release log. + +Release 1.5.33: when AWR readiness reports missing function or view permission, resolve the target Agent schema and return the archived DBA SQL with the real account substituted. Never execute the SQL and never emit an unresolved `` placeholder. + +Release 1.5.34: update archived `HENLO_AWR_EXPORT` to v1.1 so null rows do not reach `DBMS_LOB.WRITEAPPEND`, preserve report row boundaries with newlines, and keep deployment strictly manual through the DBA SQL output. + +Release 1.5.35: change the Skill default transit endpoint to `https://ts.henlo.net`, update configuration and documentation examples, preserve existing login/session fields while migrating only `transit_url`, validate, commit and push to Gitea, then pull and verify the installed Skill. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3315e27 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,382 @@ +## 1.5.35 (2026-08-14) + +### HTTPS transit endpoint +- Changed the default Skill transit endpoint from `http://bos.henlo.net:6357` to `https://ts.henlo.net`. +- Updated runtime defaults, configuration templates, examples, direct HTTP examples, and capability metadata to use the HTTPS domain. +- Existing runtime tokens and selected clients are preserved when only `transit_url` is migrated. + +## 1.5.34 (2026-07-21) + +### AWR export function recovery +- Updated archived `SYS.HENLO_AWR_EXPORT` to v1.1: ignore null report rows before `DBMS_LOB.WRITEAPPEND` and preserve row boundaries with an explicit newline. +- Fixes production `ORA-06502` raised by the v1.0 function when Oracle's AWR report stream contains an empty row. +- The Skill continues to return the SQL for manual DBA execution only and never runs DDL. + +## 1.5.33 (2026-07-21) + +### AWR DBA preparation +- When `awr_status` reports `permission_required`, the Skill now resolves the target Agent's actual Oracle schema and returns the archived `HENLO_AWR_EXPORT` deployment/grant SQL for manual DBA execution. +- The SQL is never executed by the Skill and is only shown after reminding the operator to confirm Diagnostics Pack authorization. +- Added the explicit `DBA_HIST_SNAPSHOT` read grant required by Agent readiness checks; unresolved schemas never produce SQL containing the `` placeholder. + +## 1.5.32 (2026-07-21) + +### AWR report delivery +- Added `awr_status`, `awr_list`, and `awr_download` commands using authenticated transit-server APIs. +- AWR HTML is downloaded byte-for-byte from the Agent and is never rendered locally; downloads are limited to 50 MB and use strict `yyyyMMdd` validation. +- Documented the mandatory Diagnostics Pack confirmation and Agent `awr.enabled` / `awr.license_confirmed` gates. + +## 1.5.31 (2026-07-21) + +### Client authorization refresh +- `clients` now replaces the local client cache with the transit server's current authorization snapshot, so newly authorized clients appear and revoked clients disappear without re-entering `secretKey`. +- Client refresh continues to use only the existing transit-server access token. + +## 1.5.29 (2026-07-15) + +### Inspection SYS deployment +- When inspection preflight fails, the Skill reads the target Agent's configured Oracle schema through the `version` action. +- SYS function SQL replaces `` with the actual customer schema before it is shown to the user. +- If the schema cannot be determined or is invalid, the Skill stops with a configuration error and never emits placeholder authorization SQL. +- Updated `HENLO_ORA_MONITOR` to the complete v3.0 CLOB JSON contract used by Agent inspection actions. + +## 1.5.28 (2026-07-13) + +### Inspection preflight +- Restored mandatory `sys_function_check` before `inspection_report` creates a new report. +- Missing SYS/current-schema monitor functions now stop collection and return the complete deployment and grant SQL bundle. +- Reading an existing latest report no longer requires SYS preflight; `inspection_latest --refresh` still does. + +## 1.5.26 (2026-07-13) + +### Documentation +- Documented the Skill OSS release path and planned `skill_update` check/download/confirm workflow. +- Documented preservation of local `config.json`, `outputs/` and logs during updates. +- Marked the runtime `skill_update` command as not implemented yet. + +--- + +## 1.5.25 (2026-07-13) + +### HTML archive delivery +- Retry short-lived 404 responses while transit-server asynchronously commits the report archive. +- Return a readable error after retries instead of a traceback. + +--- + +## 1.5.24 (2026-07-13) + +### HTML download +- Fixed HTML URL construction when the local config does not contain a duplicate `transit_url` field. +- Network download failures now return a readable Skill error instead of a Python traceback. + +--- + +## 1.5.23 (2026-07-13) + +### Inspection preflight +- Fixed the missing `check_inspection_sys_functions` implementation. +- Inspection generation now stops before Agent collection when the SYS monitor function is unavailable and returns the complete grant SQL bundle. +- Protected HTML URLs must be downloaded through Skill authentication. + +--- + +## 1.5.22 (2026-07-13) + +### 发布规范 +- 完善 Skill 版本号、CHANGELOG、项目 AGENTS.md 发布记录和实际提交的一致性要求。 + +--- + +## 1.5.21 (2026-07-13) + +### SYS function deployment +- Added `sys_functions [--json]` to generate the complete SYS function deployment bundle for Agent installation. +- The command automatically includes every `.sql` file under `references/sys-functions/` and substitutes `` with the customer Agent schema. +- Documented the rule that every future SYS function must be archived in that directory. + +--- + +## 1.5.20 (2026-07-10) + +### 功能 +- 文档补充 Agent 版本查询命令: ersion agent 、--all、--json。 +- 明确在线 Agent 直接确认版本,离线客户返回最后心跳版本和离线状态。 +- 补充自然语言触发示例:查询所有客户的 Agent 版本号、查询指定客户版本号。 + +--- + +## 1.5.20 (2026-07-10) + +### 功能 +- 巡检 HTML/Markdown 支持服务器 IP、操作系统版本、启动时间、运行时长、关键进程和 TCP 连接统计。 +- 巡检报告支持 Oracle ACTIVE/总会话数、Buffer Cache 与 Library Cache 命中率,并区分真实零值和采集失败。 +- 修正慢 SQL 列映射、表空间容量/自动扩展上限映射和高内存事件误标为 OOM 的报告问题。 +- AWR 明确列入未来开发计划,当前版本不采集、不生成 AWR 报告。 + +--- + +## 1.5.19 (2026-07-10) + +### 功能 +- 新版服务器巡检命令成功返回后默认生成固定格式 HTML 文件,无需再显式传入 `--html`。 +- `inspection_report`、`inspection_latest`、`inspection_get` 增加 `--markdown [path]`(别名 `--md`),可在生成 HTML 的同时导出固定格式 Markdown 文件。 +- `--json` 输出增加 `artifacts` 字段,返回已生成的 HTML/Markdown 文件路径,同时保持标准 JSON 输出。 + +--- + +## 1.5.18 (2026-07-10) + +### 功能 +- 移除旧版 `checkup` 对外命令能力,服务器体检统一走新版 `inspection_report` / `inspection_latest` / `inspection_get`。 +- `inspection_report`、`inspection_latest`、`inspection_get` 增加 `--html [path]`,按固定 HTML 模板导出巡检报告;不传路径时写入 `outputs/`。 +- `config.json` 读取兼容 UTF-8 BOM。 + +--- + +## 1.5.17 (2026-07-09) + +### 功能 +- 增加 `inspection_report [clientCode] [--json]` 命令,通过 transit-server 生成服务器巡检报告并归档。 +- 增加 `inspection_latest [clientCode] [--refresh] [--json]` 命令,读取最近归档巡检报告;加 `--refresh` 时重新生成后返回。 +- 增加 `inspection_get [--json]` 命令,按 `TS_INSPECTION_REPORT.ID` 读取历史巡检报告。 + +--- + +## 1.5.16 (2026-07-08) + +### 文档 +- 记录 skill Git 源地址:`http://124.220.155.147:3000/qiangayi/oracle-jump-query`。 +- 明确后续更新 skill 时以该 Git 地址为准,已安装目录只作为运行和验证目录。 + +--- + +## 1.5.15 (2026-07-08) + +### 文档 +- 补充 BOS 提交流程规则:生成提交 SQL 或说明提交流程时,如果已知 `userId`,调用 `AD_TABLE.PROC_SUBMIT` 前需要先更新主单据修改人和修改时间,再调用提交存储过程。 +- 强调修改人、修改时间字段名必须先通过 `AD_COLUMN` 或表结构确认,不要凭经验硬写字段名。 + +--- + +## 1.5.14 (2026-07-08) + +### 文档 +- 补充 `AD_TABLE` 表单事件过程规则:`HAS_TRIG_BD='Y'` 时 `TRIG_BD` 为删除前触发过程(bd),`HAS_TRIG_AC='Y'` 时 `TRIG_AC` 为新增后触发过程(ac),`HAS_TRIG_AM='Y'` 时 `TRIG_AM` 为修改后触发过程(am)。 +- 更新 BOS 元数据查询模板,分析业务表时同时查看 `PROC_SUBMIT` 和 `TRIG_BD/TRIG_AC/TRIG_AM`。 + +--- + +## 1.5.13 (2026-07-06) + +### 功能 +- 在 `SKILL.md`/README/命令说明中补充 `agent_update [clientCode] [timeout]` 能力,便于其他 Agent 通过 `capabilities` 发现并触发客户 Agent 更新。 +- skill 默认中转机统一为 `http://bos.henlo.net:6357`,不再使用旧 IP 或 `zjhenlo.henlo.net`。 + +--- + +## 1.5.12 (2026-07-06) + +### 功能 +- 增加 `agent_update [clientCode] [timeout]` 命令,通过中转机触发 Agent 自动更新;升级包下载地址由中转机返回,按 OSS `latest.json` 方案下载。 +- 默认中转机地址调整为 `http://bos.henlo.net:6357`。 + +--- + +## 1.5.11 (2026-07-02) + +### 文档 +- 增加商品明细常见字段说明:`M_PRODUCT_ID` 对应款号,`M_PRODUCTALIAS_ID` 对应条码,`M_ATTRIBUTESETINSTANCE_ID` 对应色码属性 ASI。 +- 增加 ASI 色码属性规则:`M_ATTRIBUTESETINSTANCE.VALUE1/VALUE1_CODE/VALUE1_ID` 对应颜色,关联 `M_COLOR.ID`;`VALUE2/VALUE2_CODE/VALUE2_ID` 对应尺码,关联 `M_SIZE.ID`。 + +--- + +## 1.5.10 (2026-07-01) + +### 功能 +- `clients` 和 `status` 成功获取服务端 client 列表后,会同步刷新本地 `client_list` 缓存。 +- `switch` 支持按 client code 或 client title/name 切换;找不到时自动刷新 client 列表后再匹配。 +- client 名称模糊匹配到多个结果时,不自动切换,只列出候选项并要求用户指定更准确的 client code。 + +--- + +## 1.5.9 (2026-07-01) + +### 文档 +- 整理 `SKILL.md` 为执行入口和索引页,将详细说明拆分到 `references/`。 +- 新增完整原文备份 `references/legacy-skill-v1.5.8.md`,确保整理过程不遗漏既有内容。 +- 记录文档维护规则:精简主文档时只能移动内容到 references,删除内容必须先经过用户同意。 + +--- + +## 1.5.8 (2026-07-01) + +### 文档 +- 记录 `AD_TABLE_TEXT` 分析结论:可通过 BOS 元数据表反推出系统菜单、模块目录、业务表、字段、主子表、动作、权限目录和报表模板结构。 +- 增加 BOS 系统地图分析链路:`AD_SUBSYSTEM -> AD_TABLECATEGORY -> AD_ACCORDION -> AD_TABLE -> AD_COLUMN / AD_REFBYTABLE / AD_ACTION`。 + +--- + +## 1.5.7 (2026-06-17) + +### 文档 +- 增加条码/SKU 查询优先字段:查询条码时首查 `M_PRODUCT_ALIAS.NO`,再根据条码关联商品和库存。 + +--- + +## 1.5.6 (2026-06-17) + +### 文档 +- 增加常用业务对象说明:商品/款号默认对应 `M_PRODUCT`,条码/SKU 默认对应 `M_PRODUCT_ALIAS`,款号与条码是一对多关系。 +- 增加库存查询约束:库存通常查询 `V_FA_STORAGE`,该视图数据量大,必须通过店仓 + 款号/条码条件查询,并配合行数限制。 + +--- + +## 1.5.5 (2026-06-17) + +### 文档 +- 增加生产查询限制说明:直接查询业务表时必须带明确过滤条件和行数限制,不要执行无条件整表查询。 +- 更新 `query` 示例,强调使用日期、状态、单号、门店、主键等业务条件,并配合 `ROWNUM` 或分页限制。 + +--- + +## 1.5.4 (2026-06-16) + +### 文档 +- 记录新增业务表单时的提交状态字段默认规则:如果表存在 `STATUS`、`STATUSERID`、`STATUSTIME`,新增草稿表单通常只设置 `STATUS='1'`,不对 `STATUSERID`、`STATUSTIME` 赋值。 +- 记录“提交”语义:如果用户提到提交,通常指执行 `AD_TABLE.PROC_SUBMIT` 对应的提交存储过程,提交人和提交时间等字段由提交逻辑处理。 + +--- + +## 1.5.3 (2026-06-16) + +### 文档 +- 记录 `AD_COLUMN.OBTAINMANNER = 'select'` 的字段值域查询规则:通过 `AD_LIMITVALUE_GROUP_ID` 查询子表 `AD_LIMITVALUE`。 +- 当用户使用显示值描述业务含义时,生成查询或新增记录 SQL 前必须先映射为数据库实际值。例如 `XH_ORDER_FTP.BILLTYPE` 显示值“券核销”对应实际入库值 `VOU_USED`。 + +--- + +## 1.5.2 (2026-06-15) + +### 文档 +- 记录新增业务表记录时的 SQL 生成规则:主键 `ID` 使用 `get_sequences('<表名称>')`。 +- 如果表存在 `DOCNO` 字段,先查询 `AD_COLUMN` 中该字段的 `SEQUENCENAME` 单据编号生成器;有值时使用 `Get_SequenceNo('', 37)` 生成单据编号。 +- 如果表存在 `AD_CLIENT_ID` 字段,默认值为 `37`;如果存在 `AD_ORG_ID` 字段,默认值为 `27`。 +- 子表的 `AD_CLIENT_ID` 和 `AD_ORG_ID` 默认继承父表对应字段值。 + +--- + +# 更新日志 + +## 1.5.1 (2026-06-15) + +### 调整 +- Oracle 运维监控函数调用增加当前账号兜底:优先调用 `SYS.HENLO_ORA_MONITOR`,失败后自动尝试 `HENLO_ORA_MONITOR`。 +- 函数缺失提示增加双安装方案:可安装到 SYS 并授权,也可在当前 Agent 登录账号已有系统视图权限时安装到当前账号。 + +--- + +## 1.5.0 (2026-06-15) + +### 新增 +- 新增 `ops [clientCode]`,用于单项 Oracle 日常运维监控。 +- 新增 `ops_report [clientCode]`,用于生成 Oracle 运维日报。 +- 支持通过中转机 `/api/oracle_ops` 和 `/api/oracle_ops_report` 调用 Agent 受控运维监控 action。 +- 支持 `SYS.HENLO_ORA_MONITOR` 定义者权限函数方案,缺失时输出 SYS 函数源码和授权 SQL。 + +### 调整 +- Oracle 运维监控不再由 skill 直接拼 SQL,而是由中转机鉴权、Agent 白名单 action、SYS 函数共同控制。 +- 报告中单项失败不影响整体输出,失败项会在报告末尾汇总。 + +### 文档 +- 更新 README、SKILL.md、agent.md,记录 Oracle 运维报告、单项命令和 SYS 权限函数方案。 + +--- + +## 1.4.0 (2026-06-11) + +### 新增 +- 新增低调入口 `checkup [clientCode]`,用于查询客户服务器体检报告。 +- 服务器体检报告通过中转机 `/api/server_status` 获取,返回 Markdown 表格。 +- 体检报告字段包括 `clientCode`、`info_cpu_rate`、`info_memory_rate`、`info_memory`、`bak_path`、`bak_lastdate`、`computer`。 +- 支持用户自然语言询问“服务器体检报告 / 服务器状态 / 备份情况”时调用 `checkup`。 + +### 调整 +- `login [clientCode]` 改为登录中转机,由中转机调用 BOS `ts_login` 并签发 `access_token`。 +- `clientCode` 为客户服务器编号,改为非必填;不传时默认使用 BOS 返回的第一个可用 client。 +- 本地配置不再保存 `secretKey`,只保存中转机 token、过期时间、当前 client 和可访问 client 列表。 +- 同一个 `secretKey` 同时只允许一个设备在线;新设备登录后旧 token 立即失效。 + +### 文档 +- 更新 `README.md` 和 `SKILL.md`,补充 `checkup` 服务器体检报告说明。 +- 记录 `clientCode` 非必填,以及 token 失效后需要重新 login 的行为。 + +--- +## 1.3.0 (2026-06-10) + +### ✨ 新增 +- 表空间查询 5 层自动降级策略 + - 第 1 层:`DBA_DATA_FILES` + `DBA_FREE_SPACE` 直接查询(需 `SELECT_CATALOG_ROLE`) + - 第 2 层:`sys.get_tablespace_usage()` 专用函数(返回 `SYS_REFCURSOR`) + - 第 3 层:`HENLO_TS_USAGE` 授权 VIEW + - 第 4 层:`SYS.HENLO_TS_USAGE` 授权 VIEW(带 schema 前缀) + - 第 5 层:`USER_FREE_SPACE` 回退查询(始终可用) +- 自动输出 DBA 授权 SQL(无权限时自动打印 CREATE VIEW + GRANT 语句) +- 函数 `SYS_REFCURSOR` 返回值的正则解析,支持从 Go 结构体 dump 中提取表格数据 +- `version` 命令:查看当前版本号 + +### 🔧 修复 +- 视图创建在 `SYS` schema 下时,自动尝试 `sys.` 前缀查询 +- 交互模式和 CLI 模式显示统一:区分 DBA 查询 / 函数查询 / VIEW 查询 / 降级回退 + +### 📝 文档 +- `SKILL.md`:新增表空间查询文档(5 层降级 + 授权 SQL + schema 前缀说明) +- `docs/操作示例.md`:新增完整操作示例(含实际 HENLO 环境输出) +- 新增 `CHANGELOG.md`、`VERSION` 文件 + +--- + +## 1.2.0 (2026-06-09) + +### ✨ 新增 +- 表空间查询基础功能 +- 支持 `DBA_DATA_FILES` + `DBA_FREE_SPACE` 查询 +- 交互模式支持 `tablespace` / `tablespaces` 命令 +- CLI 模式支持 `tablespace` / `tablespaces` 命令 + +### 🔧 修复 +- 权限不足时自动降级到 `USER_FREE_SPACE` 查询 + +--- + +## 1.1.0 (2026-XX-XX) + +### ✨ 新增 +- 数据字典查询(AD_TABLE / AD_COLUMN / AD_REFBYTABLE) +- `get_tables` 命令:通过存储过程依赖发现表结构 +- `describe_table` 命令:查询表结构(列、索引、行数、注释) +- `get_userspermsql` 命令:生成用户权限 SQL +- `perm` 命令:查询用户权限 +- `permq` 命令:带权限过滤的查询 +- 交互式模式 + +### 🔧 修复 +- 各项命令的异常处理优化 + +--- + +## 1.0.0 (2026-XX-XX) + +### ✨ 初始版本 +- Oracle 跳板查询基础架构 +- 通过中转服务 + Agent 查询远程 Oracle +- 支持存储过程源码分析、依赖查询、触发器查询 +- 多租户授权验证(BOS ts_login / ts_client_check) +- 支持 `analyze`、`source`、`list`、`deps`、`trigger` 等命令 +- 支持 `query` 命令直接执行 SELECT(只读) + +## 1.5.30 (2026-07-21) + +### Documentation +- Documented `AD_TABLE.MASK` as the table's declared business-action codes: `A` add, `M` modify, `D` delete, `Q` query, `U` cancel submission, `V` void, and `S` submit. +- Restored Git/Gitea as the only Skill update source; removed the OSS and `skill_update` release/update instructions. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1bd864d --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# Oracle Jump Query Skill + +通过中转服务查询远程 Oracle 数据库的 AI 技能,支持存储过程分析、表结构查询、数据权限控制。 + +## 架构 + +``` +用户/AI → 中转服务 (:6357) → WebSocket → Agent → Oracle 数据库 +``` + +- **中转服务**:转发 HTTP 请求到数据库服务器的 Agent +- **Agent**:部署在数据库服务器,通过 WebSocket 连接中转服务,无需开放数据库端口 +- **安全**:数据库服务器仅需出站 WebSocket 连接,防火墙友好 + +## 环境要求 + +- Python 3.6+ +- `pip install requests` + +## 维护来源 + +本 skill 的 Git 源地址: + +```text +http://124.220.155.147:3000/qiangayi/oracle-jump-query +``` + +需要更新 skill 时,以该地址为准进行拉取、修改、提交和同步。`C:\Users\qiang\.codex\skills\oracle-jump-query` 是安装后的运行目录,不作为长期源码目录。 + +## 快速开始 + +更多面向日常使用的问法和操作流程,见 [操作示例](docs/操作示例.md)。 + +### 1. 配置 + +编辑 `scripts/config.json`: + +```json +{ + "transit_url": "https://ts.henlo.net", + "server_id": "server-001", + "access_token": "", + "expires_at": "" +} +``` + +先登录中转机后再查询: + +```bash +python oracle_skill.py login [clientCode] +python oracle_skill.py status +``` + +`secretKey` 只用于登录请求,不会保存到本地配置文件。`clientCode` 是客户服务器编号,非必填;不传时中转机会默认选择 BOS 返回的第一个可用 client。登录成功后本地仅保存中转机签发的 `access_token`、过期时间和可访问 client 列表。 + +同一个 `secretKey` 同时只允许一个设备在线。另一台设备重新登录后,当前设备的 token 会立即失效,需要重新执行 `login [clientCode]`。 + +### 2. 运行交互模式 + +```bash +cd scripts +python oracle_skill.py +``` + +### 3. 运行指定命令 + +```bash +# 分析存储过程 +python oracle_skill.py analyze bosnds3 M_RETAIL_SUBMIT + +# 查询表结构 +python oracle_skill.py describe bosnds3 xcx_so + +# 执行 SELECT 查询:生产表必须带业务过滤条件和行数限制 +python oracle_skill.py query "SELECT * FROM M_RETAIL WHERE BILLDATE = 20260501 AND ROWNUM <= 20" + +# 获取用户数据权限 +python oracle_skill.py perm 940 12983 +``` + +## 命令列表 + +| 命令 | 说明 | 示例 | +|------|------|------| +| `analyze ` | 完整分析存储过程(源码+依赖+表+触发器) | `analyze bosnds3 M_RETAIL_SUBMIT` | +| `list ` | 列出 schema 下所有存储过程 | `list bosnds3` | +| `source ` | 获取存储过程源码 | `source bosnds3 M_RETAIL_SUBMIT` | +| `deps ` | 获取依赖(表/存储过程) | `deps bosnds3 M_RETAIL_SUBMIT` | +| `describe ` | 查询表结构(列、类型、索引、行数) | `describe bosnds3 xcx_so` | +| `query ` | 执行 SELECT 查询(仅支持 SELECT;生产表必须带过滤条件和行数限制) | `query SELECT * FROM M_RETAIL WHERE BILLDATE=20260501 AND ROWNUM<=20` | +| `tablespace / tablespaces` | 查询表空间使用情况 | `tablespace` | +| `perm ` | 查询用户数据权限 | `perm 940 12983` | +| `qperm ` | 带权限过滤的查询 | `qperm 940 12983 SELECT * FROM M_OTHER_INOUT` | +| `login [clientCode]` | 登录中转机 + 选择客户服务器,clientCode 非必填 | `login mykey HENLO` | +| `logout` | 登出中转机并清除本地 token | `logout` | +| `status` | 查看中转机登录状态 + 当前 client | `status` | +| `switch ` | 按 code 或名称切换 client;找不到时自动刷新 client 列表 | `switch HENLO` | +| `clients` | 无感刷新当前用户最新授权的 client 列表及在线状态(只使用当前登录 token) | `clients` | +| `inspection_report [clientCode] [--json] [--html [path]] [--markdown [path]]` | 生成服务器巡检报告并归档,默认导出固定格式 HTML,可同时导出 Markdown | `inspection_report HENLO --markdown` | +| `inspection_latest [clientCode] [--refresh] [--json] [--html [path]] [--markdown [path]]` | 查看最近归档巡检报告并默认导出 HTML;加 `--refresh` 时重新生成 | `inspection_latest HENLO --refresh --markdown` | +| `inspection_get [--json] [--html [path]] [--markdown [path]]` | 按归档 ID 查看历史巡检报告并默认导出 HTML | `inspection_get 123 --markdown` | +| `agent_update [clientCode] [timeout]` | 触发客户 Agent 自动更新,升级包由 Agent 从 OSS 下载 | `agent_update HENLO 300` | +| `discover [schema] [filter]` | 发现核心业务表 | `discover bosnds3 M_%` | +| `nl2sql ` | 自然语言转 SQL | `nl2sql bosnds3 查询花都二店5月销售额` | +| `servers` | 列出在线 Agent | `servers` | +| `health` | 检查中转服务状态 | `health` | +| `capabilities --json` | 获取版本和命令定义(供程序调用) | `capabilities --json` | + +## 生产查询限制 + +查询业务表时一定要带条件查询,不要直接查询整表。生产环境中业务表通常数据量很大,无条件 `SELECT * FROM
` 可能返回大量结果、拖慢 Agent 或影响数据库。 + +建议: + +- 必须包含 `WHERE` 条件,优先使用日期、单号、门店、客户、状态、主键等业务过滤条件。 +- 必须加 `ROWNUM <= N` 或分页限制。 +- 用户没有提供条件时,先追问日期、单号、门店等范围;不要直接查整表。 +- 只想了解字段时,优先使用 `describe`、`AD_TABLE`、`AD_COLUMN`。 + +推荐: + +```sql +SELECT * +FROM M_RETAIL +WHERE BILLDATE = 20260501 + AND STATUS = '2' + AND ROWNUM <= 20 +``` + +不要这样查: + +```sql +SELECT * FROM M_RETAIL +``` + +## 常用业务对象说明 + +- “商品”“款号”通常指 `M_PRODUCT` 表。 +- “条码”“SKU”通常指 `M_PRODUCT_ALIAS` 表。 +- 查询条码/SKU 时,首查 `M_PRODUCT_ALIAS.NO` 字段。 +- 款号和条码是一对多关系:一个款号可以对应多个条码/SKU。 +- 商品明细里常见的 `M_PRODUCT_ID`、`M_PRODUCTALIAS_ID`、`M_ATTRIBUTESETINSTANCE_ID` 分别对应款号、条码、色码属性 ASI。 +- `M_ATTRIBUTESETINSTANCE` 的 `VALUE1`、`VALUE1_CODE`、`VALUE1_ID` 对应颜色名称、颜色编号、颜色表 `M_COLOR.ID`;`VALUE2`、`VALUE2_CODE`、`VALUE2_ID` 对应尺码名称、尺码编号、尺码表 `M_SIZE.ID`。 +- 涉及库存查询时,通常查询 `V_FA_STORAGE` 视图。 +- `V_FA_STORAGE` 数据量很大,必须通过店仓 + 款号/条码条件查询,不要直接查整张库存视图。 + +库存查询建议: + +- 用户只说“查库存”但没有给出店仓或款号/条码时,先追问条件。 +- 通过款号查库存时,围绕 `M_PRODUCT` 找商品。 +- 通过条码/SKU 查库存时,优先用 `M_PRODUCT_ALIAS.NO` 找条码,再关联到商品。 +- 通过颜色/尺码查商品明细或库存时,优先用 `M_ATTRIBUTESETINSTANCE` 的 `VALUE1_ID` / `VALUE2_ID` 或 `VALUE1_CODE` / `VALUE2_CODE` 过滤。 +- 查询 `V_FA_STORAGE` 时必须加店仓条件、款号/条码条件和 `ROWNUM` 或分页限制。 + +## 应用场景 + +### 场景一:分析存储过程业务逻辑 + +分析 `M_RETAIL_SUBMIT` 存储过程: + +``` +输入:python oracle_skill.py analyze bosnds3 M_RETAIL_SUBMIT + +输出: +- 完整源码(PL/SQL) +- 依赖的表、视图、嵌套存储过程 +- 涉及的表结构(列名、类型、长度) +- 触发器列表 +``` + +### 场景二:查询数据字典 + +通过 `AD_TABLE`、`AD_COLUMN` 查询表和字段的业务含义: + +```sql +-- 查询 M_RETAIL 的字段描述 +SELECT DBNAME, NAME, DESCRIPTION, COLTYPE +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = 'M_RETAIL') +ORDER BY ORDERNO +``` + +也可以通过 BOS 元数据体系分析系统菜单、模块目录和业务表结构。`AD_TABLE_TEXT` 过程显示 BOS 的系统地图主要由 `AD_SUBSYSTEM -> AD_TABLECATEGORY -> AD_ACCORDION -> AD_TABLE -> AD_COLUMN / AD_REFBYTABLE / AD_ACTION` 驱动,报表模板由 `AD_CXTAB` 相关表维护,权限挂载点在 `DIRECTORY`。 + +```sql +SELECT ss.NAME AS SUBSYSTEM, + tc.NAME AS TABLE_CATEGORY, + ac.NAME AS ACCORDION, + t.ID AS AD_TABLE_ID, + t.NAME AS TABLE_NAME, + t.DESCRIPTION, + t.PROC_SUBMIT, + d.NAME AS DIRECTORY_NAME +FROM AD_TABLE t +LEFT JOIN AD_TABLECATEGORY tc ON tc.ID = t.AD_TABLECATEGORY_ID +LEFT JOIN AD_SUBSYSTEM ss ON ss.ID = tc.AD_SUBSYSTEM_ID +LEFT JOIN AD_ACCORDION ac ON ac.ID = t.AD_ACCORDION_ID +LEFT JOIN DIRECTORY d ON d.ID = t.DIRECTORY_ID +WHERE t.NAME LIKE '%关键词%' OR t.DESCRIPTION LIKE '%关键词%' +ORDER BY ss.NAME, tc.ORDERNO, ac.ORDERNO, t.ORDERNO, t.NAME +``` + +### 场景三:带权限过滤的查询 + +普通用户查询时自动拼接权限条件(如按门店过滤): + +``` +输入:python oracle_skill.py qperm 940 12983 SELECT * FROM M_OTHER_INOUT + +自动拼接:C_STORE_ID IN(...) WHERE 条件 +输出:仅返回用户有权限查看的数据 +``` + +## 数据权限模型 + +数据库内置 `get_userspermsql` 函数: + +```sql +FUNCTION get_userspermsql( + p_users_id NUMBER, -- 用户ID + p_tableid NUMBER, -- 表ID (AD_TABLE.ID) + p_col VARCHAR2 -- 字段名,可为NULL +) RETURN CLOB -- 返回权限过滤SQL +``` + +返回值示例: +- 空字符串:全权限(无需过滤) +- `C_STORE_ID IN(1,2,3)`:仅可查看指定门店 + +## 常见问题 + +| 问题 | 解决方法 | +|------|----------| +| 无法连接中转服务 | 检查 `config.json` 中的 `transit_url` 地址和端口 | +| agent not found | 确认 Agent 已启动并连接成功 | +| 请求超时 | Agent 可能卡住,增大 `timeout` 参数 | + +## 相关项目 + +- [恒诺云打印 Print_Tool](https://gitee.com/qiangayi/henlo_mid) - .NET 10 升级项目 +- [Henlo_Mid 通用接口](https://gitee.com/qiangayi/henlo_mid) - 企业内部系统集成 + +## License + +MIT +## 服务器巡检报告 + +当用户询问“给我 HENLO 的服务器体检报告 / 巡检报告 / 运维体检报告 / 备份和资源情况”时,统一调用新版巡检入口: + +```bash +python scripts/oracle_skill.py inspection_report HENLO +python scripts/oracle_skill.py inspection_report HENLO --markdown +python scripts/oracle_skill.py inspection_latest HENLO --refresh --markdown +``` + +巡检命令成功后默认把固定格式 HTML 写入 `outputs/`。增加 `--markdown`(或 `--md`)会同时生成 Markdown;`--html` 和 `--markdown` 后均可指定自定义输出路径。 + +HTML 导出使用固定模板,CSS、章节顺序和表格结构保持不变,只替换报告编号、采集时间、指标值、告警和明细行。不传路径时写入 skill 目录下的 `outputs/`。 + +当前报告支持服务器 IP、系统版本、启动时间/运行时长、关键进程、TCP 连接统计,以及 Oracle ACTIVE/总会话数和 Buffer/Library Cache 命中率。AWR 已记录在未来开发计划中,当前版本不执行 AWR 采集;详细边界见 `references/oracle-ops-and-checkup.md`。 +## Oracle 日常运维监控 + +单项监控: + +```bash +python scripts/oracle_skill.py ops active_slow_sql HENLO +python scripts/oracle_skill.py ops blocking_locks HENLO +python scripts/oracle_skill.py ops ora_errors HENLO +``` + +统一日报: + +```bash +python scripts/oracle_skill.py ops_report HENLO +``` + +`clientCode` 可省略,不传时使用当前登录 client。第一版支持的监控项包括:`active_slow_sql`、`history_top_sql`、`fullscan_sql`、`plan_heavy_sql`、`blocking_locks`、`long_transactions`、`inactive_sessions`、`datafiles`、`undo`、`memory`、`io_waits`、`background_process`、`ora_errors`、`invalid_objects`、`tablespace`。 + +涉及 `v$`、`dba_`、AWR、alert log 等 SYS/DBA 权限视图时,Agent 会优先调用 `SYS.HENLO_ORA_MONITOR`,如果不可用则自动尝试当前登录账号下的 `HENLO_ORA_MONITOR`。如果两个函数都不存在或未授权,命令会输出两种安装方案:有 SYS 账号时安装到 SYS 并授权;没有 SYS 账号但当前登录账号已有系统视图查询权限时,直接安装到当前账号。 + +生成巡检报告时,Skill 会先执行 SYS 函数版本与授权预检。预检失败后会读取目标 Agent 的实际 Oracle schema,并把完整 SQL 中的授权账号自动替换为该 schema;输出中不得保留 `` 占位符。无法取得 schema 时停止生成报告并提示检查 Agent 配置。 + +## 新增业务记录 SQL 生成规则 + +本 skill 默认只读,不直接执行写库操作;当需要分析或生成新增业务记录 SQL 示例时,遵守以下 BOS 默认取值规则: + +- 新增记录主键 `ID` 使用 `get_sequences('<表名称>')`。 +- 如果表存在 `DOCNO` 字段,先到 `AD_COLUMN` 查询该字段的 `SEQUENCENAME`。 +- 如果 `SEQUENCENAME` 有值,`DOCNO` 使用 `Get_SequenceNo('', 37)`。 +- 如果表存在 `AD_CLIENT_ID` 字段,默认值为 `37`。 +- 如果表存在 `AD_ORG_ID` 字段,默认值为 `27`。 +- 子表 `AD_CLIENT_ID`、`AD_ORG_ID` 默认继承父表对应字段值。 +- 新增业务表单时,如果表存在 `STATUS`、`STATUSERID`、`STATUSTIME` 字段,通常只设置 `STATUS='1'`,不对 `STATUSERID`、`STATUSTIME` 赋值。 +- `STATUS='1'` 表示草稿/未提交;提交相关字段通常由提交逻辑维护。 +- 如果用户提到“提交”,通常指该表在 `AD_TABLE.PROC_SUBMIT` 中配置的提交存储过程。 +- 生成提交 SQL 或说明提交流程时,如果已知 `userId`,调用 `AD_TABLE.PROC_SUBMIT` 前需要先更新主单据修改人和修改时间,再调用提交存储过程;修改人、修改时间字段名必须先通过 `AD_COLUMN` 或表结构确认,不要凭经验硬写字段名。 +- 分析 BOS 业务表时,同时查看 `AD_TABLE` 的表单事件过程:`HAS_TRIG_BD='Y'` 时 `TRIG_BD` 为删除前触发过程(bd),`HAS_TRIG_AC='Y'` 时 `TRIG_AC` 为新增后触发过程(ac),`HAS_TRIG_AM='Y'` 时 `TRIG_AM` 为修改后触发过程(am)。 + +## 字段值域映射规则 + +当 `AD_COLUMN` 中某个字段的 `OBTAINMANNER` 值为 `select` 时,该字段的可选值不应直接猜测,需要通过 `AD_LIMITVALUE_GROUP_ID` 查询子表 `AD_LIMITVALUE`,取得“数据库实际值”和“显示值”的映射关系。 + +示例:`XH_ORDER_FTP.BILLTYPE` 的显示值“券核销”,对应实际入库值是 `VOU_USED`。当用户说“类型为券核销”时,生成查询条件或新增记录 SQL 应使用实际值: + +```sql +XH_ORDER_FTP.BILLTYPE = 'VOU_USED' +``` + +查询模板: + +```sql +SELECT c.DBNAME, + c.NAME, + c.OBTAINMANNER, + c.AD_LIMITVALUE_GROUP_ID, + v.VALUE, + v.NAME AS DISPLAY_NAME +FROM AD_COLUMN c +LEFT JOIN AD_LIMITVALUE v ON v.AD_LIMITVALUE_GROUP_ID = c.AD_LIMITVALUE_GROUP_ID +WHERE c.AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = '<表名称>') + AND UPPER(c.DBNAME) = UPPER('<字段名>') +ORDER BY v.ORDERNO, v.ID +``` + + +## Agent 版本查询 + +自然语言提示词:查询所有客户的 Agent 版本号;或查询 HENLO、RENBEN 的 Agent 版本号。 + +```powershell +python scripts/oracle_skill.py version agent HENLO RENBEN --json +python scripts/oracle_skill.py version agent --all --json +``` + +## 从 Git 更新 Skill + +Skill 仅通过 Gitea Git 仓库更新,不使用 OSS 或 `skill_update` 命令。源码仓库完成修改、提交和推送后,在已安装目录执行: + +```powershell +cd C:\Users\qiang\.codex\skills\oracle-jump-query +git pull --ff-only origin main +python scripts/oracle_skill.py capabilities --json +``` + +在线 Agent 直接确认当前版本;离线客户返回最后一次心跳版本和离线状态。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..d9bc3f3 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,197 @@ +--- +name: oracle-jump-query +description: Oracle 跳板查询技能。通过中转服务查询远程 Oracle 数据库的元数据、存储过程源码、依赖、表结构、触发器、业务字典、表空间、服务器体检和 Oracle 运维监控。连接数据库默认为只读,禁止通过 skill 查询通道直接执行 INSERT、UPDATE、DELETE、MERGE、DDL 等写操作。当用户需要分析 Oracle 存储过程、查找依赖表、查询表结构、理解 BOS 业务元数据、生成只读查询 SQL 或生成需人工确认的写库 SQL 示例时触发。 +--- + +# Oracle 跳板查询 + +> **版本:v1.5.35** · [更新日志](./CHANGELOG.md) + +## 使用原则 + +使用本 skill 时,先确认当前登录状态和 client。用户指定“恒诺 / 品小二 / 未芮 / 康奈”等服务器时,可以用 `switch ` 按 code 或名称切换;找不到目标服务器时,skill 会先刷新 client 列表再重试匹配。若名称不够准确并匹配到多个 client,只列出候选项,请用户指定更准确的 client code,不要直接猜测切换。 + +本 skill 的数据库通道默认只读。可以生成 SQL 和说明,但不得通过 `query` / `qperm` 执行写库语句,包括 `INSERT`、`UPDATE`、`DELETE`、`MERGE`、`DDL`、提交存储过程和其他会改变业务数据的调用。用户要求写库时,只生成 SQL,并明确说明未执行。 + +生产环境查询必须带明确 `WHERE` 条件和行数限制。不要为了“先看看数据”查询整表;涉及库存视图、业务大表、统计查询时尤其要先限定门店、日期、单号、状态、款号、条码或主键范围。 + +## 参考文档 + +详细规则已经拆分到 `references/`,处理对应任务时必须读取相关文件: + +- [命令、登录与调用方式](./references/commands-and-auth.md) + 登录、client 切换、CLI 命令、HTTP action、故障排查。 +- [生产查询限制与常用业务规则](./references/query-and-business-rules.md) + 生产查询限制、商品/款号/条码/库存规则、新增业务记录 SQL 生成规则、字段值域映射。 +- [BOS 数据字典与系统地图](./references/bos-metadata.md) + `AD_TABLE`、`AD_COLUMN`、`AD_REFBYTABLE`、`AD_TABLE_TEXT`、BOS 系统菜单和表结构地图、数据权限模型。 +- [表空间、服务器体检与 Oracle 运维监控](./references/oracle-ops-and-checkup.md) + 表空间、服务器体检、Oracle 运维报告、SYS/当前账号函数授权方案和 AWR 未来开发计划。 +- [整理前完整 SKILL.md 备份](./references/legacy-skill-v1.5.8.md) + 为防止内容遗漏保留的完整原文;如发现拆分文档缺项,以此为准补回。 + +## 常用命令 + +命令均在 skill 目录执行: + +```powershell +cd C:\Users\qiang\.codex\skills\oracle-jump-query +python scripts/oracle_skill.py [args] +``` + +登录中转机: + +```powershell +python scripts/oracle_skill.py login [clientCode] +``` + +`clientCode` 是客户服务器编号,非必填;不传时中转机会默认选择 BOS 返回的第一个可用 client。登录成功后本地只保存中转机签发的 `access_token` 和过期时间,不保存 BOS `secretKey`。同一个 `secretKey` 同时只允许一个设备在线,其他设备重新登录后,当前 token 会失效。 + +常用命令: + +| 命令 | 说明 | +|------|------| +| `status` | 查看登录状态、当前 client 和 Agent 状态 | +| `clients` | 无感刷新当前用户最新授权的 client 列表(只使用当前登录 token) | +| `switch ` | 按 code 或名称切换当前 client;找不到时自动刷新 client 列表 | +| `analyze ` | 完整分析存储过程 | +| `source ` | 获取存储过程源码 | +| `deps ` | 获取依赖对象 | +| `tables ` | 获取相关表结构 | +| `describe
` | 查询表结构 | +| `query ` | 执行只读 SELECT 查询 | +| `qperm ` | 带权限过滤的只读查询 | +| `tablespace` / `tablespaces` | 查询表空间 | +| `ops [clientCode]` | 单项 Oracle 运维监控 | +| `ops_report [clientCode]` | Oracle 运维报告 | +| `inspection_report [clientCode] [--json] [--html [path]] [--markdown [path]]` | 生成服务器巡检报告并归档,默认导出固定格式 HTML,可同时导出 Markdown | +| `inspection_latest [clientCode] [--refresh] [--json] [--html [path]] [--markdown [path]]` | 查看最近归档巡检报告并默认导出 HTML;加 `--refresh` 时重新生成 | +| `inspection_get [--json] [--html [path]] [--markdown [path]]` | 按归档 ID 查看历史巡检报告并默认导出 HTML | +| `awr_status [clientCode] [--json]` | 查看 AWR 授权确认、权限、快照和最近生成状态,不触发生成 | +| `awr_list [clientCode] [--json]` | 列出 Agent 已生成并保留的 AWR 报告 | +| `awr_download [--output path]` | 经中转机下载 Agent 原始 AWR HTML,不在本地渲染 | +| `agent_update [clientCode] [timeout]` | 触发客户 Agent 自动更新;由中转机下发更新动作,Agent 按 OSS latest.json 返回的包地址下载升级 | + +## BOS 分析优先路径 + +遇到不懂的 BOS 业务术语、表单、菜单、模块、按钮、状态、字段含义时,优先走元数据,不要靠表名猜测: + +```text +AD_SUBSYSTEM + -> AD_TABLECATEGORY + -> AD_ACCORDION + -> AD_TABLE + -> AD_COLUMN + -> AD_REFBYTABLE + -> AD_ACTION + -> DIRECTORY + -> AD_CXTAB / AD_CXTAB_JPARA / AD_CXTAB_DIMENSION / AD_CXTAB_FACT +``` + +处理单据类表时: + +- 先查 `AD_TABLE` 找表名、业务说明、`MASK`、`PROC_SUBMIT`、权限目录。`MASK` 是该表声明支持的业务动作代码:`A` 新增、`M` 修改、`D` 删除、`Q` 查询、`U` 取消提交、`V` 作废、`S` 提交;可由多个字母组合,分析功能时以实际值为准。 +- 同查 `AD_TABLE` 的表单事件过程:`HAS_TRIG_BD='Y'` 时 `TRIG_BD` 是删除前触发的存储过程(简称 bd);`HAS_TRIG_AC='Y'` 时 `TRIG_AC` 是新增后触发的存储过程(简称 ac);`HAS_TRIG_AM='Y'` 时 `TRIG_AM` 是修改后触发的存储过程(简称 am)。 +- 再查 `AD_COLUMN` 理解字段含义、控件类型、默认值、引用列和值域。 +- 明细表必须通过 `AD_REFBYTABLE` 查找,不要靠命名规则猜。 +- 若 `AD_COLUMN.OBTAINMANNER='select'`,必须通过 `AD_LIMITVALUE_GROUP_ID` 查询 `AD_LIMITVALUE`,把显示值映射成数据库实际值。 +- 如果用户提到“提交”,通常指执行或分析 `AD_TABLE.PROC_SUBMIT` 对应提交过程,不要简单理解为直接更新 `STATUS='2'`。 +- `AD_TABLE_TEXT` 可作为 BOS 系统地图经验来源:它说明菜单、模块、业务表、字段、主子表、动作、权限目录和报表模板主要由 BOS 元数据表驱动。 + +常用系统地图 SQL 见 [BOS 数据字典与系统地图](./references/bos-metadata.md)。 + +## 业务规则速记 + +- 商品、款号通常指 `M_PRODUCT`。 +- 条码、SKU 通常指 `M_PRODUCT_ALIAS`;查询条码时首查 `M_PRODUCT_ALIAS.NO`。 +- 款号和条码是一对多关系。 +- 商品明细里的 `M_PRODUCT_ID`、`M_PRODUCTALIAS_ID`、`M_ATTRIBUTESETINSTANCE_ID` 通常分别对应款号、条码、色码属性 ASI。 +- `M_ATTRIBUTESETINSTANCE` 是商品色码属性表:`VALUE1`/`VALUE1_CODE`/`VALUE1_ID` 对应颜色名称、颜色编号、颜色表 `M_COLOR.ID`;`VALUE2`/`VALUE2_CODE`/`VALUE2_ID` 对应尺码名称、尺码编号、尺码表 `M_SIZE.ID`。 +- 库存通常查 `V_FA_STORAGE`,必须同时带店仓条件和款号/条码条件,并加行数限制。 +- 新增业务记录 SQL 示例中,主键 `ID` 使用 `get_sequences('<表名称>')`。 +- 如表存在 `DOCNO`,先查 `AD_COLUMN.SEQUENCENAME`;有值时用 `Get_SequenceNo('', 37)` 取单据编号。 +- 如表存在 `AD_CLIENT_ID`,默认值 `37`;如存在 `AD_ORG_ID`,默认值 `27`。 +- 子表的 `AD_CLIENT_ID`、`AD_ORG_ID` 默认继承父表。 +- 新增表单若存在 `STATUS`、`STATUSERID`、`STATUSTIME`,通常只设置 `STATUS='1'`,不设置提交人和提交时间。 +- 生成提交 SQL 或说明提交流程时,如果已知 `userId`,调用 `AD_TABLE.PROC_SUBMIT` 前需要先更新主单据修改人和修改时间,再调用提交存储过程;修改人、修改时间字段名必须先通过 `AD_COLUMN` 或表结构确认,不要凭经验硬写字段名。 + +## 运维能力速记 + +当用户说“给我 HENLO 的 Oracle 运维报告 / 数据库日报 / Oracle 巡检”时,使用: + +```powershell +python scripts/oracle_skill.py ops_report HENLO +``` + +当用户说“给我 HENLO 的服务器巡检报告 / 运维体检报告 / 查看最近一次巡检报告”时,使用: + +```powershell +python scripts/oracle_skill.py inspection_report HENLO +python scripts/oracle_skill.py inspection_latest HENLO +python scripts/oracle_skill.py inspection_report HENLO --markdown +``` + +巡检命令成功后默认在 `outputs/` 生成 HTML;需要 Markdown 时增加 `--markdown` 或 `--md`,也可在参数后指定输出路径。 + +生成新报告前必须先执行 `sys_function_check`。若 `SYS.HENLO_ORA_MONITOR` 和当前 schema 的 `HENLO_ORA_MONITOR` 均不可执行或版本契约不兼容,立即停止采集并返回完整 SYS 函数与授权 SQL;不得先生成部分报告。Skill 必须使用目标 Agent 返回的有效 Oracle schema 自动替换 ``,直接向用户提供可执行 SQL,不得让用户手工修改占位符;无法确定 schema 时停止并提示检查 Agent 配置。读取既有历史报告不需要重复执行该预检,只有 `inspection_latest --refresh` 需要。 + +当用户要求“更新客户 Agent / 触发 Agent 升级 / 给某客户发版升级”时,使用: + +```powershell +python scripts/oracle_skill.py agent_update HENLO 300 +``` + +`clientCode` 可省略,默认使用当前已切换的 client;`timeout` 默认 300 秒。该命令只触发中转机的 `/api/admin/agent_update/trigger`,实际升级包由 Agent 从 OSS 下载,不再从 transit-server 本机下载。 + +涉及 `v$`、`dba_`、AWR、SYS 权限视图时,Agent 会优先调用 `SYS.HENLO_ORA_MONITOR`;如果不可用,再尝试当前登录账号下的 `HENLO_ORA_MONITOR`。函数缺失或无权限时,输出安装 SQL,让用户用 SYS 或有视图查询权限的当前账号安装后再试。 + +AWR 报告只有在客户 DBA 已确认 Diagnostics Pack 授权,且 Agent 配置 `awr.enabled=true`、`awr.license_confirmed=true` 后才可用。日常巡检不会隐式生成 AWR;Skill 只列出或下载 Agent 已生成的 HTML: + +```powershell +python scripts/oracle_skill.py awr_status WEIRUI +python scripts/oracle_skill.py awr_list WEIRUI +python scripts/oracle_skill.py awr_download WEIRUI 20260720 +``` + +## 内容维护 + +本 skill 的 Git 源地址: + +```text +http://124.220.155.147:3000/qiangayi/oracle-jump-query +``` + +需要更新 skill 时,以该地址为准进行拉取、修改、提交和同步。不要把 `C:\Users\qiang\.codex\skills\oracle-jump-query` 当作长期源码目录;该目录只用于安装后的运行和验证。 + +整理文档时不得删除既有内容。需要精简主 `SKILL.md` 时,只能把内容移动到 `references/`,并更新索引;确实要删除内容,必须先得到用户明确同意。 + + +## Agent version query + +Natural language: query all customer Agent versions, or query HENLO and RENBEN Agent versions. + +Commands: +- python scripts/oracle_skill.py version agent HENLO RENBEN --json +- python scripts/oracle_skill.py version agent --all --json + +Online Agents return their current version; offline customers return the last heartbeat version and offline status. + +## SYS function deployment bundle + +Generate all SYS-created functions required by the Skill for a customer Agent schema: + +```powershell +python scripts/oracle_skill.py sys_functions BOSNDS3 +``` + +## Skill 更新方式 + +Skill 仅通过 Gitea Git 仓库更新,不使用 OSS 发布或 `skill_update` 命令。源码修改、提交和推送均在源码仓库完成;已安装目录仅执行拉取与验证: + +```powershell +cd C:\Users\qiang\.codex\skills\oracle-jump-query +git pull --ff-only origin main +python scripts/oracle_skill.py capabilities --json +``` + +The command reads every SQL file under `references/sys-functions/`, replaces ``, and only outputs SQL for a DBA to execute. It never writes to the customer database. Any future SYS function must be added to that directory so this command includes it automatically. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ff4e38d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.5.35 diff --git a/config.json b/config.json new file mode 100644 index 0000000..a6f89e1 --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "transit_url": "https://ts.henlo.net", + "server_id": "RENBEN", + "access_token": "", + "expires_at": "" +} diff --git a/docs/操作示例.md b/docs/操作示例.md new file mode 100644 index 0000000..bd2ab49 --- /dev/null +++ b/docs/操作示例.md @@ -0,0 +1,482 @@ +# Oracle Jump Query 操作示例 + +这份文档用于说明如何向 `Oracle Jump Query` 提问,以及它会调用哪些只读能力完成查询。适合给业务分析、存储过程排查、表结构梳理、权限数据查询等场景使用。 + +## 基本原则 + +- 只执行只读操作:支持查询元数据和执行 `SELECT`,禁止 `INSERT`、`UPDATE`、`DELETE`、`MERGE`、`TRUNCATE`、`DROP`、`ALTER` 等写操作或结构变更。 +- 优先给出明确对象:尽量提供 `schema`、表名、存储过程名、用户 ID、`AD_TABLE.ID` 等信息。 +- 业务词先查字典:遇到“零售单”“采购入库”“调拨单”等业务词时,先从 `AD_TABLE`、`AD_COLUMN`、`AD_REFBYTABLE` 确认表和字段。 +- 单据统计加状态条件:统计业务单据时,通常需要过滤 `ISACTIVE = 'Y' AND STATUS = '2'`。 +- 明细表不靠猜:主表到明细表关系应通过 `AD_REFBYTABLE` 查询确认。 + +## 常见操作 + +### 1. 检查服务状态 + +用于确认中转服务和在线 Agent 是否可用。 + +用户可以这样问: + +```text +检查一下 Oracle Jump Query 服务是否正常,看看有哪些在线 Agent。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py health +python scripts/oracle_skill.py servers +``` + +### 2. 查询存储过程源码 + +用于查看某个存储过程的完整 PL/SQL 源码。 + +用户可以这样问: + +```text +帮我看一下 BOS.M_RETAIL_SUBMIT 的源码。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py source BOS M_RETAIL_SUBMIT +``` + +### 3. 完整分析存储过程 + +用于一次性查看源码、依赖对象、相关表结构和触发器。 + +用户可以这样问: + +```text +完整分析 BOS.M_RETAIL_SUBMIT,包括源码、依赖表、相关表结构和触发器。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py analyze BOS M_RETAIL_SUBMIT +``` + +适合排查: + +- 提交流程做了哪些校验 +- 存储过程更新了哪些业务表 +- 是否调用了其他过程或函数 +- 哪些触发器可能影响结果 + +### 4. 查询过程依赖 + +用于单独查看某个过程依赖的表、视图、函数或其他过程。 + +用户可以这样问: + +```text +查一下 BOS.M_RETAIL_SUBMIT 依赖了哪些表和过程。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py deps BOS M_RETAIL_SUBMIT +``` + +### 5. 查询表结构 + +用于查询字段、类型、索引、行数、注释等表结构信息。 + +用户可以这样问: + +```text +查一下 BOSNDS3.XCX_SO 的表结构。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py describe BOSNDS3 XCX_SO +``` + +### 6. 根据业务词找表 + +用于从中文业务名称反查系统中的业务表。 + +用户可以这样问: + +```text +帮我找“零售单”对应哪张表,以及提交过程是什么。 +``` + +对应 SQL: + +```sql +SELECT ID, NAME, TABLENAME, DESCRIPTION, PROC_SUBMIT +FROM AD_TABLE +WHERE NAME LIKE '%零售%' + OR DESCRIPTION LIKE '%零售%'; +``` + +对应命令: + +```bash +python scripts/oracle_skill.py query "SELECT ID, NAME, TABLENAME, DESCRIPTION, PROC_SUBMIT FROM AD_TABLE WHERE NAME LIKE '%零售%' OR DESCRIPTION LIKE '%零售%'" +``` + +### 7. 查询字段业务含义 + +用于确认字段显示名、业务描述和字段类型。 + +用户可以这样问: + +```text +查一下 M_RETAILITEM 每个字段的业务含义。 +``` + +对应 SQL: + +```sql +SELECT DBNAME, NAME, DESCRIPTION, COLTYPE +FROM AD_COLUMN +WHERE AD_TABLE_ID = ( + SELECT ID FROM AD_TABLE WHERE NAME = 'M_RETAILITEM' +) +ORDER BY ORDERNO; +``` + +对应命令: + +```bash +python scripts/oracle_skill.py query "SELECT DBNAME, NAME, DESCRIPTION, COLTYPE FROM AD_COLUMN WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = 'M_RETAILITEM') ORDER BY ORDERNO" +``` + +### 8. 查询主表和明细表关系 + +用于确认主表有哪些明细表,以及明细表通过哪个字段关联主表。 + +用户可以这样问: + +```text +查一下 M_RETAIL 有哪些明细表,外键字段是什么。 +``` + +对应 SQL: + +```sql +SELECT t1.NAME AS MAIN_TABLE, + t2.NAME AS DETAIL_TABLE, + c.DBNAME AS REF_COLUMN, + r.ASSOCTYPE +FROM AD_REFBYTABLE r +LEFT JOIN AD_TABLE t1 ON r.AD_TABLE_ID = t1.ID +LEFT JOIN AD_TABLE t2 ON r.AD_REFBY_TABLE_ID = t2.ID +LEFT JOIN AD_COLUMN c ON r.AD_REFBY_COLUMN_ID = c.ID +WHERE t1.NAME = 'M_RETAIL'; +``` + +对应命令: + +```bash +python scripts/oracle_skill.py query "SELECT t1.NAME AS MAIN_TABLE, t2.NAME AS DETAIL_TABLE, c.DBNAME AS REF_COLUMN, r.ASSOCTYPE FROM AD_REFBYTABLE r LEFT JOIN AD_TABLE t1 ON r.AD_TABLE_ID = t1.ID LEFT JOIN AD_TABLE t2 ON r.AD_REFBY_TABLE_ID = t2.ID LEFT JOIN AD_COLUMN c ON r.AD_REFBY_COLUMN_ID = c.ID WHERE t1.NAME = 'M_RETAIL'" +``` + +### 9. 查询有效已提交单据 + +用于统计或分析正式业务数据。单据类数据通常要排除草稿、审批中、作废或删除记录。 + +用户可以这样问: + +```text +查询 2026-05-01 的有效已提交零售单,返回前 10 条。 +``` + +对应 SQL: + +```sql +SELECT * +FROM M_RETAIL +WHERE ISACTIVE = 'Y' + AND STATUS = '2' + AND BILLDATE = 20260501 + AND ROWNUM <= 10; +``` + +对应命令: + +```bash +python scripts/oracle_skill.py query "SELECT * FROM M_RETAIL WHERE ISACTIVE = 'Y' AND STATUS = '2' AND BILLDATE = 20260501 AND ROWNUM <= 10" +``` + +### 10. 查询用户数据权限 + +用于查看某个用户对某张业务表的数据范围。 + +用户可以这样问: + +```text +查一下用户 940 对 M_OTHER_INOUT 的数据权限,表 ID 是 12983。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py perm 940 12983 +``` + +返回结果通常包含: + +- `perm_sql`:权限过滤条件 +- `has_restriction`:是否存在权限限制 +- 空权限条件:通常表示全权限 + +### 11. 执行带权限过滤的查询 + +用于按用户权限自动拼接过滤条件后查询数据。 + +用户可以这样问: + +```text +用用户 940 的权限查询 M_OTHER_INOUT,表 ID 是 12983,只看 2026-05-01 的数据。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py qperm 940 12983 "SELECT * FROM M_OTHER_INOUT WHERE BILLDATE = 20260501" +``` + +系统会自动把 `get_userspermsql` 返回的权限条件追加到查询中。 + +### 12. 查询表空间使用情况 + +用于查看数据库表空间的整体使用状况,包括总大小、已用、剩余、使用率、是否自动拓展等信息。 + +#### 自动降级策略 + +脚本按优先级自动尝试 **5 种方式**,无需手动切换: + +| 优先级 | 方式 | 条件 | +|:---:|------|------| +| 1️⃣ | 直查 `DBA_DATA_FILES` + `DBA_FREE_SPACE` | 用户有 `SELECT_CATALOG_ROLE` | +| 2️⃣ | DBA 专用函数 `sys.get_tablespace_usage()` | DBA 已执行 CREATE FUNCTION | +| 3️⃣ | 查询 DBA 授权 VIEW `HENLO_TS_USAGE` | DBA 已执行授权 SQL | +| 4️⃣ | 查询 `SYS.HENLO_TS_USAGE`(带 sys.前缀) | DBA 以 SYS 身份创建的视图 | +| 5️⃣ | 查 `USER_FREE_SPACE` | 始终可用(仅剩余空间) | + +> **经验**:DBA 以 SYS 身份登录创建的 VIEW 归属到 `SYS` schema,需用 `sys.` 前缀才能查到。脚本会自动先试无前缀,再试 `sys.` 前缀。 + +#### 无权限时的处理流程 + +当用户无 DBA 系统视图权限时,脚本会自动输出以下内容: + +1. 尝试 DBA 专用函数(如果有) +2. 尝试 DBA 授权 VIEW(无前缀 → sys.前缀) +3. 如果前两步都不存在,输出给 DBA 的授权 SQL +4. 回退到 `USER_FREE_SPACE` 查询,显示各表空间的剩余空间 + +#### HENLO 环境实际输出(授权前) + +``` +📊 表空间使用情况(用户视图 -- 无DBA权限,仅显示名称和剩余空间): +| 表空间名称 | 剩余MB | +| --- | --- | +| BOSNDS3 | 518.81 | +| BOSNDS3_IDX | 17.56 | +| RETAILNDS3 | 9.31 | +| RETAILNDS3_IDX | 19 | +| SYSAUX | 329.94 | +| SYSTEM | 7.19 | +| ULOG | 39 | +| UNDOTBS1 | 474.38 | +| USERS | 2.31 | +``` + +#### DBA 授权后的实际完整输出 + +DBA 以 SYS 执行授权 SQL 后,再次查询自动获得完整信息(脚本自动识别 `SYS.HENLO_TS_USAGE`): + +``` +📊 表空间使用情况一览(通过 DBA 授权 VIEW): +| 表空间名称 | 总大小MB | 已用MB | 剩余MB | 使用率% | 自动拓展 | 最大可拓展MB | 最大使用率% | +| --- | --- | --- | --- | --- | --- | --- | --- | +| SYSTEM | 1230 | 1222.8 | 7.2 | 99.4 | YES | 32768 | 3.7 | +| SYSAUX | 6620 | 6290.1 | 329.9 | 95 | YES | 32768 | 19.2 | +| UNDOTBS1 | 505 | 30.6 | 474.4 | 6.1 | YES | 32768 | 0.1 | +| BOSNDS3_IDX | 20 | 2.4 | 17.6 | 12.2 | YES | 32768 | 0 | +| ULOG | 40 | 1 | 39 | 2.5 | YES | 32768 | 0 | +| RETAILNDS3_IDX | 20 | 1 | 19 | 5 | YES | 32768 | 0 | +| USERS | 5 | 2.7 | 2.3 | 53.8 | YES | 32768 | 0 | +| BOSNDS3 | 10690 | 10171.2 | 518.8 | 95.1 | YES | 32768 | 31 | +| RETAILNDS3 | 110 | 100.7 | 9.3 | 91.5 | YES | 32768 | 0.3 | + +**共 9 行** +``` + +⚠️ 脚本自动检测到 3 个表空间使用率 ≥ 95% 并输出告警。 + +#### 字段解释 + +| 字段 | 说明 | +|------|------| +| 表空间名称 | Oracle 表空间名称 | +| 总大小MB | 当前数据文件总大小(MB) | +| 已用MB | 已分配使用的空间(MB) | +| 剩余MB | 剩余可用空间(MB) | +| 使用率% | (已用 / 总大小)× 100% | +| 自动拓展 | YES=数据文件自动扩展,NO=固定大小 | +| 最大可拓展MB | 自动扩展后能达到的最大值(MB);不自动扩展时=当前大小 | +| 最大使用率% | (已用 / 最大可拓展)× 100%,反映最终可用的百分比 | + +#### 阈值参考 + +| 使用率 | 状态 | 建议 | +|--------|------|------| +| < 80% | ✅ 正常 | 无需处理 | +| 80% ~ 95% | 🔶 关注 | 评估未来增长,制定扩容计划 | +| ≥ 95% | ⚠️ 紧急 | 尽快扩容或清理空间! | + +> **智能告警规则**:脚本逐行解析表空间数据,自动判断衡量指标。 +> - `自动拓展=YES`:按 **最大使用率%**(已用/最大可拓展)判断 +> - `自动拓展=NO`:按当前 **使用率%**(已用/当前总大小)判断 +> +> 例如 SYSTEM 使用率 99.4% 但最大使用率仅 3.7%,说明只是当前数据文件写满而非存储瓶颈,自动扩展后即可释放。 + +#### 给 DBA 的授权说明 + +当无 DBA 权限时,脚本会**自动输出**以下信息,请交给 DBA 执行: + +```sql +-- 以 SYS 身份执行 +CREATE OR REPLACE VIEW SYS.HENLO_TS_USAGE AS +SELECT + df.tablespace_name AS "表空间名称", + ROUND(df.total_bytes / 1048576, 1) AS "总大小MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / 1048576, 1) AS "已用MB", + ROUND(NVL(fs.free_bytes, 0) / 1048576, 1) AS "剩余MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / df.total_bytes * 100, 1) AS "使用率%", + df.autoextensible AS "自动拓展", + ROUND(df.max_bytes / 1048576, 1) AS "最大可拓展MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / NULLIF(df.max_bytes, 0) * 100, 1) AS "最大使用率%" +FROM ( + SELECT + tablespace_name, + SUM(bytes) AS total_bytes, + SUM(CASE WHEN autoextensible = 'YES' THEN maxbytes ELSE bytes END) AS max_bytes, + MAX(CASE WHEN autoextensible = 'YES' THEN 'YES' ELSE 'NO' END) AS autoextensible + FROM dba_data_files + GROUP BY tablespace_name +) df +LEFT JOIN ( + SELECT tablespace_name, SUM(bytes) AS free_bytes + FROM dba_free_space + GROUP BY tablespace_name +) fs ON df.tablespace_name = fs.tablespace_name +ORDER BY 6 DESC; + +GRANT SELECT ON SYS.HENLO_TS_USAGE TO bosnds3; +``` + +> **为什么加 `SYS.` 前缀**:DBA 以 SYS 身份执行 `CREATE VIEW` 时,视图创建在 `SYS` schema 下。`GRANT` 也必须指定 `SYS.` 前缀,否则普通用户无法找到。脚本会自动处理带前缀和不带前缀两种情况。 + +> **原理**:Oracle 视图默认使用定义者权限(`DEFINER`),以创建者的 DBA 身份执行。普通用户仅需 `SELECT` 权限,比直接授予 `SELECT_CATALOG_ROLE` 更安全。 + +#### 给 AI 的指令 + +当用户通过 AI 查询表空间时,流程: + +1. 自动调用 `python oracle_skill.py tablespace` +2. 脚本自动尝试 5 层降级 +3. 如果全部权限方案不可用,输出授权 SQL + 回退到剩余空间查询 +4. 将授权 SQL 发送给用户,让其联系 DBA 执行 +5. DBA 执行后,再次查询即可自动使用完整信息 +6. 注意:DBA 以 SYS 执行时,VIEW 会创建在 `SYS` schema 下,GRANT 也需带 `SYS.` 前缀 + +用户可以这样问: + +```text +帮我查一下数据库表空间使用情况 +查看表空间 +表空间够不够用 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py tablespace +# 或 +python scripts/oracle_skill.py tablespaces +``` + +### 13. 生成 NL2SQL 字典 + +用于为自然语言转 SQL 准备 schema 字典。 + +用户可以这样问: + +```text +给 BOSNDS3 的 RETAIL 业务域生成 NL2SQL schema 字典。 +``` + +对应命令: + +```bash +python scripts/oracle_skill.py nl2sql BOSNDS3 RETAIL +``` + +## 典型完整流程 + +### 流程一:分析一个业务提交流程 + +用户可以这样问: + +```text +帮我分析零售单提交逻辑,从业务表、提交过程、依赖表到关键校验都梳理出来。 +``` + +推荐执行顺序: + +1. 从 `AD_TABLE` 查询“零售单”对应主表和 `PROC_SUBMIT`。 +2. 使用 `source` 或 `analyze` 查询提交过程源码。 +3. 使用 `deps` 查询依赖对象。 +4. 使用 `describe` 查询关键表结构。 +5. 结合源码解释提交校验、状态变更、库存或财务影响。 + +### 流程二:构造一条可靠统计 SQL + +用户可以这样问: + +```text +我要统计 2026 年 5 月零售销售额,帮我确认主表、明细表、金额字段并生成 SQL。 +``` + +推荐执行顺序: + +1. 查 `AD_TABLE`,确认零售主表。 +2. 查 `AD_REFBYTABLE`,确认零售明细表。 +3. 查 `AD_COLUMN`,确认数量、金额、日期、门店字段。 +4. 生成 SQL 时加入 `ISACTIVE = 'Y' AND STATUS = '2'`。 +5. 如需按用户权限过滤,使用 `qperm`。 + +示例 SQL: + +```sql +SELECT r.C_STORE_ID, + SUM(i.TOT_AMT_ACTUAL) AS SALE_AMOUNT +FROM M_RETAIL r +JOIN M_RETAILITEM i ON i.M_RETAIL_ID = r.ID +WHERE r.ISACTIVE = 'Y' + AND r.STATUS = '2' + AND r.BILLDATE BETWEEN 20260501 AND 20260531 +GROUP BY r.C_STORE_ID; +``` + +## 排查建议 + +- `agent not found`:先执行 `servers`,确认 Agent 是否在线。 +- 查询超时:缩小查询范围,或对 `analyze` 类命令增加超时时间。 +- 查不到业务表:先扩大 `AD_TABLE` 的关键字范围,再结合 `DESCRIPTION` 判断。 +- 字段含义不明确:优先查 `AD_COLUMN.DESCRIPTION`,不要只凭字段名猜。 +- 统计结果异常:检查是否遗漏 `ISACTIVE`、`STATUS`、日期范围、门店权限等条件。 + diff --git a/references/bos-metadata.md b/references/bos-metadata.md new file mode 100644 index 0000000..cd7e95d --- /dev/null +++ b/references/bos-metadata.md @@ -0,0 +1,254 @@ +# BOS 数据字典与系统地图 + +> 本文件由 SKILL.md 整理拆分而来,保留原说明内容。 + +## 数据字典(AD_TABLE / AD_COLUMN / AD_REFBYTABLE) + +### 通用查询规则 + +处理 BOS 业务单据(如零售单、采购单、调拨单、销售单等)时,必须遵循以下查询路径: + +**1. 术语 → 表名(AD_TABLE)** + +遇到不懂的业务术语(如"零售单提交"、"采购入库"),先查 AD_TABLE: + +```sql +SELECT ID, + NAME, + TABLENAME, + DESCRIPTION, + MASK, + PROC_SUBMIT, + HAS_TRIG_BD, + TRIG_BD, + HAS_TRIG_AC, + TRIG_AC, + HAS_TRIG_AM, + TRIG_AM +FROM AD_TABLE +WHERE NAME LIKE '%关键词%' OR DESCRIPTION LIKE '%关键词%' +``` + +- `NAME`:表名(英文,如 M_RETAIL) +- `DESCRIPTION`:业务说明(中文,如"零售单") +- `MASK`:表支持的业务动作代码,可由多个字母组成:`A`=新增、`M`=修改、`D`=删除、`Q`=查询、`U`=取消提交、`V`=作废、`S`=提交。分析表单功能、按钮或流程时,应以该字段实际包含的代码判断该表声明支持的动作,不要仅凭表名推断。 +- `PROC_SUBMIT`:该单据的提交存储过程名称(非空即代表该表有提交逻辑) +- `HAS_TRIG_BD` / `TRIG_BD`:`HAS_TRIG_BD='Y'` 时,`TRIG_BD` 是删除前触发的存储过程,简称 `bd` +- `HAS_TRIG_AC` / `TRIG_AC`:`HAS_TRIG_AC='Y'` 时,`TRIG_AC` 是新增后触发的存储过程,简称 `ac` +- `HAS_TRIG_AM` / `TRIG_AM`:`HAS_TRIG_AM='Y'` 时,`TRIG_AM` 是修改后触发的存储过程,简称 `am` + +分析 BOS 业务表时,`PROC_SUBMIT` 和这些 `TRIG_*` 字段都属于业务逻辑入口。不要只看物理触发器或过程源码;先从 `AD_TABLE` 判断表单事件过程是否配置,再按过程名继续查源码、依赖和影响表。 + +**2. 字段含义(AD_COLUMN)** + +不理解某个字段的业务含义时,查 AD_COLUMN: + +```sql +SELECT DBNAME, NAME, DESCRIPTION, COLTYPE +FROM AD_COLUMN +WHERE AD_TABLE_ID = +ORDER BY ORDERNO +``` + +**3. 主表 → 明细表(AD_REFBYTABLE)** + +对于单据类表,其明细表、付款表等子表必须通过 AD_REFBYTABLE 查找,不要靠猜测命名规则: + +```sql +SELECT t1.NAME AS MAIN_TABLE, + t2.NAME AS DETAIL_TABLE, + c.DBNAME AS REF_COLUMN, + r.ASSOCTYPE +FROM AD_REFBYTABLE r +LEFT JOIN AD_TABLE t1 ON r.AD_TABLE_ID = t1.ID +LEFT JOIN AD_TABLE t2 ON r.AD_REFBY_TABLE_ID = t2.ID +LEFT JOIN AD_COLUMN c ON r.AD_REFBY_COLUMN_ID = c.ID +WHERE t1.NAME = '' +``` + +- `ASSOCTYPE`:"n"=一对多明细,"1"=强关联/嵌入 +- 得到明细表名后,再通过 AD_COLUMN 查看各明细表的字段含义 + +### 核心表说明 + +| 表 | 说明 | +|------|------| +| `AD_TABLE` | 所有业务表的元数据注册表,每条记录对应一张业务表 | +| `AD_COLUMN` | 所有业务字段的元数据注册表,含字段名(DBNAME)、显示名(NAME)、描述(DESCRIPTION)、类型(COLTYPE)等 | +| `AD_REFBYTABLE` | **主表与明细表的关联关系**,记录主表→明细表的外键引用 | + +### AD_REFBYTABLE 关联字段 + +| 字段 | 说明 | +|------|------| +| `AD_TABLE_ID` | 主表ID → 关联 AD_TABLE.ID | +| `AD_REFBY_TABLE_ID` | 明细表ID → 关联 AD_TABLE.ID | +| `AD_REFBY_COLUMN_ID` | 明细表中的外键列ID → 关联 AD_COLUMN.ID,通过 AD_COLUMN.DBNAME 获取实际列名 | +| `ASSOCTYPE` | 关联类型("1"=强关联/嵌入, "n"=一对多明细) | + +### 查询主表→明细表关系的 SQL 模板 + +```sql +SELECT t1.NAME AS MAIN_TABLE, + t2.NAME AS DETAIL_TABLE, + c.DBNAME AS REF_COLUMN, + r.ASSOCTYPE +FROM AD_REFBYTABLE r +LEFT JOIN AD_TABLE t1 ON r.AD_TABLE_ID = t1.ID +LEFT JOIN AD_TABLE t2 ON r.AD_REFBY_TABLE_ID = t2.ID +LEFT JOIN AD_COLUMN c ON r.AD_REFBY_COLUMN_ID = c.ID +WHERE t1.NAME = 'M_RETAIL' +``` + +### 示例:M_RETAIL 的明细表 + +| 主表 | 明细表 | 外键列 | 关联类型 | +|------|--------|--------|----------| +| M_RETAIL | M_RETAILITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAILPAYITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_PRO_ITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_RATEITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_DISITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_VOUCHERSITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_BATCHITEM | M_RETAIL_ID | n | + +### 查询字段描述的 SQL 模板 + +```sql +SELECT DBNAME, NAME, DESCRIPTION, COLTYPE +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = 'M_RETAILITEM') +ORDER BY ORDERNO +``` + +### BOS 系统地图分析(AD_TABLE_TEXT 经验) + +`BOSNDS3.AD_TABLE_TEXT` 是 BOS 内部的表配置导出脚本生成器。分析该过程可以反推出 BOS 元数据体系:菜单、模块、业务表、字段、主子表、按钮动作、提交过程、权限目录和报表模板主要由数据字典表驱动。 + +当用户要求分析“BOS 系统菜单 / 模块目录 / 表结构地图 / 某个业务模块结构”时,优先按下面的元数据链路整理,不要只看物理表: + +```text +AD_SUBSYSTEM + -> AD_TABLECATEGORY + -> AD_ACCORDION + -> AD_TABLE + -> AD_COLUMN + -> AD_REFBYTABLE + -> AD_ACTION + -> DIRECTORY + -> AD_CXTAB / AD_CXTAB_JPARA / AD_CXTAB_DIMENSION / AD_CXTAB_FACT +``` + +核心含义: + +- `AD_SUBSYSTEM`:子系统。 +- `AD_TABLECATEGORY`:表类别 / 模块分类。 +- `AD_ACCORDION`:折叠菜单 / 菜单分组。 +- `AD_TABLE`:业务表注册表,含 `DESCRIPTION`、`MASK`(读写业务动作)、`PROC_SUBMIT`、`HAS_TRIG_BD/TRIG_BD`、`HAS_TRIG_AC/TRIG_AC`、`HAS_TRIG_AM/TRIG_AM`、`DIRECTORY_ID`、`AD_TABLECATEGORY_ID`、`AD_ACCORDION_ID` 等。 +- `AD_COLUMN`:字段元数据,含字段名、显示名、业务描述、控件类型、引用列、默认值、限定值组等。 +- `AD_REFBYTABLE`:主表到明细表、子表的关系。 +- `AD_ACTION`:表动作、按钮、URL、存储过程脚本入口。 +- `DIRECTORY`:数据权限目录挂载点。 +- `AD_CXTAB` 相关表:公开查询 / 报表模板及其参数、维度、指标。 + +常用系统地图 SQL: + +```sql +SELECT ss.NAME AS SUBSYSTEM, + tc.NAME AS TABLE_CATEGORY, + ac.NAME AS ACCORDION, + t.ID AS AD_TABLE_ID, + t.NAME AS TABLE_NAME, + t.DESCRIPTION, + t.MASK, + t.PROC_SUBMIT, + t.HAS_TRIG_BD, + t.TRIG_BD, + t.HAS_TRIG_AC, + t.TRIG_AC, + t.HAS_TRIG_AM, + t.TRIG_AM, + d.NAME AS DIRECTORY_NAME +FROM AD_TABLE t +LEFT JOIN AD_TABLECATEGORY tc ON tc.ID = t.AD_TABLECATEGORY_ID +LEFT JOIN AD_SUBSYSTEM ss ON ss.ID = tc.AD_SUBSYSTEM_ID +LEFT JOIN AD_ACCORDION ac ON ac.ID = t.AD_ACCORDION_ID +LEFT JOIN DIRECTORY d ON d.ID = t.DIRECTORY_ID +WHERE t.NAME LIKE '%关键词%' OR t.DESCRIPTION LIKE '%关键词%' +ORDER BY ss.NAME, tc.ORDERNO, ac.ORDERNO, t.ORDERNO, t.NAME +``` + +分析单个业务表时,至少同时检查: + +- `AD_TABLE`:表归属、提交过程、表单事件过程(bd/ac/am)、权限目录。 +- `AD_COLUMN`:字段业务含义和值域。 +- `AD_REFBYTABLE`:明细表和外键列。 +- `AD_ACTION`:按钮和动作脚本。 +- `ALL_TRIGGERS` / `USER_OBJECTS`:触发器、同名前缀过程或函数。 + +注意:这套元数据能还原 BOS 业务结构,不等于完整物理数据库模型。复杂过程内部逻辑、动态 SQL、触发器副作用和前端特殊页面仍需要结合源码继续分析。 + + +## 数据权限系统 + +### GET_USERSPERMSQL 函数 + +数据库内置 `get_userspermsql` 函数,用于获取用户的数据权限过滤条件。 + +**函数签名:** +```sql +FUNCTION get_userspermsql( + p_users_id NUMBER, -- 用户ID (USERS.ID) + p_tableid NUMBER, -- 表ID (AD_TABLE.ID) + p_col VARCHAR2 -- 字段名 (AD_COLUMN.DBNAME),可为NULL +) RETURN CLOB -- 返回权限过滤SQL +``` + +**返回值:** +- 空字符串:全权限(无需过滤) +- SQL条件:如 `C_STORE_ID IN (...)`,需拼接到 WHERE 子句 + +**权限模型:** +``` +用户 → GROUPUSER → 用户组 → GROUPPERM → 安全目录 → 权限SQL + ↓ + AD_TABLE.DIRECTORY_ID +``` + +### 使用权限过滤查询 + +**1. 查询用户权限:** +```bash +python oracle_skill.py perm 940 12983 +``` +返回: +```json +{ + "user_id": "940", + "table_id": "12983", + "perm_sql": "M_OTHER_INOUT.C_STORE_ID IN(...)", + "has_restriction": true +} +``` + +**2. 带权限过滤的查询:** +```bash +python oracle_skill.py qperm 940 12983 "SELECT * FROM M_OTHER_INOUT WHERE BILLDATE=20260501" +``` +自动拼接权限条件后执行: +```sql +SELECT * FROM M_OTHER_INOUT +WHERE BILLDATE=20260501 +AND M_OTHER_INOUT.C_STORE_ID IN(...) +``` + +### 常用表ID参考 + +| 表名 | AD_TABLE.ID | 说明 | +|------|-------------|------| +| M_RETAIL | 12964 | 零售单主表 | +| M_RETAILITEM | 12965 | 零售明细 | +| M_OTHER_INOUT | 12983 | 其他出入库 | +| C_STORE_CHKDAY | 18182 | 门店盘点日 | + + diff --git a/references/commands-and-auth.md b/references/commands-and-auth.md new file mode 100644 index 0000000..efd8822 --- /dev/null +++ b/references/commands-and-auth.md @@ -0,0 +1,175 @@ +# 命令、登录与调用方式 + +> 本文件由 SKILL.md 整理拆分而来,保留原说明内容。 + +## 概述 + +通过中转服务(Transit Server)查询远程 Oracle 数据库服务器的存储过程元数据,无需开放数据库端口。 + +## 架构 + +``` +AI Skill → HTTP → Transit Server (:6357) → WebSocket → Agent → Oracle +``` + +## 前置条件 + +1. 中转服务已部署并运行(默认地址: https://ts.henlo.net) +2. Agent 已部署到数据库服务器并连接中转服务 +3. 本地已安装 Python 依赖:`pip install requests` + +## 配置 + +配置文件位于技能目录下:`D:\work\恒诺\长期支持\跳板查询\demo\ai-skill\config.json` + +```json +{ + "transit_url": "https://ts.henlo.net", + "server_id": "server-001", + "access_token": "", + "expires_at": "" +} +``` + +使用查询命令前先登录中转机: + +```bash +python oracle_skill.py login [clientCode] +``` + +登录成功后只保存中转机签发的 `access_token` 和过期时间,不保存 BOS `secretKey`。`clientCode` 是客户服务器编号,非必填;不传时中转机会默认选择 BOS 返回的第一个可用 client。 + +同一个 `secretKey` 同时只允许一个设备在线。另一台设备重新登录后,当前设备的 token 会立即失效,需要重新执行 `login [clientCode]`。 + +## 调用方式 + +使用 `scripts/oracle_skill.py` 脚本: + +```bash +python scripts/oracle_skill.py [args] +``` + +### 可用命令 + +| 命令 | 说明 | 示例 | +|------|------|------| +| `analyze ` | 完整分析存储过程 | `python oracle_skill.py analyze BOS M_RETAIL_SUBMIT` | +| `list ` | 列出 schema 下所有存储过程 | `python oracle_skill.py list BOS` | +| `source ` | 获取存储过程源码 | `python oracle_skill.py source BOS M_RETAIL_SUBMIT` | +| `deps ` | 获取依赖(表/存储过程) | `python oracle_skill.py deps BOS M_RETAIL_SUBMIT` | +| `tables ` | 获取相关表结构 | `python oracle_skill.py tables BOS M_RETAIL_SUBMIT` | +| `describe
` | 查询表结构(列、索引、行数) | `python oracle_skill.py describe bosnds3 xcx_so` | +| `discover [schema] [domain]` | 发现核心业务域(NL2SQL前置) | `python oracle_skill.py discover BOSNDS3 RETAIL` | +| `nl2sql [schema] [domain]` | 生成 NL2SQL Schema 字典 | `python oracle_skill.py nl2sql BOSNDS3 RETAIL` | +| `query ` | 执行 SELECT 查询,生产表必须带过滤条件和行数限制 | `python oracle_skill.py query "SELECT * FROM M_RETAIL WHERE BILLDATE=20260501 AND ROWNUM<=20"` | +| `perm [col]` | 查询用户数据权限 | `python oracle_skill.py perm 940 12983` | +| `qperm ` | 带权限过滤的查询 | `python oracle_skill.py qperm 940 12983 "SELECT * FROM M_OTHER_INOUT"` | +| `login [clientCode]` | 登录中转机 + 选择客户服务器,clientCode 非必填 | `python oracle_skill.py login mykey HENLO` | +| `logout` | 登出中转机并清除本地 token | `python oracle_skill.py logout` | +| `status` | 查看中转机登录状态 + 当前 client | `python oracle_skill.py status` | +| `switch ` | 按 code 或名称切换 client;找不到时自动刷新 client 列表 | `python oracle_skill.py switch HENLO` | +| `clients` | 无感刷新当前用户最新授权的 client 列表及在线状态;只使用当前登录 token | `python oracle_skill.py clients` | +| `agent_update [clientCode] [timeout]` | 触发客户 Agent 自动更新,升级包由 Agent 从 OSS 下载 | `python oracle_skill.py agent_update HENLO 300` | +| `awr_status [clientCode]` | 查看 AWR 授权和可用状态,不生成报告 | `python oracle_skill.py awr_status WEIRUI` | +| `awr_list [clientCode]` | 列出 Agent 已生成的 AWR 报告 | `python oracle_skill.py awr_list WEIRUI` | +| `awr_download ` | 下载 Agent 原始 AWR HTML | `python oracle_skill.py awr_download WEIRUI 20260720` | +| `servers` | 列出在线 Agent | `python oracle_skill.py servers` | +| `health` | 检查中转服务状态 | `python oracle_skill.py health` | +| `tablespace / tablespaces` | 查询表空间使用情况 | `python oracle_skill.py tablespace` | + +### 交互模式 + +不传参数直接运行,进入交互模式: + +```bash +python scripts/oracle_skill.py +``` + + +## 分析报告内容 + +`analyze` 命令返回完整分析报告,包含: +- **源码**:完整 PL/SQL 代码 +- **依赖**:涉及的表、视图、嵌套存储过程/函数 +- **表结构**:相关表的列信息(列名、类型、长度、可空) +- **触发器**:相关表上的触发器列表 + +## 直接 HTTP 调用 + +不通过脚本,直接调用中转服务 API: + +### 分析存储过程 + +```bash +curl -X POST https://ts.henlo.net/api/query \ + -H "Content-Type: application/json" \ + -d '{ + "server_id": "server-001", + "action": "analyze_procedure", + "schema": "BOS", + "name": "M_RETAIL_SUBMIT", + "timeout": 60 + }' +``` + +### 查询表结构 + +```bash +curl -X POST https://ts.henlo.net/api/query \ + -H "Content-Type: application/json" \ + -d '{ + "server_id": "server-001", + "action": "describe_table", + "schema": "bosnds3", + "name": "xcx_so", + "timeout": 30 + }' +``` + +### action 类型 + +| action | 说明 | +|--------|------| +| `analyze_procedure` | 完整分析 | +| `list_procedures` | 列出存储过程 | +| `get_source` | 源码 | +| `get_dependencies` | 依赖 | +| `get_tables` | 表结构(通过存储过程依赖) | +| `describe_table` | 查询表结构(列、索引、行数、注释) | +| `execute_query` | 执行 SELECT 查询(只读)—— 表空间查询也复用此 action | +| `schema_discovery` | 发现核心业务域 | +| `generate_nl2sql_schema` | 生成 NL2SQL Schema 字典 | + + +## 故障排查 + +| 问题 | 排查 | +|------|------| +| `无法连接中转服务` | 检查 transit_url 地址和端口 | +| `agent not found` | 确认 Agent 已启动并连接成功 | +| `请求超时` | Agent 可能卡住,增大 timeout 参数 | +| Oracle 连接失败 | 检查 Agent 的 config.json 中 Oracle 配置 | + + + + +## Agent 版本查询 + +自然语言可说:查询所有客户的 Agent 版本号,或查询指定客户版本号。 + +```powershell +python scripts/oracle_skill.py version agent HENLO RENBEN --json +python scripts/oracle_skill.py version agent --all --json +``` + +## Skill Git 更新流程 + +Skill 使用 Gitea Git 仓库作为唯一更新来源,不使用 OSS 或 `skill_update` 命令。源码仓库完成修改、提交和推送后,在已安装目录执行: + +```powershell +cd C:\Users\qiang\.codex\skills\oracle-jump-query +git pull --ff-only origin main +python scripts/oracle_skill.py capabilities --json +``` + +在线 Agent 返回当前版本;离线客户返回最后一次心跳版本和离线状态。 diff --git a/references/legacy-skill-v1.5.8.md b/references/legacy-skill-v1.5.8.md new file mode 100644 index 0000000..aa4b4d3 --- /dev/null +++ b/references/legacy-skill-v1.5.8.md @@ -0,0 +1,692 @@ +--- +name: oracle-jump-query +description: Oracle 跳板查询技能。通过中转服务查询远程 Oracle 数据库的元数据(存储过程源码、依赖、表结构、触发器、直接查表),连接数据库为只读,禁止任何写操作。当用户需要分析 Oracle 存储过程、查找依赖表、查询表结构、查看业务逻辑时触发。 +--- + +# Oracle 跳板查询 + +> **版本:v1.5.8** · [更新日志](./CHANGELOG.md) + +## 概述 + +通过中转服务(Transit Server)查询远程 Oracle 数据库服务器的存储过程元数据,无需开放数据库端口。 + +## 架构 + +``` +AI Skill → HTTP → Transit Server (:6357) → WebSocket → Agent → Oracle +``` + +## 前置条件 + +1. 中转服务已部署并运行(IP: 47.97.171.89, Port: 6357) +2. Agent 已部署到数据库服务器并连接中转服务 +3. 本地已安装 Python 依赖:`pip install requests` + +## 配置 + +配置文件位于技能目录下:`D:\work\恒诺\长期支持\跳板查询\demo\ai-skill\config.json` + +```json +{ + "transit_url": "http://47.97.171.89:6357", + "server_id": "server-001", + "access_token": "", + "expires_at": "" +} +``` + +使用查询命令前先登录中转机: + +```bash +python oracle_skill.py login [clientCode] +``` + +登录成功后只保存中转机签发的 `access_token` 和过期时间,不保存 BOS `secretKey`。`clientCode` 是客户服务器编号,非必填;不传时中转机会默认选择 BOS 返回的第一个可用 client。 + +同一个 `secretKey` 同时只允许一个设备在线。另一台设备重新登录后,当前设备的 token 会立即失效,需要重新执行 `login [clientCode]`。 + +## 调用方式 + +使用 `scripts/oracle_skill.py` 脚本: + +```bash +python scripts/oracle_skill.py [args] +``` + +### 可用命令 + +| 命令 | 说明 | 示例 | +|------|------|------| +| `analyze ` | 完整分析存储过程 | `python oracle_skill.py analyze BOS M_RETAIL_SUBMIT` | +| `list ` | 列出 schema 下所有存储过程 | `python oracle_skill.py list BOS` | +| `source ` | 获取存储过程源码 | `python oracle_skill.py source BOS M_RETAIL_SUBMIT` | +| `deps ` | 获取依赖(表/存储过程) | `python oracle_skill.py deps BOS M_RETAIL_SUBMIT` | +| `tables ` | 获取相关表结构 | `python oracle_skill.py tables BOS M_RETAIL_SUBMIT` | +| `describe
` | 查询表结构(列、索引、行数) | `python oracle_skill.py describe bosnds3 xcx_so` | +| `discover [schema] [domain]` | 发现核心业务域(NL2SQL前置) | `python oracle_skill.py discover BOSNDS3 RETAIL` | +| `nl2sql [schema] [domain]` | 生成 NL2SQL Schema 字典 | `python oracle_skill.py nl2sql BOSNDS3 RETAIL` | +| `query ` | 执行 SELECT 查询,生产表必须带过滤条件和行数限制 | `python oracle_skill.py query "SELECT * FROM M_RETAIL WHERE BILLDATE=20260501 AND ROWNUM<=20"` | +| `perm [col]` | 查询用户数据权限 | `python oracle_skill.py perm 940 12983` | +| `qperm ` | 带权限过滤的查询 | `python oracle_skill.py qperm 940 12983 "SELECT * FROM M_OTHER_INOUT"` | +| `login [clientCode]` | 登录中转机 + 选择客户服务器,clientCode 非必填 | `python oracle_skill.py login mykey HENLO` | +| `logout` | 登出中转机并清除本地 token | `python oracle_skill.py logout` | +| `status` | 查看中转机登录状态 + 当前 client | `python oracle_skill.py status` | +| `switch ` | 切换 client | `python oracle_skill.py switch OTHER_CLIENT` | +| `clients` | 获取当前用户可访问 client 及在线状态 | `python oracle_skill.py clients` | +| `servers` | 列出在线 Agent | `python oracle_skill.py servers` | +| `health` | 检查中转服务状态 | `python oracle_skill.py health` | +| `tablespace / tablespaces` | 查询表空间使用情况 | `python oracle_skill.py tablespace` | + +### 交互模式 + +不传参数直接运行,进入交互模式: + +```bash +python scripts/oracle_skill.py +``` + +## 生产查询限制 + +直接查询业务表时,必须带明确过滤条件,不要直接查询整表。生产环境数据量很大,`SELECT * FROM
` 这类无条件查询可能返回大量数据、拖慢 Agent 或影响数据库。 + +生成或执行 `query` / `qperm` SQL 时遵守: + +- 必须有 `WHERE` 条件,优先使用日期、单号、门店、客户、状态、主键等业务过滤条件。 +- 必须加行数限制,例如 `ROWNUM <= 20`;需要分页时分批查询。 +- 不要为了“先看看数据”执行无条件整表查询。 +- 统计查询也要限定业务范围,例如日期区间、单据状态、门店范围。 +- 如果用户没有给出过滤条件,先追问条件,或先用 `describe` / `AD_COLUMN` / `AD_TABLE` 查询结构和字段含义。 + +推荐示例: + +```sql +SELECT * +FROM M_RETAIL +WHERE BILLDATE = 20260501 + AND STATUS = '2' + AND ROWNUM <= 20 +``` + +避免示例: + +```sql +SELECT * FROM M_RETAIL +``` + +## 常用业务对象说明 + +当用户使用商品、款号、条码、SKU、库存等业务词时,默认按以下对象理解: + +- “商品”“款号”通常指 `M_PRODUCT` 表。 +- “条码”“SKU”通常指 `M_PRODUCT_ALIAS` 表。 +- 查询条码/SKU 时,首查 `M_PRODUCT_ALIAS.NO` 字段。 +- 款号和条码是一对多关系:一个 `M_PRODUCT` 可以对应多条 `M_PRODUCT_ALIAS`。 +- 如果涉及库存查询,通常查询 `V_FA_STORAGE` 视图。 +- `V_FA_STORAGE` 数据量很大,必须同时带店仓条件和款号/条码条件,不允许只查整张库存视图。 + +库存查询过滤规则: + +- 店仓条件应优先使用门店、仓库、店仓 ID 或店仓编码等字段,先通过 `describe` 或 `AD_COLUMN` 确认实际字段名。 +- 款号条件走 `M_PRODUCT`;条码/SKU 条件走 `M_PRODUCT_ALIAS`。 +- 通过条码查库存时,应先用 `M_PRODUCT_ALIAS.NO` 找到对应商品,再关联或过滤 `V_FA_STORAGE`。 +- 必须加 `ROWNUM` 或分页限制;如果用户没有提供店仓或款号/条码,先追问,不要直接查询库存视图。 + +示例思路: + +```sql +-- 伪示例:实际字段名需先 describe / AD_COLUMN 确认 +SELECT * +FROM V_FA_STORAGE s +WHERE s.C_STORE_ID = :store_id + AND s.M_PRODUCT_ID = :product_id + AND ROWNUM <= 20 +``` + +## 分析报告内容 + +`analyze` 命令返回完整分析报告,包含: +- **源码**:完整 PL/SQL 代码 +- **依赖**:涉及的表、视图、嵌套存储过程/函数 +- **表结构**:相关表的列信息(列名、类型、长度、可空) +- **触发器**:相关表上的触发器列表 + +## 直接 HTTP 调用 + +不通过脚本,直接调用中转服务 API: + +### 分析存储过程 + +```bash +curl -X POST http://47.97.171.89:6357/api/query \ + -H "Content-Type: application/json" \ + -d '{ + "server_id": "server-001", + "action": "analyze_procedure", + "schema": "BOS", + "name": "M_RETAIL_SUBMIT", + "timeout": 60 + }' +``` + +### 查询表结构 + +```bash +curl -X POST http://47.97.171.89:6357/api/query \ + -H "Content-Type: application/json" \ + -d '{ + "server_id": "server-001", + "action": "describe_table", + "schema": "bosnds3", + "name": "xcx_so", + "timeout": 30 + }' +``` + +### action 类型 + +| action | 说明 | +|--------|------| +| `analyze_procedure` | 完整分析 | +| `list_procedures` | 列出存储过程 | +| `get_source` | 源码 | +| `get_dependencies` | 依赖 | +| `get_tables` | 表结构(通过存储过程依赖) | +| `describe_table` | 查询表结构(列、索引、行数、注释) | +| `execute_query` | 执行 SELECT 查询(只读)—— 表空间查询也复用此 action | +| `schema_discovery` | 发现核心业务域 | +| `generate_nl2sql_schema` | 生成 NL2SQL Schema 字典 | + +## 数据字典(AD_TABLE / AD_COLUMN / AD_REFBYTABLE) + +### 通用查询规则 + +处理 BOS 业务单据(如零售单、采购单、调拨单、销售单等)时,必须遵循以下查询路径: + +**1. 术语 → 表名(AD_TABLE)** + +遇到不懂的业务术语(如"零售单提交"、"采购入库"),先查 AD_TABLE: + +```sql +SELECT ID, NAME, TABLENAME, DESCRIPTION, PROC_SUBMIT +FROM AD_TABLE +WHERE NAME LIKE '%关键词%' OR DESCRIPTION LIKE '%关键词%' +``` + +- `NAME`:表名(英文,如 M_RETAIL) +- `DESCRIPTION`:业务说明(中文,如"零售单") +- `PROC_SUBMIT`:该单据的提交存储过程名称(非空即代表该表有提交逻辑) + +**2. 字段含义(AD_COLUMN)** + +不理解某个字段的业务含义时,查 AD_COLUMN: + +```sql +SELECT DBNAME, NAME, DESCRIPTION, COLTYPE +FROM AD_COLUMN +WHERE AD_TABLE_ID = +ORDER BY ORDERNO +``` + +**3. 主表 → 明细表(AD_REFBYTABLE)** + +对于单据类表,其明细表、付款表等子表必须通过 AD_REFBYTABLE 查找,不要靠猜测命名规则: + +```sql +SELECT t1.NAME AS MAIN_TABLE, + t2.NAME AS DETAIL_TABLE, + c.DBNAME AS REF_COLUMN, + r.ASSOCTYPE +FROM AD_REFBYTABLE r +LEFT JOIN AD_TABLE t1 ON r.AD_TABLE_ID = t1.ID +LEFT JOIN AD_TABLE t2 ON r.AD_REFBY_TABLE_ID = t2.ID +LEFT JOIN AD_COLUMN c ON r.AD_REFBY_COLUMN_ID = c.ID +WHERE t1.NAME = '' +``` + +- `ASSOCTYPE`:"n"=一对多明细,"1"=强关联/嵌入 +- 得到明细表名后,再通过 AD_COLUMN 查看各明细表的字段含义 + +### 核心表说明 + +| 表 | 说明 | +|------|------| +| `AD_TABLE` | 所有业务表的元数据注册表,每条记录对应一张业务表 | +| `AD_COLUMN` | 所有业务字段的元数据注册表,含字段名(DBNAME)、显示名(NAME)、描述(DESCRIPTION)、类型(COLTYPE)等 | +| `AD_REFBYTABLE` | **主表与明细表的关联关系**,记录主表→明细表的外键引用 | + +### AD_REFBYTABLE 关联字段 + +| 字段 | 说明 | +|------|------| +| `AD_TABLE_ID` | 主表ID → 关联 AD_TABLE.ID | +| `AD_REFBY_TABLE_ID` | 明细表ID → 关联 AD_TABLE.ID | +| `AD_REFBY_COLUMN_ID` | 明细表中的外键列ID → 关联 AD_COLUMN.ID,通过 AD_COLUMN.DBNAME 获取实际列名 | +| `ASSOCTYPE` | 关联类型("1"=强关联/嵌入, "n"=一对多明细) | + +### 查询主表→明细表关系的 SQL 模板 + +```sql +SELECT t1.NAME AS MAIN_TABLE, + t2.NAME AS DETAIL_TABLE, + c.DBNAME AS REF_COLUMN, + r.ASSOCTYPE +FROM AD_REFBYTABLE r +LEFT JOIN AD_TABLE t1 ON r.AD_TABLE_ID = t1.ID +LEFT JOIN AD_TABLE t2 ON r.AD_REFBY_TABLE_ID = t2.ID +LEFT JOIN AD_COLUMN c ON r.AD_REFBY_COLUMN_ID = c.ID +WHERE t1.NAME = 'M_RETAIL' +``` + +### 示例:M_RETAIL 的明细表 + +| 主表 | 明细表 | 外键列 | 关联类型 | +|------|--------|--------|----------| +| M_RETAIL | M_RETAILITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAILPAYITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_PRO_ITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_RATEITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_DISITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_VOUCHERSITEM | M_RETAIL_ID | n | +| M_RETAIL | M_RETAIL_BATCHITEM | M_RETAIL_ID | n | + +### 查询字段描述的 SQL 模板 + +```sql +SELECT DBNAME, NAME, DESCRIPTION, COLTYPE +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = 'M_RETAILITEM') +ORDER BY ORDERNO +``` + +### BOS 系统地图分析(AD_TABLE_TEXT 经验) + +`BOSNDS3.AD_TABLE_TEXT` 是 BOS 内部的表配置导出脚本生成器。分析该过程可以反推出 BOS 元数据体系:菜单、模块、业务表、字段、主子表、按钮动作、提交过程、权限目录和报表模板主要由数据字典表驱动。 + +当用户要求分析“BOS 系统菜单 / 模块目录 / 表结构地图 / 某个业务模块结构”时,优先按下面的元数据链路整理,不要只看物理表: + +```text +AD_SUBSYSTEM + -> AD_TABLECATEGORY + -> AD_ACCORDION + -> AD_TABLE + -> AD_COLUMN + -> AD_REFBYTABLE + -> AD_ACTION + -> DIRECTORY + -> AD_CXTAB / AD_CXTAB_JPARA / AD_CXTAB_DIMENSION / AD_CXTAB_FACT +``` + +核心含义: + +- `AD_SUBSYSTEM`:子系统。 +- `AD_TABLECATEGORY`:表类别 / 模块分类。 +- `AD_ACCORDION`:折叠菜单 / 菜单分组。 +- `AD_TABLE`:业务表注册表,含 `DESCRIPTION`、`PROC_SUBMIT`、`DIRECTORY_ID`、`AD_TABLECATEGORY_ID`、`AD_ACCORDION_ID` 等。 +- `AD_COLUMN`:字段元数据,含字段名、显示名、业务描述、控件类型、引用列、默认值、限定值组等。 +- `AD_REFBYTABLE`:主表到明细表、子表的关系。 +- `AD_ACTION`:表动作、按钮、URL、存储过程脚本入口。 +- `DIRECTORY`:数据权限目录挂载点。 +- `AD_CXTAB` 相关表:公开查询 / 报表模板及其参数、维度、指标。 + +常用系统地图 SQL: + +```sql +SELECT ss.NAME AS SUBSYSTEM, + tc.NAME AS TABLE_CATEGORY, + ac.NAME AS ACCORDION, + t.ID AS AD_TABLE_ID, + t.NAME AS TABLE_NAME, + t.DESCRIPTION, + t.PROC_SUBMIT, + d.NAME AS DIRECTORY_NAME +FROM AD_TABLE t +LEFT JOIN AD_TABLECATEGORY tc ON tc.ID = t.AD_TABLECATEGORY_ID +LEFT JOIN AD_SUBSYSTEM ss ON ss.ID = tc.AD_SUBSYSTEM_ID +LEFT JOIN AD_ACCORDION ac ON ac.ID = t.AD_ACCORDION_ID +LEFT JOIN DIRECTORY d ON d.ID = t.DIRECTORY_ID +WHERE t.NAME LIKE '%关键词%' OR t.DESCRIPTION LIKE '%关键词%' +ORDER BY ss.NAME, tc.ORDERNO, ac.ORDERNO, t.ORDERNO, t.NAME +``` + +分析单个业务表时,至少同时检查: + +- `AD_TABLE`:表归属、提交过程、权限目录。 +- `AD_COLUMN`:字段业务含义和值域。 +- `AD_REFBYTABLE`:明细表和外键列。 +- `AD_ACTION`:按钮和动作脚本。 +- `ALL_TRIGGERS` / `USER_OBJECTS`:触发器、同名前缀过程或函数。 + +注意:这套元数据能还原 BOS 业务结构,不等于完整物理数据库模型。复杂过程内部逻辑、动态 SQL、触发器副作用和前端特殊页面仍需要结合源码继续分析。 + +## 表空间查询 + +查询 Oracle 表空间使用情况。 + +### 自动降级策略 + +脚本按优先级尝试**5 种方式**,无需人工切换: + +| 优先级 | 方式 | 数据来源 | 条件 | 输出字段 | +|:---:|------|----------|------|----------| +| 1️⃣ | 直接 DBA 查询 | `DBA_DATA_FILES` + `DBA_FREE_SPACE` | 用户有 `SELECT_CATALOG_ROLE` | 全部 8 个字段 | +| 2️⃣ | DBA 专用函数 | `sys.get_tablespace_usage()`(返回 SYS_REFCURSOR) | DBA 已执行 CREATE FUNCTION + GRANT | 全部 5 个字段(自动解析) | +| 3️⃣ | DBA 授权 VIEW(无前缀) | `HENLO_TS_USAGE` 视图 | DBA 已执行授权 SQL | 全部 8 个字段 | +| 4️⃣ | DBA 授权 VIEW(sys.前缀) | `SYS.HENLO_TS_USAGE` 视图 | DBA 以 SYS 身份创建时自动处理 | 全部 8 个字段 | +| 5️⃣ | 用户视图 | `USER_FREE_SPACE` | 始终可用 | 仅表空间名称 + 剩余MB | + +> **经验**:DBA 以 SYS 身份登录创建的 VIEW 会归属到 `SYS` schema,需用 `sys.HENLO_TS_USAGE` 查询。脚本会自动先试无前缀、再试 `sys.` 前缀。 + +### 给 DBA 的授权 SQL + +如果当前用户缺少系统视图权限,脚本会**自动输出**以下 SQL。将其交给 DBA 在 Oracle 中执行一次: + +```sql +CREATE OR REPLACE VIEW SYS.HENLO_TS_USAGE AS +SELECT + df.tablespace_name AS "表空间名称", + ROUND(df.total_bytes / 1048576, 1) AS "总大小MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / 1048576, 1) AS "已用MB", + ROUND(NVL(fs.free_bytes, 0) / 1048576, 1) AS "剩余MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / df.total_bytes * 100, 1) AS "使用率%", + df.autoextensible AS "自动拓展", + ROUND(df.max_bytes / 1048576, 1) AS "最大可拓展MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / NULLIF(df.max_bytes, 0) * 100, 1) AS "最大使用率%" +FROM ( + SELECT + tablespace_name, + SUM(bytes) AS total_bytes, + SUM(CASE WHEN autoextensible = 'YES' THEN maxbytes ELSE bytes END) AS max_bytes, + MAX(CASE WHEN autoextensible = 'YES' THEN 'YES' ELSE 'NO' END) AS autoextensible + FROM dba_data_files + GROUP BY tablespace_name +) df +LEFT JOIN ( + SELECT tablespace_name, SUM(bytes) AS free_bytes + FROM dba_free_space + GROUP BY tablespace_name +) fs ON df.tablespace_name = fs.tablespace_name +ORDER BY 6 DESC; + +GRANT SELECT ON SYS.HENLO_TS_USAGE TO bosnds3; +``` + +> **原理**:Oracle 视图默认使用**定义者权限**(`DEFINER`),以视图创建者的 DBA 身份执行,普通用户仅需视图的 `SELECT` 权限。这比授予 `SELECT_CATALOG_ROLE` 更安全——只暴露这一个视图的数据。 + +DBA 执行授权后,再次运行 `tablespace` 命令即可自动使用完整查询。 + +### 输出字段 + +| 字段 | 说明 | +|------|------| +| 表空间名称 | 表空间名称 | +| 总大小MB | 当前所有数据文件总大小 | +| 已用MB | 已使用的空间 | +| 剩余MB | 剩余可用空间 | +| 使用率% | 已用 / 总大小 × 100% | +| 自动拓展 | YES=自动扩展,NO=不自动扩展 | +| 最大可拓展MB | 开启自动扩展后的最大可达大小 | +| 最大使用率% | 已用 / 最大可拓展 × 100%(参考阈值 5%) | + +### 触发语法 + +用户可以说: +- 「帮我查询下表空间」 +- 「查看表空间使用情况」 +- 「表空间是否够用」 + +```bash +python oracle_skill.py tablespace +# 或 +python oracle_skill.py tablespaces +``` + +### 阈值规则 + +| 使用率 | 状态 | 建议 | +|--------|------|------| +| < 80% | ✅ 正常 | 无需处理 | +| 80~95% | 🔶 关注 | 评估未来增长,制定扩容计划 | +| ≥ 95% | ⚠️ 紧急 | 尽快扩容或清理空间! | + +> **智能告警规则**:脚本逐行解析表空间数据,自动判断衡量指标。 +> 若表空间**已开启自动扩展**(`自动拓展=YES`),按 **最大使用率%** 判断(已用/最大可拓展); +> 若**未开启**(`NO`),按当前 **使用率%** 判断(已用/当前总大小)。 +> +> 这让告警更准确。例如:SYSTEM 使用率 99.4% 但最大使用率仅 3.7%,说明只是当前数据文件写满了,自动扩展后即释放空间,并非真正的存储瓶颈。 + +### 使用场景 + +1. **查主表的明细表**:通过 AD_REFBYTABLE 找到主表关联的所有明细表及外键列 +2. **查字段含义**:通过 AD_COLUMN 的 DESCRIPTION 字段了解业务含义 +3. **构建 JOIN 查询**:根据外键列名自动拼接主表→明细表的 JOIN 条件 +4. **理解业务模型**:通过表间关系理解业务单据结构(如零售单→零售明细→支付明细) + +### 4. 统计数据场景的过滤条件 + +在 BOS 系统中,进行统计数据查询时,必须遵循以下业务规则: + +**业务单据通用过滤条件**: +- `ISACTIVE = 'Y'`:单据有效(Y=可用,N=已删除/作废) +- `STATUS = '2'`:单据已提交(1=未提交,2=已提交,3=审批中) + +**查询模板**: +```sql +-- 查询有效且已提交的业务单据 +SELECT * FROM <业务表名> +WHERE ISACTIVE = 'Y' AND STATUS = '2' +-- 其他业务条件... +``` + +**字段含义**: +| 字段 | 值 | 业务含义 | +|------|-----|----------| +| `ISACTIVE` | 'Y' | 单据有效,可参与统计 | +| `ISACTIVE` | 'N' | 单据已删除/作废,应排除 | +| `STATUS` | '1' | 草稿/未提交,应排除 | +| `STATUS` | '2' | **已提交**,可参与统计 | +| `STATUS` | '3' | 审批中,通常排除(除非统计审批流程) | + +**应用场景**: +- 统计销售额、销售量 → 必须用 `ISACTIVE='Y' AND STATUS='2'` +- 查询历史订单 → 必须用 `ISACTIVE='Y' AND STATUS='2'` +- 分析业务趋势 → 必须用 `ISACTIVE='Y' AND STATUS='2'` + +**注意事项**: +1. 明细表(如 M_RETAILITEM)通常继承主表状态,但有时也有自己的状态字段 +2. 部分表可能使用 `ISSTOP` 代替 `ISACTIVE`,逻辑相反('Y'=停用) +3. 查询前应先通过 AD_COLUMN 确认字段名和值域 + +## 数据权限系统 + +### GET_USERSPERMSQL 函数 + +数据库内置 `get_userspermsql` 函数,用于获取用户的数据权限过滤条件。 + +**函数签名:** +```sql +FUNCTION get_userspermsql( + p_users_id NUMBER, -- 用户ID (USERS.ID) + p_tableid NUMBER, -- 表ID (AD_TABLE.ID) + p_col VARCHAR2 -- 字段名 (AD_COLUMN.DBNAME),可为NULL +) RETURN CLOB -- 返回权限过滤SQL +``` + +**返回值:** +- 空字符串:全权限(无需过滤) +- SQL条件:如 `C_STORE_ID IN (...)`,需拼接到 WHERE 子句 + +**权限模型:** +``` +用户 → GROUPUSER → 用户组 → GROUPPERM → 安全目录 → 权限SQL + ↓ + AD_TABLE.DIRECTORY_ID +``` + +### 使用权限过滤查询 + +**1. 查询用户权限:** +```bash +python oracle_skill.py perm 940 12983 +``` +返回: +```json +{ + "user_id": "940", + "table_id": "12983", + "perm_sql": "M_OTHER_INOUT.C_STORE_ID IN(...)", + "has_restriction": true +} +``` + +**2. 带权限过滤的查询:** +```bash +python oracle_skill.py qperm 940 12983 "SELECT * FROM M_OTHER_INOUT WHERE BILLDATE=20260501" +``` +自动拼接权限条件后执行: +```sql +SELECT * FROM M_OTHER_INOUT +WHERE BILLDATE=20260501 +AND M_OTHER_INOUT.C_STORE_ID IN(...) +``` + +### 常用表ID参考 + +| 表名 | AD_TABLE.ID | 说明 | +|------|-------------|------| +| M_RETAIL | 12964 | 零售单主表 | +| M_RETAILITEM | 12965 | 零售明细 | +| M_OTHER_INOUT | 12983 | 其他出入库 | +| C_STORE_CHKDAY | 18182 | 门店盘点日 | + +## 故障排查 + +| 问题 | 排查 | +|------|------| +| `无法连接中转服务` | 检查 transit_url 地址和端口 | +| `agent not found` | 确认 Agent 已启动并连接成功 | +| `请求超时` | Agent 可能卡住,增大 timeout 参数 | +| Oracle 连接失败 | 检查 Agent 的 config.json 中 Oracle 配置 | + +## Oracle 日常运维监控触发 + +当用户说“给我 HENLO 的 Oracle 运维报告”、“生成 HENLO 数据库日报”、“做一次 HENLO Oracle 巡检”这类请求时,调用: + +```bash +python scripts/oracle_skill.py ops_report HENLO +``` + +当用户询问单项运维状态时,调用 `ops [clientCode]`: + +```bash +python scripts/oracle_skill.py ops blocking_locks HENLO +python scripts/oracle_skill.py ops active_slow_sql HENLO +python scripts/oracle_skill.py ops ora_errors HENLO +``` + +常用自然语言映射: + +| 用户意图 | 命令 | +|---|---| +| 有没有锁阻塞 | `ops blocking_locks ` | +| 当前有没有慢 SQL | `ops active_slow_sql ` | +| 最近 ORA 错误 | `ops ora_errors ` | +| UNDO 风险 | `ops undo ` | +| IO 等待 | `ops io_waits ` | +| 内存使用 | `ops memory ` | +| 失效对象 | `ops invalid_objects ` | + +涉及 SYS/DBA 权限视图时,Agent 会优先调用 `SYS.HENLO_ORA_MONITOR`;如果不可用,再尝试当前登录账号下的 `HENLO_ORA_MONITOR`。如果两个函数都不存在或未授权,输出两种安装方案:有 SYS 账号时安装到 SYS 并授权;没有 SYS 账号但当前登录账号已有系统视图查询权限时,安装到当前账号后重试。 + +## 服务器体检报告触发 + +当用户说“给我 HENLO 的服务器体检报告”、“查询 HENLO 的服务器状态”、“看看 HENLO 的备份情况”这类请求时,调用: + +```bash +python scripts/oracle_skill.py checkup HENLO +``` + +如果用户没有指定客户服务器编号,则调用: + +```bash +python scripts/oracle_skill.py checkup +``` + +输出字段固定为 `clientCode`、`info_cpu_rate`、`info_memory_rate`、`info_memory`、`bak_path`、`bak_lastdate`、`computer`。`clientCode` 是客户服务器编号,非必填;不传时使用当前登录 client。 + +## 新增业务记录 SQL 生成规则 + +当用户要求分析或生成新增业务表记录的 SQL 示例时,仍然保持只读安全边界:skill 可以生成 SQL 和说明,但不能直接通过查询通道执行 INSERT、UPDATE、DELETE、MERGE、DDL 等写操作。 + +生成新增记录 SQL 时遵守以下默认规则: + +- 主表或子表新增记录的主键 `ID` 使用 `get_sequences('<表名称>')` 取值。 +- 如果目标表存在 `DOCNO` 字段,先查询 `AD_COLUMN` 中该字段是否配置 `SEQUENCENAME` 单据编号生成器。 +- 如果 `DOCNO` 对应的 `SEQUENCENAME` 有值,则单据编号使用 `Get_SequenceNo('', 37)` 取值。 +- 如果目标表存在 `AD_CLIENT_ID` 字段,默认值使用 `37`。 +- 如果目标表存在 `AD_ORG_ID` 字段,默认值使用 `27`。 +- 子表的 `AD_CLIENT_ID` 和 `AD_ORG_ID` 默认取父表记录中对应字段值,不单独写固定值。 +- 新增业务表单时,如果目标表存在 `STATUS`、`STATUSERID`、`STATUSTIME` 字段,通常只给 `STATUS` 赋默认值 `'1'`,不对 `STATUSERID`、`STATUSTIME` 赋值。 +- `STATUS='1'` 表示草稿/未提交;提交人、提交时间等字段由提交动作或提交存储过程处理。 +- 如果用户明确提到“提交”,通常不是直接把 `STATUS` 改成 `'2'`,而是应先查询 `AD_TABLE.PROC_SUBMIT`,按该表配置的提交存储过程理解提交逻辑。 + +查询 `DOCNO` 编号生成器示例: + +```sql +SELECT SEQUENCENAME +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = '<表名称>') + AND UPPER(DBNAME) = 'DOCNO' +``` + +## 字段值域映射规则(AD_COLUMN / AD_LIMITVALUE) + +当用户要求解释字段含义、生成查询条件、分析业务类型,或生成新增业务记录 SQL 示例时,如果目标字段在 `AD_COLUMN` 中的 `OBTAINMANNER` 值为 `select`,必须继续通过 `AD_LIMITVALUE_GROUP_ID` 查询 `AD_LIMITVALUE`,拿到显示值与数据库实际值的映射关系。 + +关键规则: + +- `AD_COLUMN.OBTAINMANNER = 'select'` 表示该字段是受限值列表。 +- `AD_COLUMN.AD_LIMITVALUE_GROUP_ID` 关联 `AD_LIMITVALUE.AD_LIMITVALUE_GROUP_ID`。 +- 用户通常说的是显示值,例如“券核销”。 +- SQL 查询条件和新增记录 SQL 必须使用数据库实际值,例如 `VOU_USED`。 +- 不要把显示值直接写入业务表字段,除非 `AD_LIMITVALUE` 查询结果证明显示值就是实际值。 + +示例:`XH_ORDER_FTP.BILLTYPE` 字段,当显示值为“券核销”时,对应的数据库实际值是 `VOU_USED`。因此用户说“类型为券核销”时,应生成: + +```sql +XH_ORDER_FTP.BILLTYPE = 'VOU_USED' +``` + +查询字段值域映射模板: + +```sql +SELECT c.DBNAME, + c.NAME, + c.OBTAINMANNER, + c.AD_LIMITVALUE_GROUP_ID, + v.VALUE, + v.NAME AS DISPLAY_NAME +FROM AD_COLUMN c +LEFT JOIN AD_LIMITVALUE v ON v.AD_LIMITVALUE_GROUP_ID = c.AD_LIMITVALUE_GROUP_ID +WHERE c.AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = '<表名称>') + AND UPPER(c.DBNAME) = UPPER('<字段名>') +ORDER BY v.ORDERNO, v.ID +``` + +使用时先查字段: + +```sql +SELECT ID, DBNAME, NAME, DESCRIPTION, OBTAINMANNER, AD_LIMITVALUE_GROUP_ID +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = 'XH_ORDER_FTP') + AND UPPER(DBNAME) = 'BILLTYPE' +``` + +如果 `OBTAINMANNER='select'` 且 `AD_LIMITVALUE_GROUP_ID` 有值,再查: + +```sql +SELECT VALUE, NAME AS DISPLAY_NAME +FROM AD_LIMITVALUE +WHERE AD_LIMITVALUE_GROUP_ID = +ORDER BY ORDERNO, ID +``` diff --git a/references/oracle-ops-and-checkup.md b/references/oracle-ops-and-checkup.md new file mode 100644 index 0000000..1b5c024 --- /dev/null +++ b/references/oracle-ops-and-checkup.md @@ -0,0 +1,203 @@ +# 表空间、服务器体检与 Oracle 运维监控 + +> 本文件由 SKILL.md 整理拆分而来,保留原说明内容。 + +## 表空间查询 + +查询 Oracle 表空间使用情况。 + +### 自动降级策略 + +脚本按优先级尝试**5 种方式**,无需人工切换: + +| 优先级 | 方式 | 数据来源 | 条件 | 输出字段 | +|:---:|------|----------|------|----------| +| 1️⃣ | 直接 DBA 查询 | `DBA_DATA_FILES` + `DBA_FREE_SPACE` | 用户有 `SELECT_CATALOG_ROLE` | 全部 8 个字段 | +| 2️⃣ | DBA 专用函数 | `sys.get_tablespace_usage()`(返回 SYS_REFCURSOR) | DBA 已执行 CREATE FUNCTION + GRANT | 全部 5 个字段(自动解析) | +| 3️⃣ | DBA 授权 VIEW(无前缀) | `HENLO_TS_USAGE` 视图 | DBA 已执行授权 SQL | 全部 8 个字段 | +| 4️⃣ | DBA 授权 VIEW(sys.前缀) | `SYS.HENLO_TS_USAGE` 视图 | DBA 以 SYS 身份创建时自动处理 | 全部 8 个字段 | +| 5️⃣ | 用户视图 | `USER_FREE_SPACE` | 始终可用 | 仅表空间名称 + 剩余MB | + +> **经验**:DBA 以 SYS 身份登录创建的 VIEW 会归属到 `SYS` schema,需用 `sys.HENLO_TS_USAGE` 查询。脚本会自动先试无前缀、再试 `sys.` 前缀。 + +### 给 DBA 的授权 SQL + +如果当前用户缺少系统视图权限,脚本会**自动输出**以下 SQL。将其交给 DBA 在 Oracle 中执行一次: + +```sql +CREATE OR REPLACE VIEW SYS.HENLO_TS_USAGE AS +SELECT + df.tablespace_name AS "表空间名称", + ROUND(df.total_bytes / 1048576, 1) AS "总大小MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / 1048576, 1) AS "已用MB", + ROUND(NVL(fs.free_bytes, 0) / 1048576, 1) AS "剩余MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / df.total_bytes * 100, 1) AS "使用率%", + df.autoextensible AS "自动拓展", + ROUND(df.max_bytes / 1048576, 1) AS "最大可拓展MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / NULLIF(df.max_bytes, 0) * 100, 1) AS "最大使用率%" +FROM ( + SELECT + tablespace_name, + SUM(bytes) AS total_bytes, + SUM(CASE WHEN autoextensible = 'YES' THEN maxbytes ELSE bytes END) AS max_bytes, + MAX(CASE WHEN autoextensible = 'YES' THEN 'YES' ELSE 'NO' END) AS autoextensible + FROM dba_data_files + GROUP BY tablespace_name +) df +LEFT JOIN ( + SELECT tablespace_name, SUM(bytes) AS free_bytes + FROM dba_free_space + GROUP BY tablespace_name +) fs ON df.tablespace_name = fs.tablespace_name +ORDER BY 6 DESC; + +GRANT SELECT ON SYS.HENLO_TS_USAGE TO bosnds3; +``` + +> **原理**:Oracle 视图默认使用**定义者权限**(`DEFINER`),以视图创建者的 DBA 身份执行,普通用户仅需视图的 `SELECT` 权限。这比授予 `SELECT_CATALOG_ROLE` 更安全——只暴露这一个视图的数据。 + +DBA 执行授权后,再次运行 `tablespace` 命令即可自动使用完整查询。 + +### 输出字段 + +| 字段 | 说明 | +|------|------| +| 表空间名称 | 表空间名称 | +| 总大小MB | 当前所有数据文件总大小 | +| 已用MB | 已使用的空间 | +| 剩余MB | 剩余可用空间 | +| 使用率% | 已用 / 总大小 × 100% | +| 自动拓展 | YES=自动扩展,NO=不自动扩展 | +| 最大可拓展MB | 开启自动扩展后的最大可达大小 | +| 最大使用率% | 已用 / 最大可拓展 × 100%(参考阈值 5%) | + +### 触发语法 + +用户可以说: +- 「帮我查询下表空间」 +- 「查看表空间使用情况」 +- 「表空间是否够用」 + +```bash +python oracle_skill.py tablespace +# 或 +python oracle_skill.py tablespaces +``` + +### 阈值规则 + +| 使用率 | 状态 | 建议 | +|--------|------|------| +| < 80% | ✅ 正常 | 无需处理 | +| 80~95% | 🔶 关注 | 评估未来增长,制定扩容计划 | +| ≥ 95% | ⚠️ 紧急 | 尽快扩容或清理空间! | + +> **智能告警规则**:脚本逐行解析表空间数据,自动判断衡量指标。 +> 若表空间**已开启自动扩展**(`自动拓展=YES`),按 **最大使用率%** 判断(已用/最大可拓展); +> 若**未开启**(`NO`),按当前 **使用率%** 判断(已用/当前总大小)。 +> +> 这让告警更准确。例如:SYSTEM 使用率 99.4% 但最大使用率仅 3.7%,说明只是当前数据文件写满了,自动扩展后即释放空间,并非真正的存储瓶颈。 + +### 使用场景 + +1. **查主表的明细表**:通过 AD_REFBYTABLE 找到主表关联的所有明细表及外键列 +2. **查字段含义**:通过 AD_COLUMN 的 DESCRIPTION 字段了解业务含义 +3. **构建 JOIN 查询**:根据外键列名自动拼接主表→明细表的 JOIN 条件 +4. **理解业务模型**:通过表间关系理解业务单据结构(如零售单→零售明细→支付明细) + +### 4. 统计数据场景的过滤条件 + +在 BOS 系统中,进行统计数据查询时,必须遵循以下业务规则: + +**业务单据通用过滤条件**: +- `ISACTIVE = 'Y'`:单据有效(Y=可用,N=已删除/作废) +- `STATUS = '2'`:单据已提交(1=未提交,2=已提交,3=审批中) + +**查询模板**: +```sql +-- 查询有效且已提交的业务单据 +SELECT * FROM <业务表名> +WHERE ISACTIVE = 'Y' AND STATUS = '2' +-- 其他业务条件... +``` + +**字段含义**: +| 字段 | 值 | 业务含义 | +|------|-----|----------| +| `ISACTIVE` | 'Y' | 单据有效,可参与统计 | +| `ISACTIVE` | 'N' | 单据已删除/作废,应排除 | +| `STATUS` | '1' | 草稿/未提交,应排除 | +| `STATUS` | '2' | **已提交**,可参与统计 | +| `STATUS` | '3' | 审批中,通常排除(除非统计审批流程) | + +**应用场景**: +- 统计销售额、销售量 → 必须用 `ISACTIVE='Y' AND STATUS='2'` +- 查询历史订单 → 必须用 `ISACTIVE='Y' AND STATUS='2'` +- 分析业务趋势 → 必须用 `ISACTIVE='Y' AND STATUS='2'` + +**注意事项**: +1. 明细表(如 M_RETAILITEM)通常继承主表状态,但有时也有自己的状态字段 +2. 部分表可能使用 `ISSTOP` 代替 `ISACTIVE`,逻辑相反('Y'=停用) +3. 查询前应先通过 AD_COLUMN 确认字段名和值域 + + +## Oracle 日常运维监控触发 + +当用户说“给我 HENLO 的 Oracle 运维报告”、“生成 HENLO 数据库日报”、“做一次 HENLO Oracle 巡检”这类请求时,调用: + +```bash +python scripts/oracle_skill.py ops_report HENLO +``` + +当用户询问单项运维状态时,调用 `ops [clientCode]`: + +```bash +python scripts/oracle_skill.py ops blocking_locks HENLO +python scripts/oracle_skill.py ops active_slow_sql HENLO +python scripts/oracle_skill.py ops ora_errors HENLO +``` + +常用自然语言映射: + +| 用户意图 | 命令 | +|---|---| +| 有没有锁阻塞 | `ops blocking_locks ` | +| 当前有没有慢 SQL | `ops active_slow_sql ` | +| 最近 ORA 错误 | `ops ora_errors ` | +| UNDO 风险 | `ops undo ` | +| IO 等待 | `ops io_waits ` | +| 内存使用 | `ops memory ` | +| 失效对象 | `ops invalid_objects ` | + +涉及 SYS/DBA 权限视图时,Agent 会优先调用 `SYS.HENLO_ORA_MONITOR`;如果不可用,再尝试当前登录账号下的 `HENLO_ORA_MONITOR`。如果两个函数都不存在或未授权,输出两种安装方案:有 SYS 账号时安装到 SYS 并授权;没有 SYS 账号但当前登录账号已有系统视图查询权限时,安装到当前账号后重试。 + +## 服务器巡检报告触发 + +当用户说“给我 HENLO 的服务器体检报告”、“查询 HENLO 的服务器状态”、“看看 HENLO 的备份情况”、“生成巡检 HTML”这类请求时,统一使用归档巡检入口: + +```bash +python scripts/oracle_skill.py inspection_report HENLO +python scripts/oracle_skill.py inspection_latest HENLO +python scripts/oracle_skill.py inspection_latest HENLO --refresh +python scripts/oracle_skill.py inspection_report HENLO --markdown +``` + +`inspection_report` 会实时请求 Agent 的 `inspection_report_data` 并归档到 `TS_INSPECTION_REPORT`;`inspection_latest` 默认读取最近一次历史快照,加 `--refresh` 时重新采集并返回。三个巡检命令成功后均默认使用固定格式模板导出 HTML,保证每次结构一致,只替换数据内容;增加 `--markdown`(或 `--md`)可同时导出固定格式 Markdown。 + +当前巡检数据包括服务器 IP、操作系统版本、启动时间、运行时长、关键进程、TCP 连接状态、Oracle ACTIVE/总会话数,以及 Buffer Cache、Library Cache 命中率。TCP 连接统计与 Oracle 会话数是两个独立指标,不混合计算。 + +## AWR 报告 + +AWR 仅在客户 DBA 明确确认 Diagnostics Pack 授权,且 Agent 同时配置 `awr.enabled=true`、`awr.license_confirmed=true` 后启用。Skill 不执行授权 SQL,不随日常巡检隐式生成 AWR,也不在本地重新渲染 HTML。 + +当 `awr_status` 返回 `permission_required` 时,Skill 会从目标 Agent 的 `version` action 解析实际 Oracle schema,并返回已替换账号的 `HENLO_AWR_EXPORT` 创建/授权 SQL,供 DBA 在确认 Diagnostics Pack 授权后手工执行。无法解析有效 schema 时停止输出 SQL,不保留 `` 占位符。 + +`HENLO_AWR_EXPORT` v1.1 对 Oracle AWR 报告流中的空输出行进行保护,避免把 `LENGTH(NULL)` 传给 `DBMS_LOB.WRITEAPPEND` 触发 `ORA-06502`;每个输出行后追加换行,保持 HTML 原始行边界。若旧版函数已生成 `skipped` 元数据,先由 DBA 手工升级函数,再升级并重启 Agent `1.8.12+` 触发一次恢复重试。 + +```powershell +python scripts/oracle_skill.py awr_status WEIRUI +python scripts/oracle_skill.py awr_list WEIRUI +python scripts/oracle_skill.py awr_download WEIRUI 20260720 +``` + +Agent 默认在东八区每日 01:00 尝试生成前一日 AWR,并按配置保留本地文件。`awr_download` 经 transit-server 权限校验后原样下载 Agent 文件,默认保存到 `outputs/AWR--.html`,最大 50 MB。AWR 失败不影响服务器基础巡检、Oracle 会话和缓存命中率等常规项目。 diff --git a/references/query-and-business-rules.md b/references/query-and-business-rules.md new file mode 100644 index 0000000..f623e81 --- /dev/null +++ b/references/query-and-business-rules.md @@ -0,0 +1,152 @@ +# 生产查询限制与常用业务规则 + +> 本文件由 SKILL.md 整理拆分而来,保留原说明内容。 + +## 生产查询限制 + +直接查询业务表时,必须带明确过滤条件,不要直接查询整表。生产环境数据量很大,`SELECT * FROM
` 这类无条件查询可能返回大量数据、拖慢 Agent 或影响数据库。 + +生成或执行 `query` / `qperm` SQL 时遵守: + +- 必须有 `WHERE` 条件,优先使用日期、单号、门店、客户、状态、主键等业务过滤条件。 +- 必须加行数限制,例如 `ROWNUM <= 20`;需要分页时分批查询。 +- 不要为了“先看看数据”执行无条件整表查询。 +- 统计查询也要限定业务范围,例如日期区间、单据状态、门店范围。 +- 如果用户没有给出过滤条件,先追问条件,或先用 `describe` / `AD_COLUMN` / `AD_TABLE` 查询结构和字段含义。 + +推荐示例: + +```sql +SELECT * +FROM M_RETAIL +WHERE BILLDATE = 20260501 + AND STATUS = '2' + AND ROWNUM <= 20 +``` + +避免示例: + +```sql +SELECT * FROM M_RETAIL +``` + +## 常用业务对象说明 + +当用户使用商品、款号、条码、SKU、库存等业务词时,默认按以下对象理解: + +- “商品”“款号”通常指 `M_PRODUCT` 表。 +- “条码”“SKU”通常指 `M_PRODUCT_ALIAS` 表。 +- 查询条码/SKU 时,首查 `M_PRODUCT_ALIAS.NO` 字段。 +- 款号和条码是一对多关系:一个 `M_PRODUCT` 可以对应多条 `M_PRODUCT_ALIAS`。 +- 商品明细表中常见的三个商品维度字段: + - `M_PRODUCT_ID`:款号 / 商品,关联 `M_PRODUCT.ID`。 + - `M_PRODUCTALIAS_ID`:条码 / SKU,关联 `M_PRODUCT_ALIAS.ID`。 + - `M_ATTRIBUTESETINSTANCE_ID`:色码属性 ASI,关联 `M_ATTRIBUTESETINSTANCE.ID`。 +- `M_ATTRIBUTESETINSTANCE` 用于记录商品色码属性: + - `VALUE1`:颜色名称。 + - `VALUE1_CODE`:颜色编号。 + - `VALUE1_ID`:颜色表 ID,关联 `M_COLOR.ID`。 + - `VALUE2`:尺码名称。 + - `VALUE2_CODE`:尺码编号。 + - `VALUE2_ID`:尺码表 ID,关联 `M_SIZE.ID`。 +- 如果涉及库存查询,通常查询 `V_FA_STORAGE` 视图。 +- `V_FA_STORAGE` 数据量很大,必须同时带店仓条件和款号/条码条件,不允许只查整张库存视图。 + +库存查询过滤规则: + +- 店仓条件应优先使用门店、仓库、店仓 ID 或店仓编码等字段,先通过 `describe` 或 `AD_COLUMN` 确认实际字段名。 +- 款号条件走 `M_PRODUCT`;条码/SKU 条件走 `M_PRODUCT_ALIAS`。 +- 颜色、尺码、色码属性条件优先走 `M_ATTRIBUTESETINSTANCE`,再分别通过 `VALUE1_ID` 关联 `M_COLOR`、通过 `VALUE2_ID` 关联 `M_SIZE`。 +- 通过条码查库存时,应先用 `M_PRODUCT_ALIAS.NO` 找到对应商品,再关联或过滤 `V_FA_STORAGE`。 +- 必须加 `ROWNUM` 或分页限制;如果用户没有提供店仓或款号/条码,先追问,不要直接查询库存视图。 + +示例思路: + +```sql +-- 伪示例:实际字段名需先 describe / AD_COLUMN 确认 +SELECT * +FROM V_FA_STORAGE s +WHERE s.C_STORE_ID = :store_id + AND s.M_PRODUCT_ID = :product_id + AND ROWNUM <= 20 +``` + + +## 新增业务记录 SQL 生成规则 + +当用户要求分析或生成新增业务表记录的 SQL 示例时,仍然保持只读安全边界:skill 可以生成 SQL 和说明,但不能直接通过查询通道执行 INSERT、UPDATE、DELETE、MERGE、DDL 等写操作。 + +生成新增记录 SQL 时遵守以下默认规则: + +- 主表或子表新增记录的主键 `ID` 使用 `get_sequences('<表名称>')` 取值。 +- 如果目标表存在 `DOCNO` 字段,先查询 `AD_COLUMN` 中该字段是否配置 `SEQUENCENAME` 单据编号生成器。 +- 如果 `DOCNO` 对应的 `SEQUENCENAME` 有值,则单据编号使用 `Get_SequenceNo('', 37)` 取值。 +- 如果目标表存在 `AD_CLIENT_ID` 字段,默认值使用 `37`。 +- 如果目标表存在 `AD_ORG_ID` 字段,默认值使用 `27`。 +- 子表的 `AD_CLIENT_ID` 和 `AD_ORG_ID` 默认取父表记录中对应字段值,不单独写固定值。 +- 新增业务表单时,如果目标表存在 `STATUS`、`STATUSERID`、`STATUSTIME` 字段,通常只给 `STATUS` 赋默认值 `'1'`,不对 `STATUSERID`、`STATUSTIME` 赋值。 +- `STATUS='1'` 表示草稿/未提交;提交人、提交时间等字段由提交动作或提交存储过程处理。 +- 如果用户明确提到“提交”,通常不是直接把 `STATUS` 改成 `'2'`,而是应先查询 `AD_TABLE.PROC_SUBMIT`,按该表配置的提交存储过程理解提交逻辑。 +- 生成提交 SQL 或说明提交流程时,如果已知 `userId`,调用 `AD_TABLE.PROC_SUBMIT` 前需要先更新主单据修改人和修改时间,再调用提交存储过程;修改人、修改时间字段名必须先通过 `AD_COLUMN` 或表结构确认,不要凭经验硬写字段名。 + +查询 `DOCNO` 编号生成器示例: + +```sql +SELECT SEQUENCENAME +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = '<表名称>') + AND UPPER(DBNAME) = 'DOCNO' +``` + +## 字段值域映射规则(AD_COLUMN / AD_LIMITVALUE) + +当用户要求解释字段含义、生成查询条件、分析业务类型,或生成新增业务记录 SQL 示例时,如果目标字段在 `AD_COLUMN` 中的 `OBTAINMANNER` 值为 `select`,必须继续通过 `AD_LIMITVALUE_GROUP_ID` 查询 `AD_LIMITVALUE`,拿到显示值与数据库实际值的映射关系。 + +关键规则: + +- `AD_COLUMN.OBTAINMANNER = 'select'` 表示该字段是受限值列表。 +- `AD_COLUMN.AD_LIMITVALUE_GROUP_ID` 关联 `AD_LIMITVALUE.AD_LIMITVALUE_GROUP_ID`。 +- 用户通常说的是显示值,例如“券核销”。 +- SQL 查询条件和新增记录 SQL 必须使用数据库实际值,例如 `VOU_USED`。 +- 不要把显示值直接写入业务表字段,除非 `AD_LIMITVALUE` 查询结果证明显示值就是实际值。 + +示例:`XH_ORDER_FTP.BILLTYPE` 字段,当显示值为“券核销”时,对应的数据库实际值是 `VOU_USED`。因此用户说“类型为券核销”时,应生成: + +```sql +XH_ORDER_FTP.BILLTYPE = 'VOU_USED' +``` + +查询字段值域映射模板: + +```sql +SELECT c.DBNAME, + c.NAME, + c.OBTAINMANNER, + c.AD_LIMITVALUE_GROUP_ID, + v.VALUE, + v.NAME AS DISPLAY_NAME +FROM AD_COLUMN c +LEFT JOIN AD_LIMITVALUE v ON v.AD_LIMITVALUE_GROUP_ID = c.AD_LIMITVALUE_GROUP_ID +WHERE c.AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = '<表名称>') + AND UPPER(c.DBNAME) = UPPER('<字段名>') +ORDER BY v.ORDERNO, v.ID +``` + +使用时先查字段: + +```sql +SELECT ID, DBNAME, NAME, DESCRIPTION, OBTAINMANNER, AD_LIMITVALUE_GROUP_ID +FROM AD_COLUMN +WHERE AD_TABLE_ID = (SELECT ID FROM AD_TABLE WHERE NAME = 'XH_ORDER_FTP') + AND UPPER(DBNAME) = 'BILLTYPE' +``` + +如果 `OBTAINMANNER='select'` 且 `AD_LIMITVALUE_GROUP_ID` 有值,再查: + +```sql +SELECT VALUE, NAME AS DISPLAY_NAME +FROM AD_LIMITVALUE +WHERE AD_LIMITVALUE_GROUP_ID = +ORDER BY ORDERNO, ID +``` + diff --git a/references/sys-functions/README.md b/references/sys-functions/README.md new file mode 100644 index 0000000..75ad073 --- /dev/null +++ b/references/sys-functions/README.md @@ -0,0 +1,18 @@ +# HENLO Oracle SYS functions + +These scripts are DBA-operated SQL artifacts. The Agent never executes `GRANT`, DDL, or any write operation. + +Apply order: + +1. `henlo_ora_monitor.sql` +2. `henlo_awr_export.sql` only when the DBA has confirmed Diagnostics Pack licensing and AWR access. + +Replace `` with the read-only Oracle account used by the Agent. Record the applied version in the database change log. + +Generate the complete current bundle with: + +```powershell +python scripts/oracle_skill.py sys_functions +``` + +The command reads every `.sql` file in this directory and replaces the schema placeholder. Any new SYS function must be added here so it is automatically included. diff --git a/references/sys-functions/henlo_awr_export.sql b/references/sys-functions/henlo_awr_export.sql new file mode 100644 index 0000000..208747b --- /dev/null +++ b/references/sys-functions/henlo_awr_export.sql @@ -0,0 +1,23 @@ +-- HENLO_AWR_EXPORT v1.1 +-- DBA only. Requires Diagnostics Pack authorization. +CREATE OR REPLACE FUNCTION SYS.HENLO_AWR_EXPORT( + P_BEGIN_SNAP NUMBER, P_END_SNAP NUMBER, P_DBID NUMBER DEFAULT NULL, P_INSTANCE NUMBER DEFAULT NULL +) RETURN CLOB AUTHID DEFINER IS + L_DBID NUMBER := P_DBID; + L_INSTANCE NUMBER := P_INSTANCE; + L_HTML CLOB; +BEGIN + IF L_DBID IS NULL THEN SELECT DBID INTO L_DBID FROM V$DATABASE; END IF; + IF L_INSTANCE IS NULL THEN SELECT INSTANCE_NUMBER INTO L_INSTANCE FROM V$INSTANCE; END IF; + DBMS_LOB.CREATETEMPORARY(L_HTML, TRUE); + FOR R IN (SELECT OUTPUT FROM TABLE(DBMS_WORKLOAD_REPOSITORY.AWR_REPORT_HTML(L_DBID, L_INSTANCE, P_BEGIN_SNAP, P_END_SNAP))) LOOP + IF R.OUTPUT IS NOT NULL THEN + DBMS_LOB.WRITEAPPEND(L_HTML, LENGTH(R.OUTPUT), R.OUTPUT); + END IF; + DBMS_LOB.WRITEAPPEND(L_HTML, 1, CHR(10)); + END LOOP; + RETURN L_HTML; +END; +/ +GRANT EXECUTE ON SYS.HENLO_AWR_EXPORT TO ; +GRANT SELECT ON SYS.DBA_HIST_SNAPSHOT TO ; diff --git a/references/sys-functions/henlo_ora_monitor.sql b/references/sys-functions/henlo_ora_monitor.sql new file mode 100644 index 0000000..74fbb66 --- /dev/null +++ b/references/sys-functions/henlo_ora_monitor.sql @@ -0,0 +1,134 @@ +-- HENLO_ORA_MONITOR v3.0 +-- DBA only. Replace before execution. +-- Returns JSON in a CLOB. This return contract is required by Agent 1.8.6+. +CREATE OR REPLACE FUNCTION SYS.HENLO_ORA_MONITOR( + P_ITEM VARCHAR2, P_MINUTES NUMBER DEFAULT 60, P_TOP_N NUMBER DEFAULT 20 +) RETURN CLOB AUTHID DEFINER IS + L_ITEM VARCHAR2(64) := LOWER(TRIM(P_ITEM)); + L_SQL VARCHAR2(32767); + L_TITLE VARCHAR2(200); + L_RESULT CLOB; + L_ROWS CLOB; + L_COLS CLOB; + L_CUR INTEGER; + L_COL_COUNT INTEGER; + L_DESC DBMS_SQL.DESC_TAB2; + L_VALUE VARCHAR2(4000); + L_ROW_COUNT NUMBER := 0; + L_TOP_N NUMBER := LEAST(GREATEST(NVL(P_TOP_N, 20), 1), 100); + L_EXEC INTEGER; + + FUNCTION ESC(P_TEXT VARCHAR2) RETURN VARCHAR2 IS + BEGIN + IF P_TEXT IS NULL THEN RETURN ''; END IF; + RETURN REPLACE(REPLACE(REPLACE(REPLACE(P_TEXT, '\', '\\'), '"', '\"'), CHR(13), ' '), CHR(10), ' '); + END; + + PROCEDURE APPEND_TEXT(P_TEXT VARCHAR2) IS + BEGIN + DBMS_LOB.WRITEAPPEND(L_RESULT, LENGTH(P_TEXT), P_TEXT); + END; +BEGIN + IF L_ITEM = 'capabilities' THEN + L_TITLE := 'Monitor capabilities'; + L_SQL := 'SELECT ''3.0'' function_version, ''active_slow_sql,history_top_sql,fullscan_sql,plan_heavy_sql,blocking_locks,long_transactions,inactive_sessions,session_counts,cache_hit,datafiles,undo,memory,io_waits,background_process,ora_errors,invalid_objects,tablespace,instance_status,awr_snapshots'' supported_items FROM dual'; + ELSIF L_ITEM = 'instance_status' THEN + L_TITLE := 'Instance status'; + L_SQL := 'SELECT i.status, d.open_mode, d.log_mode, TO_CHAR(i.startup_time,''YYYY-MM-DD HH24:MI:SS'') startup_time, i.instance_name FROM v$instance i CROSS JOIN v$database d'; + ELSIF L_ITEM = 'active_slow_sql' THEN + L_TITLE := 'Active slow SQL'; + L_SQL := 'SELECT * FROM (SELECT s.sid, s.serial# serial_no, s.username, s.machine, s.program, q.sql_id, SUBSTR(q.sql_text,1,500) sql_text, s.last_call_et elapsed_sec, s.status, s.event, s.wait_class FROM v$session s LEFT JOIN v$sql q ON s.sql_id=q.sql_id WHERE s.status=''ACTIVE'' AND s.type<>''BACKGROUND'' ORDER BY s.last_call_et DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'history_top_sql' THEN + L_TITLE := 'AWR top SQL'; + L_SQL := 'SELECT * FROM (SELECT s.sql_id, ROUND(SUM(s.elapsed_time_delta)/1000000,2) total_elapsed_s, ROUND(SUM(s.cpu_time_delta)/1000000,2) total_cpu_s, SUM(s.executions_delta) executions, SUBSTR(MAX(t.sql_text),1,500) sql_text FROM dba_hist_sqlstat s JOIN dba_hist_snapshot sn ON s.snap_id=sn.snap_id AND s.dbid=sn.dbid AND s.instance_number=sn.instance_number JOIN dba_hist_sqltext t ON s.sql_id=t.sql_id AND s.dbid=t.dbid WHERE sn.begin_interval_time>=SYSDATE-(NVL(' || NVL(P_MINUTES,60) || ',60)/1440) GROUP BY s.sql_id ORDER BY total_elapsed_s DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'fullscan_sql' THEN + L_TITLE := 'Possible full scan SQL'; + L_SQL := 'SELECT * FROM (SELECT sql_id, executions, rows_processed, SUBSTR(sql_text,1,500) sql_text FROM v$sql WHERE executions>100 AND LOWER(sql_text) LIKE ''%select%from%'' AND LOWER(sql_text) NOT LIKE ''%where%'' ORDER BY rows_processed DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'plan_heavy_sql' THEN + L_TITLE := 'High buffer SQL'; + L_SQL := 'SELECT * FROM (SELECT sql_id, plan_hash_value, buffer_gets, disk_reads, executions, ROUND(buffer_gets/DECODE(executions,0,1,executions),2) avg_buffer_gets FROM v$sql WHERE executions>0 ORDER BY buffer_gets DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'blocking_locks' THEN + L_TITLE := 'Blocking locks'; + L_SQL := 'SELECT * FROM (SELECT l.sid blocker_sid, s1.serial# blocker_serial, s1.username blocker_user, w.sid waiter_sid, s2.serial# waiter_serial, s2.username waiter_user, l.lmode locked_mode, s2.event wait_event FROM v$lock l LEFT JOIN v$session s1 ON l.sid=s1.sid LEFT JOIN v$lock w ON l.id1=w.id1 AND l.id2=w.id2 AND w.request>0 LEFT JOIN v$session s2 ON w.sid=s2.sid WHERE l.block=1) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'long_transactions' THEN + L_TITLE := 'Long transactions'; + L_SQL := 'SELECT * FROM (SELECT s.sid, s.serial# serial_no, s.username, t.start_time, t.used_ublk, t.used_urec, s.sql_id FROM v$transaction t JOIN v$session s ON t.ses_addr=s.saddr ORDER BY t.start_time) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'inactive_sessions' THEN + L_TITLE := 'Inactive sessions'; + L_SQL := 'SELECT * FROM (SELECT sid, serial# serial_no, username, machine, program, last_call_et FROM v$session WHERE status=''INACTIVE'' AND last_call_et>1800 ORDER BY last_call_et DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'session_counts' THEN + L_TITLE := 'Session counts'; + L_SQL := 'SELECT SUM(CASE WHEN status=''ACTIVE'' AND type<>''BACKGROUND'' THEN 1 ELSE 0 END) active_sessions, SUM(CASE WHEN type<>''BACKGROUND'' THEN 1 ELSE 0 END) total_sessions FROM v$session'; + ELSIF L_ITEM = 'cache_hit' THEN + L_TITLE := 'Cache hit ratio'; + L_SQL := 'SELECT ''BUFFER_CACHE'' metric, ROUND((1-pr.value/NULLIF(cg.value+bg.value,0))*100,2) hit_percent FROM v$sysstat pr,v$sysstat cg,v$sysstat bg WHERE pr.name=''physical reads'' AND cg.name=''consistent gets'' AND bg.name=''db block gets'' UNION ALL SELECT ''LIBRARY_CACHE'',ROUND(SUM(pinhits)/NULLIF(SUM(pins),0)*100,2) FROM v$librarycache'; + ELSIF L_ITEM = 'datafiles' THEN + L_TITLE := 'Datafiles'; + L_SQL := 'SELECT * FROM (SELECT file_name,tablespace_name,ROUND(bytes/1024/1024/1024,2) curr_gb,ROUND(maxbytes/1024/1024/1024,2) max_gb,ROUND(bytes/DECODE(maxbytes,0,bytes,maxbytes)*100,2) file_used_pct FROM dba_data_files WHERE maxbytes>0 ORDER BY file_used_pct DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'undo' THEN + L_TITLE := 'Undo usage'; + L_SQL := 'SELECT * FROM (SELECT tablespace_name,status,ROUND(SUM(bytes)/1024/1024/1024,2) gb FROM dba_undo_extents GROUP BY tablespace_name,status ORDER BY gb DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'memory' THEN + L_TITLE := 'SGA and PGA'; + L_SQL := 'SELECT metric,value_mb FROM (SELECT ''PGA_USED'' metric,ROUND(SUM(pga_used_mem)/1024/1024,2) value_mb FROM v$process UNION ALL SELECT ''PGA_ALLOC'',ROUND(SUM(pga_alloc_mem)/1024/1024,2) FROM v$process UNION ALL SELECT component,ROUND(current_size/1024/1024,2) FROM v$sga_dynamic_components) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'io_waits' THEN + L_TITLE := 'IO waits'; + L_SQL := 'SELECT * FROM (SELECT event,wait_class,total_waits,ROUND(time_waited/100,2) time_waited_sec,ROUND(average_wait/100,2) avg_wait_ms FROM v$system_event WHERE wait_class IN (''User I/O'',''System I/O'') ORDER BY time_waited_sec DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'background_process' THEN + L_TITLE := 'Background processes'; + L_SQL := 'SELECT * FROM (SELECT pname,status,program,spid FROM v$process WHERE background=1 ORDER BY pname) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'ora_errors' THEN + L_TITLE := 'Recent ORA errors'; + L_SQL := 'SELECT * FROM (SELECT originating_timestamp origin_timestamp,SUBSTR(message_text,1,1000) message_text FROM v$diag_alert_ext WHERE originating_timestamp>=SYSTIMESTAMP-NUMTODSINTERVAL(NVL(' || NVL(P_MINUTES,60) || ',60),''MINUTE'') AND message_text LIKE ''%ORA-%'' ORDER BY originating_timestamp DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'invalid_objects' THEN + L_TITLE := 'Invalid objects'; + L_SQL := 'SELECT * FROM (SELECT object_type,owner,object_name,status FROM dba_objects WHERE status<>''VALID'' ORDER BY owner,object_type,object_name) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'tablespace' THEN + L_TITLE := 'Tablespace usage'; + L_SQL := 'SELECT * FROM (SELECT df.tablespace_name,ROUND(df.total_bytes/1024/1024/1024,2) total_gb,ROUND((df.total_bytes-NVL(fs.free_bytes,0))/1024/1024/1024,2) used_gb,ROUND(NVL(fs.free_bytes,0)/1024/1024/1024,2) free_gb,ROUND((df.total_bytes-NVL(fs.free_bytes,0))/df.total_bytes*100,2) used_pct FROM (SELECT tablespace_name,SUM(bytes) total_bytes FROM dba_data_files GROUP BY tablespace_name) df LEFT JOIN (SELECT tablespace_name,SUM(bytes) free_bytes FROM dba_free_space GROUP BY tablespace_name) fs ON df.tablespace_name=fs.tablespace_name ORDER BY used_pct DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSIF L_ITEM = 'awr_snapshots' THEN + L_TITLE := 'AWR snapshots'; + L_SQL := 'SELECT * FROM (SELECT snap_id,dbid,instance_number,begin_interval_time,end_interval_time FROM dba_hist_snapshot ORDER BY snap_id DESC) WHERE ROWNUM<=' || L_TOP_N; + ELSE + RETURN '{"success":false,"item":"' || ESC(L_ITEM) || '","title":"Unknown","status":"failed","columns":[],"rows":[],"warnings":[],"error":"unknown item"}'; + END IF; + + DBMS_LOB.CREATETEMPORARY(L_RESULT, TRUE); + DBMS_LOB.CREATETEMPORARY(L_ROWS, TRUE); + DBMS_LOB.CREATETEMPORARY(L_COLS, TRUE); + L_CUR := DBMS_SQL.OPEN_CURSOR; + DBMS_SQL.PARSE(L_CUR, L_SQL, DBMS_SQL.NATIVE); + DBMS_SQL.DESCRIBE_COLUMNS2(L_CUR, L_COL_COUNT, L_DESC); + FOR I IN 1..L_COL_COUNT LOOP + DBMS_SQL.DEFINE_COLUMN(L_CUR, I, L_VALUE, 4000); + IF I > 1 THEN DBMS_LOB.WRITEAPPEND(L_COLS, 1, ','); END IF; + DBMS_LOB.WRITEAPPEND(L_COLS, LENGTH(L_DESC(I).COL_NAME) + 2, '"' || ESC(L_DESC(I).COL_NAME) || '"'); + END LOOP; + L_EXEC := DBMS_SQL.EXECUTE(L_CUR); + WHILE DBMS_SQL.FETCH_ROWS(L_CUR) > 0 LOOP + L_ROW_COUNT := L_ROW_COUNT + 1; + IF L_ROW_COUNT > 1 THEN DBMS_LOB.WRITEAPPEND(L_ROWS, 1, ','); END IF; + DBMS_LOB.WRITEAPPEND(L_ROWS, 1, '['); + FOR I IN 1..L_COL_COUNT LOOP + DBMS_SQL.COLUMN_VALUE(L_CUR, I, L_VALUE); + IF I > 1 THEN DBMS_LOB.WRITEAPPEND(L_ROWS, 1, ','); END IF; + L_VALUE := '"' || ESC(SUBSTR(L_VALUE, 1, 1000)) || '"'; + DBMS_LOB.WRITEAPPEND(L_ROWS, LENGTH(L_VALUE), L_VALUE); + END LOOP; + DBMS_LOB.WRITEAPPEND(L_ROWS, 1, ']'); + END LOOP; + DBMS_SQL.CLOSE_CURSOR(L_CUR); + + APPEND_TEXT('{"success":true,"item":"' || ESC(L_ITEM) || '","title":"' || ESC(L_TITLE) || '","status":"normal","columns":['); + DBMS_LOB.APPEND(L_RESULT, L_COLS); + APPEND_TEXT('],"rows":['); + DBMS_LOB.APPEND(L_RESULT, L_ROWS); + APPEND_TEXT('],"warnings":[]}'); + RETURN L_RESULT; +EXCEPTION + WHEN OTHERS THEN + IF DBMS_SQL.IS_OPEN(L_CUR) THEN DBMS_SQL.CLOSE_CURSOR(L_CUR); END IF; + RETURN '{"success":false,"item":"' || ESC(L_ITEM) || '","title":"' || ESC(NVL(L_TITLE,L_ITEM)) || '","status":"failed","columns":[],"rows":[],"warnings":[],"error":"' || ESC(SQLERRM) || '"}'; +END; +/ +GRANT EXECUTE ON SYS.HENLO_ORA_MONITOR TO ; diff --git a/scripts/config.template.json b/scripts/config.template.json new file mode 100644 index 0000000..864a00e --- /dev/null +++ b/scripts/config.template.json @@ -0,0 +1,10 @@ +{ + "transit_url": "https://ts.henlo.net", + "server_id": "", + "access_token": "", + "expires_at": "", + "user_name": "", + "client_code": "", + "client_title": "", + "client_list": [] +} diff --git a/scripts/oracle_skill.py b/scripts/oracle_skill.py new file mode 100644 index 0000000..1bf8ef9 --- /dev/null +++ b/scripts/oracle_skill.py @@ -0,0 +1,3501 @@ +#!/usr/bin/env python3 +""" +Oracle Jump Query - AI Skill (Improved Version) +通过中转服务查询远程 Oracle 存储过程元数据 + +改进点: +1. 增加重试机制(可配置重试次数) +2. 增加详细日志输出(方便诊断问题) +3. 超时时间可配置 +4. 增加 Agent 状态检查 +5. 增加请求超时分级(快速命令 vs 慢速命令) +""" + +import json +import os +import sys +import time +import logging +import io +import re +import html +import requests +from typing import Optional, Dict, Any, List, Tuple + +import argparse +from datetime import datetime, timezone + +# 修复 Windows 控制台 UTF-8 输出 +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +# ============================================================ +# 配置(修改为你的中转服务地址和认证信息) +# ============================================================ +TRANSIT_URL = "https://ts.henlo.net" +AUTH_TOKEN = "" +DEFAULT_SERVER_ID = "server-001" +DEFAULT_TIMEOUT = 60 # 默认超时(秒) +MAX_RETRIES = 3 # 最大重试次数 +RETRY_DELAY = 2 # 重试延迟(秒) +BOS_API_URL = "http://bos.henlo.net/servlet/doserverdata" # BOS 统一接口地址 +MAX_AWR_DOWNLOAD_BYTES = 50 * 1024 * 1024 +AWR_DATE_PATTERN = re.compile(r"^\d{8}$") + +# ============================================================ +# 版本号 +# ============================================================ +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SKILL_DIR = os.path.dirname(SCRIPT_DIR) +VERSION_FILE = os.path.join(SKILL_DIR, "VERSION") + +NO_PROXY_HOSTS = "bos.henlo.net,124.220.155.147,127.0.0.1,localhost" +for _proxy_key in ("NO_PROXY", "no_proxy"): + _existing_no_proxy = os.environ.get(_proxy_key, "") + _items = [item.strip() for item in _existing_no_proxy.split(",") if item.strip()] + for _host in NO_PROXY_HOSTS.split(","): + if _host not in _items: + _items.append(_host) + os.environ[_proxy_key] = ",".join(_items) + + +def get_version(): + """读取 VERSION 文件中的版本号""" + try: + with open(VERSION_FILE, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception: + return "0.0.0" + + +VERSION = get_version() + +# 表空间查询 SQL(需要 SELECT_CATALOG_ROLE 或 DBA 权限,建议 DBA 授权) +TABLESPACE_SQL_DBA = r""" +SELECT + df.tablespace_name AS "表空间名称", + ROUND(df.total_bytes / 1048576, 1) AS "总大小MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / 1048576, 1) AS "已用MB", + ROUND(NVL(fs.free_bytes, 0) / 1048576, 1) AS "剩余MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / df.total_bytes * 100, 1) AS "使用率%", + df.autoextensible AS "自动拓展", + ROUND(df.max_bytes / 1048576, 1) AS "最大可拓展MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / NULLIF(df.max_bytes, 0) * 100, 1) AS "最大使用率%" +FROM ( + SELECT + tablespace_name, + SUM(bytes) AS total_bytes, + SUM(CASE WHEN autoextensible = 'YES' THEN maxbytes ELSE bytes END) AS max_bytes, + MAX(CASE WHEN autoextensible = 'YES' THEN 'YES' ELSE 'NO' END) AS autoextensible + FROM dba_data_files + GROUP BY tablespace_name +) df +LEFT JOIN ( + SELECT tablespace_name, SUM(bytes) AS free_bytes + FROM dba_free_space + GROUP BY tablespace_name +) fs ON df.tablespace_name = fs.tablespace_name +ORDER BY 6 DESC +""" + +# 表空间查询回退 SQL(仅 USER_FREE_SPACE,始终可用) +TABLESPACE_SQL_FREE = """ +SELECT tablespace_name AS "表空间名称", + ROUND(SUM(bytes) / 1048576, 2) AS "剩余MB" +FROM user_free_space +GROUP BY tablespace_name +ORDER BY tablespace_name +""" + +# DBA 授权 VIEW 名称:DBA 执行一次授权 SQL 后即可使用 +DBA_TS_VIEW = "HENLO_TS_USAGE" +DBA_TS_VIEW_FULL = f"sys.{DBA_TS_VIEW}" +TABLESPACE_SQL_DBA_VIEW = f"""SELECT * FROM {DBA_TS_VIEW}""" +TABLESPACE_SQL_DBA_VIEW_FULL = f"""SELECT * FROM {DBA_TS_VIEW_FULL}""" + +# 尝试使用 DBA 创建的专用函数(返回 SYS_REFCURSOR) +TABLESPACE_SQL_DBA_FUNC = """SELECT sys.get_tablespace_usage FROM DUAL""" + +_TS_FUNC_COLUMNS = ["表空间名称", "总大小MB", "已用MB", "剩余MB", "使用率%"] +_TS_FUNC_RE = re.compile(r'\[(\w+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\]') + +# 表空间使用率字段索引(完整 8 列表) +_TS_COL_NAMES = 0 +_TS_COL_TOTAL = 1 +_TS_COL_USED = 2 +_TS_COL_FREE = 3 +_TS_COL_PCT = 4 +_TS_COL_AUTO = 5 +_TS_COL_MAX = 6 +_TS_COL_MAXPCT = 7 + + +def _check_ts_warnings(data_str: str) -> bool: + """ + 智能检查表空间告警: + - 优先按表解析( | ... | 格式的 markdown 行) + - 自动扩展(YES)→ 看最大使用率%列 + - 非自动扩展(NO)→ 看当前使用率%列 + - 若解析失败则回退到字符串模糊匹配 + - 回退路径忽略最大使用率%列,仅匹配当前使用率%列 + - 函数查询路径(5 列)一律匹配第 5 列(使用率%) + """ + has_warning = False + records = [] # (名称, 有效使用率) + for line in data_str.split("\n"): + line = line.strip() + if not line.startswith("|") or line.startswith("|---"): + continue + cells = [c.strip() for c in line.split("|")] + cells = [c for c in cells if c] # 去掉首尾空单元格 + if len(cells) >= 5: + name = cells[_TS_COL_NAMES] + if len(cells) >= 8: + # 完整 8 列表(VIEW / DBA 路径) + if cells[_TS_COL_AUTO] == "YES": + try: + rate = float(cells[_TS_COL_MAXPCT]) + records.append((name, rate)) + if rate >= 95: + has_warning = True + except ValueError: + pass + else: + try: + rate = float(cells[_TS_COL_PCT]) + records.append((name, rate)) + if rate >= 95: + has_warning = True + except ValueError: + pass + elif len(cells) == 5: + # 函数查询 5 列表 + try: + rate = float(cells[_TS_COL_PCT]) + records.append((name, rate)) + if rate >= 95: + has_warning = True + except ValueError: + pass + if has_warning: + print("⚠️ 警告:以下表空间使用率过高,请尽快扩容!") + # 对已触发告警的记录排序输出 + warned = [(n, r) for n, r in records if r >= 95] + warned.sort(key=lambda x: -x[1]) + for n, r in warned: + print(f" 🔴 {n}: {r}%") + return has_warning + +# 给 DBA 的授权 SQL(仅在无权限时输出) +DBA_GRANT_SQL = f'''-- ════════════════════════════════════════════════════════ +-- 以下 SQL 请交给 DBA 在 Oracle 中执行一次 +-- 作用:创建一个授权视图,让普通用户 bosnds3 也能查询表空间完整信息 +-- 此视图使用定义者权限(默认),以 DBA 身份运行,不会暴露其他数据 +-- ════════════════════════════════════════════════════════ + +CREATE OR REPLACE VIEW {DBA_TS_VIEW} AS +SELECT + df.tablespace_name AS "表空间名称", + ROUND(df.total_bytes / 1048576, 1) AS "总大小MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / 1048576, 1) AS "已用MB", + ROUND(NVL(fs.free_bytes, 0) / 1048576, 1) AS "剩余MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / df.total_bytes * 100, 1) AS "使用率%", + df.autoextensible AS "自动拓展", + ROUND(df.max_bytes / 1048576, 1) AS "最大可拓展MB", + ROUND((df.total_bytes - NVL(fs.free_bytes, 0)) / NULLIF(df.max_bytes, 0) * 100, 1) AS "最大使用率%" +FROM ( + SELECT + tablespace_name, + SUM(bytes) AS total_bytes, + SUM(CASE WHEN autoextensible = 'YES' THEN maxbytes ELSE bytes END) AS max_bytes, + MAX(CASE WHEN autoextensible = 'YES' THEN 'YES' ELSE 'NO' END) AS autoextensible + FROM dba_data_files + GROUP BY tablespace_name +) df +LEFT JOIN ( + SELECT tablespace_name, SUM(bytes) AS free_bytes + FROM dba_free_space + GROUP BY tablespace_name +) fs ON df.tablespace_name = fs.tablespace_name +ORDER BY 6 DESC; + +GRANT SELECT ON {DBA_TS_VIEW_FULL} TO bosnds3; +''' + + +def _parse_func_result(data_str): + """从 SYS_REFCURSOR 返回的 Go 结构体中提取表格数据""" + matches = _TS_FUNC_RE.findall(data_str) + if not matches: + return None + # 构建 Markdown 表格 + header = "| " + " | ".join(_TS_FUNC_COLUMNS) + " |" + sep = "|---" * len(_TS_FUNC_COLUMNS) + "|" + rows = [] + for m in matches: + row = "| " + " | ".join(m) + " |" + rows.append(row) + return header + "\n" + sep + "\n" + "\n".join(rows) + + +def query_tablespace(server_id): + """ + 查询表空间使用情况,按优先级尝试四种方式: + 1. DBA_DATA_FILES 直接查询(需 SELECT_CATALOG_ROLE) + 2. DBA 创建的专用函数 get_tablespace_usage(返回 SYS_REFCURSOR) + 3. DBA 授权的专用 VIEW HENLO_TS_USAGE + 4. USER_FREE_SPACE 简单查询(始终可用,仅剩余空间) + """ + schema = "BOSNDS3" + + # 第 1 步:尝试 DBA 查询 + result = query(server_id, "execute_query", schema, "", + timeout=DEFAULT_TIMEOUT, max_retries=1, + sql=TABLESPACE_SQL_DBA) + if result.get("success"): + return result + + err = (result.get("error") or "").lower() + is_perm_error = "ora-00942" in err or "insufficient privileges" in err + if not is_perm_error: + return result + + # 第 2 步:尝试 DBA 创建的专用函数(返回 SYS_REFCURSOR) + logger.info("DBA 视图不可用,尝试 DBA 专用函数...") + result_func = query(server_id, "execute_query", schema, "", + timeout=DEFAULT_TIMEOUT, max_retries=1, + sql=TABLESPACE_SQL_DBA_FUNC) + if result_func.get("success"): + raw_data = result_func.get("data", "") + parsed = _parse_func_result(raw_data) + if parsed: + result_func["_via_func"] = True + result_func["data"] = parsed + return result_func + else: + # 解析失败但数据可用,保留原始数据 + logger.info("函数返回了数据但格式无法解析,保留原始输出") + result_func["_via_func"] = True + result_func["_parse_failed"] = True + return result_func + + # 第 3 步:尝试 DBA 授权 VIEW(无 schema 前缀) + logger.info("DBA 函数不可用,尝试 DBA 授权 VIEW...") + result2 = query(server_id, "execute_query", schema, "", + timeout=DEFAULT_TIMEOUT, max_retries=1, + sql=TABLESPACE_SQL_DBA_VIEW) + if result2.get("success"): + result2["_via_view"] = True + return result2 + + # 第 3b 步:尝试 DBA 授权 VIEW(带 sys. 前缀,某些库可能创建在 SYS 下) + logger.info("VIEW 无前缀不可用,尝试 sys. 前缀...") + result2b = query(server_id, "execute_query", schema, "", + timeout=DEFAULT_TIMEOUT, max_retries=1, + sql=TABLESPACE_SQL_DBA_VIEW_FULL) + if result2b.get("success"): + result2b["_via_view"] = True + return result2b + + # 第 4 步:全部失败,输出授权 SQL + 回退到 USER_FREE_SPACE + logger.info("所有 DBA 方案不可用,回退到 USER_FREE_SPACE") + _print_dba_grant_sql() + + result3 = query(server_id, "execute_query", schema, "", + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES, + sql=TABLESPACE_SQL_FREE) + if result3.get("success"): + result3["_fallback"] = True + return result3 + + +def _print_dba_grant_sql(): + """打印给 DBA 执行的授权 SQL 和说明""" + print("\n" + "═" * 70) + print("⚠️ 当前 Oracle 用户无 DBA 系统视图权限,无法获取完整表空间信息。") + print("\n请将以下 SQL 交给 DBA 执行一次:") + print("─" * 70) + print(DBA_GRANT_SQL) + print("─" * 70) + print("📋 DBA 执行授权后,再次查询即可获得完整信息。") + print("=" * 70) + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +def get_script_dir(): + """获取脚本自身所在目录""" + return os.path.dirname(os.path.abspath(__file__)) + + +def get_config(): + """Load local runtime config, creating it from the checked-in template when absent.""" + cfg_path = os.path.join(get_script_dir(), "config.json") + template_path = os.path.join(get_script_dir(), "config.template.json") + for path in (cfg_path, "config.json"): + try: + with open(path, "r", encoding="utf-8-sig") as f: + return json.load(f) + except FileNotFoundError: + continue + except json.JSONDecodeError as exc: + logger.warning("Invalid local config %s: %s", path, exc) + break + cfg = {"transit_url": TRANSIT_URL, "server_id": "", "access_token": "", "expires_at": "", "user_name": "", "client_code": "", "client_title": "", "client_list": []} + try: + with open(template_path, "r", encoding="utf-8-sig") as f: + cfg.update(json.load(f)) + except (FileNotFoundError, json.JSONDecodeError): + pass + save_config(cfg) + return cfg + + +def save_config(cfg: dict): + """保存配置到脚本同目录的 config.json""" + cfg_path = os.path.join(get_script_dir(), "config.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + logger.info(f"配置已保存到 {cfg_path}") + + +def get_client_code(client: Dict[str, Any]) -> str: + """Return normalized client code from server response.""" + return str(client.get("code") or client.get("clientCode") or "").strip() + + +def get_client_title(client: Dict[str, Any]) -> str: + """Return normalized client title/name from server response.""" + return str(client.get("title") or client.get("name") or "").strip() + + +def normalize_client(client: Dict[str, Any]) -> Dict[str, Any]: + """Normalize client response while preserving status fields.""" + normalized = dict(client) + normalized["code"] = get_client_code(client) + normalized["title"] = get_client_title(client) + return normalized + + +def normalize_client_list(clients: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Normalize client list and drop entries without code.""" + result = [] + seen = set() + for client in clients or []: + normalized = normalize_client(client) + code = normalized.get("code", "") + if not code or code in seen: + continue + seen.add(code) + result.append(normalized) + return result + + +def merge_client_lists(base: List[Dict[str, Any]], updates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Merge client metadata by code, preserving title from either source.""" + merged: Dict[str, Dict[str, Any]] = {} + order: List[str] = [] + for client in normalize_client_list(base) + normalize_client_list(updates): + code = client.get("code", "") + if not code: + continue + if code not in merged: + merged[code] = {} + order.append(code) + merged[code].update(client) + if not merged[code].get("title"): + merged[code]["title"] = get_client_title(client) + return [merged[code] for code in order] + + +def sync_client_cache(cfg: Dict[str, Any], clients: List[Dict[str, Any]]) -> bool: + """Write refreshed clients into config and refresh current client title.""" + normalized = normalize_client_list(clients) + cfg["client_list"] = normalized + current_code = cfg.get("client_code") or cfg.get("server_id") + if current_code: + for client in normalized: + if client.get("code") == current_code: + cfg["client_code"] = client.get("code", current_code) + cfg["client_title"] = client.get("title", cfg.get("client_title", "")) + cfg["server_id"] = cfg["client_code"] + break + return True + + +def refresh_client_cache(cfg: Optional[Dict[str, Any]] = None, quiet: bool = False) -> List[Dict[str, Any]]: + """Refresh the authorized client list from transit server and persist it locally.""" + cfg = cfg or get_config() or {} + transit_url = cfg.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(cfg): + return [] + + clients: List[Dict[str, Any]] = normalize_client_list(cfg.get("client_list", [])) + + try: + resp = requests.get(f"{transit_url}/api/me", headers=make_headers(), timeout=10) + if resp.status_code == 401: + if not quiet: + print("❌ 中转机登录态已失效,请重新登录") + return clients + resp.raise_for_status() + me = resp.json() + if me.get("expires_at"): + cfg["expires_at"] = me.get("expires_at") + if me.get("user", {}).get("name"): + cfg["user_name"] = me.get("user", {}).get("name") + except requests.exceptions.RequestException as e: + if not quiet: + print(f"⚠️ 从 /api/me 刷新 client 失败: {e}") + + try: + resp = requests.get(f"{transit_url}/api/clients", headers=make_headers(), timeout=10) + if resp.status_code == 401: + if not quiet: + print("❌ 中转机登录态已失效,请重新登录") + return clients + resp.raise_for_status() + data = resp.json() + # /api/clients is authoritative for the current authorization snapshot. + # Replace instead of merging so revoked clients disappear from local cache. + clients = normalize_client_list(data.get("clients", [])) + except requests.exceptions.RequestException as e: + if not quiet: + print(f"⚠️ 从 /api/clients 刷新 client 失败: {e}") + return clients + + if sync_client_cache(cfg, clients): + save_config(cfg) + return clients + + +def find_client_matches(clients: List[Dict[str, Any]], selector: str) -> Tuple[List[Dict[str, Any]], str]: + """Find client by code/title/name. Exact matches win over fuzzy matches.""" + selector = (selector or "").strip() + if not selector: + return [], "empty" + normalized = normalize_client_list(clients) + needle = selector.lower() + + exact_code = [c for c in normalized if c.get("code", "").lower() == needle] + if exact_code: + return exact_code, "code" + + exact_title = [c for c in normalized if c.get("title", "").lower() == needle] + if exact_title: + return exact_title, "title" + + fuzzy = [] + seen = set() + for client in normalized: + code = client.get("code", "") + title = client.get("title", "") + if needle in code.lower() or needle in title.lower(): + if code not in seen: + seen.add(code) + fuzzy.append(client) + return fuzzy, "fuzzy" + + +def print_client_candidates(clients: List[Dict[str, Any]]): + """Print selectable client candidates.""" + for client in normalize_client_list(clients): + code = client.get("code", "") + title = client.get("title", "") + online = client.get("online") + authorized = client.get("authorized") + status = "" + if online is not None or authorized is not None: + online_text = "在线" if online else "离线" + auth_text = "已授权" if authorized else "未授权" + status = f" ({online_text}, {auth_text})" + print(f" - {code} ({title}){status}") + + +def ts_api_call(method: str, params: dict, timeout: int = 15) -> Dict[str, Any]: + """ + 调用 BOS 统一接口 + + Args: + method: 接口方法名(如 ts_login, ts_client_check) + params: 请求参数 + timeout: 超时时间(秒) + + Returns: + dict: {"code": 0/1, "success": True/False, "message": ..., "data": ...} + """ + payload = {"method": method, "params": params} + try: + logger.info(f"调用 BOS 接口: {method}") + resp = requests.post(BOS_API_URL, json=payload, timeout=timeout) + resp.raise_for_status() + result = resp.json() + logger.info(f"BOS 接口响应: code={result.get('code')}, success={result.get('success')}") + return result + except requests.exceptions.ConnectionError as e: + return {"code": 1, "success": False, "message": f"无法连接 BOS 接口: {e}", "data": None} + except requests.exceptions.Timeout: + return {"code": 1, "success": False, "message": f"BOS 接口超时({timeout}秒)", "data": None} + except Exception as e: + return {"code": 1, "success": False, "message": f"BOS 接口错误: {e}", "data": None} + + +def cmd_clients(): + """获取当前用户可访问 client 列表及在线状态(通过中转服务的 /api/clients 接口)""" + config = get_config() or {} + if not ensure_logged_in(config): + return + clients = refresh_client_cache(config) + if not clients: + print("没有可用的 client") + return + print(f"共 {len(clients)} 个 client:") + print("-" * 60) + for cl in clients: + code = cl.get("code", "?") + title = cl.get("title", "?") + online = cl.get("online", False) + authorized = cl.get("authorized", False) + status_icon = "[OK]" if online and authorized else ("[?]" if online else "[X]") + auth_text = "已授权" if authorized else "未授权" + online_text = "在线" if online else "离线" + print(f" {status_icon} {code} - {title} ({online_text}, {auth_text})") + print("-" * 60) + + +def get_server_status(client_code: str = "") -> Dict[str, Any]: + """Get server health status from transit server.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + if not client_code: + client_code = config.get("client_code", "") + if not client_code: + return {"success": False, "error": "clientCode is required"} + try: + resp = requests.get( + f"{transit_url}/api/server_status", + params={"client_code": client_code}, + headers=make_headers(), + timeout=DEFAULT_TIMEOUT + 5, + ) + if resp.status_code == 401: + return {"success": False, "error": "login required or token expired"} + if resp.status_code == 403: + try: + return {"success": False, "error": resp.json().get("error", resp.text)} + except Exception: + return {"success": False, "error": resp.text} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def cmd_checkup(client_code: str = ""): + """Print server health report as a Markdown table.""" + result = get_server_status(client_code) + if not result.get("success"): + print(f"Error: {result.get('error', 'unknown error')}") + return + data = result.get("data", {}) or {} + fields = [ + "clientCode", + "info_cpu_rate", + "info_memory_rate", + "info_memory", + "bak_path", + "bak_lastdate", + "computer", + ] + print("| " + " | ".join(fields) + " |") + print("| " + " | ".join(["---"] * len(fields)) + " |") + print("| " + " | ".join(str(data.get(f, "") or "") for f in fields) + " |") + if data.get("log_saved") is False: + print(f"\nNote: server log was not saved to BOS: {data.get('log_error', '')}") + + +def _load_sys_function_bundle(schema: str = "") -> str: + schema = (schema or "").strip() + if schema != "" and not re.fullmatch(r"[A-Za-z][A-Za-z0-9_$#]*", schema): + raise ValueError("schema must be a valid Oracle identifier") + root = os.path.join(SKILL_DIR, "references", "sys-functions") + sections = [] + for name in sorted(f for f in os.listdir(root) if f.lower().endswith(".sql")): + with open(os.path.join(root, name), "r", encoding="utf-8-sig") as f: + sections.append(f"-- ===== {name} =====\n" + f.read().replace("", schema).rstrip()) + return "\n\n".join(sections) + "\n" + + +def _load_sys_function_sql(filename: str, schema: str) -> str: + schema = (schema or "").strip().upper() + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_$#]*", schema): + raise ValueError("schema must be a valid Oracle identifier") + if filename != os.path.basename(filename) or not filename.lower().endswith(".sql"): + raise ValueError("invalid SYS function filename") + path = os.path.join(SKILL_DIR, "references", "sys-functions", filename) + with open(path, "r", encoding="utf-8-sig") as f: + return f.read().replace("", schema) + + +def _resolve_agent_schema(client_code: str) -> str: + version_result = query(client_code, "version", timeout=15) + version_data = version_result.get("data") or {} + if isinstance(version_data, str): + try: + version_data = json.loads(version_data) + except json.JSONDecodeError: + version_data = {} + schema = (version_data.get("schema") or "").strip().upper() + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_$#]*", schema): + return "" + return schema + + +def check_inspection_sys_functions(client_code: str) -> Dict[str, Any]: + result = query(client_code, "sys_function_check", timeout=30) + if not result.get("success"): + return {"success": False, "error": result.get("error", "SYS function check failed")} + data = result.get("data") or {} + if isinstance(data, str): + try: data = json.loads(data) + except json.JSONDecodeError: data = {} + if not data.get("success"): + schema = (data.get("schema") or "").strip() + if not schema: + schema = _resolve_agent_schema(client_code) + return { + "success": False, + "error": "SYS.HENLO_ORA_MONITOR is missing, incompatible, or not executable", + "agent_schema": schema, + "sys_function_check": data, + } + return {"success": True, "data": data} + + +def _inspection_grant_result(result: Dict[str, Any]) -> Dict[str, Any]: + schema = (result.get("agent_schema") or "").strip() + if not schema or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_$#]*", schema): + result["grant_sql_required"] = True + result["grant_sql"] = "" + result["error"] = "SYS function preflight failed and the Agent Oracle schema could not be determined" + result["next_step"] = "请检查 Agent oracle.schema 配置;Skill 不会输出包含 占位符的授权 SQL" + return result + result["grant_sql_required"] = True + result["agent_schema"] = schema.upper() + result["grant_sql"] = _load_sys_function_bundle(schema.upper()) + result["next_step"] = f"请由客户 DBA 使用 SYS 执行 grant_sql(授权账号 {schema.upper()}),完成后重新生成巡检报告" + return result + + +def inspection_report(client_code: str = "", timeout: int = 90, report_type: str = "manual") -> Dict[str, Any]: + """Generate and archive a server inspection report through transit server.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + if not client_code: + client_code = config.get("client_code", "") + if not client_code: + return {"success": False, "error": "clientCode is required"} + sys_check = check_inspection_sys_functions(client_code) + if not sys_check.get("success"): + return _inspection_grant_result(sys_check) + payload = { + "client_code": client_code, + "report_type": report_type, + "timeout": timeout, + } + try: + resp = requests.post( + f"{transit_url}/api/inspection_report", + json=payload, + headers=make_headers(), + timeout=max(30, timeout + 15), + ) + if resp.status_code in (400, 401, 403, 404, 502, 503, 504): + try: + return {"success": False, "error": resp.json().get("error", resp.text), "status_code": resp.status_code} + except Exception: + return {"success": False, "error": resp.text, "status_code": resp.status_code} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def inspection_report_latest(client_code: str = "", refresh: bool = False) -> Dict[str, Any]: + """Read the latest archived inspection report, optionally refreshing it first.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + if not client_code: + client_code = config.get("client_code", "") + if not client_code: + return {"success": False, "error": "clientCode is required"} + if refresh: + sys_check = check_inspection_sys_functions(client_code) + if not sys_check.get("success"): + return _inspection_grant_result(sys_check) + params = {"client_code": client_code} + if refresh: + params["refresh"] = "true" + try: + resp = requests.get( + f"{transit_url}/api/inspection_report/latest", + params=params, + headers=make_headers(), + timeout=120 if refresh else DEFAULT_TIMEOUT + 5, + ) + if resp.status_code in (400, 401, 403, 404, 502, 503, 504): + try: + return {"success": False, "error": resp.json().get("error", resp.text), "status_code": resp.status_code} + except Exception: + return {"success": False, "error": resp.text, "status_code": resp.status_code} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def inspection_report_get(report_id: str) -> Dict[str, Any]: + """Read an archived inspection report by ID.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + report_id = str(report_id or "").strip() + if not report_id: + return {"success": False, "error": "report id is required"} + try: + resp = requests.get( + f"{transit_url}/api/inspection_report/{report_id}", + headers=make_headers(), + timeout=DEFAULT_TIMEOUT + 5, + ) + if resp.status_code in (400, 401, 403, 404, 502, 503, 504): + try: + return {"success": False, "error": resp.json().get("error", resp.text), "status_code": resp.status_code} + except Exception: + return {"success": False, "error": resp.text, "status_code": resp.status_code} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def _awr_client_code(client_code: str = "") -> str: + cfg = get_config() or {} + return (client_code or cfg.get("client_code") or cfg.get("server_id") or "").strip() + + +def awr_status(client_code: str = "") -> Dict[str, Any]: + """Read AWR readiness without triggering report generation.""" + cfg = get_config() or {} + if not ensure_logged_in(cfg): + return {"success": False, "error": "login required"} + client_code = _awr_client_code(client_code) + if not client_code: + return {"success": False, "error": "clientCode is required"} + try: + resp = requests.get( + f"{str(cfg.get('transit_url') or TRANSIT_URL).rstrip('/')}/api/awr/status", + params={"client_code": client_code}, + headers=make_headers(), + timeout=DEFAULT_TIMEOUT, + ) + if not resp.ok: + try: + return {"success": False, "error": resp.json().get("error", resp.text), "status_code": resp.status_code} + except Exception: + return {"success": False, "error": resp.text, "status_code": resp.status_code} + result = resp.json() + data = result.get("data") or {} + if isinstance(data, dict) and data.get("status") == "permission_required": + result["dba_sql_required"] = True + schema = _resolve_agent_schema(client_code) + if schema: + result["agent_schema"] = schema + result["dba_sql"] = _load_sys_function_sql("henlo_awr_export.sql", schema) + result["next_step"] = f"请由客户 DBA 在确认 Diagnostics Pack 授权后执行 dba_sql(授权账号 {schema}),Skill 不会自动执行" + else: + result["agent_schema"] = "" + result["dba_sql"] = "" + result["next_step"] = "请检查 Agent oracle.schema 配置;Skill 不会输出包含 占位符的 SQL" + return result + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def awr_list(client_code: str = "") -> Dict[str, Any]: + """List Agent-generated AWR reports without downloading their HTML.""" + cfg = get_config() or {} + if not ensure_logged_in(cfg): + return {"success": False, "error": "login required"} + client_code = _awr_client_code(client_code) + if not client_code: + return {"success": False, "error": "clientCode is required"} + try: + resp = requests.get( + f"{str(cfg.get('transit_url') or TRANSIT_URL).rstrip('/')}/api/awr/list", + params={"client_code": client_code}, + headers=make_headers(), + timeout=DEFAULT_TIMEOUT, + ) + if not resp.ok: + try: + return {"success": False, "error": resp.json().get("error", resp.text), "status_code": resp.status_code} + except Exception: + return {"success": False, "error": resp.text, "status_code": resp.status_code} + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def _safe_awr_client_part(client_code: str) -> str: + value = re.sub(r"[^A-Za-z0-9_-]+", "_", client_code).strip("_") + return value or "CLIENT" + + +def download_awr_report(client_code: str, date: str, output_path: str = "") -> Dict[str, Any]: + """Download the exact AWR HTML supplied by Agent through transit-server.""" + cfg = get_config() or {} + if not ensure_logged_in(cfg): + return {"success": False, "error": "login required"} + client_code = _awr_client_code(client_code) + date = (date or "").strip() + if not client_code: + return {"success": False, "error": "clientCode is required"} + if not AWR_DATE_PATTERN.fullmatch(date): + return {"success": False, "error": "date must use yyyyMMdd format"} + if not output_path: + out_dir = os.path.join(SKILL_DIR, "outputs") + os.makedirs(out_dir, exist_ok=True) + output_path = os.path.join(out_dir, f"AWR-{_safe_awr_client_part(client_code)}-{date}.html") + output_path = os.path.abspath(output_path) + parent = os.path.dirname(output_path) + if parent: + os.makedirs(parent, exist_ok=True) + temp_path = output_path + ".part" + try: + if os.path.exists(temp_path): + os.remove(temp_path) + except OSError as e: + return {"success": False, "error": str(e)} + total = 0 + try: + with requests.get( + f"{str(cfg.get('transit_url') or TRANSIT_URL).rstrip('/')}/api/awr/download", + params={"client_code": client_code, "date": date}, + headers=make_headers(), + timeout=120, + stream=True, + ) as resp: + if not resp.ok: + try: + error = resp.json().get("error", resp.text) + except Exception: + error = resp.text + return {"success": False, "error": error, "status_code": resp.status_code} + content_length = resp.headers.get("Content-Length", "") + if content_length.isdigit() and int(content_length) > MAX_AWR_DOWNLOAD_BYTES: + return {"success": False, "error": f"AWR report exceeds {MAX_AWR_DOWNLOAD_BYTES} bytes"} + with open(temp_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + total += len(chunk) + if total > MAX_AWR_DOWNLOAD_BYTES: + raise ValueError(f"AWR report exceeds {MAX_AWR_DOWNLOAD_BYTES} bytes") + f.write(chunk) + os.replace(temp_path, output_path) + return {"success": True, "client_code": client_code, "date": date, "path": output_path, "size": total} + except (requests.exceptions.RequestException, OSError, ValueError) as e: + try: + if os.path.exists(temp_path): + os.remove(temp_path) + except OSError: + pass + return {"success": False, "error": str(e)} + + +def cmd_awr_status(client_code: str = "", as_json: bool = False): + result = awr_status(client_code) + if as_json or not result.get("success"): + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + data = result.get("data") or {} + print(f"AWR status: {data.get('status', '-')}") + print(f"Client: {result.get('client_code') or _awr_client_code(client_code)}") + print(f"Available: {data.get('available', False)}") + print(f"Enabled: {data.get('enabled', False)}") + print(f"Diagnostics Pack confirmed: {data.get('license_confirmed', False)}") + if data.get("latest_date"): + print(f"Latest date: {data.get('latest_date')}") + if data.get("reason"): + print(f"Reason: {data.get('reason')}") + if result.get("dba_sql_required"): + if result.get("dba_sql"): + print(f"Agent schema: {result.get('agent_schema')}") + print("DBA SQL (manual execution only; Diagnostics Pack authorization must be confirmed first):") + print(result.get("dba_sql")) + print(f"Next step: {result.get('next_step', '')}") + + +def cmd_awr_list(client_code: str = "", as_json: bool = False): + result = awr_list(client_code) + if as_json or not result.get("success"): + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + rows = result.get("data") or [] + if not rows: + print("No AWR reports are available.") + return + print("DATE STATUS BEGIN_SNAP END_SNAP GENERATED_AT REASON") + for row in rows: + print(f"{row.get('date', '-'):<9} {row.get('status', '-'):<8} {str(row.get('begin_snap', '-')):<10} {str(row.get('end_snap', '-')):<8} {row.get('generated_at', '-')} {row.get('reason', '')}") + + +def cmd_awr_download(client_code: str, date: str, output_path: str = "", as_json: bool = False): + result = download_awr_report(client_code, date, output_path) + if as_json or not result.get("success"): + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + print(f"AWR report: {result['path']}") + print(f"Size: {result['size']} bytes") + + +def _inspection_report_payload(result: Dict[str, Any]) -> Dict[str, Any]: + data = result.get("data") or {} + if isinstance(data, dict) and data.get("report_json"): + report_json = data.get("report_json") + if isinstance(report_json, str): + try: + return json.loads(report_json) + except Exception: + return {} + if isinstance(report_json, dict): + return report_json + if isinstance(data, dict) and ("memory" in data or "disks" in data or "alerts" in data): + return data + return {} + + +def _fmt_report_number(value): + if value is None or value == "": + return "" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _html_escape(value) -> str: + if value is None: + return "" + return html.escape(str(value), quote=True) + + +def _pct(value) -> str: + if value is None or value == "": + return "" + try: + return f"{float(value):.2f}%" + except Exception: + return _html_escape(value) + + +def _gb(value) -> str: + if value is None or value == "": + return "" + try: + return f"{float(value):.2f}G" + except Exception: + return _html_escape(value) + + +def _tag(level: str, text: str = "") -> str: + level = str(level or "").lower() + if level in ("critical", "bad", "error", "failed"): + cls, label = "bad", text or "重大异常" + elif level in ("warning", "warn", "partial"): + cls, label = "warn", text or "普通异常" + elif level in ("pending", "unknown", "not_checked"): + cls, label = "pending", text or "待接入" + else: + cls, label = "ok", text or "正常" + return f'{_html_escape(label)}' + + +def _status_level_from_percent(value, warn=80, bad=90): + try: + n = float(value) + except Exception: + return "pending" + if n >= bad: + return "critical" + if n >= warn: + return "warning" + return "ok" + + +def _cache_hit_level(value): + try: + n = float(value) + except Exception: + return "pending" + if n < 80: + return "critical" + if n < 90: + return "warning" + return "ok" + + +def _render_process_summary(processes): + if not processes: + return "未返回关键进程状态", "pending" + rows = [] + missing = False + for process in processes: + if isinstance(process, str): + rows.append(_html_escape(process)) + continue + name = process.get("name") or "unknown" + running = bool(process.get("running")) + count = process.get("count", 0) + missing = missing or not running + label = f"{name}:{'运行中' if running else '未运行'}" + if running: + label += f"({count} 个)" + rows.append(_html_escape(label)) + return "
".join(rows), "warning" if missing else "ok" + + +def _find_ops(payload: Dict[str, Any], item_name: str) -> Dict[str, Any]: + for item in payload.get("oracle_ops") or []: + if isinstance(item, dict) and item.get("item") == item_name: + return item + return {} + + +def _ops_count(item: Dict[str, Any]) -> int: + rows = item.get("rows") if isinstance(item, dict) else [] + return len(rows or []) + + +def _render_ops_rows(item: Dict[str, Any], empty_text: str, max_rows: int = 6) -> str: + rows = item.get("rows") if isinstance(item, dict) else [] + if not rows: + return f'
' + columns = item.get("columns") or [] + html_rows = [] + for row in rows[:max_rows]: + data = {str(columns[i]).upper(): row[i] for i in range(min(len(columns), len(row)))} + session = f"{data.get('USERNAME', '')} / {data.get('SID', '')},{data.get('SERIAL_NO', '')}".strip(" /,") + detail = data.get("SQL_TEXT", "") + source = " / ".join(value for value in (data.get("MACHINE", ""), data.get("PROGRAM", "")) if value) + html_rows.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + return "\n".join(html_rows) + + +def _render_tablespace_cards(payload: Dict[str, Any]) -> str: + cards = [] + tablespace_item = _find_ops(payload, "tablespace") + datafile_item = _find_ops(payload, "datafiles") + datafile_columns = [str(value).upper() for value in (datafile_item.get("columns") or [])] + datafiles = {} + for row in datafile_item.get("rows") or []: + data = {datafile_columns[i]: row[i] for i in range(min(len(datafile_columns), len(row)))} + name = data.get("TABLESPACE_NAME", "") + if name and (name not in datafiles or float(data.get("MAX_GB") or 0) > float(datafiles[name].get("MAX_GB") or 0)): + datafiles[name] = data + columns = [str(value).upper() for value in (tablespace_item.get("columns") or [])] + for row in tablespace_item.get("rows") or []: + data = {columns[i]: row[i] for i in range(min(len(columns), len(row)))} + name = data.get("TABLESPACE_NAME") or (row[0] if row else "") + datafile = datafiles.get(name, {}) + used = data.get("USED_PCT", "") + max_gb = datafile.get("MAX_GB", "") + current_gb = data.get("TOTAL_GB", "") + used_gb = data.get("USED_GB", "") + free_gb = data.get("FREE_GB", "") + max_used = datafile.get("FILE_USED_PCT", "") + try: + extend_free = round(float(max_gb) - float(used_gb), 2) if max_gb != "" else "" + except Exception: + extend_free = "" + level_value = max_used if max_used != "" else used + level = _status_level_from_percent(level_value, 85, 95) + metrics = [("当前大小", f"{current_gb}G"), ("已用", f"{used_gb}G"), ("当前剩余", f"{free_gb}G"), + ("自动扩展上限", f"{max_gb}G" if max_gb != "" else "未返回"), + ("可扩展剩余", f"{extend_free}G" if extend_free != "" else "未返回"), + ("最大空间使用率", f"{level_value}%" if level_value != "" else "未返回")] + metric_html = "".join(f'
{_html_escape(k)}
{_html_escape(v)}
' for k, v in metrics) + advice = "当前未达到重大阈值,建议持续观察。" if level == "ok" else "空间使用率较高,建议评估扩容、清理历史数据或确认自动扩展上限。" + cards.append('
' f'
{_html_escape(name)}
{_tag(level)}
' f'
{metric_html}
' f'
{_html_escape(advice)}
') + if not cards: + return '
未返回表空间明细。
' + return "\n".join(cards) + + +def _render_alert_rows(payload: Dict[str, Any]) -> str: + alerts = payload.get("alerts") or [] + failed = payload.get("failed_items") or [] + rows = [] + for alert in alerts: + level = alert.get("level", "warning") if isinstance(alert, dict) else "warning" + title = alert.get("title", "异常") if isinstance(alert, dict) else str(alert) + desc = alert.get("desc", "") if isinstance(alert, dict) else "" + rows.append(f"") + for item in failed: + name = item.get("item", "巡检项") if isinstance(item, dict) else "巡检项" + err = item.get("error", "") if isinstance(item, dict) else str(item) + rows.append(f"") + if not rows: + rows.append(f"") + return "\n".join(rows) + + +def render_inspection_report_html(result: Dict[str, Any]) -> str: + payload = _inspection_report_payload(result) + record = result.get("data") if isinstance(result.get("data"), dict) else {} + client = result.get("client_code") or payload.get("client_code") or record.get("client_code") or get_server_id() + report_no = result.get("report_no") or record.get("report_no") or f"CHECK-{client}" + collected_at = payload.get("collected_at") or record.get("collected_at") or "" + server = payload.get("server") or {} + memory = payload.get("memory") or {} + disks = payload.get("disks") or [] + oracle = payload.get("oracle") or {} + connections = server.get("connections") or {} + process_summary, process_level = _render_process_summary(server.get("processes")) + cache_hit = oracle.get("cache_hit") or {} + buffer_cache = cache_hit.get("buffer_cache_percent") + library_cache = cache_hit.get("library_cache_percent") + alerts = payload.get("alerts") or [] + overall = "warning" if alerts or payload.get("failed_items") else "ok" + cpu_level = _status_level_from_percent(server.get("cpu_percent"), 80, 90) + mem_level = _status_level_from_percent(memory.get("used_percent"), 80, 90) + oracle_level = "ok" if oracle.get("online") else "critical" + blocking = _find_ops(payload, "blocking_locks") + long_tx = _find_ops(payload, "long_transactions") + slow = _find_ops(payload, "active_slow_sql") + ip_addresses = ", ".join(server.get("ip_addresses") or []) or "待采集" + os_text = " ".join(str(value) for value in (server.get("os"), server.get("os_version")) if value) or "待采集" + connection_text = "采集失败" + connection_level = "pending" + if connections.get("available"): + connection_text = "总计 {total};已建立 {established};监听 {listening};TIME_WAIT {time_wait};CLOSE_WAIT {close_wait}".format( + total=connections.get("total", 0), established=connections.get("established", 0), + listening=connections.get("listening", 0), time_wait=connections.get("time_wait", 0), + close_wait=connections.get("close_wait", 0)) + connection_level = "ok" + disk_rows = [] + disk_burst = "未爆满" + for disk in disks: + level = disk.get("status") or _status_level_from_percent(disk.get("used_percent"), 85, 95) + if level in ("warning", "critical", "bad"): + disk_burst = "存在风险" + disk_rows.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not disk_rows: + disk_rows.append(f"") + disk_rows.append(f"") + conclusion = "本次巡检未发现明显异常。" + if alerts: + conclusion = "本次巡检发现:" + ";".join(_html_escape(a.get("title", "")) for a in alerts if isinstance(a, dict)) + "。建议按异常明细处理。" + return f""" + + + + + 服务器巡检报告 + + +
+

服务器巡检报告

本报告由 Agent 自动采集服务器与 Oracle 数据库巡检结果后生成,用于运维归档、工单报备和客户沟通。
报告编号:{_html_escape(report_no)}
巡检时间:{_html_escape(collected_at)}
巡检方式:手动巡检
总体状态:{_tag(overall)}
+

一、基础信息

{_html_escape(empty_text)}
{_html_escape(data.get('ELAPSED_SEC', ''))} 秒{_html_escape(session)}{_html_escape(data.get('SQL_ID', ''))}{_html_escape(data.get('EVENT', ''))}{_tag('warning' if item.get('status') == 'warning' else 'ok')}{_html_escape(source)}
{_html_escape(detail)}
{_tag(level)}{_html_escape(title)}{_html_escape(desc)}按运维规范排查并持续观察。
{_tag('warning')}{_html_escape(name)}{_html_escape(err)}检查授权、网络或 Agent 日志后重试。
{_tag('ok')}未发现异常本次巡检未产生告警。保持观察。
{_html_escape(disk.get('drive'))}{_pct(disk.get('used_percent'))}{_gb(disk.get('used_gb'))} / {_gb(disk.get('free_gb'))}读 {_html_escape(disk.get('read_mb_s', 0))} MB/s
写 {_html_escape(disk.get('write_mb_s', 0))} MB/s
{_tag(level)}24h 使用率峰值 {_pct(disk.get('used_peak_24h_percent'))}。
未返回磁盘明细。
磁盘爆满检测{_html_escape(disk_burst)}普通:≥ 85%
重大:≥ 95%
-{_tag('warning' if disk_burst != '未爆满' else 'ok')}按各分区当前使用率和 24h 峰值判断。
客户名称{_html_escape(client)}服务器编号{_html_escape(client)}
服务器 IP{_html_escape(ip_addresses)}计算机名{_html_escape(server.get('computer') or '待采集')}
操作系统{_html_escape(os_text)}数据库状态{'在线' if oracle.get('online') else '异常'}
启动时间{_html_escape(server.get('boot_time') or '待采集')}运行时长{_html_escape(server.get('uptime') or '待采集')}
+

二、服务器资源巡检

2.1 基础运行状态
巡检项结果值预警规则状态说明
服务器在线状态{'在线' if server.get('online') else '离线'}离线即重大异常{_tag('ok' if server.get('online') else 'critical')}Agent WebSocket 在线,最近心跳正常。
CPU 使用率{_pct(server.get('cpu_percent'))}普通:≥ 80%
重大:≥ 90%
{_tag(cpu_level)}当前 CPU 使用率按 Agent 实时采集值判断。
运行时长{_html_escape(server.get('uptime') or '待采集')}采集失败时标记待接入{_tag('ok' if server.get('uptime') else 'pending')}启动时间:{_html_escape(server.get('boot_time') or '待采集')}。
关键进程{process_summary}任一配置进程未运行即异常{_tag(process_level)}检查项由 Agent 配置 inspection_processes 定义。
TCP 连接数{_html_escape(connection_text)}CLOSE_WAIT 持续增长需关注{_tag(connection_level)}{_html_escape(connections.get('error') or '来自 netstat -ano -p tcp 实时统计。')}
+
2.2 内存状态
巡检项结果值预警规则状态说明
实时内存使用率{_pct(memory.get('used_percent'))}普通:≥ 80%
重大:≥ 90%
{_tag(mem_level)}当前内存使用率按 Agent 实时采集值判断。
已用 / 剩余内存{_gb(memory.get('used_gb'))} / {_gb(memory.get('free_gb'))}剩余低于 10% 为重大异常{_tag(mem_level)}总内存 {_gb(memory.get('total_gb'))}。
内存峰值1h {_pct(memory.get('peak_1h_percent'))}
24h {_pct(memory.get('peak_24h_percent'))}
普通:≥ 85%
重大:≥ 95%
{_tag(_status_level_from_percent(memory.get('peak_24h_percent'),85,95))}来自 Agent 本地滚动指标缓存。
内存溢出检测{len(memory.get('oom_events') or [])} 条事件发现 OOM / 相关错误即异常{_tag('warning' if memory.get('oom_events') else 'ok')}按采集周期内内存异常事件汇总。
+
2.3 磁盘状态
{''.join(disk_rows)}
分区使用率已用 / 剩余空间读写速率状态说明
+

三、数据库巡检结果

3.1 基础状态
巡检项结果值预警阈值状态说明
数据库服务在线状态{'在线' if oracle.get('online') else '异常'}不可连接即重大异常{_tag(oracle_level)}{_html_escape(oracle.get('error') or 'Agent 可正常连接 Oracle,基础查询成功。')}
数据库会话数{_html_escape(oracle.get('active_sessions')) if oracle.get('active_sessions') is not None else '采集失败'} 个 ACTIVE
{_html_escape(oracle.get('total_sessions')) if oracle.get('total_sessions') is not None else '-'} 个用户会话
ACTIVE 普通:≥ 50
重大:≥ 100
{_tag(_status_level_from_percent(oracle.get('active_sessions'),50,100))}来源:{_html_escape(oracle.get('session_count_source') or '未采集')}。
Buffer Cache 命中率{_pct(buffer_cache) if buffer_cache is not None else '采集失败'}普通:< 90%
重大:< 80%
{_tag(_cache_hit_level(buffer_cache))}来自 v$sysstat。
Library Cache 命中率{_pct(library_cache) if library_cache is not None else '采集失败'}普通:< 90%
重大:< 80%
{_tag(_cache_hit_level(library_cache))}来自 v$librarycache。
事务运行状态长事务 {_ops_count(long_tx)} 条普通:≥ 1 条
重大:≥ 3 条
{_tag('warning' if _ops_count(long_tx) else 'ok')}按 Oracle 运维监控函数返回结果统计。
阻塞会话{_ops_count(blocking)} 条普通:≥ 1 条
重大:≥ 3 条
{_tag('warning' if _ops_count(blocking) else 'ok')}未返回记录时视为未发现阻塞。
+
3.2 慢 SQL 检测明细
慢 SQL 不只展示数量,还应保留 SQL_ID、会话、耗时、等待事件和 SQL 文本,便于后续定位来源对象或业务过程。
{_render_ops_rows(slow, '未发现当前慢 SQL。')}
巡检项用户/会话SQL_ID等待事件状态SQL 文本 / 溯源
+
3.3 表空间检测明细
表空间报告应列出当前空间和自动扩展上限。若数据文件允许自动扩展,异常判断优先参考“最大空间使用率”和“剩余可扩展空间”,避免只按当前大小误报。
{_render_tablespace_cards(payload)}
+
3.4 存储过程 / 自定义 SQL 巡检明细
如果巡检项来自自定义 SQL 或存储过程,需要展示执行对象、SQL/过程内容、返回字段和异常判定依据。
巡检名称执行对象返回结果状态SQL / 存储过程内容
Oracle 运维监控函数SYS.HENLO_ORA_MONITOR{'可执行' if payload.get('oracle_ops') else '未返回'}{_tag('ok' if payload.get('oracle_ops') else 'warning')}Agent 优先调用 SYS 下的巡检函数,失败时回退到当前 schema。
SELECT SYS.HENLO_ORA_MONITOR(:item, :minutes, :top_n) FROM DUAL
+

四、异常明细

{_render_alert_rows(payload)}
异常等级异常类型异常说明处理建议
+

五、巡检结论

{'普通异常' if overall == 'warning' else '正常'}
{conclusion}
+ +""" + + +def write_inspection_report_html(result: Dict[str, Any], path: str = "") -> str: + payload = _inspection_report_payload(result) + client = result.get("client_code") or payload.get("client_code") or get_server_id() + report_no = result.get("report_no") or f"inspection-report-{client}" + if not path: + out_dir = os.path.join(SKILL_DIR, "outputs") + os.makedirs(out_dir, exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", f"{report_no}-{client}") + path = os.path.join(out_dir, f"{safe_name}.html") + else: + parent = os.path.dirname(os.path.abspath(path)) + if parent: + os.makedirs(parent, exist_ok=True) + html_text = render_inspection_report_html(result) + with open(path, "w", encoding="utf-8") as f: + f.write(html_text) + return os.path.abspath(path) + + +def _markdown_escape(value) -> str: + if value is None: + return "" + return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "
").replace("\n", "
") + + +def render_inspection_report_markdown(result: Dict[str, Any]) -> str: + payload = _inspection_report_payload(result) + record = result.get("data") if isinstance(result.get("data"), dict) else {} + client = result.get("client_code") or payload.get("client_code") or record.get("client_code") or get_server_id() + report_no = result.get("report_no") or record.get("report_no") or f"CHECK-{client}" + collected_at = payload.get("collected_at") or record.get("collected_at") or "" + server = payload.get("server") or {} + memory = payload.get("memory") or {} + disks = payload.get("disks") or [] + oracle = payload.get("oracle") or {} + alerts = payload.get("alerts") or [] + failed = payload.get("failed_items") or [] + missing = payload.get("missing_permissions") or payload.get("missing_grants") or [] + connections = server.get("connections") or {} + cache_hit = oracle.get("cache_hit") or {} + process_items = [] + for process in server.get("processes") or []: + if isinstance(process, str): + process_items.append(process) + else: + process_items.append(f"{process.get('name', 'unknown')}={'运行中' if process.get('running') else '未运行'}({process.get('count', 0)})") + process_text = ";".join(process_items) or "未返回关键进程状态" + stale = result.get("stale") + overall = "普通异常" if alerts or failed else "正常" + + lines = [ + f"# 服务器巡检报告 - {_markdown_escape(client)}", + "", + f"- 报告编号:{_markdown_escape(report_no)}", + f"- 巡检时间:{_markdown_escape(collected_at)}", + f"- 报告类型:{'历史快照' if stale else '实时报告'}", + f"- 总体状态:{overall}", + "", + "## 一、基础信息", + "", + "| 项目 | 内容 |", + "| --- | --- |", + f"| 客户/服务器编号 | {_markdown_escape(client)} |", + f"| 计算机名 | {_markdown_escape(server.get('computer') or '待确认')} |", + f"| 服务器 IP | {_markdown_escape(', '.join(server.get('ip_addresses') or []) or '待采集')} |", + f"| 操作系统 | {_markdown_escape(' '.join(str(value) for value in (server.get('os'), server.get('os_version')) if value) or '待采集')} |", + f"| 启动时间 | {_markdown_escape(server.get('boot_time') or '待采集')} |", + f"| 服务器状态 | {'在线' if server.get('online') else '离线'} |", + f"| Oracle 状态 | {'在线' if oracle.get('online') else '异常'} |", + "", + "## 二、服务器资源巡检", + "", + "### 2.1 基础运行状态", + "", + "| 巡检项 | 结果值 | 状态 |", + "| --- | --- | --- |", + f"| 服务器在线状态 | {'在线' if server.get('online') else '离线'} | {'正常' if server.get('online') else '重大异常'} |", + f"| CPU 使用率 | {_markdown_escape(_pct(server.get('cpu_percent')))} | {_markdown_escape(_status_level_from_percent(server.get('cpu_percent'), 80, 90))} |", + f"| 运行时长 | {_markdown_escape(server.get('uptime') or '待采集')} | {'正常' if server.get('uptime') else '待接入'} |", + f"| 关键进程 | {_markdown_escape(process_text)} | {'正常' if process_items and all((p if isinstance(p, str) else p.get('running')) for p in (server.get('processes') or [])) else '异常或待采集'} |", + f"| TCP 连接数 | 总计 {_markdown_escape(connections.get('total', '-'))};已建立 {_markdown_escape(connections.get('established', '-'))};监听 {_markdown_escape(connections.get('listening', '-'))};TIME_WAIT {_markdown_escape(connections.get('time_wait', '-'))};CLOSE_WAIT {_markdown_escape(connections.get('close_wait', '-'))} | {'正常' if connections.get('available') else '采集失败'} |", + "", + "### 2.2 内存状态", + "", + "| 总量 | 已用 | 剩余 | 使用率 | 1h 峰值 | 24h 峰值 | OOM 事件 |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| {total} | {used} | {free} | {used_pct} | {peak1} | {peak24} | {oom} |".format( + total=_markdown_escape(_gb(memory.get("total_gb"))), + used=_markdown_escape(_gb(memory.get("used_gb"))), + free=_markdown_escape(_gb(memory.get("free_gb"))), + used_pct=_markdown_escape(_pct(memory.get("used_percent"))), + peak1=_markdown_escape(_pct(memory.get("peak_1h_percent"))), + peak24=_markdown_escape(_pct(memory.get("peak_24h_percent"))), + oom=len(memory.get("oom_events") or []), + ), + "", + "### 2.3 磁盘状态", + "", + "| 分区 | 总量 | 已用 | 剩余 | 使用率 | 24h 峰值 | 读速率 MB/s | 写速率 MB/s | 状态 |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + if disks: + for disk in disks: + lines.append( + "| {drive} | {total} | {used} | {free} | {used_pct} | {peak24} | {read} | {write} | {status} |".format( + drive=_markdown_escape(disk.get("drive")), + total=_markdown_escape(_gb(disk.get("total_gb"))), + used=_markdown_escape(_gb(disk.get("used_gb"))), + free=_markdown_escape(_gb(disk.get("free_gb"))), + used_pct=_markdown_escape(_pct(disk.get("used_percent"))), + peak24=_markdown_escape(_pct(disk.get("used_peak_24h_percent"))), + read=_markdown_escape(disk.get("read_mb_s", 0)), + write=_markdown_escape(disk.get("write_mb_s", 0)), + status=_markdown_escape(disk.get("status") or "unknown"), + ) + ) + else: + lines.append("| - | - | - | - | - | - | - | - | 未返回磁盘明细 |") + + lines.extend([ + "", + "## 三、数据库巡检结果", + "", + "| 巡检项 | 结果值 | 状态 |", + "| --- | --- | --- |", + f"| 数据库服务 | {'在线' if oracle.get('online') else '异常'} | {'正常' if oracle.get('online') else '重大异常'} |", + f"| 活跃会话数 | {_markdown_escape(oracle.get('active_sessions') if oracle.get('active_sessions') is not None else '采集失败')} | {_markdown_escape(_status_level_from_percent(oracle.get('active_sessions'), 50, 100))} |", + f"| 用户会话总数 | {_markdown_escape(oracle.get('total_sessions') if oracle.get('total_sessions') is not None else '采集失败')} | {_markdown_escape(oracle.get('session_count_source') or '未采集')} |", + f"| Buffer Cache 命中率 | {_markdown_escape(_pct(cache_hit.get('buffer_cache_percent')) if cache_hit.get('buffer_cache_percent') is not None else '采集失败')} | {_markdown_escape(_cache_hit_level(cache_hit.get('buffer_cache_percent')))} |", + f"| Library Cache 命中率 | {_markdown_escape(_pct(cache_hit.get('library_cache_percent')) if cache_hit.get('library_cache_percent') is not None else '采集失败')} | {_markdown_escape(_cache_hit_level(cache_hit.get('library_cache_percent')))} |", + f"| 长事务 | {_ops_count(_find_ops(payload, 'long_transactions'))} 条 | {'普通异常' if _ops_count(_find_ops(payload, 'long_transactions')) else '正常'} |", + f"| 阻塞会话 | {_ops_count(_find_ops(payload, 'blocking_locks'))} 条 | {'普通异常' if _ops_count(_find_ops(payload, 'blocking_locks')) else '正常'} |", + f"| 当前慢 SQL | {_ops_count(_find_ops(payload, 'active_slow_sql'))} 条 | {'普通异常' if _ops_count(_find_ops(payload, 'active_slow_sql')) else '正常'} |", + "", + "## 四、异常明细", + "", + "| 等级 | 类型 | 说明 |", + "| --- | --- | --- |", + ]) + if alerts or failed: + for alert in alerts: + if isinstance(alert, dict): + lines.append(f"| {_markdown_escape(alert.get('level', 'warning'))} | {_markdown_escape(alert.get('title') or alert.get('type') or '异常')} | {_markdown_escape(alert.get('desc') or alert.get('value') or '')} |") + else: + lines.append(f"| warning | 异常 | {_markdown_escape(alert)} |") + for item in failed: + if isinstance(item, dict): + lines.append(f"| warning | {_markdown_escape(item.get('item') or '巡检项')} | {_markdown_escape(item.get('error') or item.get('reason') or '')} |") + else: + lines.append(f"| warning | 巡检项 | {_markdown_escape(item)} |") + else: + lines.append("| normal | 未发现异常 | 本次巡检未产生告警。 |") + + lines.extend(["", "## 五、巡检结论", "", f"本次巡检总体状态:**{overall}**。"]) + if missing: + lines.extend(["", "### 缺失授权", ""]) + lines.extend(f"- {_markdown_escape(item)}" for item in missing) + lines.extend(["", "---", "", "生成来源:Oracle Jump Query Agent / 固定格式 Markdown 巡检报告", ""]) + return "\n".join(lines) + + +def write_inspection_report_markdown(result: Dict[str, Any], path: str = "") -> str: + payload = _inspection_report_payload(result) + client = result.get("client_code") or payload.get("client_code") or get_server_id() + report_no = result.get("report_no") or f"inspection-report-{client}" + if not path: + out_dir = os.path.join(SKILL_DIR, "outputs") + os.makedirs(out_dir, exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", f"{report_no}-{client}") + path = os.path.join(out_dir, f"{safe_name}.md") + else: + parent = os.path.dirname(os.path.abspath(path)) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(render_inspection_report_markdown(result)) + return os.path.abspath(path) + + +def _parse_report_cli_args(args): + as_json = "--json" in args + html_path = "" + markdown_path = None + clean = [] + i = 0 + while i < len(args): + arg = args[i] + if arg == "--json": + i += 1 + continue + if arg == "--html": + html_path = "" + if i + 1 < len(args) and not args[i + 1].startswith("--"): + html_path = args[i + 1] + i += 2 + continue + i += 1 + continue + if arg in ("--markdown", "--md"): + markdown_path = "" + if i + 1 < len(args) and not args[i + 1].startswith("--"): + markdown_path = args[i + 1] + i += 2 + continue + i += 1 + continue + clean.append(arg) + i += 1 + return clean, as_json, html_path, markdown_path + + +def _print_inspection_report(result: Dict[str, Any]): + if not result.get("success"): + print(f"Error: {result.get('error', 'unknown error')}") + if result.get("status_code"): + print(f"HTTP status: {result.get('status_code')}") + return + + payload = _inspection_report_payload(result) + record = result.get("data") if isinstance(result.get("data"), dict) else {} + client = result.get("client_code") or payload.get("client_code") or record.get("client_code") or get_server_id() + stale = result.get("stale") + title_suffix = "历史快照" if stale else "实时报告" + print(f"# 服务器巡检报告 - {client} ({title_suffix})") + print() + if result.get("report_no"): + print(f"- 报告号: {result.get('report_no')}") + if record.get("id"): + print(f"- 归档 ID: {_fmt_report_number(record.get('id'))}") + if payload.get("collected_at"): + print(f"- 采集时间: {payload.get('collected_at')}") + elif record.get("collected_at"): + print(f"- 采集时间: {record.get('collected_at')}") + if payload.get("duration_ms") is not None: + print(f"- 耗时: {payload.get('duration_ms')} ms") + if result.get("archive_saved") is not None: + print(f"- 已归档: {result.get('archive_saved')}") + if stale is not None: + print(f"- 历史快照: {stale}") + + memory = payload.get("memory") or {} + if memory: + print() + print("## 内存") + print("| 总量GB | 已用GB | 剩余GB | 使用率 | 1h峰值 | 24h峰值 |") + print("| --- | --- | --- | --- | --- | --- |") + print("| {total} | {used} | {free} | {used_pct}% | {peak1}% | {peak24}% |".format( + total=memory.get("total_gb", ""), + used=memory.get("used_gb", ""), + free=memory.get("free_gb", ""), + used_pct=memory.get("used_percent", ""), + peak1=memory.get("peak_1h_percent", ""), + peak24=memory.get("peak_24h_percent", ""), + )) + + disks = payload.get("disks") or [] + if disks: + print() + print("## 磁盘") + print("| 分区 | 总量GB | 已用GB | 剩余GB | 使用率 | 24h峰值 | 状态 |") + print("| --- | --- | --- | --- | --- | --- | --- |") + for disk in disks: + print("| {drive} | {total} | {used} | {free} | {used_pct}% | {peak24}% | {status} |".format( + drive=disk.get("drive", ""), + total=disk.get("total_gb", ""), + used=disk.get("used_gb", ""), + free=disk.get("free_gb", ""), + used_pct=disk.get("used_percent", ""), + peak24=disk.get("used_peak_24h_percent", ""), + status=disk.get("status", ""), + )) + + alerts = payload.get("alerts") or [] + if alerts: + print() + print("## 告警") + for alert in alerts: + title = alert.get("title") or alert.get("type") or "告警" + desc = alert.get("desc") or alert.get("value") or "" + level = alert.get("level") or "" + print(f"- [{level}] {title}: {desc}") + + failed = payload.get("failed_items") or [] + if failed: + print() + print("## 未完成项目") + for item in failed: + if isinstance(item, dict): + print(f"- {item.get('item', '')}: {item.get('error') or item.get('reason') or ''}") + else: + print(f"- {item}") + + missing = payload.get("missing_permissions") or payload.get("missing_grants") or [] + if missing: + print() + print("## 缺失授权") + for item in missing: + print(f"- {item}") + + oracle = payload.get("oracle") or {} + oracle_text = json.dumps(oracle, ensure_ascii=False).lower() + needs_sys = any(token in oracle_text for token in ("unknown item", "ora-00942", "henlo_ora_monitor", "instance status")) + if needs_sys: + _print_inspection_sys_guidance() + + +def _print_inspection_sys_guidance(): + """Show the DBA-applied SYS function artifacts when inspection grants are missing.""" + skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + sys_dir = os.path.join(skill_root, "references", "sys-functions") + files = ["henlo_ora_monitor.sql", "henlo_awr_export.sql"] + print() + print("## 需要 SYS 授权") + print("当前巡检检测到 Oracle SYS 函数或动态性能视图授权尚未完成。请由 DBA/SYS 手工执行以下留档 SQL,先将 替换为 Agent 实际登录账号。Skill 不会自动执行授权。") + print(f"SQL 留档目录: {sys_dir}") + for name in files: + path = os.path.join(sys_dir, name) + print(f"- {path}") + print("执行顺序:henlo_ora_monitor.sql;确认 Diagnostics Pack 授权后再执行 henlo_awr_export.sql。授权完成后重新生成巡检报告。") + + +def download_inspection_report_html(result: Dict[str, Any], path: str = "") -> str: + """Download the fixed HTML rendered by transit-server; Skill only stores it locally.""" + cfg = get_config() or {} + transit_url = str(cfg.get("transit_url") or get_transit_url() or TRANSIT_URL).rstrip("/") + html_url = str(result.get("html_url") or "") + if not html_url: + report_no = str(result.get("report_no") or result.get("data", {}).get("report_no") or "") + if report_no: + html_url = f"{transit_url}/api/inspection_report/{report_no}/html" + if html_url.startswith("/"): + html_url = transit_url + html_url + if not html_url: + raise RuntimeError("transit-server did not return html_url") + if result.get("archive_saved") is False: + raise RuntimeError("巡检报告尚未完成归档,暂时无法下载 HTML;请稍后重试") + response = None + last_error = "" + for attempt in range(5): + try: + response = requests.get(html_url, headers=make_headers(), timeout=30) + if response.status_code != 404: + response.raise_for_status() + break + last_error = "HTML archive is still pending" + except requests.RequestException as exc: + last_error = str(exc) + if attempt < 4: + time.sleep(1) + if response is None or response.status_code == 404: + raise RuntimeError(f"巡检报告 HTML 下载失败:{last_error},请确认 transit-server 已完成归档") + if not path: + out_dir = os.path.join(SKILL_DIR, "outputs") + os.makedirs(out_dir, exist_ok=True) + report_no = str(result.get("report_no") or "inspection-report") + path = os.path.join(out_dir, report_no + ".html") + else: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "wb") as f: + f.write(response.content) + return path + + +def cmd_agent_versions(client_codes=None, all_clients=False, as_json=False): + cfg = get_config() or {} + clients = refresh_client_cache(cfg, quiet=True) + if not clients: + result = {"success": False, "error": "无法获取客户列表:登录已失效或本地没有客户缓存,请先执行 login"} + print(json.dumps(result, ensure_ascii=False, indent=2) if as_json else result["error"]) + return + wanted = {str(c).upper() for c in (client_codes or [])} + rows = [] + for client in clients: + code = get_client_code(client) + if not all_clients and code.upper() not in wanted: + continue + row = {"client_code": code, "title": get_client_title(client), "version": client.get("version", ""), "status": "offline"} + try: + result = query(code, "version", "", "", timeout=15, max_retries=0) + if result.get("success"): + data = result.get("data") + if isinstance(data, str): + data = json.loads(data) + if isinstance(data, dict) and data.get("version"): + row["version"] = data["version"] + row["status"] = "online" + except Exception as exc: + row["error"] = str(exc) + rows.append(row) + if as_json: + print(json.dumps({"success": True, "servers": rows}, ensure_ascii=False, indent=2)) + else: + for row in rows: + print(f"{row['client_code']}: {row.get('version') or '-'} ({row['status']})") + + +def _output_inspection_report(result: Dict[str, Any], as_json: bool, html_path="", markdown_path=None): + if not result.get("success"): + if as_json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + _print_inspection_report(result) + return + + artifacts = {"html": download_inspection_report_html(result, html_path or "")} + if markdown_path is not None: + artifacts["markdown"] = write_inspection_report_markdown(result, markdown_path) + + if as_json: + output = dict(result) + output["artifacts"] = artifacts + print(json.dumps(output, ensure_ascii=False, indent=2)) + return + + print(f"HTML report: {artifacts['html']}") + if artifacts.get("markdown"): + print(f"Markdown report: {artifacts['markdown']}") + print() + _print_inspection_report(result) + + +def cmd_inspection_report(client_code: str = "", as_json: bool = False, timeout: int = 90, html_path="", markdown_path=None): + result = inspection_report(client_code, timeout=timeout) + _output_inspection_report(result, as_json, html_path, markdown_path) + + +def cmd_inspection_latest(client_code: str = "", as_json: bool = False, refresh: bool = False, html_path="", markdown_path=None): + result = inspection_report_latest(client_code, refresh=refresh) + _output_inspection_report(result, as_json, html_path, markdown_path) + + +def cmd_inspection_get(report_id: str, as_json: bool = False, html_path="", markdown_path=None): + result = inspection_report_get(report_id) + _output_inspection_report(result, as_json, html_path, markdown_path) + + +def get_upgrade_admin_token(cfg: Optional[Dict[str, Any]] = None) -> str: + """Get transit-server agent update admin token.""" + token = os.environ.get("ORACLE_JUMP_UPGRADE_ADMIN_TOKEN", "").strip() + if token: + return token + cfg = cfg or get_config() or {} + token = str(cfg.get("upgrade_admin_token", "") or "").strip() + if token: + return token + raise RuntimeError( + "Agent update requires ORACLE_JUMP_UPDATE_ADMIN_TOKEN or " + "agent_update_admin_token in the local config." + ) + + +def agent_update(client_code: str = "", timeout: int = 300) -> Dict[str, Any]: + """Trigger a remote agent self-update through transit-server.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + if not client_code: + client_code = config.get("client_code") or config.get("server_id", "") + if not client_code: + return {"success": False, "error": "clientCode is required"} + if timeout <= 0 or timeout > 600: + timeout = 300 + payload = {"server_id": client_code, "timeout": timeout} + headers = make_headers() + headers["X-Upgrade-Admin-Token"] = get_upgrade_admin_token(config) + try: + resp = requests.post( + f"{transit_url}/api/admin/agent_update/trigger", + json=payload, + headers=headers, + timeout=timeout + 15, + ) + if resp.status_code in (400, 401, 403, 404, 502, 503, 504): + try: + return {"success": False, "error": resp.json().get("error", resp.text), "status_code": resp.status_code} + except Exception: + return {"success": False, "error": resp.text, "status_code": resp.status_code} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def cmd_agent_update(client_code: str = "", timeout: int = 300): + """Print remote agent update trigger result.""" + result = agent_update(client_code, timeout) + if not result.get("success"): + print(f"Error: {result.get('error', 'unknown error')}") + if result.get("status_code"): + print(f"HTTP status: {result.get('status_code')}") + return + print("Agent update triggered.") + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +DEFAULT_OPS_REPORT_ITEMS = [ + "active_slow_sql", + "blocking_locks", + "long_transactions", + "tablespace", + "datafiles", + "undo", + "memory", + "io_waits", + "ora_errors", + "invalid_objects", +] + + +def oracle_ops(item: str, client_code: str = "", minutes: int = 60, top_n: int = 20, timeout: int = 30) -> Dict[str, Any]: + """Call transit server oracle ops endpoint.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + if not client_code: + client_code = config.get("client_code", "") + payload = { + "client_code": client_code, + "item": item, + "minutes": minutes, + "top_n": top_n, + "timeout": timeout, + } + try: + resp = requests.post(f"{transit_url}/api/oracle_ops", json=payload, headers=make_headers(), timeout=timeout + 5) + if resp.status_code in (400, 401, 403, 503): + try: + return {"success": False, "error": resp.json().get("error", resp.text)} + except Exception: + return {"success": False, "error": resp.text} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def oracle_ops_report(client_code: str = "", items=None, minutes: int = 60, top_n: int = 20, per_item_timeout: int = 30) -> Dict[str, Any]: + """Call transit server oracle ops report endpoint.""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + if not ensure_logged_in(config): + return {"success": False, "error": "login required"} + if not client_code: + client_code = config.get("client_code", "") + payload = { + "client_code": client_code, + "items": items or DEFAULT_OPS_REPORT_ITEMS, + "minutes": minutes, + "top_n": top_n, + "per_item_timeout": per_item_timeout, + } + try: + resp = requests.post( + f"{transit_url}/api/oracle_ops_report", + json=payload, + headers=make_headers(), + timeout=max(30, per_item_timeout * len(payload["items"]) + 10), + ) + if resp.status_code in (400, 401, 403, 503): + try: + return {"success": False, "error": resp.json().get("error", resp.text)} + except Exception: + return {"success": False, "error": resp.text} + resp.raise_for_status() + return resp.json() + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e)} + + +def _markdown_table(columns, rows): + if not columns: + return "未发现异常" + lines = [] + lines.append("| " + " | ".join(str(c) for c in columns) + " |") + lines.append("| " + " | ".join(["---"] * len(columns)) + " |") + for row in rows or []: + values = [str(v) if v is not None else "" for v in row] + while len(values) < len(columns): + values.append("") + lines.append("| " + " | ".join(values[:len(columns)]) + " |") + if len(lines) == 2: + lines.append("| " + " | ".join([""] * len(columns)) + " |") + return "\n".join(lines) + + +def _print_ops_item(data: Dict[str, Any]): + title = data.get("title") or data.get("item") or "Oracle 运维监控" + status = data.get("status", "") + print(f"## {title}") + if status: + print(f"状态: {status}") + if data.get("warnings"): + print("\n告警:") + for warning in data.get("warnings", []): + print(f"- {warning}") + if data.get("error"): + print(f"\n错误: {data.get('error')}") + print() + print(_markdown_table(data.get("columns", []), data.get("rows", []))) + if data.get("grant_sql_required") and data.get("grant_sql"): + print("\n### SYS 授权函数安装 SQL") + print("```sql") + print(data.get("grant_sql")) + print("```") + + +def cmd_ops(item: str, client_code: str = ""): + result = oracle_ops(item, client_code, timeout=60 if item == "history_top_sql" else 30) + if not result.get("success"): + print(f"Error: {result.get('error', 'unknown error')}") + return + _print_ops_item(result.get("data", {}) or {}) + + +def cmd_ops_report(client_code: str = ""): + result = oracle_ops_report(client_code) + if not result.get("success"): + print(f"Error: {result.get('error', 'unknown error')}") + return + client = result.get("client_code", client_code or get_server_id()) + summary = result.get("summary", {}) or {} + print(f"# Oracle 运维日报 - {client}") + print() + print("## 总览") + print(f"- 高风险: {summary.get('high', 0)}") + print(f"- 警告: {summary.get('warning', 0)}") + print(f"- 正常: {summary.get('normal', 0)}") + print(f"- 未完成: {summary.get('failed', 0)}") + for item in result.get("items", []) or []: + print() + _print_ops_item(item) + failed = result.get("failed_items", []) or [] + if failed: + print("\n## 未完成项目") + print("| item | reason |") + print("| --- | --- |") + for f in failed: + print(f"| {f.get('item', '')} | {f.get('error', '')} |") + if result.get("grant_sql_required") and result.get("grant_sql"): + print("\n## SYS 授权函数") + print("当前库缺少或无法执行 `SYS.HENLO_ORA_MONITOR` / 当前账号 `HENLO_ORA_MONITOR`。") + print("如果有 SYS 账号,优先按 Option A 安装;如果当前 Agent 登录账号已有 v$/dba_/AWR 查询权限,可按 Option B 安装到当前账号。") + print("```sql") + print(result.get("grant_sql")) + print("```") + + +def cmd_sys_functions(schema: str = "", as_json: bool = False): + """Print every SYS function SQL currently required by the skill.""" + schema = (schema or "").strip() + if schema != "" and not re.fullmatch(r"[A-Za-z][A-Za-z0-9_$#]*", schema): + raise ValueError("schema must be a valid Oracle identifier") + root = os.path.join(SKILL_DIR, "references", "sys-functions") + files = sorted(f for f in os.listdir(root) if f.lower().endswith(".sql")) + items = [] + for name in files: + with open(os.path.join(root, name), "r", encoding="utf-8-sig") as f: + items.append({"file": name, "sql": f.read().replace("", schema)}) + if as_json: + print(json.dumps({"success": True, "schema": schema, "files": items}, ensure_ascii=False, indent=2)) + return + print("-- Oracle Jump Query SYS function deployment bundle") + print(f"-- Grant target schema: {schema}") + print("-- Execute as SYS. This command only generates SQL.\n") + for item in items: + print(f"-- ===== {item['file']} =====") + print(item["sql"].rstrip() + "\n") + + +def cmd_login(secret_key: str, client_code: str = ""): + """ + 登录中转机并选择 client + + 流程: + 1. 调中转机 /api/login,由中转机调用 BOS ts_login + 2. 中转机验证 clientCode(客户服务器编号,可选)并签发 access_token + 3. 保存到 config.json + """ + cfg = get_config() or {} + transit_url = cfg.get("transit_url", TRANSIT_URL) + + try: + resp = requests.post( + f"{transit_url}/api/login", + json={"secret_key": secret_key, "client_code": client_code}, + timeout=20, + ) + if resp.status_code in (401, 403): + try: + err = resp.json().get("error", resp.text) + except Exception: + err = resp.text + print(f"❌ 登录失败: {err}") + return + resp.raise_for_status() + result = resp.json() + except requests.exceptions.RequestException as e: + print(f"❌ 登录失败,无法连接中转机: {e}") + return + + if not result.get("success"): + print(f"❌ 登录失败: {result.get('error', '未知错误')}") + return + + current_client = result.get("current_client", {}) + client_list = result.get("client_list", []) + access_token = result.get("access_token", "") + if not access_token: + print("❌ 登录失败: 中转机未返回 access_token") + return + + # 3. 保存配置 + cfg.pop("secret_key", None) + cfg["access_token"] = access_token + cfg["expires_at"] = result.get("expires_at", "") + cfg["user_name"] = result.get("user", {}).get("name", "") + cfg["client_code"] = current_client.get("code", client_code) + cfg["client_title"] = current_client.get("title", "") + cfg["client_list"] = client_list + cfg["server_id"] = cfg["client_code"] + save_config(cfg) + + print(f"✅ 登录成功") + print(f" 用户: {cfg.get('user_name', '')}") + print(f" 指向 client: {cfg['client_code']} ({cfg.get('client_title', '')})") + print(f" 过期时间: {cfg.get('expires_at', '')}") + if len(client_list) > 1: + print(f" 可切换 client: {', '.join(c['code'] for c in client_list)}") + + +def cmd_logout(): + """登出,清除登录信息""" + cfg = get_config() or {} + token = cfg.get("access_token", "") + transit_url = cfg.get("transit_url", TRANSIT_URL) + if token: + try: + requests.post(f"{transit_url}/api/logout", headers=make_headers(), timeout=10) + except Exception: + pass + # 保留 transit_url 和 server_id,清除登录相关 + keep_keys = {"transit_url", "server_id", "default_schema"} + new_cfg = {k: v for k, v in cfg.items() if k in keep_keys} + save_config(new_cfg) + print("✅ 已登出") + + +def cmd_status(): + """显示当前登录状态和选中的 client""" + config = get_config() or {} + transit_url = config.get("transit_url", TRANSIT_URL) + print("当前状态:") + print("-" * 40) + if not config.get("access_token"): + print(" 未登录,请使用 login [clientCode] 登录") + print("-" * 40) + return + + if is_token_expired(config): + print(" 登录已过期,请重新执行 login [clientCode]") + print(f" 过期时间:{config.get('expires_at', '')}") + print("-" * 40) + return + + try: + resp = requests.get(f"{transit_url}/api/me", headers=make_headers(), timeout=5) + if resp.status_code == 401: + print(" 中转机登录态已失效,请重新登录") + print("-" * 40) + return + resp.raise_for_status() + me = resp.json() + user = me.get("user", {}) + client_list = me.get("client_list", []) + if sync_client_cache(config, client_list): + if me.get("expires_at"): + config["expires_at"] = me.get("expires_at") + if user.get("name"): + config["user_name"] = user.get("name") + save_config(config) + print(f" 用户:{user.get('name', config.get('user_name', '未知'))}") + print(f" client:{config.get('client_code', '未选择')} ({config.get('client_title', '')})") + print(f" 过期时间:{me.get('expires_at', config.get('expires_at', ''))}") + if client_list: + print(f" 可用 client:") + for cl in client_list: + marker = " <--" if cl["code"] == config.get("client_code") else "" + print(f" - {cl['code']} ({cl.get('title', '')}){marker}") + except Exception as e: + print(f" 无法从中转机获取状态: {e}") + + # 显示 Agent 在线状态 + try: + resp = requests.get(f"{transit_url}/api/clients", headers=make_headers(), timeout=5) + if resp.status_code == 200: + data = resp.json() + if sync_client_cache(config, data.get("clients", [])): + save_config(config) + current_code = config.get("client_code", "") + for cl in data.get("clients", []): + if cl.get("code") == current_code: + online = "在线" if cl.get("online") else "离线" + auth = "已授权" if cl.get("authorized") else "未授权" + print(f" Agent 状态:{online} | {auth}") + break + except Exception: + pass # 静默失败 + print("-" * 40) + +def cmd_switch(client_selector: str): + """切换当前 client,支持 code 或 title/name 匹配。""" + cfg = get_config() + if not cfg: + print("❌ 未登录,请先使用 login 登录") + return + + if not ensure_logged_in(cfg): + return + + client_list = cfg.get("client_list", []) + matches, match_type = find_client_matches(client_list, client_selector) + + if not matches: + print(f"未在本地缓存中找到 client: {client_selector},正在刷新 client 列表...") + client_list = refresh_client_cache(cfg) + matches, match_type = find_client_matches(client_list, client_selector) + + if not matches: + print(f"❌ client '{client_selector}' 不在可用列表中") + print("可用 client:") + print_client_candidates(client_list) + return + + if len(matches) > 1: + print(f"⚠️ client '{client_selector}' 匹配到多个结果,请指定更准确的 client code:") + print_client_candidates(matches) + return + + matched_client = normalize_client(matches[0]) + cfg["client_code"] = matched_client["code"] + cfg["client_title"] = matched_client.get("title", "") + cfg["server_id"] = matched_client["code"] + sync_client_cache(cfg, client_list) + save_config(cfg) + print(f"✅ 已切换到 client: {matched_client['code']} ({matched_client.get('title', '')})") + + +def make_headers(): + """构建 HTTP 请求头""" + headers = {"Content-Type": "application/json"} + token = "" + cfg = get_config() + if cfg: + token = cfg.get("access_token", "") + if not token: + token = AUTH_TOKEN + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def parse_expires_at(value: str) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except Exception: + return None + + +def is_token_expired(cfg: dict) -> bool: + expires_at = parse_expires_at(cfg.get("expires_at", "")) + if not expires_at: + return False + now = datetime.now(expires_at.tzinfo or timezone.utc) + return now >= expires_at + + +def ensure_logged_in(cfg: Optional[dict] = None) -> bool: + cfg = cfg or get_config() or {} + if not cfg.get("access_token"): + print("❌ 未登录,请先执行: python oracle_skill.py login [clientCode]") + return False + if is_token_expired(cfg): + print("❌ 登录已过期,请重新执行: python oracle_skill.py login [clientCode]") + print(" 注意:同一个 secretKey 在其他设备重新登录后,本设备也需要重新登录。") + return False + return True + + +def get_transit_url(): + """获取中转服务 URL""" + cfg = get_config() + if cfg and "transit_url" in cfg: + return cfg["transit_url"] + return TRANSIT_URL + + +def get_server_id(): + """获取默认服务器 ID""" + cfg = get_config() + if cfg and "server_id" in cfg: + return cfg["server_id"] + return DEFAULT_SERVER_ID + + +def query(server_id: str, action: str, schema: str = "", name: str = "", + timeout: int = DEFAULT_TIMEOUT, max_retries: int = MAX_RETRIES, + sql: str = "") -> Dict[str, Any]: + """ + 发送查询请求到中转服务(带重试机制) + + Args: + server_id: 服务器 ID + action: 操作类型(analyze_procedure, list_procedures, 等等) + schema: Schema 名称 + name: 存储过程名称 + timeout: 超时时间(秒) + max_retries: 最大重试次数 + sql: SQL 语句(execute_query 用) + + Returns: + dict: {"success": True/False, "data": ..., "error": ...} + """ + if not ensure_logged_in(): + return {"success": False, "error": "login required"} + + url = f"{get_transit_url()}/api/query" + payload = { + "server_id": server_id, + "action": action, + "schema": schema, + "name": name, + "timeout": timeout, + } + if sql: + payload["sql"] = sql + + last_error = None + + for attempt in range(max_retries + 1): + try: + logger.info(f"尝试 {attempt + 1}/{max_retries + 1}: POST {url}") + logger.debug(f"Payload: {payload}") + + resp = requests.post( + url, + json=payload, + headers=make_headers(), + timeout=timeout + 3 # 额外 3 秒用于网络延迟(中转在 timeout 秒后即返回 504) + ) + + resp.raise_for_status() + result = resp.json() + + logger.info(f"请求成功: {result.get('success', 'unknown')}") + return result + + except requests.exceptions.ConnectionError as e: + last_error = f"无法连接中转服务 {url},请检查地址和端口: {e}" + logger.error(last_error) + + except requests.exceptions.Timeout as e: + last_error = f"请求超时({timeout}秒),Agent 可能未响应或处理时间过长: {e}" + logger.error(last_error) + + except requests.exceptions.HTTPError as e: + last_error = f"HTTP 错误: {e}" + logger.error(last_error) + + except Exception as e: + last_error = f"未知错误: {e}" + logger.error(last_error) + + # 如果不是最后一次尝试,则等待后重试 + if attempt < max_retries: + logger.info(f"等待 {RETRY_DELAY} 秒后重试...") + time.sleep(RETRY_DELAY) + + # 所有重试都失败了 + return {"success": False, "error": last_error} + + +def list_servers(max_retries: int = MAX_RETRIES): + """列出所有在线的 Agent 服务器(带重试)""" + if not ensure_logged_in(): + return {"error": "login required"} + + url = f"{get_transit_url()}/api/servers" + + for attempt in range(max_retries + 1): + try: + logger.info(f"获取服务器列表: {url}") + resp = requests.get(url, headers=make_headers(), timeout=10) + resp.raise_for_status() + return resp.json() + except Exception as e: + if attempt < max_retries: + logger.warning(f"获取服务器列表失败,重试中... ({e})") + time.sleep(RETRY_DELAY) + else: + logger.error(f"获取服务器列表失败: {e}") + return {"error": str(e)} + + +def health_check(max_retries: int = MAX_RETRIES): + """检查中转服务健康状态(带重试)""" + url = f"{get_transit_url()}/api/health" + + for attempt in range(max_retries + 1): + try: + logger.info(f"健康检查: {url}") + resp = requests.get(url, timeout=5) + resp.raise_for_status() + return resp.json() + except Exception as e: + if attempt < max_retries: + logger.warning(f"健康检查失败,重试中... ({e})") + time.sleep(RETRY_DELAY) + else: + logger.error(f"健康检查失败: {e}") + return {"error": str(e)} + + +def check_agent_status(server_id: str) -> Dict[str, Any]: + """ + 检查 Agent 状态 + + Returns: + dict: {"online": True/False, "server_id": ..., "error": ...} + """ + logger.info(f"检查 Agent 状态: {server_id}") + + result = list_servers() + + if "error" in result: + return {"online": False, "error": result["error"]} + + servers = result.get("servers", []) + + for s in servers: + if s.get("server_id") == server_id: + return {"online": True, "server_id": server_id, "info": s} + + return {"online": False, "error": f"Agent {server_id} 不在线"} + + +def query_with_permission(user_id: str, table_id: str, sql: str, + timeout: int = DEFAULT_TIMEOUT) -> Dict[str, Any]: + """ + 执行带权限过滤的查询 + + 流程: + 1. 调用 get_user_perm 获取用户权限 + 2. 解析权限SQL + 3. 拼接到原SQL的WHERE条件 + 4. 执行最终SQL + + Args: + user_id: 用户ID + table_id: 表ID (AD_TABLE.ID) + sql: 原始SQL查询 + timeout: 超时时间 + + Returns: + dict: 查询结果 + """ + logger.info(f"查询用户 {user_id} 对表 {table_id} 的权限...") + + # 1. 获取权限 + param = f"{user_id},{table_id}" + perm_result = query(get_server_id(), "get_user_perm", "BOSNDS3", param, + timeout=30, max_retries=MAX_RETRIES) + + if not perm_result.get("success"): + return perm_result + + # 2. 解析权限SQL + try: + perm_data = json.loads(perm_result.get("data", "{}")) + except json.JSONDecodeError: + return {"success": False, "error": "权限数据解析失败"} + + perm_sql = perm_data.get("perm_sql", "") + has_restriction = perm_data.get("has_restriction", False) + + logger.info(f"权限结果: has_restriction={has_restriction}") + + # 3. 拼接SQL + if has_restriction and perm_sql: + # 改进的 WHERE 拼接:找到最外层 WHERE 的位置 + # 1. 去除注释 + import re + sql_no_comments = re.sub(r'--.*?$', '', sql, flags=re.MULTILINE) + sql_no_comments = re.sub(r'/\*.*?\*/', '', sql_no_comments, flags=re.DOTALL) + + # 2. 找到最外层 WHERE(忽略子查询中的 WHERE) + # 简单方法:找到最后一个 FROM 后的 WHERE(如果存在) + # 更健壮的方法:使用 SQL 解析器,这里简化处理 + sql_upper = sql_no_comments.upper() + + # 检查是否有 WHERE + if "WHERE" in sql_upper: + # 找到 WHERE 位置 + where_pos = sql_upper.rfind("WHERE") + # 检查 WHERE 是否在括号内(子查询) + # 统计 WHERE 前的 '(' 和 ')' 数量 + before_where = sql_no_comments[:where_pos] + open_paren = before_where.count('(') + close_paren = before_where.count(')') + if open_paren == close_paren: + # 最外层 WHERE,追加 AND + final_sql = f"{sql} AND {perm_sql}" + else: + # WHERE 在子查询中,在末尾添加 WHERE + # 检查是否已有 GROUP BY / ORDER BY / LIMIT + if any(kw in sql_upper for kw in ["GROUP BY", "ORDER BY", "LIMIT", "FETCH"]): + # 在这些关键字前插入 WHERE + for kw in ["GROUP BY", "ORDER BY", "LIMIT", "FETCH"]: + if kw in sql_upper: + kw_pos = sql_upper.find(kw) + final_sql = f"{sql[:kw_pos]} WHERE {perm_sql} {sql[kw_pos:]}" + break + else: + # 在末尾添加 WHERE + final_sql = f"{sql} WHERE {perm_sql}" + else: + # 无 WHERE,添加 WHERE + # 检查是否已有 GROUP BY / ORDER BY / LIMIT + if any(kw in sql_upper for kw in ["GROUP BY", "ORDER BY", "LIMIT", "FETCH"]): + for kw in ["GROUP BY", "ORDER BY", "LIMIT", "FETCH"]: + if kw in sql_upper: + kw_pos = sql_upper.find(kw) + final_sql = f"{sql[:kw_pos]} WHERE {perm_sql} {sql[kw_pos:]}" + break + else: + # 在末尾添加 WHERE + final_sql = f"{sql} WHERE {perm_sql}" + logger.info(f"拼接权限条件: {perm_sql[:100]}...") + else: + # 全权限或无限制 + final_sql = sql + logger.info("用户无权限限制,直接执行原SQL") + + # 4. 执行最终SQL + logger.info(f"执行最终SQL: {final_sql[:100]}...") + return query(get_server_id(), "execute_query", "BOSNDS3", "", + timeout=timeout, max_retries=MAX_RETRIES, + sql=final_sql) + + +def print_result(result: Dict[str, Any]): + """格式化输出结果""" + if result.get("success"): + data = result.get("data", "") + print(data) + else: + error = result.get("error", "未知错误") + print(f"❌ 错误: {error}", file=sys.stderr) + logger.error(f"查询失败: {error}") + + +def interactive_mode(): + """交互式模式""" + # Global variable declarations (must be at the beginning of the function) + global DEFAULT_TIMEOUT, MAX_RETRIES, DEFAULT_SERVER_ID + + print("=" * 50) + print(" Oracle Jump Query - AI Skill (Improved)") + print(" 输入 help 查看命令,exit 退出") + print("=" * 50) + + # Check transit server + health = health_check() + if "error" in health: + print(f"⚠️ 中转服务不可用: {health['error']}") + print("请检查 config.json 中的 transit_url 配置") + else: + agents = health.get('agents', 0) + print(f"✅ 中转服务正常 (在线 Agent: {agents})") + + # 检查默认 Agent 是否在线 + agent_status = check_agent_status(get_server_id()) + if agent_status["online"]: + print(f"✅ Agent {get_server_id()} 在线") + else: + print(f"⚠️ Agent {get_server_id()} 不在线: {agent_status.get('error', '')}") + + print() + + while True: + try: + line = input("> ").strip() + except (EOFError, KeyboardInterrupt): + print("\n再见!") + break + + if not line: + continue + + parts = line.split() + cmd = parts[0].lower() + + if cmd == "exit" or cmd == "quit": + print("再见!") + break + + elif cmd == "help": + print(""" +可用命令: + analyze - 分析存储过程(源码+依赖+表+触发器) + list - 列出 schema 下的所有存储过程 + source - 获取存储过程源码 + deps - 获取存储过程依赖 + tables - 获取相关表结构 + describe - 查询表结构(列、索引、行数) + discover [schema] [domain] - 发现核心业务域(NL2SQL前置) + nl2sql [schema] [domain] - 生成 NL2SQL Schema 字典 + query - 执行 SELECT 查询(生产表必须带 WHERE 条件和行数限制) + perm [col] - 查询用户数据权限 + qperm - 带权限过滤的查询 + login [clientCode]- 登录中转机 + 选择客户服务器 + logout - 登出 + status - 查看登录状态 + 当前 client + switch - 按 code 或名称切换 client + servers - 列出在线 Agent + health - 检查中转服务状态 + tablespace / tablespaces - 查询表空间使用情况 + inspection_report [client] - 生成并归档巡检报告,默认导出 HTML(可加 --markdown) + inspection_latest [client] - 查看最近报告并导出 HTML(可加 --refresh / --markdown) + inspection_get - 查看指定归档报告并导出 HTML(可加 --markdown) + awr_status [client] - 查看 AWR 授权、权限和生成状态 + awr_list [client] - 列出 Agent 已生成的 AWR 报告 + awr_download - 下载指定日期的 AWR HTML(yyyyMMdd) + agent - 检查指定 Agent 状态 + server - 切换目标服务器 + timeout - 设置默认超时时间 + retry - 设置重试次数 + debug - 切换调试模式 + version - 查看 Skill 版本号 + exit - 退出 +""") + + elif cmd == "version": + print(f"Oracle Jump Query Skill v{VERSION}") + print(f"脚本路径: {__file__}") + print(f"Skill 目录: {SKILL_DIR}") + + elif cmd == "login": + if len(parts) < 2: + print("用法: login [clientCode]") + continue + cmd_login(parts[1], parts[2] if len(parts) >= 3 else "") + + elif cmd == "logout": + cmd_logout() + + elif cmd == "status": + cmd_status() + + elif cmd == "ops": + if len(parts) < 2: + print("用法: ops [clientCode]") + continue + cmd_ops(parts[1], parts[2] if len(parts) >= 3 else "") + + elif cmd == "ops_report": + cmd_ops_report(parts[1] if len(parts) >= 2 else "") + + elif cmd == "sys_functions": + args = [p for p in parts[1:] if not p.startswith("--")] + cmd_sys_functions(args[0] if args else "", "--json" in parts) + + elif cmd == "inspection_report": + args, as_json, html_path, markdown_path = _parse_report_cli_args(parts[1:]) + cmd_inspection_report(args[0] if args else "", as_json=as_json, html_path=html_path, markdown_path=markdown_path) + + elif cmd == "inspection_latest": + refresh = "--refresh" in parts + args, as_json, html_path, markdown_path = _parse_report_cli_args([p for p in parts[1:] if p != "--refresh"]) + cmd_inspection_latest(args[0] if args else "", as_json=as_json, refresh=refresh, html_path=html_path, markdown_path=markdown_path) + + elif cmd == "inspection_get": + if len(parts) < 2: + print("用法: inspection_get [--json] [--html [path]] [--markdown [path]]") + continue + args, as_json, html_path, markdown_path = _parse_report_cli_args(parts[1:]) + cmd_inspection_get(args[0], as_json=as_json, html_path=html_path, markdown_path=markdown_path) + + elif cmd == "awr_status": + args = [p for p in parts[1:] if p != "--json"] + cmd_awr_status(args[0] if args else "", as_json="--json" in parts) + + elif cmd == "awr_list": + args = [p for p in parts[1:] if p != "--json"] + cmd_awr_list(args[0] if args else "", as_json="--json" in parts) + + elif cmd == "awr_download": + args = [p for p in parts[1:] if not p.startswith("--")] + if len(args) < 2: + print("用法: awr_download [--output ] [--json]") + continue + output_path = "" + if "--output" in parts: + output_index = parts.index("--output") + if output_index + 1 < len(parts): + output_path = parts[output_index + 1] + cmd_awr_download(args[0], args[1], output_path=output_path, as_json="--json" in parts) + + elif cmd == "agent_update": + timeout = int(parts[2]) if len(parts) >= 3 and parts[2].isdigit() else 300 + cmd_agent_update(parts[1] if len(parts) >= 2 else "", timeout) + + elif cmd == "switch": + if len(parts) < 2: + print("用法: switch ") + continue + cmd_switch(" ".join(parts[1:])) + + elif cmd == "servers": + result = list_servers() + if "error" in result: + print(f"❌ {result['error']}") + else: + servers = result.get("servers", []) + if not servers: + print("没有在线的 Agent") + else: + for s in servers: + print(f" 🟢 {s['server_id']}") + + elif cmd == "health": + result = health_check() + if "error" in result: + print(f"❌ {result['error']}") + else: + print(f"✅ 中转服务正常 (在线 Agent: {result.get('agents', 0)})") + + elif cmd == "agent": + if len(parts) < 2: + status = check_agent_status(get_server_id()) + else: + status = check_agent_status(parts[1]) + + if status["online"]: + print(f"✅ Agent {status['server_id']} 在线") + print(f" 信息: {status.get('info', {})}") + else: + print(f"❌ Agent 不在线: {status.get('error', '')}") + + elif cmd == "server": + if len(parts) < 2: + print(f"当前服务器: {get_server_id()}") + else: + DEFAULT_SERVER_ID = parts[1] + print(f"切换到服务器: {parts[1]}") + + elif cmd == "timeout": + if len(parts) < 2: + print(f"当前超时时间: {DEFAULT_TIMEOUT} 秒") + else: + try: + DEFAULT_TIMEOUT = int(parts[1]) + print(f"超时时间设置为: {DEFAULT_TIMEOUT} 秒") + except ValueError: + print("用法: timeout <秒数>") + + elif cmd == "retry": + if len(parts) < 2: + print(f"当前重试次数: {MAX_RETRIES}") + else: + try: + MAX_RETRIES = int(parts[1]) + print(f"重试次数设置为: {MAX_RETRIES}") + except ValueError: + print("用法: retry <次数>") + + elif cmd == "debug": + # 切换调试模式 + if logger.level == logging.DEBUG: + logger.setLevel(logging.INFO) + print("调试模式: 关闭") + else: + logger.setLevel(logging.DEBUG) + print("调试模式: 开启") + + elif cmd == "analyze": + if len(parts) < 3: + print("用法: analyze ") + continue + + print(f"⏳ 正在分析 {parts[1]}.{parts[2]} (超时 {DEFAULT_TIMEOUT * 2} 秒)...") + result = query(get_server_id(), "analyze_procedure", parts[1], parts[2], + timeout=DEFAULT_TIMEOUT * 2, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "list": + if len(parts) < 2: + print("用法: list ") + continue + + print(f"⏳ 正在获取存储过程列表...") + result = query(get_server_id(), "list_procedures", parts[1], + timeout=30, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "source": + if len(parts) < 3: + print("用法: source ") + continue + + print(f"⏳ 正在获取源码...") + result = query(get_server_id(), "get_source", parts[1], parts[2], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "deps": + if len(parts) < 3: + print("用法: deps ") + continue + + print(f"⏳ 正在获取依赖...") + result = query(get_server_id(), "get_dependencies", parts[1], parts[2], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "tables": + if len(parts) < 3: + print("用法: tables ") + continue + + print(f"⏳ 正在获取表结构...") + result = query(get_server_id(), "get_tables", parts[1], parts[2], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "describe": + if len(parts) < 3: + print("用法: describe
") + continue + + print(f"⏳ 正在查询表 {parts[1]}.{parts[2]} ...") + result = query(get_server_id(), "describe_table", parts[1], parts[2], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "discover": + # NL2SQL: 发现核心业务域 + schema = parts[1] if len(parts) > 1 else get_config().get("default_schema", "BOSNDS3") + domain = parts[2] if len(parts) > 2 else "" + print(f"⏳ 正在发现业务域 (schema={schema}, domain={domain or 'ALL'})...") + result = query(get_server_id(), "schema_discovery", schema, domain, + timeout=DEFAULT_TIMEOUT * 3, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "nl2sql": + # NL2SQL: 生成完整 Schema + schema = parts[1] if len(parts) > 1 else get_config().get("default_schema", "BOSNDS3") + domain = parts[2] if len(parts) > 2 else "RETAIL" + print(f"⏳ 正在生成 NL2SQL Schema (schema={schema}, domain={domain})...") + result = query(get_server_id(), "generate_nl2sql_schema", schema, domain, + timeout=DEFAULT_TIMEOUT * 3, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "query": + # execute_query: 执行任意 SELECT + if len(parts) < 2: + print("用法: query ") + print(" 示例: query SELECT * FROM M_RETAIL WHERE BILLDATE = 20260501 AND ROWNUM <= 20") + continue + # 支持可选 server_id 前缀:query henlo "SELECT ..." + known_servers = ["henlo", "renben", "HENLO", "RENBEN"] + sql_parts = parts[1:] + server_override = None + if parts[1].lower() in known_servers: + server_override = parts[1].upper() + sql_parts = parts[2:] + if not sql_parts: + print("用法: query ") + continue + sql = " ".join(sql_parts) + server_id = server_override if server_override else get_server_id() + print(f"⏳ 执行查询 [{server_id}]: {sql[:80]}...") + result = query(server_id, "execute_query", "BOSNDS3", "", + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES, + sql=sql) + print_result(result) + + elif cmd == "perm": + # get_user_perm: 获取用户权限 + if len(parts) < 3: + print("用法: perm [columnName]") + print(" 示例: perm 1015 12964") + print(" 示例: perm 940 12983 C_STORE_ID") + continue + user_id = parts[1] + table_id = parts[2] + col_name = parts[3] if len(parts) > 3 else "" + param = f"{user_id},{table_id}" if not col_name else f"{user_id},{table_id},{col_name}" + print(f"⏳ 查询用户 {user_id} 对表 {table_id} 的权限...") + result = query(get_server_id(), "get_user_perm", "BOSNDS3", param, + timeout=30, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "qperm": + # query_with_perm: 带权限过滤的查询 + if len(parts) < 4: + print("用法: qperm ") + print(" 示例: qperm 1015 12964 SELECT * FROM M_RETAIL WHERE BILLDATE=20260501") + continue + user_id = parts[1] + table_id = parts[2] + sql = " ".join(parts[3:]) + print(f"⏳ 执行带权限过滤的查询 (用户={user_id}, 表={table_id})...") + result = query_with_permission(user_id, table_id, sql) + print_result(result) + + elif cmd == "clients": + cmd_clients() + + elif cmd in ("tablespace", "tablespaces"): + print(f"⏳ 正在查询表空间使用情况...") + result = query_tablespace(get_server_id()) + if result.get("success"): + via_func = result.get("_via_func", False) + via_view = result.get("_via_view", False) + is_fallback = result.get("_fallback", False) + parse_failed = result.get("_parse_failed", False) + if via_func and parse_failed: + print("\n📊 表空间使用情况一览(通过 DBA 专用函数,原始格式):") + elif via_func: + print("\n📊 表空间使用情况一览(通过 DBA 专用函数):") + elif via_view: + print("\n📊 表空间使用情况一览(通过 DBA 授权 VIEW):") + elif is_fallback: + print("\n📊 表空间使用情况(用户视图 — 无DBA权限,仅显示名称和剩余空间):") + print("─" * 65) + else: + print("\n📊 表空间使用情况一览:") + print("─" * 110) + print(result.get("data", "")) + print("─" * 65 if is_fallback else "─" * 110) + if not is_fallback: + _check_ts_warnings(result.get("data", "")) + else: + print_result(result) + + else: + print(f"未知命令: {cmd},输入 help 查看帮助") + + + +# === capabilities command (version + command discovery) === +_cap_parser = argparse.ArgumentParser(prog="oracle_skill", add_help=False) +_cap_subparsers = _cap_parser.add_subparsers(dest="subcmd") +p = _cap_subparsers.add_parser('capabilities') +p.add_argument('--json', action='store_true', help='Output pure JSON') + +def cmd_capabilities(args): + """ + Return Skill version and all available command definitions (JSON format) + + Args: + args: Command line arguments, including --json flag + """ + # Define all available commands + commands = [ + { + "name": "analyze", + "description": "Analyze stored procedure: source + dependency tables + nested procedures + triggers", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name (e.g. bosnds3)" + }, + "procedure": { + "type": "string", + "description": "Stored procedure name (e.g. M_RETAIL_SUBMIT)" + } + }, + "required": ["schema", "procedure"] + } + }, + { + "name": "list", + "description": "List all stored procedures in specified schema", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + } + }, + "required": ["schema"] + } + }, + { + "name": "source", + "description": "Get stored procedure source code", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "procedure": { + "type": "string", + "description": "Stored procedure name" + } + }, + "required": ["schema", "procedure"] + } + }, + { + "name": "describe", + "description": "Query table structure (field names, types, comments)", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "table": { + "type": "string", + "description": "Table name" + } + }, + "required": ["schema", "table"] + } + }, + { + "name": "search", + "description": "Search tables or stored procedures (supports fuzzy search)", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "keyword": { + "type": "string", + "description": "Search keyword" + } + }, + "required": ["schema", "keyword"] + } + }, + { + "name": "query", + "description": "Execute SELECT query (only allows SELECT statements; production table queries must include WHERE filters and row limits)", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "sql": { + "type": "string", + "description": "SQL SELECT query statement. For production tables, include explicit WHERE filters and ROWNUM/page limits; do not query whole tables." + } + }, + "required": ["schema", "sql"] + } + }, + { + "name": "discover", + "description": "Schema exploration: list all tables or search specific tables", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "filter": { + "type": "string", + "description": "Optional, table name filter condition (e.g. M_%)" + } + }, + "required": ["schema"] + } + }, + { + "name": "nl2sql", + "description": "Natural language to SQL (requires DeepSeek API)", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "question": { + "type": "string", + "description": "Natural language question (e.g. 'Query sales of Huadu Store 2 in May')" + } + }, + "required": ["schema", "question"] + } + }, + { + "name": "perm", + "description": "Get user data permission SQL (via stored procedure get_userspermsql)", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "userid": { + "type": "string", + "description": "User ID" + }, + "table": { + "type": "string", + "description": "Table name (e.g. M_RETAIL)" + } + }, + "required": ["schema", "userid", "table"] + } + }, + { + "name": "qperm", + "description": "Quick permission SQL retrieval (simplified version)", + "parameters": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "description": "Database schema name" + }, + "userid": { + "type": "string", + "description": "User ID" + }, + "table": { + "type": "string", + "description": "Table name" + } + }, + "required": ["schema", "userid", "table"] + } + }, + { + "name": "login", + "description": "Login to transit server and select a client", + "parameters": { + "type": "object", + "properties": { + "secretKey": { + "type": "string", + "description": "BOS secret key for authentication" + }, + "clientCode": { + "type": "string", + "description": "Optional client/server code to select (must be in user's clientList). If omitted, the transit server selects the first available client." + } + }, + "required": ["secretKey"] + } + }, + { + "name": "logout", + "description": "Logout and clear login credentials", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "status", + "description": "Show current login status and selected client", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "clients", + "description": "Refresh the current user's authorized client list and online status using the existing transit-server access token.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ops", + "description": "Run one Oracle daily operations monitor item through transit server and agent.", + "parameters": { + "type": "object", + "properties": { + "item": { + "type": "string", + "description": "Monitor item, such as active_slow_sql, blocking_locks, tablespace, memory, io_waits, ora_errors" + }, + "clientCode": { + "type": "string", + "description": "Optional client/server code. If omitted, the current client is used." + } + }, + "required": ["item"] + } + }, + { + "name": "ops_report", + "description": "Generate an Oracle daily operations report for a client.", + "parameters": { + "type": "object", + "properties": { + "clientCode": { + "type": "string", + "description": "Optional client/server code. If omitted, the current client is used." + } + }, + "required": [] + } + }, + { + "name": "inspection_report", + "description": "Generate and archive a server inspection report for a client.", + "parameters": { + "type": "object", + "properties": { + "clientCode": { + "type": "string", + "description": "Optional client/server code. If omitted, the current client is used." + }, + "json": { + "type": "boolean", + "description": "Output raw JSON instead of Markdown summary." + }, + "html": { + "type": "string", + "description": "HTML output path. HTML is generated by default; use an empty value to write to the default outputs directory." + }, + "markdown": { + "type": "string", + "description": "Optional Markdown output path. Use an empty value to write to the default outputs directory." + } + }, + "required": [] + } + }, + { + "name": "inspection_latest", + "description": "Read the latest archived server inspection report for a client.", + "parameters": { + "type": "object", + "properties": { + "clientCode": { + "type": "string", + "description": "Optional client/server code. If omitted, the current client is used." + }, + "refresh": { + "type": "boolean", + "description": "Generate a fresh report before returning it." + }, + "json": { + "type": "boolean", + "description": "Output raw JSON instead of Markdown summary." + }, + "html": { + "type": "string", + "description": "HTML output path. HTML is generated by default; use an empty value to write to the default outputs directory." + }, + "markdown": { + "type": "string", + "description": "Optional Markdown output path. Use an empty value to write to the default outputs directory." + } + }, + "required": [] + } + }, + { + "name": "inspection_get", + "description": "Read an archived server inspection report by report ID.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "TS_INSPECTION_REPORT.ID" + }, + "json": { + "type": "boolean", + "description": "Output raw JSON instead of Markdown summary." + }, + "html": { + "type": "string", + "description": "HTML output path. HTML is generated by default; use an empty value to write to the default outputs directory." + }, + "markdown": { + "type": "string", + "description": "Optional Markdown output path. Use an empty value to write to the default outputs directory." + } + }, + "required": ["id"] + } + }, + { + "name": "agent_update", + "description": "Trigger a remote Agent self-update through transit-server. The Agent downloads the upgrade package from OSS.", + "parameters": { + "type": "object", + "properties": { + "clientCode": { + "type": "string", + "description": "Optional client/server code. If omitted, the current client is used." + }, + "timeout": { + "type": "integer", + "description": "Optional timeout seconds, default 300, max 600." + } + }, + "required": [] + } + }, + { + "name": "awr_status", + "description": "Read AWR authorization, readiness and latest generated report status without generating a report.", + "parameters": { + "type": "object", + "properties": { + "clientCode": {"type": "string", "description": "Optional client/server code."}, + "json": {"type": "boolean", "description": "Output machine-readable JSON."} + }, + "required": [] + } + }, + { + "name": "awr_list", + "description": "List AWR reports already generated and stored by the target Agent.", + "parameters": { + "type": "object", + "properties": { + "clientCode": {"type": "string", "description": "Optional client/server code."}, + "json": {"type": "boolean", "description": "Output machine-readable JSON."} + }, + "required": [] + } + }, + { + "name": "awr_download", + "description": "Download an Agent-generated AWR HTML report through transit-server without local rendering.", + "parameters": { + "type": "object", + "properties": { + "clientCode": {"type": "string", "description": "Client/server code."}, + "date": {"type": "string", "description": "AWR report date in yyyyMMdd format."}, + "output": {"type": "string", "description": "Optional local output path."} + }, + "required": ["clientCode", "date"] + } + }, + { + "name": "switch", + "description": "Switch to a different client by code or title/name", + "parameters": { + "type": "object", + "properties": { + "clientCode": { + "type": "string", + "description": "Client code or title/name to switch to" + } + }, + "required": ["clientCode"] + } + }, + { + "name": "capabilities", + "description": "Show Skill version and all available command definitions", + "parameters": { + "type": "object", + "properties": { + "json": { + "type": "boolean", + "description": "Whether to output pure JSON format" + } + } + } + } + ] + + # Build return result + result = { + "version": VERSION, + "skill_name": "oracle-jump-query", + "description": "Oracle Jump Query Skill - Query remote Oracle database via transit server", + "transit_server": "https://ts.henlo.net", + "default_schema": "bosnds3", + "commands": commands, + "metadata": { + "total_commands": len(commands), + "supported_actions": [ + "analyze", "list", "source", "describe", + "search", "query", "discover", "nl2sql", + "perm", "qperm", "login", "logout", + "status", "switch", "clients", "ops", "ops_report", "inspection_report", "inspection_latest", "inspection_get", "awr_status", "awr_list", "awr_download", "agent_update", "capabilities" + ], + "python_version": "3.6+", + "dependencies": ["requests"] + } + } + + # Output format + if hasattr(args, 'json') and args.json: + # Pure JSON output (no log interference) + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + # Friendly format output + print("Oracle Jump Query Skill - Capabilities") + print("=" * 60) + print("Version: " + result['version']) + print("Transit Server: " + result['transit_server']) + print("Default Schema: " + result['default_schema']) + print("\nAvailable Commands (" + str(result['metadata']['total_commands']) + "):") + print("-" * 60) + + for i, cmd in enumerate(commands, 1): + print(str(i) + ". " + cmd['name']) + print(" Description: " + cmd['description']) + if 'parameters' in cmd and 'properties' in cmd['parameters']: + params = cmd['parameters']['properties'] + if params: + print(" Parameters: " + ', '.join(params.keys())) + print() + + print("=" * 60) + print("For machine-readable output, use: --json") + + +def main(): + # Global variable declarations for main function + global DEFAULT_TIMEOUT, MAX_RETRIES, DEFAULT_SERVER_ID + + if len(sys.argv) == 1: + interactive_mode() + return + + cmd = sys.argv[1].lower() + + if cmd == "servers": + result = list_servers() + print(json.dumps(result, indent=2, ensure_ascii=False)) + + elif cmd == "health": + result = health_check() + print(json.dumps(result, indent=2, ensure_ascii=False)) + + elif cmd == "version": + print(f"Oracle Jump Query Skill v{VERSION}") + if len(sys.argv) >= 3 and sys.argv[2] == "agent": + args = [a for a in sys.argv[3:] if a not in ("--all", "--json")] + cmd_agent_versions(args, all_clients="--all" in sys.argv[3:], as_json="--json" in sys.argv[3:]) + return + + elif cmd == "agent": + if len(sys.argv) >= 3: + status = check_agent_status(sys.argv[2]) + else: + status = check_agent_status(get_server_id()) + + print(json.dumps(status, indent=2, ensure_ascii=False)) + + elif cmd == "analyze" and len(sys.argv) >= 4: + result = query(get_server_id(), "analyze_procedure", sys.argv[2], sys.argv[3], + timeout=DEFAULT_TIMEOUT * 2, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "list" and len(sys.argv) >= 3: + result = query(get_server_id(), "list_procedures", sys.argv[2], + timeout=30, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "source" and len(sys.argv) >= 4: + result = query(get_server_id(), "get_source", sys.argv[2], sys.argv[3], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "deps" and len(sys.argv) >= 4: + result = query(get_server_id(), "get_dependencies", sys.argv[2], sys.argv[3], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "tables" and len(sys.argv) >= 4: + result = query(get_server_id(), "get_tables", sys.argv[2], sys.argv[3], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "describe" and len(sys.argv) >= 4: + result = query(get_server_id(), "describe_table", sys.argv[2], sys.argv[3], + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "discover" and len(sys.argv) >= 2: + schema = sys.argv[2] if len(sys.argv) > 2 else "BOSNDS3" + domain = sys.argv[3] if len(sys.argv) > 3 else "" + result = query(get_server_id(), "schema_discovery", schema, domain, + timeout=DEFAULT_TIMEOUT * 3, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "nl2sql" and len(sys.argv) >= 2: + schema = sys.argv[2] if len(sys.argv) > 2 else "BOSNDS3" + domain = sys.argv[3] if len(sys.argv) > 3 else "RETAIL" + result = query(get_server_id(), "generate_nl2sql_schema", schema, domain, + timeout=DEFAULT_TIMEOUT * 3, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "query" and len(sys.argv) >= 2: + sql = " ".join(sys.argv[2:]) + result = query(get_server_id(), "execute_query", "BOSNDS3", "", + timeout=DEFAULT_TIMEOUT, max_retries=MAX_RETRIES, + sql=sql) + print_result(result) + + elif cmd == "perm" and len(sys.argv) >= 4: + user_id = sys.argv[2] + table_id = sys.argv[3] + col_name = sys.argv[4] if len(sys.argv) > 4 else "" + param = f"{user_id},{table_id}" if not col_name else f"{user_id},{table_id},{col_name}" + result = query(get_server_id(), "get_user_perm", "BOSNDS3", param, + timeout=30, max_retries=MAX_RETRIES) + print_result(result) + + elif cmd == "qperm" and len(sys.argv) >= 5: + user_id = sys.argv[2] + table_id = sys.argv[3] + sql = " ".join(sys.argv[4:]) + result = query_with_permission(user_id, table_id, sql) + print_result(result) + + elif cmd == "login" and len(sys.argv) >= 3: + cmd_login(sys.argv[2], sys.argv[3] if len(sys.argv) >= 4 else "") + + elif cmd == "logout": + cmd_logout() + + elif cmd == "status": + cmd_status() + + elif cmd == "ops" and len(sys.argv) >= 3: + cmd_ops(sys.argv[2], sys.argv[3] if len(sys.argv) >= 4 else "") + + elif cmd == "ops_report": + cmd_ops_report(sys.argv[2] if len(sys.argv) >= 3 else "") + + elif cmd == "sys_functions": + args = [p for p in sys.argv[2:] if not p.startswith("--")] + cmd_sys_functions(args[0] if args else "", "--json" in sys.argv[2:]) + + elif cmd == "inspection_report": + args, as_json, html_path, markdown_path = _parse_report_cli_args(sys.argv[2:]) + cmd_inspection_report(args[0] if args else "", as_json=as_json, html_path=html_path, markdown_path=markdown_path) + + elif cmd == "inspection_latest": + refresh = "--refresh" in sys.argv[2:] + args, as_json, html_path, markdown_path = _parse_report_cli_args([p for p in sys.argv[2:] if p != "--refresh"]) + cmd_inspection_latest(args[0] if args else "", as_json=as_json, refresh=refresh, html_path=html_path, markdown_path=markdown_path) + + elif cmd == "inspection_get" and len(sys.argv) >= 3: + args, as_json, html_path, markdown_path = _parse_report_cli_args(sys.argv[2:]) + cmd_inspection_get(args[0], as_json=as_json, html_path=html_path, markdown_path=markdown_path) + + elif cmd == "awr_status": + args = [p for p in sys.argv[2:] if p != "--json"] + cmd_awr_status(args[0] if args else "", as_json="--json" in sys.argv[2:]) + + elif cmd == "awr_list": + args = [p for p in sys.argv[2:] if p != "--json"] + cmd_awr_list(args[0] if args else "", as_json="--json" in sys.argv[2:]) + + elif cmd == "awr_download" and len(sys.argv) >= 4: + output_path = "" + if "--output" in sys.argv[4:]: + output_index = sys.argv.index("--output") + if output_index + 1 < len(sys.argv): + output_path = sys.argv[output_index + 1] + cmd_awr_download(sys.argv[2], sys.argv[3], output_path=output_path, as_json="--json" in sys.argv[4:]) + + elif cmd == "agent_update": + timeout = int(sys.argv[3]) if len(sys.argv) >= 4 and sys.argv[3].isdigit() else 300 + cmd_agent_update(sys.argv[2] if len(sys.argv) >= 3 else "", timeout) + + elif cmd == "switch" and len(sys.argv) >= 3: + cmd_switch(" ".join(sys.argv[2:])) + + + elif cmd == "clients": + cmd_clients() + + elif cmd in ("tablespace", "tablespaces"): + print(f"⏳ 正在查询表空间使用情况...") + result = query_tablespace(get_server_id()) + if result.get("success"): + via_func = result.get("_via_func", False) + via_view = result.get("_via_view", False) + is_fallback = result.get("_fallback", False) + parse_failed = result.get("_parse_failed", False) + if via_func and parse_failed: + print("\n📊 表空间使用情况一览(通过 DBA 专用函数,原始格式):") + elif via_func: + print("\n📊 表空间使用情况一览(通过 DBA 专用函数):") + elif via_view: + print("\n📊 表空间使用情况一览(通过 DBA 授权 VIEW):") + elif is_fallback: + print("\n📊 表空间使用情况(用户视图 — 无DBA权限,仅显示名称和剩余空间):") + print("─" * 65) + else: + print("\n📊 表空间使用情况一览:") + print("─" * 110) + print(result.get("data", "")) + print("─" * 65 if is_fallback else "─" * 110) + if not is_fallback: + _check_ts_warnings(result.get("data", "")) + else: + print_result(result) + + elif cmd == "capabilities": + args = _cap_parser.parse_args(["capabilities"] + sys.argv[2:]) + cmd_capabilities(args) + return + + else: + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/scripts/oracle_skill.py.backup b/scripts/oracle_skill.py.backup new file mode 100644 index 0000000..e2839fb --- /dev/null +++ b/scripts/oracle_skill.py.backup @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +Oracle Jump Query - AI Skill +通过中转服务查询远程 Oracle 存储过程元数据 + +使用方式: + python oracle_skill.py analyze BOS M_RETAIL_SUBMIT + python oracle_skill.py list BOS + python oracle_skill.py source BOS M_RETAIL_SUBMIT + python oracle_skill.py deps BOS M_RETAIL_SUBMIT + python oracle_skill.py tables BOS M_RETAIL_SUBMIT + python oracle_skill.py servers +""" + +import json +import os +import sys +import requests + +# ============================================================ +# 配置(修改为你的中转服务地址和认证信息) +# ============================================================ +TRANSIT_URL = "http://bos.henlo.net:6357" +AUTH_TOKEN = "" +DEFAULT_SERVER_ID = "server-001" + + +def get_script_dir(): + """获取脚本自身所在目录""" + return os.path.dirname(os.path.abspath(__file__)) + + +def get_config(): + """从 config.json 加载配置(优先脚本目录,其次当前目录)""" + # 优先从脚本同目录加载 + cfg_path = os.path.join(get_script_dir(), "config.json") + try: + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + return cfg + except FileNotFoundError: + pass + # 其次从当前目录加载 + try: + with open("config.json", "r", encoding="utf-8") as f: + cfg = json.load(f) + return cfg + except FileNotFoundError: + return None + + +def make_headers(): + """构建 HTTP 请求头""" + headers = {"Content-Type": "application/json"} + token = AUTH_TOKEN + cfg = get_config() + if cfg: + token = cfg.get("auth_token", token) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def get_transit_url(): + """获取中转服务 URL""" + cfg = get_config() + if cfg and "transit_url" in cfg: + return cfg["transit_url"] + return TRANSIT_URL + + +def get_server_id(): + """获取默认服务器 ID""" + cfg = get_config() + if cfg and "server_id" in cfg: + return cfg["server_id"] + return DEFAULT_SERVER_ID + + +def query(server_id, action, schema="", name="", timeout=60): + """发送查询请求到中转服务""" + url = f"{get_transit_url()}/api/query" + payload = { + "server_id": server_id, + "action": action, + "schema": schema, + "name": name, + "timeout": timeout, + } + + try: + resp = requests.post(url, json=payload, headers=make_headers(), timeout=timeout + 5) + resp.raise_for_status() + return resp.json() + except requests.exceptions.ConnectionError: + return {"success": False, "error": "无法连接中转服务,请检查地址和端口"} + except requests.exceptions.Timeout: + return {"success": False, "error": "请求超时,Agent 可能未响应"} + except requests.exceptions.HTTPError as e: + return {"success": False, "error": f"HTTP 错误: {e}"} + + +def list_servers(): + """列出所有在线的 Agent 服务器""" + url = f"{get_transit_url()}/api/servers" + try: + resp = requests.get(url, headers=make_headers(), timeout=10) + resp.raise_for_status() + return resp.json() + except Exception as e: + return {"error": str(e)} + + +def health_check(): + """检查中转服务健康状态""" + url = f"{get_transit_url()}/api/health" + try: + resp = requests.get(url, timeout=5) + resp.raise_for_status() + return resp.json() + except Exception as e: + return {"error": str(e)} + + +def print_result(result): + """格式化输出结果""" + if result.get("success"): + data = result.get("data", "") + print(data) + else: + error = result.get("error", "未知错误") + print(f"❌ 错误: {error}") + + +def interactive_mode(): + """交互式模式""" + print("=" * 50) + print(" Oracle Jump Query - AI Skill") + print(" 输入 help 查看命令,exit 退出") + print("=" * 50) + + # Check transit server + health = health_check() + if "error" in health: + print(f"⚠️ 中转服务不可用: {health['error']}") + print("请检查 config.json 中的 transit_url 配置") + else: + print(f"✅ 中转服务正常 (在线 Agent: {health.get('agents', 0)})") + + print() + + while True: + try: + line = input("> ").strip() + except (EOFError, KeyboardInterrupt): + print("\n再见!") + break + + if not line: + continue + + parts = line.split() + cmd = parts[0].lower() + + if cmd == "exit" or cmd == "quit": + print("再见!") + break + + elif cmd == "help": + print(""" +可用命令: + analyze - 分析存储过程(源码+依赖+表+触发器) + list - 列出 schema 下的所有存储过程 + source - 获取存储过程源码 + deps - 获取存储过程依赖 + tables - 获取相关表结构 + servers - 列出在线 Agent + health - 检查中转服务状态 + server - 切换目标服务器 + exit - 退出 +""") + + elif cmd == "servers": + result = list_servers() + if "error" in result: + print(f"❌ {result['error']}") + else: + servers = result.get("servers", []) + if not servers: + print("没有在线的 Agent") + else: + for s in servers: + print(f" 🟢 {s['server_id']}") + + elif cmd == "health": + result = health_check() + if "error" in result: + print(f"❌ {result['error']}") + else: + print(f"✅ 中转服务正常 (在线 Agent: {result.get('agents', 0)})") + + elif cmd == "server": + if len(parts) < 2: + print(f"当前服务器: {get_server_id()}") + else: + global DEFAULT_SERVER_ID + DEFAULT_SERVER_ID = parts[1] + print(f"切换到服务器: {parts[1]}") + + elif cmd == "analyze": + if len(parts) < 3: + print("用法: analyze ") + continue + result = query(get_server_id(), "analyze_procedure", parts[1], parts[2], timeout=120) + print_result(result) + + elif cmd == "list": + if len(parts) < 2: + print("用法: list ") + continue + result = query(get_server_id(), "list_procedures", parts[1], timeout=30) + print_result(result) + + elif cmd == "source": + if len(parts) < 3: + print("用法: source ") + continue + result = query(get_server_id(), "get_source", parts[1], parts[2], timeout=60) + print_result(result) + + elif cmd == "deps": + if len(parts) < 3: + print("用法: deps ") + continue + result = query(get_server_id(), "get_dependencies", parts[1], parts[2], timeout=60) + print_result(result) + + elif cmd == "tables": + if len(parts) < 3: + print("用法: tables ") + continue + result = query(get_server_id(), "get_tables", parts[1], parts[2], timeout=60) + print_result(result) + + else: + print(f"未知命令: {cmd},输入 help 查看帮助") + + +def main(): + if len(sys.argv) == 1: + interactive_mode() + return + + cmd = sys.argv[1].lower() + + if cmd == "servers": + result = list_servers() + print(json.dumps(result, indent=2, ensure_ascii=False)) + + elif cmd == "health": + result = health_check() + print(json.dumps(result, indent=2, ensure_ascii=False)) + + elif cmd == "analyze" and len(sys.argv) >= 4: + result = query(get_server_id(), "analyze_procedure", sys.argv[2], sys.argv[3], timeout=120) + print_result(result) + + elif cmd == "list" and len(sys.argv) >= 3: + result = query(get_server_id(), "list_procedures", sys.argv[2], timeout=30) + print_result(result) + + elif cmd == "source" and len(sys.argv) >= 4: + result = query(get_server_id(), "get_source", sys.argv[2], sys.argv[3], timeout=60) + print_result(result) + + elif cmd == "deps" and len(sys.argv) >= 4: + result = query(get_server_id(), "get_dependencies", sys.argv[2], sys.argv[3], timeout=60) + print_result(result) + + elif cmd == "tables" and len(sys.argv) >= 4: + result = query(get_server_id(), "get_tables", sys.argv[2], sys.argv[3], timeout=60) + print_result(result) + + else: + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/tests/test_awr.py b/tests/test_awr.py new file mode 100644 index 0000000..116b013 --- /dev/null +++ b/tests/test_awr.py @@ -0,0 +1,124 @@ +import os +import tempfile +import unittest +from unittest import mock + +from scripts import oracle_skill as skill + + +class FakeResponse: + def __init__(self, chunks=None, payload=None, ok=True, status_code=200, headers=None): + self._chunks = chunks or [] + self._payload = payload or {} + self.ok = ok + self.status_code = status_code + self.headers = headers or {} + self.text = "" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def json(self): + return self._payload + + def iter_content(self, chunk_size=65536): + del chunk_size + return iter(self._chunks) + + +class AWRSkillTests(unittest.TestCase): + def test_default_transit_url_uses_https_domain(self): + self.assertEqual(skill.TRANSIT_URL, "https://ts.henlo.net") + + def config(self): + return { + "access_token": "test-token", + "expires_at": "2999-01-01T00:00:00+08:00", + "transit_url": "http://transit.example", + "client_code": "WEIRUI", + } + + def test_awr_status_uses_authenticated_dedicated_endpoint(self): + response = FakeResponse(payload={"success": True, "data": {"status": "ready"}}) + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "get", return_value=response + ) as get: + result = skill.awr_status("WEIRUI") + self.assertTrue(result["success"]) + self.assertEqual(get.call_args.args[0], "http://transit.example/api/awr/status") + self.assertEqual(get.call_args.kwargs["params"], {"client_code": "WEIRUI"}) + self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], "Bearer test-token") + + def test_awr_status_returns_schema_resolved_manual_dba_sql_when_permission_is_missing(self): + response = FakeResponse( + payload={ + "success": True, + "client_code": "WEIRUI", + "data": {"status": "permission_required", "reason": "AWR export function is missing"}, + } + ) + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "get", return_value=response + ), mock.patch.object(skill, "query", return_value={"success": True, "data": {"schema": "bosnds3"}}): + result = skill.awr_status("WEIRUI") + self.assertTrue(result["dba_sql_required"]) + self.assertEqual(result["agent_schema"], "BOSNDS3") + self.assertIn("GRANT EXECUTE ON SYS.HENLO_AWR_EXPORT TO BOSNDS3", result["dba_sql"]) + self.assertIn("GRANT SELECT ON SYS.DBA_HIST_SNAPSHOT TO BOSNDS3", result["dba_sql"]) + self.assertIn("IF R.OUTPUT IS NOT NULL THEN", result["dba_sql"]) + self.assertIn("DBMS_LOB.WRITEAPPEND(L_HTML, 1, CHR(10))", result["dba_sql"]) + self.assertNotIn("", result["dba_sql"]) + + def test_awr_status_never_emits_placeholder_when_agent_schema_is_unavailable(self): + response = FakeResponse( + payload={"success": True, "data": {"status": "permission_required", "reason": "permission denied"}} + ) + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "get", return_value=response + ), mock.patch.object(skill, "query", return_value={"success": True, "data": {"schema": ""}}): + result = skill.awr_status("WEIRUI") + self.assertTrue(result["dba_sql_required"]) + self.assertEqual(result["dba_sql"], "") + self.assertIn("oracle.schema", result["next_step"]) + + def test_download_preserves_exact_html_bytes(self): + content = "未芮 AWR".encode("utf-8") + response = FakeResponse(chunks=[content[:10], content[10:]], headers={"Content-Length": str(len(content))}) + with tempfile.TemporaryDirectory() as root: + output = os.path.join(root, "awr.html") + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "get", return_value=response + ) as get: + result = skill.download_awr_report("WEIRUI", "20260720", output) + self.assertTrue(result["success"], result) + with open(output, "rb") as f: + self.assertEqual(f.read(), content) + self.assertEqual(get.call_args.args[0], "http://transit.example/api/awr/download") + self.assertEqual(get.call_args.kwargs["params"], {"client_code": "WEIRUI", "date": "20260720"}) + + def test_download_rejects_invalid_date_before_network(self): + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "get" + ) as get: + result = skill.download_awr_report("WEIRUI", "../secret") + self.assertFalse(result["success"]) + get.assert_not_called() + + def test_download_removes_partial_file_when_size_limit_is_exceeded(self): + response = FakeResponse(chunks=[b"123456"]) + with tempfile.TemporaryDirectory() as root: + output = os.path.join(root, "awr.html") + with mock.patch.object(skill, "MAX_AWR_DOWNLOAD_BYTES", 5), mock.patch.object( + skill, "get_config", return_value=self.config() + ), mock.patch.object(skill.requests, "get", return_value=response): + result = skill.download_awr_report("WEIRUI", "20260720", output) + self.assertFalse(result["success"]) + self.assertFalse(os.path.exists(output)) + self.assertFalse(os.path.exists(output + ".part")) + + +if __name__ == "__main__": + unittest.main()