From 166007b46047d23274ec4bebf2657fb6b19370b4 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 26 Sep 2026 16:39:13 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.0.117=20=E6=B2=89=E6=B7=80?= =?UTF-8?q?=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90=E5=A5=91=E7=BA=A6=EF=BC=88?= =?UTF-8?q?SQLite=20=E9=BB=98=E8=AE=A4=20+=20=E5=8F=AF=E9=80=89=20MySQL=20?= =?UTF-8?q?=E4=BA=91=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DATA_SOURCES.md、data-sources 示例与双后端 connection 骨架;CLI 与宿主 UI 可读写同一库。要求匠厂 Host ≥ 6.9.28。 Co-authored-by: Cursor --- .env.example | 3 + CHANGELOG.md | 7 + SKILL.md | 4 +- assets/README.md | 3 +- assets/data-sources.example.json | 11 ++ assets/examples/README.md | 1 + .../examples/data-sources.mysql.example.json | 11 ++ assets/schemas/skill-actions.schema.json | 2 +- development/CHECKLIST.md | 1 + development/CONFIG.md | 17 +++ development/DATA_PATHS.md | 5 +- development/DEVELOPMENT.md | 4 +- references/ACTIONS.md | 6 +- references/CLI.md | 2 +- references/DATA_SOURCES.md | 139 ++++++++++++++++++ references/README.md | 5 +- references/SCHEMA.md | 4 +- requirements.txt | 1 + scripts/db/backend.py | 35 +++++ scripts/db/connection.py | 41 +++++- scripts/db/cursor_compat.py | 96 ++++++++++++ scripts/util/constants.py | 2 +- tests/test_db_backend.py | 49 ++++++ tests/test_docs_standards.py | 22 +++ 24 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 assets/data-sources.example.json create mode 100644 assets/examples/data-sources.mysql.example.json create mode 100644 references/DATA_SOURCES.md create mode 100644 scripts/db/backend.py create mode 100644 scripts/db/cursor_compat.py create mode 100644 tests/test_db_backend.py diff --git a/.env.example b/.env.example index 1dcb1e7..967106e 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,6 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调 # 临时目录:运行中临时文件的位置(不上侧栏) # SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp + +# MySQL 云库连接:技能走云库时在用户数据目录配置 data-sources.json,并在此填写 DSN +# MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database # 留空或不写=本地 SQLite;须 mysql:// 开头且含库名;匠厂 Host 须 ≥ 6.9.28;见 references/DATA_SOURCES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe8225..c289259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.117 + +- 沉淀匠厂 Host ≥ 6.9.28 **多数据源**契约:可选 MySQL 云库 + `data-sources.json` 声明;默认仍为本地 SQLite +- 新增 `references/DATA_SOURCES.md`、`assets/data-sources.example.json`(SQLite 默认)、`assets/examples/data-sources.mysql.example.json` +- `scripts/db/backend.py`、`cursor_compat.py` 与双后端 `connection.py`:CLI / Action 与宿主 UI 读写同一库 +- `.env.example` 增加 `MYSQL_DSN` 说明;CHECKLIST / CONFIG / DATA_PATHS / SCHEMA / ACTIONS 口径同步 + ## 1.0.116 - 沉淀宿主 **年 / 年月** 表单字段契约:`display_type: year`(`YYYY`)、`year_month` / `month`(`YYYY-MM`);与 `date` / `datetime_unix_seconds` 对称,Action 与 CRUD 均适用 diff --git a/SKILL.md b/SKILL.md index 420caff..f107dd0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.116 +version: 1.0.117 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -155,7 +155,7 @@ python {baseDir}/scripts/main.py init-db 4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。 5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。 6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。 -7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。 +7. 本地 SQLite 或可选 MySQL 云库:数据源声明见 `references/DATA_SOURCES.md`;中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。 ## 技能命名与 slug diff --git a/assets/README.md b/assets/README.md index 9de74ca..3f28dd5 100644 --- a/assets/README.md +++ b/assets/README.md @@ -4,7 +4,8 @@ - **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。 - `html/example-report.html`:HTML 快照页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`。不要放进 `pages/`。 - `html/pages/*.html`:装完就出现在侧栏的壳子页(宿主直接读安装包,不经生成)。页面用 `jiangchang.call` 调本技能已声明的命令。见 `references/ACTIONS.md`「HTML 页面报告」。模板仓库不要在这里放示例页。 -- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。 +- `data-sources.example.json`:声明式数据源默认示例(SQLite `{skillSlug}.db`);宿主安装时可复制到用户目录。MySQL 参考见 `examples/data-sources.mysql.example.json`。 +- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `data-sources.mysql.example.json`、`path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。 - `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。 `skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列)/ **`selectionMode`**(`required` \| `none` \| `optional`:表勾选如何驱动 Action;省略则宿主兼容推断)。placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层在会应用勾选时展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。弹窗 `description` / 确认黄条 / 参数说明须**用户可见中文**(勿写 `pickCount`/`async`/`pending`/CLI 子命令;见 ACTIONS.md「用户可见文案」)。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 diff --git a/assets/data-sources.example.json b/assets/data-sources.example.json new file mode 100644 index 0000000..f379ae0 --- /dev/null +++ b/assets/data-sources.example.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "sources": [ + { + "id": "primary", + "kind": "sqlite", + "displayName": "本地库", + "path": "{skillSlug}.db" + } + ] +} diff --git a/assets/examples/README.md b/assets/examples/README.md index ddc0ed4..cbf4fa3 100644 --- a/assets/examples/README.md +++ b/assets/examples/README.md @@ -1,5 +1,6 @@ # 示例 JSON +- `data-sources.mysql.example.json`:MySQL 云库 `data-sources.json` 形状参考(复制到用户数据目录并配 `.env` 中 `MYSQL_DSN`;见 `references/DATA_SOURCES.md`)。 - `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。 - `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。 - `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。 diff --git a/assets/examples/data-sources.mysql.example.json b/assets/examples/data-sources.mysql.example.json new file mode 100644 index 0000000..40ebf11 --- /dev/null +++ b/assets/examples/data-sources.mysql.example.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "sources": [ + { + "id": "primary", + "kind": "mysql", + "displayName": "业务云库", + "connectionEnv": "MYSQL_DSN" + } + ] +} diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json index 1b1e59f..d56ca83 100644 --- a/assets/schemas/skill-actions.schema.json +++ b/assets/schemas/skill-actions.schema.json @@ -85,7 +85,7 @@ "mutationKind": { "type": "string", "enum": ["create", "update", "delete", "import"], - "description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。" + "description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写已注册数据源(默认 SQLite)。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。" }, "scalarArg": { "type": ["string", "number", "boolean"] diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md index 3c885d3..ff4fede 100644 --- a/development/CHECKLIST.md +++ b/development/CHECKLIST.md @@ -151,6 +151,7 @@ checklist_version: 1.0 - [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`) - [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`) - [ ] A6-15 **P1** 若启用 `cellPresentation`:旁路表不得出现在数据管理侧栏;整行着色不得铺到详情每个字段;禁止 `all`×`all`;tone 闭集。未启用 → **N/A**(见 `references/CELL_PRESENTATION.md`) +- [ ] A6-16 **P0** 若走 **MySQL 云库**:用户目录 `data-sources.json` + `.env` DSN 已配置;Host ≥ 6.9.28;CLI / Action 与数据管理 UI 读写**同一库**(抽测 list/create);业务 DDL 支持所选后端。纯 SQLite → **N/A**(见 `references/DATA_SOURCES.md`) --- diff --git a/development/CONFIG.md b/development/CONFIG.md index 25e6645..869386e 100644 --- a/development/CONFIG.md +++ b/development/CONFIG.md @@ -230,8 +230,25 @@ config.get_float("STEP_DELAY_MIN", 1.0) - `python scripts/main.py doctor`:检查浏览器、Profile/账号、`.env` 落盘、设备等。 - `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`。 +## MySQL 云库(可选) + +技能若走 MySQL 云库(匠厂 Host **≥ 6.9.28**),须同时满足: + +1. 用户数据目录有 [`data-sources.json`](../references/DATA_SOURCES.md)(声明 `kind: mysql` 与 `connectionEnv`) +2. `.env` 配置对应 DSN,模板默认为 `MYSQL_DSN` + +```env +# MySQL 云库连接(留空=本地 SQLite) +MYSQL_DSN=mysql://user:password@host:3306/database_name +``` + +**不要**把 DSN 写进 `data-sources.json`。技能 CLI 通过 `scripts/db/backend.py` 读取同一 key,与宿主 UI 保持一致。 + +完整契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)。 + ## 相关文档 +- `DATA_SOURCES.md` — 多数据源声明(SQLite 默认 / MySQL opt-in) - `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释) - `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收 - `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释) diff --git a/development/DATA_PATHS.md b/development/DATA_PATHS.md index aa26a85..82c5142 100644 --- a/development/DATA_PATHS.md +++ b/development/DATA_PATHS.md @@ -52,8 +52,9 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\ | `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` | | `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` | | `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` | -| `{skill_slug}.db` | SQLite | `get_db_path()` | -| `.env` | 用户配置 | `config.get_env_file_path()` | +| `{skill_slug}.db` | SQLite(默认本地库) | `get_db_path()` | +| `data-sources.json` | 声明式数据源(可选;不配则宿主自动扫描 `.db`) | 用户目录根;格式见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md) | +| `.env` | 用户配置(含可选 `MYSQL_DSN`) | `config.get_env_file_path()` | 复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。 diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index faa60fe..e493b15 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -118,8 +118,8 @@ scripts/ 不写具体业务逻辑 - `db/` - 作用:SQLite 连接、建表、repository - 不写页面操作和业务编排 + 作用:数据库连接(默认 SQLite;配 `MYSQL_DSN` 时为 MySQL)、建表、repository + 不写页面操作和业务编排;多数据源契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md) - `service/` 作用:核心业务逻辑 diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 9de5afa..a256671 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -84,7 +84,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表** | 字段 / 层 | 只决定什么 | 不决定什么 | |-----------|------------|------------| | **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 | -| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** | +| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写**已注册数据源**(默认本地 SQLite);**import 时 UI 仍用宿主「导入数据」面板** | | **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | | **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 | | **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** | @@ -231,7 +231,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表** ## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入) -有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。 +有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写默认 CRUD。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。 ### 怎么选 @@ -250,7 +250,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表** 2. **写库**按表能力分流: - 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入) - 仅有 `replaces: create` → 宿主按行调用 create(兼容) - - 都没有 → 宿主直写 SQLite + - 都没有 → 宿主直写已注册数据源(默认 SQLite) **不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。 diff --git a/references/CLI.md b/references/CLI.md index 0a00133..e76b4a7 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -44,7 +44,7 @@ python {baseDir}/scripts/main.py init-db - **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。 - **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。 - **`version`**:输出 JSON(`version`、`skill`)。 -- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。 +- **`init-db`**:幂等创建/迁移当前 active 后端(默认本地 SQLite;配 `MYSQL_DSN` 时为 MySQL 云库,见 `references/DATA_SOURCES.md`),含 `_jiangchang_*` 展示元数据。成功输出一行 JSON(`ok` / `skill` / `db_path` 或等价字段),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。 - **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。 - **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。 - **排查顺序**(由快到慢): diff --git a/references/DATA_SOURCES.md b/references/DATA_SOURCES.md new file mode 100644 index 0000000..ea2a60b --- /dev/null +++ b/references/DATA_SOURCES.md @@ -0,0 +1,139 @@ +# 多数据源声明(SQLite / MySQL 云库) + +本文说明技能如何向匠厂宿主**声明**业务数据库,以及技能 CLI / Action 如何与宿主读写**同一物理库**。 + +**产品定位:** 默认仍是本地 SQLite(`{skill_slug}.db`)。需要多用户共享、集中运维或云库时,可显式声明 **MySQL**。UI / API 契约不变;`_jiangchang_*` 展示元数据仍写在同一逻辑库中。 + +**宿主版本:** 匠厂 Host **≥ 6.9.28** 才支持 `data-sources.json` 与 MySQL 连接器。 + +**完整参考实现:** [`process-work-order`](https://git.jc2009.com/client-commons/process-work-order) @ v1.0.2(工单管理 + MySQL 云库试点)。 + +--- + +## 何时用哪种后端 + +| 场景 | 推荐 | +|------|------| +| 单机、离线、小数据量、便携部署 | **SQLite**(默认,不配声明文件亦可) | +| 多用户共享、运维集中管库、与外部系统共库 | **MySQL 云库**(显式 opt-in) | + +**硬规则:一个 `sourceId` 对应一个物理库。** 禁止双写、禁止 SQLite 与 MySQL 同步镜像。 + +--- + +## 文件位置 + +均在**用户数据目录**(不是安装包目录): + +``` +{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/ +├── data-sources.json # 声明式数据源(可选;见下) +├── .env # MYSQL_DSN 等连接配置 +└── {skill_slug}.db # SQLite 模式下的本地库文件 +``` + +安装/升级时,若用户目录尚无 `data-sources.json` 且技能包内有 `assets/data-sources.example.json`,宿主会**复制一次**示例(不覆盖已有文件)。 + +模板默认示例为 **SQLite 显式声明**(与自动扫描 `{skill_slug}.db` 等价)。改走 MySQL 时,请参照 `assets/examples/data-sources.mysql.example.json` 手工替换用户目录中的 `data-sources.json`,并在 `.env` 配置 DSN。 + +若不需要声明式接入,可删除用户目录中的 `data-sources.json`,宿主恢复自动扫描 `.db` 文件。 + +--- + +## `data-sources.json` 格式 + +根对象: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `version` | `1` | 固定为 1 | +| `sources` | 数组 | 至少一条数据源 | + +每条 `sources[]`: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `id` | 是 | 英文标识,如 `primary` | +| `kind` | 是 | `sqlite` 或 `mysql` | +| `displayName` | 否 | 侧栏展示名;多库时建议填写 | +| `path` | sqlite 必填 | 相对技能数据目录的路径;可用 `{skillSlug}` 占位 | +| `connectionEnv` | mysql 必填 | `.env` 中 DSN 的环境变量名,如 `MYSQL_DSN` | + +### SQLite 示例(模板默认) + +见 [`../assets/data-sources.example.json`](../assets/data-sources.example.json)。 + +### MySQL 示例 + +见 [`../assets/examples/data-sources.mysql.example.json`](../assets/examples/data-sources.mysql.example.json)。 + +--- + +## `.env` 与 DSN + +MySQL 连接串写在用户 `.env`(或进程环境变量),**不要**写进 `data-sources.json`: + +```env +# MySQL 云库连接(不配则走本地 SQLite) +MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database +``` + +格式:`mysql://` 或 `mysql2://`,须含库名。注释写法须符合 [`../development/CONFIG.md`](../development/CONFIG.md)(配置管理会展示给用户)。 + +技能 Python 侧:`scripts/db/backend.py` 读取同一 `MYSQL_DSN`;有值则 `resolve_backend()` 为 `mysql`,否则 `sqlite`。 + +--- + +## 技能代码职责 + +### 宿主 UI(数据管理) + +宿主读取用户目录 `data-sources.json` + `.env`,经 Host 内置连接器访问库。**不依赖**技能 `connection.py`。 + +### CLI / Action / `init-db` + +**必须**与宿主使用同一后端策略,否则会出现「UI 看云库、CLI 写本地 `.db`」的分裂。 + +模板已提供: + +| 模块 | 作用 | +|------|------| +| `scripts/db/backend.py` | `resolve_backend()`、`parse_mysql_dsn()` | +| `scripts/db/cursor_compat.py` | 统一 `?` 占位符与 `executescript` | +| `scripts/db/connection.py` | `get_conn()` 双分支;`init_db()` 幂等建 `_jiangchang_*` | + +业务表 DDL / 迁移仍在技能自己的 `schema` / `init_db()` 中按 `conn.backend` 分支处理(见工单参考实现)。 + +可选:`describe_active_store()` 用于 health / 排障,输出当前连的是 sqlite 还是 mysql。 + +--- + +## MySQL 当前限制(Host) + +- 不支持 `tableAdmin`(建表/改表 UI) +- 不支持 `cellPresentation` +- 元数据与 CRUD 与 SQLite 路径一致 + +表结构变更仍由技能 `init-db` / 迁移脚本负责。 + +--- + +## 开发 Checklist(云库) + +复制新业务技能并选择 MySQL 时: + +1. Host ≥ 6.9.28 +2. 用户目录放置 `data-sources.json`(mysql 源 + `connectionEnv`) +3. `.env` 配置 `MYSQL_DSN`(或你在 json 里声明的 env 名) +4. `requirements.txt` 含 `PyMySQL>=1.1.0`(模板已声明) +5. 业务 DDL 同时支持 sqlite / mysql 分支(或仅部署 mysql) +6. **抽测**:数据管理 UI 与 CLI(如 list / create)读写同一库 +7. 见 [`../development/CHECKLIST.md`](../development/CHECKLIST.md) A6-16 + +--- + +## 与 SCHEMA / ACTIONS 的关系 + +- [`SCHEMA.md`](SCHEMA.md):`_jiangchang_*` 契约适用于**已注册数据源**上的表,不限于本地文件。 +- [`ACTIONS.md`](ACTIONS.md):无 `replaces` 时宿主直写**已注册数据源**(默认本地 SQLite)。 +- [`CLI.md`](CLI.md):`init-db` 对当前 active backend 幂等初始化元数据。 diff --git a/references/README.md b/references/README.md index eeb0ed6..2b0caf4 100644 --- a/references/README.md +++ b/references/README.md @@ -18,10 +18,11 @@ | [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 | | [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 | | [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) | -| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 | +| [`SCHEMA.md`](SCHEMA.md) | 业务表与数据管理展示契约(SQLite / MySQL) | +| [`DATA_SOURCES.md`](DATA_SOURCES.md) | 多数据源声明:SQLite 默认、可选 MySQL 云库(Host ≥ 6.9.28) | | [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) | | [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理呈现:格子 / 整行 / 整列 / 区域语义 tone(用途不限;多数技能 P0 不配) | | [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) | | [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) | -按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。 +按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;云库 / 声明式数据源读 `DATA_SOURCES.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index e0dc5a5..d0ca2f9 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -1,6 +1,6 @@ # 数据存储与数据管理展示规范 -本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。 +本文是 skill **已注册数据源**(默认本地 SQLite 文件;可选 MySQL 云库,见 [`DATA_SOURCES.md`](DATA_SOURCES.md))与匠厂宿主「数据管理」页面展示契约的**权威说明**。 **产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。 @@ -10,7 +10,7 @@ ### 技能负责 -- 定义真实 SQLite 表结构 +- 定义真实业务表结构(SQLite 或 MySQL,按技能所选后端) - 使用英文 `snake_case` 表名和字段名 - 使用 `_jiangchang_tables` 提供表级展示元数据 - 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明) diff --git a/requirements.txt b/requirements.txt index f012641..215a8d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ # 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。 +PyMySQL>=1.1.0 diff --git a/scripts/db/backend.py b/scripts/db/backend.py new file mode 100644 index 0000000..70e087b --- /dev/null +++ b/scripts/db/backend.py @@ -0,0 +1,35 @@ +"""数据库后端选择:配置了 MYSQL_DSN 时走 MySQL,否则回退本地 SQLite。""" + +from __future__ import annotations + +from typing import Literal +from urllib.parse import unquote, urlparse + +from jiangchang_skill_core import config + +DbBackend = Literal["sqlite", "mysql"] + + +def get_mysql_dsn() -> str: + return (config.get("MYSQL_DSN") or "").strip() + + +def resolve_backend() -> DbBackend: + return "mysql" if get_mysql_dsn() else "sqlite" + + +def parse_mysql_dsn(dsn: str) -> dict[str, object]: + text = dsn.strip() + if not text.startswith(("mysql://", "mysql2://")): + raise ValueError("MYSQL_DSN must be a mysql:// URL.") + parsed = urlparse(text) + database = (parsed.path or "").lstrip("/") + if not database: + raise ValueError("MYSQL_DSN must include a database name.") + return { + "host": parsed.hostname or "127.0.0.1", + "port": parsed.port or 3306, + "user": unquote(parsed.username or ""), + "password": unquote(parsed.password or ""), + "database": database, + } diff --git a/scripts/db/connection.py b/scripts/db/connection.py index c31460c..2e61318 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -1,18 +1,38 @@ -"""SQLite 连接与幂等初始化模板。 +"""SQLite / MySQL 连接与幂等初始化模板。 模板默认只建数据管理元数据表。业务表由复制后的技能自行 CREATE,并同步展示元数据。 +配置了 MYSQL_DSN 时走 MySQL 云库,否则使用本地 {skill_slug}.db。见 references/DATA_SOURCES.md。 """ from __future__ import annotations import sqlite3 +from db.backend import get_mysql_dsn, parse_mysql_dsn, resolve_backend +from db.cursor_compat import AdaptedConnection from db.display_metadata import init_display_metadata from util.runtime_paths import get_db_path -def get_conn() -> sqlite3.Connection: - return sqlite3.connect(get_db_path()) +def get_conn() -> AdaptedConnection: + backend = resolve_backend() + if backend == "mysql": + import pymysql + + params = parse_mysql_dsn(get_mysql_dsn()) + raw = pymysql.connect( + host=str(params["host"]), + port=int(params["port"]), + user=str(params["user"]), + password=str(params["password"]), + database=str(params["database"]), + charset="utf8mb4", + autocommit=False, + ) + return AdaptedConnection(raw, "mysql") + + raw = sqlite3.connect(get_db_path()) + return AdaptedConnection(raw, "sqlite") def init_db() -> None: @@ -23,3 +43,18 @@ def init_db() -> None: conn.commit() finally: conn.close() + + +def describe_active_store() -> dict[str, str]: + backend = resolve_backend() + if backend == "mysql": + params = parse_mysql_dsn(get_mysql_dsn()) + return { + "backend": "mysql", + "host": str(params["host"]), + "database": str(params["database"]), + } + return { + "backend": "sqlite", + "db_path": get_db_path(), + } diff --git a/scripts/db/cursor_compat.py b/scripts/db/cursor_compat.py new file mode 100644 index 0000000..80cbf64 --- /dev/null +++ b/scripts/db/cursor_compat.py @@ -0,0 +1,96 @@ +"""统一 SQLite / MySQL 游标:占位符 ? 与 executescript。""" + +from __future__ import annotations + +import re +from typing import Any + +from db.backend import DbBackend + +_PLACEHOLDER_RE = re.compile(r"\?") + + +def adapt_sql(sql: str, backend: DbBackend) -> str: + if backend != "mysql": + return sql + return _PLACEHOLDER_RE.sub("%s", sql) + + +def _split_sql_script(script: str) -> list[str]: + parts: list[str] = [] + for chunk in script.split(";"): + stmt = chunk.strip() + if stmt: + parts.append(stmt) + return parts + + +class AdaptedCursor: + backend: DbBackend + + def __init__(self, cursor: Any, backend: DbBackend) -> None: + self._cursor = cursor + self.backend = backend + + def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any: + adapted = adapt_sql(sql, self.backend) + if params is None: + return self._cursor.execute(adapted) + return self._cursor.execute(adapted, params) + + def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any: + return self._cursor.executemany(adapt_sql(sql, self.backend), seq_of_params) + + def fetchone(self) -> Any: + return self._cursor.fetchone() + + def fetchall(self) -> list[Any]: + return self._cursor.fetchall() + + @property + def lastrowid(self) -> int: + return int(self._cursor.lastrowid) + + @property + def rowcount(self) -> int: + return int(self._cursor.rowcount) + + def executescript(self, script: str) -> None: + if self.backend == "sqlite": + self._cursor.connection.executescript(script) + return + for stmt in _split_sql_script(script): + self.execute(stmt) + + +class AdaptedConnection: + backend: DbBackend + + def __init__(self, conn: Any, backend: DbBackend) -> None: + self._conn = conn + self.backend = backend + + def cursor(self) -> AdaptedCursor: + return AdaptedCursor(self._conn.cursor(), self.backend) + + def commit(self) -> None: + self._conn.commit() + + def close(self) -> None: + self._conn.close() + + def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any: + cur = self.cursor() + return cur.execute(sql, params) + + def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any: + cur = self.cursor() + return cur.executemany(sql, seq_of_params) + + def executescript(self, script: str) -> None: + if self.backend == "sqlite": + self._conn.executescript(script) + return + cur = self.cursor() + for stmt in _split_sql_script(script): + cur.execute(stmt) diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 020ca80..24b28d7 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.116" +SKILL_VERSION = "1.0.117" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/tests/test_db_backend.py b/tests/test_db_backend.py new file mode 100644 index 0000000..776553e --- /dev/null +++ b/tests/test_db_backend.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""数据库后端选择:默认 SQLite,DSN 解析。""" +from __future__ import annotations + +import os +import unittest + +from _support import IsolatedDataRoot + + +class TestDbBackend(unittest.TestCase): + def test_resolve_backend_defaults_to_sqlite(self) -> None: + with IsolatedDataRoot(): + from db.backend import resolve_backend + + self.assertEqual(resolve_backend(), "sqlite") + + def test_parse_mysql_dsn(self) -> None: + with IsolatedDataRoot(): + from db.backend import parse_mysql_dsn + + params = parse_mysql_dsn("mysql://root:secret@192.168.0.20:3306/demo_db") + self.assertEqual(params["host"], "192.168.0.20") + self.assertEqual(params["port"], 3306) + self.assertEqual(params["user"], "root") + self.assertEqual(params["password"], "secret") + self.assertEqual(params["database"], "demo_db") + + def test_resolve_backend_uses_mysql_dsn_from_env(self) -> None: + with IsolatedDataRoot(): + from db.backend import resolve_backend + + os.environ["MYSQL_DSN"] = "mysql://u:p@127.0.0.1:3306/t" + try: + self.assertEqual(resolve_backend(), "mysql") + finally: + os.environ.pop("MYSQL_DSN", None) + + def test_describe_active_store_sqlite(self) -> None: + with IsolatedDataRoot(): + from db.connection import describe_active_store + + info = describe_active_store() + self.assertEqual(info["backend"], "sqlite") + self.assertIn("db_path", info) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index 9793898..3aa8868 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -385,6 +385,28 @@ class TestDocsStandards(unittest.TestCase): ): self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}") + def test_data_sources_md_documents_mysql_and_declaration(self) -> None: + text = self._read("references/DATA_SOURCES.md") + for marker in ( + "data-sources.json", + "MYSQL_DSN", + "6.9.28", + "resolve_backend", + "一个 `sourceId`", + "process-work-order", + "tableAdmin", + ): + self.assertIn(marker, text, msg=f"DATA_SOURCES.md missing {marker!r}") + self.assertTrue(os.path.isfile(os.path.join(get_skill_root(), "assets/data-sources.example.json"))) + self.assertTrue( + os.path.isfile(os.path.join(get_skill_root(), "assets/examples/data-sources.mysql.example.json")) + ) + env_example = self._read(".env.example") + self.assertIn("MYSQL_DSN", env_example) + checklist = self._read("development/CHECKLIST.md") + self.assertIn("A6-16", checklist) + self.assertIn("DATA_SOURCES.md", checklist) + def test_actions_md_documents_form_schema_split(self) -> None: text = self._read("references/ACTIONS.md") for marker in (