Compare commits
6 Commits
main
..
dsh-refact
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff588a65d | |||
| 485e77ee74 | |||
| 92289b17ed | |||
| ef9508e597 | |||
| a14a8d9233 | |||
| 01218b2907 |
@@ -38,3 +38,7 @@ Thumbs.db
|
|||||||
/output/
|
/output/
|
||||||
/nul
|
/nul
|
||||||
/new_all_bag.xlsx
|
/new_all_bag.xlsx
|
||||||
|
|
||||||
|
# Workbench (dsh plugin) local artifacts
|
||||||
|
/workbench/plugin/node_modules/
|
||||||
|
/workbench/cordis.local.yml
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ var/ 默认运行数据;不属于源码
|
|||||||
- `shop_intelligence`:店铺、竞店和京东自营业绩。
|
- `shop_intelligence`:店铺、竞店和京东自营业绩。
|
||||||
- `supply_chain`:采购确认、补货、库存预警和采购单更新。
|
- `supply_chain`:采购确认、补货、库存预警和采购单更新。
|
||||||
|
|
||||||
|
## 智能工作台(dsh-refact 分支)
|
||||||
|
|
||||||
|
`workbench/` 以 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 为
|
||||||
|
agent 底座,把现有工作流以 dsh 插件形式接入 Web UI:侧边栏选中任意工作流即可提问、
|
||||||
|
诊断、修复,支持监控告警与受控启动。使用与设计见 [workbench/README.md](workbench/README.md)
|
||||||
|
与 [docs/workbench.md](docs/workbench.md)。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -File workbench\bin\start-workbench.ps1
|
||||||
|
```
|
||||||
|
|
||||||
## 开发环境
|
## 开发环境
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
+19
-1
@@ -114,7 +114,7 @@
|
|||||||
"workflow_id": "product.erp_all_shop_daily"
|
"workflow_id": "product.erp_all_shop_daily"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"at": "10:00",
|
"at": "12:00",
|
||||||
"business_date_offset_days": 0,
|
"business_date_offset_days": 0,
|
||||||
"days": [
|
"days": [
|
||||||
"Tuesday"
|
"Tuesday"
|
||||||
@@ -143,6 +143,24 @@
|
|||||||
"kind": "interval_days",
|
"kind": "interval_days",
|
||||||
"workflow_id": "product.style_analysis.interval"
|
"workflow_id": "product.style_analysis.interval"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"anchor_date": "2026-09-04",
|
||||||
|
"at": "14:00",
|
||||||
|
"business_date_offset_days": 0,
|
||||||
|
"enabled": true,
|
||||||
|
"every_days": 3,
|
||||||
|
"kind": "interval_days",
|
||||||
|
"workflow_id": "product.jd_video_upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anchor_date": "2026-09-04",
|
||||||
|
"at": "14:00",
|
||||||
|
"business_date_offset_days": 0,
|
||||||
|
"enabled": true,
|
||||||
|
"every_days": 3,
|
||||||
|
"kind": "interval_days",
|
||||||
|
"workflow_id": "product.video_upload"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"at": "08:30",
|
"at": "08:30",
|
||||||
"days": [
|
"days": [
|
||||||
|
|||||||
@@ -1638,7 +1638,7 @@
|
|||||||
{
|
{
|
||||||
"id": "product.video_upload",
|
"id": "product.video_upload",
|
||||||
"module": "product_commerce",
|
"module": "product_commerce",
|
||||||
"trigger": "manual",
|
"trigger": "scheduled",
|
||||||
"execution": {
|
"execution": {
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
@@ -1703,12 +1703,12 @@
|
|||||||
"label": "天猫话题关键词",
|
"label": "天猫话题关键词",
|
||||||
"hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。"
|
"hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。"
|
||||||
},
|
},
|
||||||
"note": "前端手动选择旗舰店或箱包店后执行;脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。"
|
"note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动选择旗舰店或箱包店后执行。脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "product.jd_video_upload",
|
"id": "product.jd_video_upload",
|
||||||
"module": "product_commerce",
|
"module": "product_commerce",
|
||||||
"trigger": "manual",
|
"trigger": "scheduled",
|
||||||
"execution": {
|
"execution": {
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
@@ -1765,7 +1765,7 @@
|
|||||||
"source_project": "product",
|
"source_project": "product",
|
||||||
"task_name": "JdVideoUpload"
|
"task_name": "JdVideoUpload"
|
||||||
},
|
},
|
||||||
"note": "前端手动幂等工作流;每次点击从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。"
|
"note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动触发。每次从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "product.tmall_baibu_apply",
|
"id": "product.tmall_baibu_apply",
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
## 配置源
|
## 配置源
|
||||||
|
|
||||||
商品 ID、ERP 款式编码和各业务飞书目标表现在统一保存在云端 PostgreSQL。
|
商品身份现在统一由产品生命进程维护,目标地址和平台画像等运行参数仍保存在云端
|
||||||
控制台“配置中心 → 款式平台配置”提供查询、新增、编辑、
|
PostgreSQL 的动态配置表。生命进程负责款式名、品牌、ERP 款式编码、各平台商品编码
|
||||||
停用和删除。工作流运行时只读取这些项目表,不再读取旧配置主表
|
以及大/小 NFC 数量;控制台“配置中心 → 款式平台配置”继续负责目标地址、画像表和启停状态。
|
||||||
|
工作流运行时将两者合并,不再读取旧配置主表
|
||||||
`TtoCb1NuQaDy3NsZWTpc0GIvnph/tblKCjplVAFrRwMC`。
|
`TtoCb1NuQaDy3NsZWTpc0GIvnph/tblKCjplVAFrRwMC`。
|
||||||
|
|
||||||
|
产品采集的统一身份门是:ERP 款式编码 + 至少一个平台商品编码。只有 ERP 款式编码的
|
||||||
|
研发新款会保留在生命进程中,但不会进入商品采集;如果指定平台没有自己的商品编码,
|
||||||
|
该平台也不会触发采集。内容汇总类工作流只需要款式名和对应目标,不因缺少商品编码而被
|
||||||
|
这道商品采集门拦截。
|
||||||
|
|
||||||
目标飞书表仍是工作流的业务输入或输出,例如销量表、主图表、人群画像表和合作达人表;
|
目标飞书表仍是工作流的业务输入或输出,例如销量表、主图表、人群画像表和合作达人表;
|
||||||
本次下线的是集中维护这些地址和商品 ID 的旧飞书索引表,不是业务目标表本身。
|
本次下线的是集中维护这些地址和商品 ID 的旧飞书索引表,不是业务目标表本身。
|
||||||
|
|
||||||
@@ -38,21 +44,25 @@
|
|||||||
|
|
||||||
| 层级 | 字段 | 使用目的 |
|
| 层级 | 字段 | 使用目的 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 款式 | 款式名、品牌、ERP 款式编码 | 统一业务身份;ERP 日采、月度销量表和款式主数据使用 |
|
| 产品生命进程 | 款式名、品牌、ERP 款式编码 | 统一业务身份;ERP 日采、月度销量表和款式主数据使用 |
|
||||||
| 款式 | 销量表、主图表 | 商品日报和天猫/京东主图工作流的款式目标表 |
|
| 产品生命进程 | `platform_product_ids` | JSONB 固定键 `tm`、`jd`、`jd_self`、`dy`,值为去重后的字符串编码数组 |
|
||||||
| 款式 | 合作达人表、自营合作达人表 | 内容采集、合作同步和笔记清单使用 |
|
| 产品生命进程 | 大 NFC 数、小 NFC 数 | 产品规格记录,默认值为 1,允许填写 0 |
|
||||||
| 款式 | 每周/每月笔记分析、平台单品分析 | 内容周月报和款式周期分析使用 |
|
| 动态配置父表 | 销量表、主图表 | 商品日报和天猫/京东主图工作流的款式目标表 |
|
||||||
| 平台 | 平台名、商品 ID、启用状态 | 天猫商品 ID、京东 SPU、京东自营 SKU、抖音/PDD 商品 ID |
|
| 动态配置父表 | 合作达人表、自营合作达人表 | 内容采集、合作同步和笔记清单使用 |
|
||||||
|
| 动态配置父表 | 每周/每月笔记分析、平台单品分析 | 内容周月报和款式周期分析使用 |
|
||||||
|
| 动态配置子表 | 平台名、启用状态和画像目标 | 商品 ID 只保留兼容快照;运行时商品 ID 由生命进程覆盖,平台停用状态仍然生效 |
|
||||||
| 平台 | 本平台人群画像表 | 天猫、京东、抖音画像采集分别写入自己的目标表 |
|
| 平台 | 本平台人群画像表 | 天猫、京东、抖音画像采集分别写入自己的目标表 |
|
||||||
|
|
||||||
数据库使用父子表:
|
数据库使用父子表:
|
||||||
|
|
||||||
- `workflow_dynamic_styles`:一个款式一行,保存 ERP 和共享飞书目标。
|
- `cmt_styles`:产品生命进程主表,一个款式一行,保存 ERP 编码、四个平台商品编码和 NFC 数量。
|
||||||
- `workflow_dynamic_style_platforms`:一个款式可有多个平台子行,只保存平台商品 ID、平台画像表和启停状态。
|
- `workflow_dynamic_styles`:一个款式一行,保存共享飞书目标及内容工作流参数。
|
||||||
|
- `workflow_dynamic_style_platforms`:一个款式可有多个平台子行,保留平台画像表、启停状态和旧商品 ID 兼容快照。
|
||||||
- 旧 `workflow_dynamic_configs` 保留为原始迁移审计,不再参与运行时读取。
|
- 旧 `workflow_dynamic_configs` 保留为原始迁移审计,不再参与运行时读取。
|
||||||
- `erp_codes`、`item_ids` 使用 PostgreSQL 数组,页面接受中英文逗号或换行并自动去重。
|
- `erp_codes`、`item_ids` 使用 PostgreSQL 数组,页面接受中英文逗号或换行并自动去重。
|
||||||
- `destinations` 使用通用 JSONB 目标列表,每项包含 `key`、`label`、`url`、`description`、`enabled`;六个旧目标字段仍同步保留,保证已有工作流兼容。后续新增分析逻辑只需增加一个稳定的 `key` 和对应飞书表地址。
|
- `destinations` 使用通用 JSONB 目标列表,每项包含 `key`、`label`、`url`、`description`、`enabled`;六个旧目标字段仍同步保留,保证已有工作流兼容。后续新增分析逻辑只需增加一个稳定的 `key` 和对应飞书表地址。
|
||||||
- 款式带递增 `revision`;整页保存和删除必须提交 `If-Match`,避免并发覆盖。
|
- 款式带递增 `revision`;整页保存和删除必须提交 `If-Match`,避免并发覆盖。
|
||||||
|
- `cmt_styles.platform_product_ids` 的四个键固定为 `tm`(天猫)、`jd`(京东旗舰店)、`jd_self`(京东自营)、`dy`(抖音);空数组表示该平台尚未补齐。
|
||||||
|
|
||||||
## 首次迁移
|
## 首次迁移
|
||||||
|
|
||||||
@@ -65,17 +75,52 @@
|
|||||||
4. 运行 v2 归一化迁移:按款式合并共享字段,按平台合并商品 ID;旧表中集中在天猫行的三平台画像地址分别迁入对应平台子行;
|
4. 运行 v2 归一化迁移:按款式合并共享字段,按平台合并商品 ID;旧表中集中在天猫行的三平台画像地址分别迁入对应平台子行;
|
||||||
5. 写入迁移标记 `workflow-style-platform-config-20260819-v2`,此后不会重复迁移。
|
5. 写入迁移标记 `workflow-style-platform-config-20260819-v2`,此后不会重复迁移。
|
||||||
|
|
||||||
|
产品生命进程身份迁移随后执行 `product-lifecycle-style-identity-20260907-v1`:
|
||||||
|
|
||||||
|
1. 为 `cmt_styles` 补齐平台商品编码和大/小 NFC 字段;
|
||||||
|
2. 将已有 `dim_style` 的 ERP、天猫、京东、京东自营、抖音编码补入生命进程的空字段;
|
||||||
|
3. 对仍没有生命进程商品编码的旧动态配置做一次兼容提升;
|
||||||
|
4. 运行时以生命进程中的非空 JSONB 固定键为准,显式空数组会清除旧动态配置中的对应编码。
|
||||||
|
|
||||||
|
因此新增款式可先只填写 ERP 款式编码,等平台商品编码补齐后再进入对应采集;不会因为动态配置表
|
||||||
|
中残留旧编码而提前采集。
|
||||||
|
|
||||||
|
### 从旧飞书表重新校准生命进程
|
||||||
|
|
||||||
|
如果需要用现有“各平台款式 ID 收集表”补齐或校准生命进程身份,使用一次性同步脚本:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --env-file 'D:/product-collector-analyze-flow/.env' `
|
||||||
|
--project 'D:/gyxx-flow' `
|
||||||
|
python -m gyxx_flow.migration.sync_feishu_style_identity
|
||||||
|
|
||||||
|
uv run --env-file 'D:/product-collector-analyze-flow/.env' `
|
||||||
|
--project 'D:/gyxx-flow' `
|
||||||
|
python -m gyxx_flow.migration.sync_feishu_style_identity --apply
|
||||||
|
|
||||||
|
uv run --env-file 'D:/product-collector-analyze-flow/.env' `
|
||||||
|
--project 'D:/gyxx-flow' `
|
||||||
|
python 'src/gyxx_flow/modules/product_commerce/db/sync_dim_style.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本按款式合并旧表的多平台行,只写入 `cmt_styles` 的品牌、ERP 编码和四个平台商品编码,
|
||||||
|
不会覆盖 NFC、图片、类目或市场标签;空源字段不会清空生命进程已有身份。拼多多等当前四键
|
||||||
|
模型之外的平台只保留在动态平台配置/审计中,不会伪装成天猫、京东或抖音编码。默认是预览,
|
||||||
|
必须显式传 `--apply` 才会写数据库。
|
||||||
|
|
||||||
服务器必须注入 `GYXX_POSTGRES_DSN`,或完整的 `PG_HOST/PG_PORT/PG_DB/PG_USER/PG_PASSWORD`。
|
服务器必须注入 `GYXX_POSTGRES_DSN`,或完整的 `PG_HOST/PG_PORT/PG_DB/PG_USER/PG_PASSWORD`。
|
||||||
没有数据库且没有数据库生成的运行缓存时,相关工作流会明确失败,不会静默回读旧飞书主表。
|
没有数据库且没有数据库生成的运行缓存时,相关工作流会明确失败,不会静默回读旧飞书主表。
|
||||||
|
|
||||||
## 运行时路径
|
## 运行时路径
|
||||||
|
|
||||||
```text
|
```text
|
||||||
控制台分组 CRUD
|
产品生命进程 cmt_styles (商品身份)
|
||||||
|
+
|
||||||
|
workflow_dynamic_styles + workflow_dynamic_style_platforms (目标/画像/启停)
|
||||||
↓
|
↓
|
||||||
workflow_dynamic_styles + workflow_dynamic_style_platforms (PostgreSQL)
|
统一运行时聚合
|
||||||
├─ 兼容聚合输出 → StyleConfigLoader → 商品经营工作流
|
├─ 完整身份门 → StyleConfigLoader → 商品经营工作流
|
||||||
└─ 兼容聚合输出 → feishu_mapping → 内容营销工作流 → 各业务飞书目标表
|
└─ 目标兼容输出 → feishu_mapping → 内容营销工作流 → 各业务飞书目标表
|
||||||
```
|
```
|
||||||
|
|
||||||
`StyleConfigLoader` 只在数据库短暂不可用时使用最近一次数据库成功读取后生成的本地缓存;
|
`StyleConfigLoader` 只在数据库短暂不可用时使用最近一次数据库成功读取后生成的本地缓存;
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,7 @@ uv run gyxx scripts run <command_id> --date 2026-08-01 --execute
|
|||||||
|
|
||||||
### 天猫视频上传工作流
|
### 天猫视频上传工作流
|
||||||
|
|
||||||
天猫视频上传注册为手动幂等工作流 `product.video_upload`,不会被常驻调度器自动触发。前端“立即运行”窗口会显示“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”单选下拉框,并允许填写天猫话题完整名称或关键词;正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行:
|
天猫视频上传注册为幂等定时工作流 `product.video_upload`,按 `config/schedules.json` 以 2026-09-04 为起始日每 3 天 14:00(Asia/Shanghai)执行;前端仍可手动选择“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”并立即运行,也可填写天猫话题完整名称或关键词。正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run gyxx run product.video_upload --date 2026-08-07
|
uv run gyxx run product.video_upload --date 2026-08-07
|
||||||
@@ -74,7 +74,7 @@ uv run gyxx scripts run product.video_upload.run --date 2026-08-07 --arg=--recor
|
|||||||
|
|
||||||
### 京东视频上传工作流
|
### 京东视频上传工作流
|
||||||
|
|
||||||
京东视频上传注册为手动幂等工作流 `product.jd_video_upload`。它不会被常驻调度器自动触发;同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`:
|
京东视频上传注册为幂等定时工作流 `product.jd_video_upload`,按 `config/schedules.json` 以 2026-09-04 为起始日每 3 天 14:00(Asia/Shanghai)执行;前端仍可手动立即运行,同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run gyxx run product.jd_video_upload --date 2026-08-07
|
uv run gyxx run product.jd_video_upload --date 2026-08-07
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 智能工作台设计(dsh-refact 分支)
|
||||||
|
|
||||||
|
> 状态:v1 已实现。本文档记录「以 deepseek-harness 为 agent 底座、gyxx-flow 工作流插件化」
|
||||||
|
> 的架构决策与边界。使用说明见 [../workbench/README.md](../workbench/README.md)。
|
||||||
|
|
||||||
|
## 目标与约束
|
||||||
|
|
||||||
|
- **底座**:deepseek-harness(`dsh`)Web UI + DeepSeek 模型 = 智能体运行时;
|
||||||
|
gyxx-flow 保持唯一的工作流执行/调度事实来源(LangGraph 引擎、`RunJournal`、
|
||||||
|
`LockManager`、`EffectLedger` 契约不变)。
|
||||||
|
- **插件化**:不重写工作流;通过 dsh「everything-is-a-plugin」扩展点把现有
|
||||||
|
控制台能力投影为智能体工具与 UI 面板。
|
||||||
|
- **UI**:在原 dsh Web UI 侧边栏追加工作流入口(`sidebar.footer.action` +
|
||||||
|
`shell.overlay` 两个**追加型**插槽,不替换任何内置区域),选中工作流即可
|
||||||
|
提问 / 诊断 / 修复。
|
||||||
|
- **生产约束**(AGENTS.md):`gyxx schedule run` 仍是唯一生产调度路径;
|
||||||
|
正式执行保持 `execute + confirmed` 双确认;控制台仍以 `--env-file` 注入云端凭据
|
||||||
|
(启动脚本默认 `D:\product-collector-analyze-flow\.env`)。
|
||||||
|
|
||||||
|
## 为什么不把 gyxx-flow 改写进 dsh monorepo
|
||||||
|
|
||||||
|
dsh 是 pnpm monorepo(host/client 双聚合、Typert 远程契约、自有构建链)。
|
||||||
|
把 Python 工作流引擎迁进去既不可能也无必要。dsh 的外部插件机制
|
||||||
|
(`--patch` cordis.yml + 绝对路径插件 + `dsh.client` 包声明)就是为这种
|
||||||
|
「外部系统接入」设计的。我们选择**进程外集成**:
|
||||||
|
|
||||||
|
- gyxx-flow 控制台 HTTP API 是唯一集成面(已含脱敏、并发守卫、写操作令牌);
|
||||||
|
- 工作台插件只是控制台的客户端 + 智能体能力注册器;
|
||||||
|
- 控制台挂了,dsh 照常可用;dsh 挂了,调度器照常跑。
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
### 1. 控制台诊断端点(Python,`src/gyxx_flow/console.py`)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/workflows/{id}` | 单工作流详情(overview 投影 + `schedule_revision`) |
|
||||||
|
| `GET /api/workflows/{id}/runs/{run_id}` | 单次运行 + 步骤明细 |
|
||||||
|
| `GET /api/workflows/{id}/runs/{run_id}/diagnosis` | 诊断包:运行 + journal trace + 脱敏日志尾部 |
|
||||||
|
|
||||||
|
诊断包日志来源:`run.json` 的 `trace.paths.log` 目录 + 运行时间窗内的控制台日志
|
||||||
|
(`logs/console/{workflow_id}-*.log`),每个文件限读尾部 256KB,经 `_sanitize_error`
|
||||||
|
脱敏并截断,单响应最多 5 个文件。`run_id` 与 `workflow_id` 强制配对(404 不泄露
|
||||||
|
跨工作流记录)。
|
||||||
|
|
||||||
|
### 2. 宿主插件(`workbench/plugin/gyxx-workbench.mjs`,零运行时依赖)
|
||||||
|
|
||||||
|
- **7 个工具**(原始 JSON-Schema `ToolDefinition`,不 import 任何 dsh 包,
|
||||||
|
保证以绝对路径加载时无解析风险);
|
||||||
|
- **系统提示词段**(`gyxx-workbench`,order 700):中文运维契约与安全规则;
|
||||||
|
- **失败监控器**:`ctx.effect` 轮询,`workflow_id+run_id` 去重,自定义事件
|
||||||
|
`gyxx-workbench/alert` + 可选自动诊断会话;
|
||||||
|
- **桥接服务**(127.0.0.1:8790):为浏览器面板代理控制台 API(控制台禁 CORS,
|
||||||
|
面板无法直连),并承载 `/bridge/ask` 会话创建与降级页面。
|
||||||
|
写操作校验 `x-gyxx-workbench: 1` + JSON content-type;CORS 仅回环来源。
|
||||||
|
|
||||||
|
### 3. 侧边栏面板(`workbench/plugin/client/`)
|
||||||
|
|
||||||
|
- React 源码经 esbuild 打包为 CJS,平台模块(react 等)按 dsh 模块表协议外置,
|
||||||
|
包装为 `window.__ModuleLoader__.load({id, factory})`——与官方 tsdown 产物同协议;
|
||||||
|
- `package.json` 的 `dsh.client` 声明让 `client-modules` 扫描器(支持路径型
|
||||||
|
Loader 条目,向上找最近 package.json)发现并提供该浏览器包;
|
||||||
|
- 插槽:`sidebar.footer.action`(「工作流」开关 + 聚合状态点)、`shell.overlay`
|
||||||
|
(左抽屉面板 + 失败 toast)。均为 list 型追加插槽,不替换内置区域;
|
||||||
|
- 面板动作:提问/诊断/修复(创建带工作流上下文的智能体会话)、试运行/正式运行
|
||||||
|
(浏览器端 confirm)、停止、运行历史查看、单运行诊断视图。
|
||||||
|
|
||||||
|
### 4. 会话创建(`/bridge/ask`)
|
||||||
|
|
||||||
|
`ctx.agents.create({ sessionId, meta: { cwd, origin } })` → `agent.followup()` 注入
|
||||||
|
带工作流上下文的首条用户消息。新会话自动出现在 dsh 会话列表(host 侧创建即入册)。
|
||||||
|
文案模板区分 ask/diagnose/repair 三种动作。
|
||||||
|
|
||||||
|
## 已知限制(v1)
|
||||||
|
|
||||||
|
- 会话创建后不能自动聚焦(dsh 客户端无公开的「选中会话」运行时 API);面板以
|
||||||
|
toast 提示用户在会话列表中点开。
|
||||||
|
- `sidebar.workspaces` 为 single 型插槽(替换即失去会话树),故面板走 overlay
|
||||||
|
抽屉而非内嵌会话树;若未来 dsh 提供追加型侧栏区块插槽,可平移。
|
||||||
|
- 客户端包协议依赖 dsh 未冻结的 developer-preview 契约;升级 dsh 需回归验证
|
||||||
|
(README「依赖的 dsh 扩展点」一节列出了核对清单)。
|
||||||
|
- `autoDiagnose` 默认关闭:自动开会话会产生模型调用成本。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- Python:`tests/test_console.py` 新增 5 用例(详情/运行详情/诊断包/越权配对/HTTP 路由)。
|
||||||
|
- 插件:`workbench/plugin/tests/`(node:test)——工具注册、离线错误结构化、
|
||||||
|
confirmed 守卫、桥接代理。
|
||||||
|
- 端到端手工验证:`start-workbench.ps1` → 面板选中工作流 → 提问/诊断/修复/试运行。
|
||||||
@@ -41,7 +41,7 @@ uv run gyxx schedule run
|
|||||||
- 查看工作流定义、执行步骤、定时规则和下一次启动时间;
|
- 查看工作流定义、执行步骤、定时规则和下一次启动时间;
|
||||||
- 修改定时类型、一个或多个时间、日期规则、启停状态和业务日期偏移;
|
- 修改定时类型、一个或多个时间、日期规则、启停状态和业务日期偏移;
|
||||||
- 配置实际支持业务通知的工作流、发送应用和一个或多个收件人;
|
- 配置实际支持业务通知的工作流、发送应用和一个或多个收件人;
|
||||||
- 在云端数据库中增删改查商品 ID、ERP 款式编码和各业务飞书目标表;
|
- 在云端数据库中增删改查业务目标表、平台画像目标和启停状态;款式名、ERP 款式编码、各平台商品编码及大/小 NFC 数统一在“产品生命进程”维护;
|
||||||
- 查看昨天或指定执行日的逐工作流运行汇总、异常原因和修复建议;
|
- 查看昨天或指定执行日的逐工作流运行汇总、异常原因和修复建议;
|
||||||
- 以安全预演或正式执行方式手动触发已注册工作流;
|
- 以安全预演或正式执行方式手动触发已注册工作流;
|
||||||
- 查看最近运行状态、步骤统计和经过脱敏的错误详情。
|
- 查看最近运行状态、步骤统计和经过脱敏的错误详情。
|
||||||
@@ -52,9 +52,10 @@ uv run gyxx schedule run
|
|||||||
|
|
||||||
## 商品与款式配置
|
## 商品与款式配置
|
||||||
|
|
||||||
侧栏“商品与款式配置”维护商品经营和内容营销共用的动态配置。页面提供款式、品牌、平台、
|
侧栏“商品与款式配置”维护商品经营和内容营销共用的动态目标配置。页面提供款式、品牌、平台、
|
||||||
商品 ID、ERP 编码、启停状态和各业务飞书目标表的查询与 CRUD;保存后从下一次新启动的工作流
|
启停状态、平台画像和各业务飞书目标表的查询与 CRUD;款式身份字段以产品生命进程为准,动态表
|
||||||
生效。编辑和删除带行版本校验,旧页面不能覆盖其他操作员已保存的新版本。
|
中的历史 ERP/商品 ID 只作为兼容快照。保存后从下一次新启动的工作流生效。编辑和删除带行版本
|
||||||
|
校验,旧页面不能覆盖其他操作员已保存的新版本。
|
||||||
|
|
||||||
配置存储在云端 PostgreSQL,旧飞书配置主表不再属于运行路径。首次迁移、字段模型、受影响
|
配置存储在云端 PostgreSQL,旧飞书配置主表不再属于运行路径。首次迁移、字段模型、受影响
|
||||||
工作流和失败回退边界见 [工作流动态配置](dynamic-workflow-config.md)。
|
工作流和失败回退边界见 [工作流动态配置](dynamic-workflow-config.md)。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from pathlib import Path, PurePosixPath
|
|||||||
from typing import Any, TextIO
|
from typing import Any, TextIO
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from gyxx_flow.adapters.browser import BrowserCookieStore
|
from gyxx_flow.adapters.browser import BrowserCookieStore, looks_like_login_url
|
||||||
from gyxx_flow.adapters.integration import (
|
from gyxx_flow.adapters.integration import (
|
||||||
RuntimeIntegrationCatalog,
|
RuntimeIntegrationCatalog,
|
||||||
_account_cookie_valid,
|
_account_cookie_valid,
|
||||||
@@ -634,11 +634,7 @@ def _origin_matches_account(
|
|||||||
|
|
||||||
|
|
||||||
def _looks_like_login_url(url: str) -> bool:
|
def _looks_like_login_url(url: str) -> bool:
|
||||||
parsed = urlparse(str(url))
|
return looks_like_login_url(url)
|
||||||
hostname = (parsed.hostname or "").casefold()
|
|
||||||
path = parsed.path.casefold()
|
|
||||||
markers = ("login", "passport", "signin", "sign-in")
|
|
||||||
return any(marker in hostname or marker in path for marker in markers)
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_account_page(page: Any, account: Any) -> dict[str, object]:
|
def _probe_account_page(page: Any, account: Any) -> dict[str, object]:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from contextlib import contextmanager
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from gyxx_flow.core.artifacts import atomic_write_json
|
from gyxx_flow.core.artifacts import atomic_write_json
|
||||||
from gyxx_flow.core.layout import DataLayout
|
from gyxx_flow.core.layout import DataLayout
|
||||||
@@ -157,3 +158,13 @@ def persist_browser_state(
|
|||||||
store.save_cookies(cookies)
|
store.save_cookies(cookies)
|
||||||
store.save_storage_state(storage_state)
|
store.save_storage_state(storage_state)
|
||||||
return len(cookies)
|
return len(cookies)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_login_url(url: str) -> bool:
|
||||||
|
"""Whether a page URL looks like a platform login/passport redirect."""
|
||||||
|
|
||||||
|
parsed = urlparse(str(url))
|
||||||
|
hostname = (parsed.hostname or "").casefold()
|
||||||
|
path = parsed.path.casefold()
|
||||||
|
markers = ("login", "passport", "signin", "sign-in")
|
||||||
|
return any(marker in hostname or marker in path for marker in markers)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from gyxx_flow.adapters.integration import (
|
|||||||
MIGRATION_KEY = "feishu-style-config-20260819-v1"
|
MIGRATION_KEY = "feishu-style-config-20260819-v1"
|
||||||
NORMALIZED_MIGRATION_KEY = "workflow-style-platform-config-20260819-v2"
|
NORMALIZED_MIGRATION_KEY = "workflow-style-platform-config-20260819-v2"
|
||||||
PERSONA_PLATFORM_MIGRATION_KEY = "workflow-style-platform-persona-20260819-v3"
|
PERSONA_PLATFORM_MIGRATION_KEY = "workflow-style-platform-persona-20260819-v3"
|
||||||
|
LIFECYCLE_IDENTITY_MIGRATION_KEY = "product-lifecycle-style-identity-20260907-v1"
|
||||||
DEFAULT_SEED_PATH = (
|
DEFAULT_SEED_PATH = (
|
||||||
Path(__file__).resolve().parents[3]
|
Path(__file__).resolve().parents[3]
|
||||||
/ "config"
|
/ "config"
|
||||||
@@ -77,6 +78,13 @@ KNOWN_PLATFORMS = (
|
|||||||
"拼多多光影行星官方旗舰店",
|
"拼多多光影行星官方旗舰店",
|
||||||
"光影行星GYXX箱包专卖店",
|
"光影行星GYXX箱包专卖店",
|
||||||
)
|
)
|
||||||
|
LIFECYCLE_PLATFORM_LABELS = {
|
||||||
|
"tm": "天猫",
|
||||||
|
"jd": "京东旗舰店",
|
||||||
|
"jd_self": "京东自营",
|
||||||
|
"dy": "抖音",
|
||||||
|
}
|
||||||
|
LIFECYCLE_PLATFORM_KEYS = tuple(LIFECYCLE_PLATFORM_LABELS)
|
||||||
|
|
||||||
STYLE_FIELDS = (
|
STYLE_FIELDS = (
|
||||||
"style_name",
|
"style_name",
|
||||||
@@ -252,7 +260,7 @@ class WorkflowConfigRepository(Protocol):
|
|||||||
def _clean_list(value: Any) -> list[str]:
|
def _clean_list(value: Any) -> list[str]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return []
|
return []
|
||||||
parts = value if isinstance(value, list) else re.split(r"[,,\r\n]", str(value))
|
parts = value if isinstance(value, (list, tuple, set)) else re.split(r"[,,\r\n]", str(value))
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for item in parts:
|
for item in parts:
|
||||||
@@ -263,6 +271,155 @@ def _clean_list(value: Any) -> list[str]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_lifecycle_product_ids(value: Any) -> tuple[bool, dict[str, list[str]]]:
|
||||||
|
"""返回 (是否由生命进程接管, 各平台商品编码)。
|
||||||
|
|
||||||
|
老数据的 JSONB 可能仍是空对象,此时允许动态配置作为迁移兼容来源;
|
||||||
|
生命进程保存的对象始终带有四个固定键,即使某个平台为空数组也要视为空值生效。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
value = None
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return False, {key: [] for key in LIFECYCLE_PLATFORM_KEYS}
|
||||||
|
managed = any(key in value for key in LIFECYCLE_PLATFORM_KEYS)
|
||||||
|
return managed, {
|
||||||
|
key: _clean_list(value.get(key)) for key in LIFECYCLE_PLATFORM_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_lifecycle_style_records(
|
||||||
|
dynamic_records: list[Mapping[str, Any]],
|
||||||
|
lifecycle_rows: list[Mapping[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Overlay lifecycle product identity on legacy target configuration rows.
|
||||||
|
|
||||||
|
``workflow_dynamic_*`` still owns Feishu target URLs and persona targets, while
|
||||||
|
``cmt_styles`` owns ERP codes and product IDs. The result intentionally keeps
|
||||||
|
the old flat runtime contract so existing collectors need no per-workflow fork.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dynamic_by_style: dict[str, list[Mapping[str, Any]]] = {}
|
||||||
|
for record in dynamic_records:
|
||||||
|
style_name = " ".join(str(record.get("style_name") or "").split())
|
||||||
|
if style_name:
|
||||||
|
dynamic_by_style.setdefault(style_name, []).append(record)
|
||||||
|
|
||||||
|
lifecycle_by_style: dict[str, Mapping[str, Any]] = {}
|
||||||
|
lifecycle_order: list[str] = []
|
||||||
|
for row in lifecycle_rows:
|
||||||
|
style_name = " ".join(str(row.get("name") or "").split())
|
||||||
|
if not style_name or style_name in lifecycle_by_style:
|
||||||
|
continue
|
||||||
|
lifecycle_by_style[style_name] = row
|
||||||
|
lifecycle_order.append(style_name)
|
||||||
|
|
||||||
|
style_order = list(dynamic_by_style)
|
||||||
|
style_order.extend(name for name in lifecycle_order if name not in dynamic_by_style)
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for style_name in style_order:
|
||||||
|
legacy_rows = dynamic_by_style.get(style_name, [])
|
||||||
|
lifecycle = lifecycle_by_style.get(style_name)
|
||||||
|
if lifecycle is None:
|
||||||
|
records.extend(dict(row) for row in legacy_rows)
|
||||||
|
continue
|
||||||
|
|
||||||
|
managed, lifecycle_ids = _decode_lifecycle_product_ids(
|
||||||
|
lifecycle.get("platform_product_ids")
|
||||||
|
)
|
||||||
|
lifecycle_erp = _clean_list(lifecycle.get("erp_style_codes"))
|
||||||
|
legacy_erp = _clean_list(legacy_rows[0].get("erp_codes")) if legacy_rows else []
|
||||||
|
erp_codes = lifecycle_erp if lifecycle_erp or managed else legacy_erp
|
||||||
|
brand = str(lifecycle.get("brand") or "").strip()
|
||||||
|
if not brand and legacy_rows:
|
||||||
|
brand = str(legacy_rows[0].get("brand") or "").strip()
|
||||||
|
|
||||||
|
template = dict(legacy_rows[0]) if legacy_rows else {
|
||||||
|
"style_name": str(lifecycle.get("name") or style_name),
|
||||||
|
"brand": brand,
|
||||||
|
"erp_codes": erp_codes,
|
||||||
|
"note": "",
|
||||||
|
"sales_bitable_url": "",
|
||||||
|
"main_image_bitable_url": "",
|
||||||
|
"creator_bitable_url": "",
|
||||||
|
"tm_persona_bitable_url": "",
|
||||||
|
"jd_persona_bitable_url": "",
|
||||||
|
"dy_persona_bitable_url": "",
|
||||||
|
"self_creator_bitable_url": "",
|
||||||
|
"weekly_note_analysis_url": "",
|
||||||
|
"style_analysis_bitable_url": "",
|
||||||
|
"style_content": "",
|
||||||
|
"destinations": [],
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic_ids: dict[str, list[str]] = {key: [] for key in LIFECYCLE_PLATFORM_KEYS}
|
||||||
|
dynamic_unknown_ids: dict[str, list[str]] = {}
|
||||||
|
dynamic_labels: list[str] = []
|
||||||
|
for row in legacy_rows:
|
||||||
|
if row.get("enabled", True) is False:
|
||||||
|
continue
|
||||||
|
label = str(row.get("platform") or "").strip()
|
||||||
|
if label and label not in dynamic_labels:
|
||||||
|
dynamic_labels.append(label)
|
||||||
|
key = next(
|
||||||
|
(key for key, known_label in LIFECYCLE_PLATFORM_LABELS.items() if known_label == label),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if key:
|
||||||
|
for item_id in _clean_list(row.get("item_ids")):
|
||||||
|
if item_id not in dynamic_ids[key]:
|
||||||
|
dynamic_ids[key].append(item_id)
|
||||||
|
elif label:
|
||||||
|
for item_id in _clean_list(row.get("item_ids")):
|
||||||
|
if item_id not in dynamic_unknown_ids.setdefault(label, []):
|
||||||
|
dynamic_unknown_ids[label].append(item_id)
|
||||||
|
|
||||||
|
if managed:
|
||||||
|
labels = []
|
||||||
|
for label in LIFECYCLE_PLATFORM_LABELS.values():
|
||||||
|
platform_rows = [
|
||||||
|
row for row in legacy_rows
|
||||||
|
if str(row.get("platform") or "").strip() == label
|
||||||
|
]
|
||||||
|
# 没有动态平台行时保持生命进程身份可被读取;一旦存在平台行,
|
||||||
|
# 则尊重该行的启停状态,避免停用后又被固定平台列表重新打开。
|
||||||
|
if not platform_rows or any(
|
||||||
|
row.get("enabled", True) is not False for row in platform_rows
|
||||||
|
):
|
||||||
|
labels.append(label)
|
||||||
|
else:
|
||||||
|
labels = list(dynamic_labels)
|
||||||
|
labels.extend(label for label in dynamic_labels if label not in labels)
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
key = next(
|
||||||
|
(key for key, known_label in LIFECYCLE_PLATFORM_LABELS.items() if known_label == label),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
output = dict(template)
|
||||||
|
output["enabled"] = True
|
||||||
|
output["style_name"] = str(lifecycle.get("name") or style_name).strip()
|
||||||
|
output["brand"] = brand
|
||||||
|
output["erp_codes"] = list(erp_codes)
|
||||||
|
output["platform"] = label
|
||||||
|
output["item_ids"] = (
|
||||||
|
list(lifecycle_ids[key])
|
||||||
|
if managed and key
|
||||||
|
else list(dynamic_ids.get(key, dynamic_unknown_ids.get(label, [])))
|
||||||
|
)
|
||||||
|
output["source_record_id"] = (
|
||||||
|
f"cmt_style:{lifecycle.get('id')}" if key and managed else output.get("source_record_id")
|
||||||
|
)
|
||||||
|
records.append(output)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
def _plain_url(value: Any) -> str:
|
def _plain_url(value: Any) -> str:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
match = re.search(r"\]\((https?://[^)]+)\)", text)
|
match = re.search(r"\]\((https?://[^)]+)\)", text)
|
||||||
@@ -579,6 +736,13 @@ class PostgresWorkflowConfigStore:
|
|||||||
)
|
)
|
||||||
if cursor.fetchone() is None:
|
if cursor.fetchone() is None:
|
||||||
self._preserve_persona_platforms(cursor)
|
self._preserve_persona_platforms(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT 1 FROM workflow_dynamic_config_migrations "
|
||||||
|
"WHERE migration_key = %s",
|
||||||
|
(LIFECYCLE_IDENTITY_MIGRATION_KEY,),
|
||||||
|
)
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
self._bootstrap_lifecycle_identity(cursor)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
except WorkflowConfigError:
|
except WorkflowConfigError:
|
||||||
@@ -779,6 +943,88 @@ class PostgresWorkflowConfigStore:
|
|||||||
(PERSONA_PLATFORM_MIGRATION_KEY, NORMALIZED_MIGRATION_KEY, preserved),
|
(PERSONA_PLATFORM_MIGRATION_KEY, NORMALIZED_MIGRATION_KEY, preserved),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _bootstrap_lifecycle_identity(self, cursor: psycopg.Cursor[Any]) -> None:
|
||||||
|
"""Promote the last dynamic-config identity snapshot to cmt_styles once."""
|
||||||
|
|
||||||
|
cursor.execute("SELECT to_regclass('public.cmt_styles') AS table_name")
|
||||||
|
table = cursor.fetchone() or {}
|
||||||
|
if not table.get("table_name"):
|
||||||
|
# Content schema is installed separately in some environments. Leave the
|
||||||
|
# marker absent so the promotion can retry after that schema is available.
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE cmt_styles "
|
||||||
|
"ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '', "
|
||||||
|
"ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] DEFAULT '{}', "
|
||||||
|
"ADD COLUMN IF NOT EXISTS platform_product_ids JSONB DEFAULT '{}'::jsonb"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT s.style_name, s.brand, s.erp_codes, p.platform, p.item_ids "
|
||||||
|
"FROM workflow_dynamic_styles s "
|
||||||
|
"JOIN workflow_dynamic_style_platforms p ON p.style_id = s.id "
|
||||||
|
"WHERE s.enabled AND p.enabled ORDER BY s.style_name, p.id"
|
||||||
|
)
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
platform_keys = {
|
||||||
|
label: key for key, label in LIFECYCLE_PLATFORM_LABELS.items()
|
||||||
|
}
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
style_name = " ".join(str(row.get("style_name") or "").split())
|
||||||
|
if not style_name:
|
||||||
|
continue
|
||||||
|
item = grouped.setdefault(
|
||||||
|
style_name,
|
||||||
|
{
|
||||||
|
"brand": str(row.get("brand") or "").strip(),
|
||||||
|
"erp_codes": _clean_list(row.get("erp_codes")),
|
||||||
|
"platform_product_ids": {
|
||||||
|
key: [] for key in LIFECYCLE_PLATFORM_KEYS
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
key = platform_keys.get(str(row.get("platform") or "").strip())
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
for product_id in _clean_list(row.get("item_ids")):
|
||||||
|
if product_id not in item["platform_product_ids"][key]:
|
||||||
|
item["platform_product_ids"][key].append(product_id)
|
||||||
|
|
||||||
|
for style_name, values in grouped.items():
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO cmt_styles "
|
||||||
|
"(name, brand, erp_style_codes, platform_product_ids) "
|
||||||
|
"VALUES (%s, %s, %s, %s::jsonb) "
|
||||||
|
"ON CONFLICT (name) DO UPDATE SET "
|
||||||
|
"brand = CASE WHEN COALESCE(BTRIM(cmt_styles.brand), '') = '' "
|
||||||
|
"THEN EXCLUDED.brand ELSE cmt_styles.brand END, "
|
||||||
|
"erp_style_codes = CASE "
|
||||||
|
"WHEN COALESCE(array_length(cmt_styles.erp_style_codes, 1), 0) = 0 "
|
||||||
|
"AND COALESCE(cmt_styles.platform_product_ids, '{}'::jsonb) = '{}'::jsonb "
|
||||||
|
"THEN EXCLUDED.erp_style_codes ELSE cmt_styles.erp_style_codes END, "
|
||||||
|
"platform_product_ids = CASE "
|
||||||
|
"WHEN COALESCE(cmt_styles.platform_product_ids, '{}'::jsonb) = '{}'::jsonb "
|
||||||
|
"THEN EXCLUDED.platform_product_ids "
|
||||||
|
"ELSE cmt_styles.platform_product_ids END, "
|
||||||
|
"updated_at = NOW()",
|
||||||
|
(
|
||||||
|
style_name,
|
||||||
|
values["brand"],
|
||||||
|
values["erp_codes"],
|
||||||
|
json.dumps(values["platform_product_ids"], ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO workflow_dynamic_config_migrations "
|
||||||
|
"(migration_key, source, record_count) VALUES (%s, %s, %s) "
|
||||||
|
"ON CONFLICT (migration_key) DO NOTHING",
|
||||||
|
(
|
||||||
|
LIFECYCLE_IDENTITY_MIGRATION_KEY,
|
||||||
|
"workflow_dynamic_styles + workflow_dynamic_style_platforms",
|
||||||
|
len(grouped),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _public_style(
|
def _public_style(
|
||||||
row: Mapping[str, Any], platforms: list[Mapping[str, Any]]
|
row: Mapping[str, Any], platforms: list[Mapping[str, Any]]
|
||||||
@@ -905,14 +1151,27 @@ class PostgresWorkflowConfigStore:
|
|||||||
with conn.cursor() as cursor:
|
with conn.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT s.*, p.id AS platform_id, p.platform, p.item_ids, "
|
"SELECT s.*, p.id AS platform_id, p.platform, p.item_ids, "
|
||||||
"p.persona_bitable_url, p.source_record_ids "
|
"p.persona_bitable_url, p.source_record_ids, "
|
||||||
|
"p.enabled AS platform_enabled "
|
||||||
"FROM workflow_dynamic_styles s "
|
"FROM workflow_dynamic_styles s "
|
||||||
"JOIN workflow_dynamic_style_platforms p ON p.style_id = s.id "
|
"JOIN workflow_dynamic_style_platforms p ON p.style_id = s.id "
|
||||||
"WHERE s.enabled AND p.enabled ORDER BY s.style_name, p.id"
|
"WHERE s.enabled ORDER BY s.style_name, p.id"
|
||||||
)
|
)
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
cursor.execute("SELECT to_regclass('public.cmt_styles') AS table_name")
|
||||||
|
cmt_table = cursor.fetchone() or {}
|
||||||
|
if cmt_table.get("table_name"):
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, name, brand, erp_style_codes, platform_product_ids "
|
||||||
|
"FROM cmt_styles ORDER BY name, id"
|
||||||
|
)
|
||||||
|
lifecycle_rows = cursor.fetchall()
|
||||||
|
else:
|
||||||
|
lifecycle_rows = []
|
||||||
personas: dict[int, dict[str, str]] = {}
|
personas: dict[int, dict[str, str]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
if row.get("platform_enabled") is False:
|
||||||
|
continue
|
||||||
style_personas = personas.setdefault(int(row["id"]), {})
|
style_personas = personas.setdefault(int(row["id"]), {})
|
||||||
style_personas[str(row["platform"])] = str(
|
style_personas[str(row["platform"])] = str(
|
||||||
row.get("persona_bitable_url") or ""
|
row.get("persona_bitable_url") or ""
|
||||||
@@ -943,10 +1202,10 @@ class PostgresWorkflowConfigStore:
|
|||||||
"style_analysis_bitable_url": row.get("style_analysis_bitable_url", ""),
|
"style_analysis_bitable_url": row.get("style_analysis_bitable_url", ""),
|
||||||
"style_content": row.get("style_content", ""),
|
"style_content": row.get("style_content", ""),
|
||||||
"destinations": _public_destinations(row),
|
"destinations": _public_destinations(row),
|
||||||
"enabled": True,
|
"enabled": row.get("platform_enabled") is not False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return records
|
return merge_lifecycle_style_records(records, lifecycle_rows)
|
||||||
|
|
||||||
def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
values = normalize_style_config(payload)
|
values = normalize_style_config(payload)
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ from gyxx_flow.ops import RunIndex, RunRecord
|
|||||||
CONSOLE_TOKEN_ENV = "GYXX_CONSOLE_TOKEN"
|
CONSOLE_TOKEN_ENV = "GYXX_CONSOLE_TOKEN"
|
||||||
MAX_REQUEST_BYTES = 64 * 1024
|
MAX_REQUEST_BYTES = 64 * 1024
|
||||||
MAX_ERROR_CHARS = 4_000
|
MAX_ERROR_CHARS = 4_000
|
||||||
|
MAX_DIAGNOSIS_LOG_FILES = 5
|
||||||
|
MAX_DIAGNOSIS_LOG_READ_BYTES = 262_144
|
||||||
MAX_ACTIVE_RUNS = 4
|
MAX_ACTIVE_RUNS = 4
|
||||||
MAX_TRACKED_OPERATIONS = 128
|
MAX_TRACKED_OPERATIONS = 128
|
||||||
PROCESS_TERMINATE_TIMEOUT_SECONDS = 5.0
|
PROCESS_TERMINATE_TIMEOUT_SECONDS = 5.0
|
||||||
@@ -102,6 +104,7 @@ MAX_NOTIFICATION_LOOKUP_PROOFS = 1024
|
|||||||
|
|
||||||
_OPERATION_ID = re.compile(r"^op-[0-9a-f]{16}$")
|
_OPERATION_ID = re.compile(r"^op-[0-9a-f]{16}$")
|
||||||
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
|
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
_SAFE_APP_PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
_SAFE_APP_PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||||
_FEISHU_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$")
|
_FEISHU_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$")
|
||||||
_FEISHU_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$")
|
_FEISHU_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$")
|
||||||
@@ -1807,6 +1810,68 @@ class WorkflowConsoleService:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def workflow_detail(self, workflow_id: str) -> dict[str, object]:
|
||||||
|
"""Return the overview projection for a single workflow."""
|
||||||
|
|
||||||
|
overview = self.overview()
|
||||||
|
workflow = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in overview["workflows"]
|
||||||
|
if item["id"] == workflow_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if workflow is None:
|
||||||
|
raise ConsoleNotFoundError("工作流不存在")
|
||||||
|
payload: dict[str, object] = {"workflow": workflow}
|
||||||
|
schedule_revision = overview.get("schedule_revision")
|
||||||
|
if isinstance(schedule_revision, str):
|
||||||
|
payload["schedule_revision"] = schedule_revision
|
||||||
|
warnings = overview.get("warnings")
|
||||||
|
if warnings:
|
||||||
|
payload["warnings"] = warnings
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def run_detail(self, workflow_id: str, run_id: str) -> dict[str, object]:
|
||||||
|
"""Return one indexed run with its journal step details."""
|
||||||
|
|
||||||
|
record = self._run_record_for(workflow_id, run_id)
|
||||||
|
return {
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"run": _run_payload(record, data_root=self.settings.data_root),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_diagnosis(self, workflow_id: str, run_id: str) -> dict[str, object]:
|
||||||
|
"""Bundle one run's journal, trace, and sanitized log tails for agents."""
|
||||||
|
|
||||||
|
record = self._run_record_for(workflow_id, run_id)
|
||||||
|
journal = _journal_trace_payload(record, data_root=self.settings.data_root)
|
||||||
|
return {
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"run": _run_payload(record, data_root=self.settings.data_root),
|
||||||
|
"journal": journal,
|
||||||
|
"logs": _diagnosis_logs(
|
||||||
|
record,
|
||||||
|
journal,
|
||||||
|
data_root=self.settings.data_root,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run_record_for(self, workflow_id: str, run_id: str) -> RunRecord:
|
||||||
|
catalog, _registered = self._catalog()
|
||||||
|
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
|
||||||
|
raise ConsoleNotFoundError("工作流不存在")
|
||||||
|
if not _SAFE_RUN_ID.fullmatch(run_id):
|
||||||
|
raise ConsoleNotFoundError("运行记录不存在")
|
||||||
|
try:
|
||||||
|
record = RunIndex(self.settings.data_root).get(run_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConsoleRequestError("运行索引中存在损坏记录") from exc
|
||||||
|
if record is None or record.workflow_id != workflow_id:
|
||||||
|
raise ConsoleNotFoundError("运行记录不存在")
|
||||||
|
return record
|
||||||
|
|
||||||
def daily_summary(
|
def daily_summary(
|
||||||
self,
|
self,
|
||||||
report_date: str | None = None,
|
report_date: str | None = None,
|
||||||
@@ -2849,6 +2914,136 @@ def _journal_steps(data_root: Path, record: RunRecord) -> list[dict[str, object]
|
|||||||
return steps
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def _journal_trace_payload(
|
||||||
|
record: RunRecord,
|
||||||
|
*,
|
||||||
|
data_root: Path,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
path = (
|
||||||
|
DataLayout(data_root).run_dir(
|
||||||
|
record.workflow_id,
|
||||||
|
record.business_date,
|
||||||
|
record.run_id,
|
||||||
|
)
|
||||||
|
/ "run.json"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
|
return {"path": None, "mode": None, "trace": None}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {"path": None, "mode": None, "trace": None}
|
||||||
|
trace = payload.get("trace")
|
||||||
|
return {
|
||||||
|
"path": path.relative_to(data_root).as_posix(),
|
||||||
|
"mode": payload.get("mode"),
|
||||||
|
"trace": trace if isinstance(trace, dict) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_within(path: Path, root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_tail(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
if size > MAX_DIAGNOSIS_LOG_READ_BYTES:
|
||||||
|
stream.seek(-MAX_DIAGNOSIS_LOG_READ_BYTES, 2)
|
||||||
|
raw = stream.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
if size > MAX_DIAGNOSIS_LOG_READ_BYTES:
|
||||||
|
newline = text.find("\n")
|
||||||
|
if newline != -1:
|
||||||
|
text = text[newline + 1 :]
|
||||||
|
if not text.strip():
|
||||||
|
return None
|
||||||
|
return _sanitize_error(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _console_logs_in_window(
|
||||||
|
console_log_root: Path,
|
||||||
|
record: RunRecord,
|
||||||
|
) -> list[Path]:
|
||||||
|
try:
|
||||||
|
started = datetime.fromisoformat(record.started_at)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
ended: datetime
|
||||||
|
if record.ended_at is not None:
|
||||||
|
try:
|
||||||
|
ended = datetime.fromisoformat(record.ended_at)
|
||||||
|
except ValueError:
|
||||||
|
ended = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
ended = datetime.now(timezone.utc)
|
||||||
|
matches: list[Path] = []
|
||||||
|
for path in console_log_root.glob(f"{record.workflow_id}-*.log"):
|
||||||
|
try:
|
||||||
|
mtime = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if mtime < started - timedelta(minutes=10):
|
||||||
|
continue
|
||||||
|
if mtime > ended + timedelta(minutes=10):
|
||||||
|
continue
|
||||||
|
matches.append(path)
|
||||||
|
matches.sort(key=lambda item: item.stat().st_mtime, reverse=True)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnosis_logs(
|
||||||
|
record: RunRecord,
|
||||||
|
journal: dict[str, object],
|
||||||
|
*,
|
||||||
|
data_root: Path,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
root = data_root.resolve()
|
||||||
|
candidates: list[Path] = []
|
||||||
|
trace = journal.get("trace")
|
||||||
|
paths = trace.get("paths") if isinstance(trace, dict) else None
|
||||||
|
log_rel = paths.get("log") if isinstance(paths, dict) else None
|
||||||
|
if isinstance(log_rel, str) and log_rel:
|
||||||
|
log_dir = (root / log_rel).resolve()
|
||||||
|
if _is_within(log_dir, root) and log_dir.is_dir():
|
||||||
|
candidates.extend(
|
||||||
|
sorted(
|
||||||
|
log_dir.glob("*.log"),
|
||||||
|
key=lambda item: item.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console_log_root = (root / "logs" / "console").resolve()
|
||||||
|
if console_log_root.is_dir():
|
||||||
|
candidates.extend(_console_logs_in_window(console_log_root, record))
|
||||||
|
logs: list[dict[str, object]] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
if resolved in seen or not _is_within(resolved, root):
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
tail = _read_log_tail(resolved)
|
||||||
|
if tail is None:
|
||||||
|
continue
|
||||||
|
logs.append(
|
||||||
|
{
|
||||||
|
"path": resolved.relative_to(root).as_posix(),
|
||||||
|
"tail": tail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(logs) >= MAX_DIAGNOSIS_LOG_FILES:
|
||||||
|
break
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_error(value: Any) -> str | None:
|
def _sanitize_error(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -3429,10 +3624,15 @@ class WorkflowConsoleRequestHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
runs_match = re.fullmatch(r"/api/workflows/([^/]+)/runs", path)
|
runs_match = re.fullmatch(r"/api/workflows/([^/]+)/runs", path)
|
||||||
|
workflow_match = re.fullmatch(r"/api/workflows/([^/]+)", path)
|
||||||
cancel_match = re.fullmatch(
|
cancel_match = re.fullmatch(
|
||||||
r"/api/workflows/([^/]+)/runs/([^/]+)",
|
r"/api/workflows/([^/]+)/runs/([^/]+)",
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
|
diagnosis_match = re.fullmatch(
|
||||||
|
r"/api/workflows/([^/]+)/runs/([^/]+)/diagnosis",
|
||||||
|
path,
|
||||||
|
)
|
||||||
scheduled_cancel_match = re.fullmatch(
|
scheduled_cancel_match = re.fullmatch(
|
||||||
r"/api/workflows/([^/]+)/scheduled-run",
|
r"/api/workflows/([^/]+)/scheduled-run",
|
||||||
path,
|
path,
|
||||||
@@ -3455,6 +3655,34 @@ class WorkflowConsoleRequestHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
self._json(HTTPStatus.OK, payload)
|
self._json(HTTPStatus.OK, payload)
|
||||||
return
|
return
|
||||||
|
if method == "GET" and diagnosis_match:
|
||||||
|
self._require_api_access(mutation=False)
|
||||||
|
workflow_id = unquote(diagnosis_match.group(1))
|
||||||
|
run_id = unquote(diagnosis_match.group(2))
|
||||||
|
payload = self.console_server.console_service.run_diagnosis(
|
||||||
|
workflow_id,
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.OK, payload)
|
||||||
|
return
|
||||||
|
if method == "GET" and cancel_match:
|
||||||
|
self._require_api_access(mutation=False)
|
||||||
|
workflow_id = unquote(cancel_match.group(1))
|
||||||
|
run_id = unquote(cancel_match.group(2))
|
||||||
|
payload = self.console_server.console_service.run_detail(
|
||||||
|
workflow_id,
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.OK, payload)
|
||||||
|
return
|
||||||
|
if method == "GET" and workflow_match:
|
||||||
|
self._require_api_access(mutation=False)
|
||||||
|
workflow_id = unquote(workflow_match.group(1))
|
||||||
|
payload = self.console_server.console_service.workflow_detail(
|
||||||
|
workflow_id,
|
||||||
|
)
|
||||||
|
self._json(HTTPStatus.OK, payload)
|
||||||
|
return
|
||||||
if method == "PUT" and schedule_match:
|
if method == "PUT" and schedule_match:
|
||||||
self._require_api_access(mutation=True)
|
self._require_api_access(mutation=True)
|
||||||
workflow_id = unquote(schedule_match.group(1))
|
workflow_id = unquote(schedule_match.group(1))
|
||||||
|
|||||||
@@ -79,7 +79,19 @@ def _lark_cli() -> str:
|
|||||||
return executable
|
return executable
|
||||||
|
|
||||||
|
|
||||||
def read_source_records() -> list[dict[str, Any]]:
|
def read_source_records(
|
||||||
|
*,
|
||||||
|
base_token: str = BASE_TOKEN,
|
||||||
|
table_id: str = TABLE_ID,
|
||||||
|
view_id: str = VIEW_ID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Read all rows from the legacy Feishu configuration table.
|
||||||
|
|
||||||
|
The defaults preserve the original seed export behavior. Callers that
|
||||||
|
need to audit a particular shared view can pass the resolved IDs instead
|
||||||
|
of duplicating the pagination and field-mapping logic.
|
||||||
|
"""
|
||||||
|
|
||||||
records: list[dict[str, Any]] = []
|
records: list[dict[str, Any]] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
while True:
|
while True:
|
||||||
@@ -88,11 +100,11 @@ def read_source_records() -> list[dict[str, Any]]:
|
|||||||
"base",
|
"base",
|
||||||
"+record-list",
|
"+record-list",
|
||||||
"--base-token",
|
"--base-token",
|
||||||
BASE_TOKEN,
|
base_token,
|
||||||
"--table-id",
|
"--table-id",
|
||||||
TABLE_ID,
|
table_id,
|
||||||
"--view-id",
|
"--view-id",
|
||||||
VIEW_ID,
|
view_id,
|
||||||
"--offset",
|
"--offset",
|
||||||
str(offset),
|
str(offset),
|
||||||
"--limit",
|
"--limit",
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""将飞书款式 ID 表一次性合并到产品生命进程身份表。
|
||||||
|
|
||||||
|
这个脚本只负责初始化/校准 ``cmt_styles`` 的身份字段:
|
||||||
|
|
||||||
|
* 按款式聚合旧表的一行多平台记录;
|
||||||
|
* 将 ERP 款式编码和四个平台商品编码去重后写入生命进程;
|
||||||
|
* 保留生命进程已有的 NFC、图片、类目、市场标签等字段;
|
||||||
|
* 空的源字段不会清空已有身份,避免旧表不完整行破坏已维护数据;
|
||||||
|
* 默认只预览,只有显式传入 ``--apply`` 才写入数据库。
|
||||||
|
|
||||||
|
运行时采集仍从 PostgreSQL 的产品生命进程读取,不会把飞书旧表重新
|
||||||
|
变成第二个运行时配置源。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from gyxx_flow.migration.feishu_style_config_seed import (
|
||||||
|
BASE_TOKEN as SOURCE_BASE_TOKEN,
|
||||||
|
)
|
||||||
|
from gyxx_flow.migration.feishu_style_config_seed import (
|
||||||
|
TABLE_ID as SOURCE_TABLE_ID,
|
||||||
|
)
|
||||||
|
from gyxx_flow.migration.feishu_style_config_seed import (
|
||||||
|
read_source_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
SOURCE_URL = (
|
||||||
|
"https://bu0zgpibak.feishu.cn/base/"
|
||||||
|
f"{SOURCE_BASE_TOKEN}?table={SOURCE_TABLE_ID}&view=vewT7L4JUr"
|
||||||
|
)
|
||||||
|
SOURCE_VIEW_ID = "vewT7L4JUr"
|
||||||
|
|
||||||
|
PLATFORM_KEYS = {
|
||||||
|
"天猫": "tm",
|
||||||
|
"京东旗舰店": "jd",
|
||||||
|
"京东": "jd",
|
||||||
|
"京东自营": "jd_self",
|
||||||
|
"抖音": "dy",
|
||||||
|
}
|
||||||
|
KNOWN_PLATFORM_KEYS = ("tm", "jd", "jd_self", "dy")
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuLifecycleSyncError(RuntimeError):
|
||||||
|
"""Raised when the source or lifecycle target cannot be safely synchronized."""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_list(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
parts = value
|
||||||
|
else:
|
||||||
|
parts = str(value).replace(",", ",").replace("\r", ",").replace("\n", ",").split(",")
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in parts:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if text and text not in seen:
|
||||||
|
seen.add(text)
|
||||||
|
result.append(text)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_style_name(value: Any) -> str:
|
||||||
|
return " ".join(str(value or "").split())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StyleIdentity:
|
||||||
|
name: str
|
||||||
|
brand: str = ""
|
||||||
|
erp_codes: list[str] = field(default_factory=list)
|
||||||
|
platform_product_ids: dict[str, list[str]] = field(
|
||||||
|
default_factory=lambda: {key: [] for key in KNOWN_PLATFORM_KEYS}
|
||||||
|
)
|
||||||
|
source_record_ids: list[str] = field(default_factory=list)
|
||||||
|
unsupported_platform_ids: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def add_unique(self, field_name: str, values: list[str]) -> None:
|
||||||
|
target = getattr(self, field_name)
|
||||||
|
for value in values:
|
||||||
|
if value not in target:
|
||||||
|
target.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_sync_plan(source_records: list[Mapping[str, Any]]) -> tuple[list[StyleIdentity], dict[str, Any]]:
|
||||||
|
"""Group the legacy per-platform rows into lifecycle style identities."""
|
||||||
|
|
||||||
|
grouped: dict[str, StyleIdentity] = {}
|
||||||
|
skipped_rows: list[dict[str, str]] = []
|
||||||
|
unknown_platform_rows: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in source_records:
|
||||||
|
config = entry.get("config") or {}
|
||||||
|
if not isinstance(config, Mapping):
|
||||||
|
skipped_rows.append({"record_id": str(entry.get("source_record_id") or ""), "reason": "配置结构无效"})
|
||||||
|
continue
|
||||||
|
source_record_id = str(entry.get("source_record_id") or "").strip()
|
||||||
|
name = _clean_style_name(config.get("style_name"))
|
||||||
|
if not name:
|
||||||
|
skipped_rows.append({"record_id": source_record_id, "reason": "款式名称为空"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
identity = grouped.setdefault(name, StyleIdentity(name=name))
|
||||||
|
brand = str(config.get("brand") or "").strip()
|
||||||
|
if brand and not identity.brand:
|
||||||
|
identity.brand = brand
|
||||||
|
identity.add_unique("erp_codes", _clean_list(config.get("erp_codes")))
|
||||||
|
if source_record_id and source_record_id not in identity.source_record_ids:
|
||||||
|
identity.source_record_ids.append(source_record_id)
|
||||||
|
|
||||||
|
platform = str(config.get("platform") or "").strip()
|
||||||
|
item_ids = _clean_list(config.get("item_ids"))
|
||||||
|
key = PLATFORM_KEYS.get(platform)
|
||||||
|
if key:
|
||||||
|
for item_id in item_ids:
|
||||||
|
if item_id not in identity.platform_product_ids[key]:
|
||||||
|
identity.platform_product_ids[key].append(item_id)
|
||||||
|
elif platform and item_ids:
|
||||||
|
unknown = identity.unsupported_platform_ids.setdefault(platform, [])
|
||||||
|
for item_id in item_ids:
|
||||||
|
if item_id not in unknown:
|
||||||
|
unknown.append(item_id)
|
||||||
|
unknown_platform_rows.append(
|
||||||
|
{
|
||||||
|
"style": name,
|
||||||
|
"platform": platform,
|
||||||
|
"item_count": len(item_ids),
|
||||||
|
"record_id": source_record_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = [grouped[name] for name in sorted(grouped)]
|
||||||
|
report = {
|
||||||
|
"source_url": SOURCE_URL,
|
||||||
|
"source_records": len(source_records),
|
||||||
|
"named_styles": len(styles),
|
||||||
|
"skipped_rows": skipped_rows,
|
||||||
|
"unknown_platform_rows": unknown_platform_rows,
|
||||||
|
"styles_without_erp": [style.name for style in styles if not style.erp_codes],
|
||||||
|
"styles_without_supported_platform_id": [
|
||||||
|
style.name
|
||||||
|
for style in styles
|
||||||
|
if not any(style.platform_product_ids.values())
|
||||||
|
],
|
||||||
|
"collectable_styles": [
|
||||||
|
style.name
|
||||||
|
for style in styles
|
||||||
|
if style.erp_codes and any(style.platform_product_ids.values())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return styles, report
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(environment: Mapping[str, str] | None = None) -> psycopg.Connection[Any]:
|
||||||
|
env = environment if environment is not None else os.environ
|
||||||
|
dsn = str(env.get("GYXX_POSTGRES_DSN", "")).strip()
|
||||||
|
if dsn:
|
||||||
|
return psycopg.connect(dsn, row_factory=dict_row)
|
||||||
|
required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
|
||||||
|
missing = [name for name in required if not str(env.get(name, "")).strip()]
|
||||||
|
if missing:
|
||||||
|
raise FeishuLifecycleSyncError(
|
||||||
|
"缺少 PostgreSQL 配置: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
port = int(env["PG_PORT"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FeishuLifecycleSyncError("PG_PORT 必须是整数") from exc
|
||||||
|
return psycopg.connect(
|
||||||
|
host=env["PG_HOST"].strip(),
|
||||||
|
port=port,
|
||||||
|
dbname=env["PG_DB"].strip(),
|
||||||
|
user=env["PG_USER"].strip(),
|
||||||
|
password=env["PG_PASSWORD"],
|
||||||
|
row_factory=dict_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_target_schema(conn: psycopg.Connection[Any]) -> None:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = 'public' AND table_name = 'cmt_styles'"
|
||||||
|
)
|
||||||
|
columns = {str(row["column_name"]) for row in cursor.fetchall()}
|
||||||
|
required = {"name", "brand", "erp_style_codes", "platform_product_ids"}
|
||||||
|
missing = sorted(required - columns)
|
||||||
|
if missing:
|
||||||
|
raise FeishuLifecycleSyncError(
|
||||||
|
"cmt_styles 缺少身份字段: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_rows(
|
||||||
|
conn: psycopg.Connection[Any], names: list[str]
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
if not names:
|
||||||
|
return {}
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name, brand, erp_style_codes, platform_product_ids "
|
||||||
|
"FROM cmt_styles WHERE name = ANY(%s)",
|
||||||
|
(names,),
|
||||||
|
)
|
||||||
|
return {str(row["name"]): dict(row) for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_platform_ids(value: Any) -> dict[str, list[str]]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
value = {}
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
value = {}
|
||||||
|
# ProductLifecycleServiceImpl strictly rejects unknown JSON keys. The
|
||||||
|
# legacy database may contain Chinese platform labels, so only carry the
|
||||||
|
# canonical four keys into the lifecycle record.
|
||||||
|
return {
|
||||||
|
key: _clean_list(value.get(key))
|
||||||
|
for key in KNOWN_PLATFORM_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_target_row(
|
||||||
|
style: StyleIdentity, existing: Mapping[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
existing = existing or {}
|
||||||
|
erp_codes = _clean_list(existing.get("erp_style_codes"))
|
||||||
|
if style.erp_codes:
|
||||||
|
erp_codes = list(style.erp_codes)
|
||||||
|
|
||||||
|
platform_ids = _json_platform_ids(existing.get("platform_product_ids"))
|
||||||
|
for key in KNOWN_PLATFORM_KEYS:
|
||||||
|
if style.platform_product_ids[key]:
|
||||||
|
platform_ids[key] = list(style.platform_product_ids[key])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": style.name,
|
||||||
|
"brand": style.brand or str(existing.get("brand") or "").strip(),
|
||||||
|
"erp_style_codes": erp_codes,
|
||||||
|
"platform_product_ids": platform_ids,
|
||||||
|
"existing": existing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_target_rows(
|
||||||
|
conn: psycopg.Connection[Any], styles: list[StyleIdentity]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
existing = _existing_rows(conn, [style.name for style in styles])
|
||||||
|
return [_merge_target_row(style, existing.get(style.name)) for style in styles]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_target_rows(
|
||||||
|
conn: psycopg.Connection[Any], rows: list[Mapping[str, Any]]
|
||||||
|
) -> int:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
for row in rows:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO cmt_styles "
|
||||||
|
"(name, brand, erp_style_codes, platform_product_ids) "
|
||||||
|
"VALUES (%s, %s, %s, %s::jsonb) "
|
||||||
|
"ON CONFLICT (name) DO UPDATE SET "
|
||||||
|
"brand = CASE WHEN EXCLUDED.brand <> '' "
|
||||||
|
"THEN EXCLUDED.brand ELSE cmt_styles.brand END, "
|
||||||
|
"erp_style_codes = CASE "
|
||||||
|
"WHEN COALESCE(array_length(EXCLUDED.erp_style_codes, 1), 0) > 0 "
|
||||||
|
"THEN EXCLUDED.erp_style_codes ELSE cmt_styles.erp_style_codes END, "
|
||||||
|
"platform_product_ids = EXCLUDED.platform_product_ids, "
|
||||||
|
"updated_at = CURRENT_TIMESTAMP",
|
||||||
|
(
|
||||||
|
row["name"],
|
||||||
|
row["brand"],
|
||||||
|
row["erp_style_codes"],
|
||||||
|
json.dumps(row["platform_product_ids"], ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_report(report: Mapping[str, Any], rows: list[Mapping[str, Any]]) -> None:
|
||||||
|
print(f"[INFO] 飞书源记录: {report['source_records']}")
|
||||||
|
print(f"[INFO] 有效款式: {report['named_styles']}")
|
||||||
|
print(f"[INFO] 将合并到 cmt_styles: {len(rows)}")
|
||||||
|
print(f"[INFO] 可采集款式: {len(report['collectable_styles'])}")
|
||||||
|
print(f"[INFO] ERP 缺失款式: {len(report['styles_without_erp'])}")
|
||||||
|
print(
|
||||||
|
"[INFO] 不支持的平台行: "
|
||||||
|
f"{len(report['unknown_platform_rows'])}(保留在旧动态平台配置,不写入四平台身份 JSON)"
|
||||||
|
)
|
||||||
|
if report["skipped_rows"]:
|
||||||
|
print(f"[WARN] 跳过空款式行: {len(report['skipped_rows'])}")
|
||||||
|
for row in rows[:5]:
|
||||||
|
print(
|
||||||
|
f" - {row['name']}: erp={row['erp_style_codes']} "
|
||||||
|
f"platform_ids={row['platform_product_ids']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(*, apply: bool, environment: Mapping[str, str] | None = None) -> int:
|
||||||
|
source_records = read_source_records(
|
||||||
|
base_token=SOURCE_BASE_TOKEN,
|
||||||
|
table_id=SOURCE_TABLE_ID,
|
||||||
|
view_id=SOURCE_VIEW_ID,
|
||||||
|
)
|
||||||
|
styles, report = build_sync_plan(source_records)
|
||||||
|
with _connect(environment) as conn:
|
||||||
|
_require_target_schema(conn)
|
||||||
|
rows = build_target_rows(conn, styles)
|
||||||
|
_print_report(report, rows)
|
||||||
|
if not apply:
|
||||||
|
print("[DRY-RUN] 未写入数据库;使用 --apply 执行同步")
|
||||||
|
return 0
|
||||||
|
applied = apply_target_rows(conn, rows)
|
||||||
|
conn.commit()
|
||||||
|
print(f"[OK] 产品生命进程 cmt_styles 已同步: {applied} 行")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="飞书款式配置同步到产品生命进程")
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="执行数据库写入;默认仅预览",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
return run(apply=args.apply)
|
||||||
|
except (FeishuLifecycleSyncError, psycopg.Error) as exc:
|
||||||
|
print(f"[FAIL] 飞书款式同步失败: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -43,6 +43,7 @@ TARGET_URLS = [
|
|||||||
"https://www.chanmama.com/bloggerRank/ujU3Wwa9udJc59DKO5dAA37ILJPsWvyg.html?activeTab=aweme",
|
"https://www.chanmama.com/bloggerRank/ujU3Wwa9udJc59DKO5dAA37ILJPsWvyg.html?activeTab=aweme",
|
||||||
"https://www.chanmama.com/bloggerRank/ulaJWIvluqa_q6bQZzLdkzmLXNz64tgg.html?activeTab=aweme",
|
"https://www.chanmama.com/bloggerRank/ulaJWIvluqa_q6bQZzLdkzmLXNz64tgg.html?activeTab=aweme",
|
||||||
]
|
]
|
||||||
|
CHANMAMA_HOME_URL = "https://www.chanmama.com/"
|
||||||
DOWNLOAD_DIR = str(PATHS.raw_root / "chanmama")
|
DOWNLOAD_DIR = str(PATHS.raw_root / "chanmama")
|
||||||
COOKIE_FILE = str(PATHS.browser_cookie_file)
|
COOKIE_FILE = str(PATHS.browser_cookie_file)
|
||||||
COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒)
|
COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒)
|
||||||
@@ -486,7 +487,17 @@ def navigate_to_target(driver, url):
|
|||||||
if attempt >= CHANMAMA_NAVIGATION_ATTEMPTS:
|
if attempt >= CHANMAMA_NAVIGATION_ATTEMPTS:
|
||||||
break
|
break
|
||||||
print(f"⚠️ 目标页未完成渲染,重新打开一次: {exc}")
|
print(f"⚠️ 目标页未完成渲染,重新打开一次: {exc}")
|
||||||
time.sleep(2)
|
# A same-URL reload can preserve the half-mounted SPA state. Go
|
||||||
|
# through the site root once so the next target gets a fresh app
|
||||||
|
# mount while retaining the authenticated browser session.
|
||||||
|
try:
|
||||||
|
driver.get(CHANMAMA_HOME_URL)
|
||||||
|
WebDriverWait(driver, 15).until(
|
||||||
|
EC.presence_of_element_located((By.TAG_NAME, "body"))
|
||||||
|
)
|
||||||
|
except Exception as reset_exc:
|
||||||
|
print(f"⚠️ 蝉妈妈页面重置失败,继续重试目标页: {reset_exc}")
|
||||||
|
time.sleep(1)
|
||||||
raise RuntimeError(f"蝉妈妈目标页加载失败: {url}; {last_error}") from last_error
|
raise RuntimeError(f"蝉妈妈目标页加载失败: {url}; {last_error}") from last_error
|
||||||
|
|
||||||
|
|
||||||
@@ -536,6 +547,16 @@ _VIDEO_LIST_READY_SCRIPT = r"""
|
|||||||
&& style.opacity !== '0' && rect.width > 0 && rect.height > 0;
|
&& style.opacity !== '0' && rect.width > 0 && rect.height > 0;
|
||||||
};
|
};
|
||||||
const body = (document.body?.innerText || '').replace(/\s+/g, ' ');
|
const body = (document.body?.innerText || '').replace(/\s+/g, ' ');
|
||||||
|
// Current Chanmama renders the list under aweme-record-wrapper before the
|
||||||
|
// virtual table has stable row geometry. The page is usable once this
|
||||||
|
// visible root exposes the list/export semantics, even if the table rows
|
||||||
|
// are still being measured.
|
||||||
|
if (/视频记录|导出数据/.test(body)
|
||||||
|
&& [...document.querySelectorAll(
|
||||||
|
'.aweme-record-wrapper,.list-auth-wrap-block,.list-box'
|
||||||
|
)].some((el) => visible(el))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (/预估曝光|曝光量/.test(body)
|
if (/预估曝光|曝光量/.test(body)
|
||||||
&& [...document.querySelectorAll('table,[role="row"],[class*="video"],[class*="list"],[class*="record"]')]
|
&& [...document.querySelectorAll('table,[role="row"],[class*="video"],[class*="list"],[class*="record"]')]
|
||||||
.some((el) => visible(el) && /预估曝光|曝光量|播放量/.test(el.innerText || ''))) {
|
.some((el) => visible(el) && /预估曝光|曝光量|播放量/.test(el.innerText || ''))) {
|
||||||
|
|||||||
+4
@@ -3,6 +3,8 @@ ALTER TABLE cmt_styles
|
|||||||
ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '',
|
ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '',
|
||||||
ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] DEFAULT '{}',
|
ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] DEFAULT '{}',
|
||||||
ADD COLUMN IF NOT EXISTS platform_product_ids JSONB DEFAULT '{}'::jsonb,
|
ADD COLUMN IF NOT EXISTS platform_product_ids JSONB DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS big_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS small_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(128) DEFAULT '',
|
ADD COLUMN IF NOT EXISTS product_type VARCHAR(128) DEFAULT '',
|
||||||
ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '',
|
ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '',
|
||||||
ADD COLUMN IF NOT EXISTS colors TEXT[] DEFAULT '{}',
|
ADD COLUMN IF NOT EXISTS colors TEXT[] DEFAULT '{}',
|
||||||
@@ -17,6 +19,8 @@ ALTER TABLE cmt_styles
|
|||||||
ADD COLUMN IF NOT EXISTS product_researched_at TIMESTAMPTZ;
|
ADD COLUMN IF NOT EXISTS product_researched_at TIMESTAMPTZ;
|
||||||
|
|
||||||
COMMENT ON COLUMN cmt_styles.platform_product_ids IS 'Product IDs grouped by Feishu platform';
|
COMMENT ON COLUMN cmt_styles.platform_product_ids IS 'Product IDs grouped by Feishu platform';
|
||||||
|
COMMENT ON COLUMN cmt_styles.big_nfc_count IS 'Large NFC product count maintained by product lifecycle';
|
||||||
|
COMMENT ON COLUMN cmt_styles.small_nfc_count IS 'Small NFC product count maintained by product lifecycle';
|
||||||
COMMENT ON COLUMN cmt_styles.product_source_urls IS 'Public pages used to verify product facts';
|
COMMENT ON COLUMN cmt_styles.product_source_urls IS 'Public pages used to verify product facts';
|
||||||
COMMENT ON COLUMN cmt_styles.product_research_status IS 'verified, partial, or pending';
|
COMMENT ON COLUMN cmt_styles.product_research_status IS 'verified, partial, or pending';
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS cmt_styles (
|
|||||||
brand VARCHAR(128) DEFAULT '',
|
brand VARCHAR(128) DEFAULT '',
|
||||||
erp_style_codes TEXT[] DEFAULT '{}',
|
erp_style_codes TEXT[] DEFAULT '{}',
|
||||||
platform_product_ids JSONB DEFAULT '{}'::jsonb,
|
platform_product_ids JSONB DEFAULT '{}'::jsonb,
|
||||||
|
big_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
small_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||||
product_type VARCHAR(128) DEFAULT '',
|
product_type VARCHAR(128) DEFAULT '',
|
||||||
dimensions VARCHAR(255) DEFAULT '',
|
dimensions VARCHAR(255) DEFAULT '',
|
||||||
colors TEXT[] DEFAULT '{}',
|
colors TEXT[] DEFAULT '{}',
|
||||||
@@ -35,6 +37,9 @@ COMMENT ON TABLE cmt_styles IS '款式表';
|
|||||||
COMMENT ON COLUMN cmt_styles.name IS '款式名称';
|
COMMENT ON COLUMN cmt_styles.name IS '款式名称';
|
||||||
COMMENT ON COLUMN cmt_styles.style_category IS '款式风格';
|
COMMENT ON COLUMN cmt_styles.style_category IS '款式风格';
|
||||||
COMMENT ON COLUMN cmt_styles.platform IS '平台分组,如天猫';
|
COMMENT ON COLUMN cmt_styles.platform IS '平台分组,如天猫';
|
||||||
|
COMMENT ON COLUMN cmt_styles.platform_product_ids IS '产品生命进程统一维护的平台商品编码,固定键 tm/jd/jd_self/dy';
|
||||||
|
COMMENT ON COLUMN cmt_styles.big_nfc_count IS '大 NFC 商品数量';
|
||||||
|
COMMENT ON COLUMN cmt_styles.small_nfc_count IS '小 NFC 商品数量';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_cmt_styles_platform ON cmt_styles (platform);
|
CREATE INDEX IF NOT EXISTS idx_cmt_styles_platform ON cmt_styles (platform);
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ BUYIN_MARKET_URL = (
|
|||||||
"&btm_show_id=a1499f13-89eb-4bc0-99f5-ceb3b8f2e600"
|
"&btm_show_id=a1499f13-89eb-4bc0-99f5-ceb3b8f2e600"
|
||||||
)
|
)
|
||||||
BUYIN_DETAIL_URL_MARK = "/dashboard/servicehall/daren-profile"
|
BUYIN_DETAIL_URL_MARK = "/dashboard/servicehall/daren-profile"
|
||||||
|
BUYIN_SEARCH_INPUT_SELECTOR = "input[placeholder*='搜达人昵称']"
|
||||||
|
|
||||||
# --buyin 仍可显式强制使用抖店达人广场;日报默认保留星图主采集,未命中
|
# --buyin 仍可显式强制使用抖店达人广场;日报默认保留星图主采集,未命中
|
||||||
# 的任务由主入口在同一批次内切换到抖店补采。
|
# 的任务由主入口在同一批次内切换到抖店补采。
|
||||||
@@ -755,17 +756,19 @@ def dismiss_popups(page: Page) -> None:
|
|||||||
|
|
||||||
def select_nickname_search(page: Page) -> None:
|
def select_nickname_search(page: Page) -> None:
|
||||||
if CONTENT_SOURCE == "buyin":
|
if CONTENT_SOURCE == "buyin":
|
||||||
if not click_by_text(page, "找达人", exact=True, timeout=5000):
|
# Buyin has a global "找达人" navigation item. Clicking the first
|
||||||
page.evaluate(
|
# text match can leave the authenticated market and land on
|
||||||
"""
|
# www.douyinec.com, where there is no creator-search input at all.
|
||||||
() => {
|
# The market route already owns the search box, so target the box
|
||||||
const target = [...document.querySelectorAll('button, a, div, span')]
|
# directly and repair the route when a stale page is still open.
|
||||||
.find((el) => (el.innerText || '').trim() === '找达人');
|
input_locator = page.locator(f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible").first
|
||||||
target?.click();
|
if not input_locator.count():
|
||||||
}
|
current_path = urlparse(str(getattr(page, "url", "") or "")).path.rstrip(
|
||||||
"""
|
"/"
|
||||||
)
|
)
|
||||||
page.wait_for_timeout(800)
|
if current_path != "/dashboard/servicehall/daren-square":
|
||||||
|
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
input_locator.wait_for(state="visible", timeout=15000)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not click_by_text(page, "昵称找人", exact=True, timeout=5000):
|
if not click_by_text(page, "昵称找人", exact=True, timeout=5000):
|
||||||
@@ -805,7 +808,12 @@ def search_creator(page: Page, creator_name: str, creator_id: str | None = None)
|
|||||||
select_nickname_search(page)
|
select_nickname_search(page)
|
||||||
# Keep the platform's original input locator. The query value is the
|
# Keep the platform's original input locator. The query value is the
|
||||||
# only thing that changes between the ID attempt and nickname fallback.
|
# only thing that changes between the ID attempt and nickname fallback.
|
||||||
input_locator = page.locator("input:visible").first
|
input_selector = (
|
||||||
|
f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible"
|
||||||
|
if CONTENT_SOURCE == "buyin"
|
||||||
|
else "input:visible"
|
||||||
|
)
|
||||||
|
input_locator = page.locator(input_selector).first
|
||||||
input_locator.wait_for(state="visible", timeout=30000)
|
input_locator.wait_for(state="visible", timeout=30000)
|
||||||
input_locator.click()
|
input_locator.click()
|
||||||
previous_result_signature = (
|
previous_result_signature = (
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ PRODUCT_SCHEDULED_WORKFLOW_IDS = (
|
|||||||
"product.erp_all_shop_daily",
|
"product.erp_all_shop_daily",
|
||||||
"product.market_rank",
|
"product.market_rank",
|
||||||
"product.tmall_baibu_apply",
|
"product.tmall_baibu_apply",
|
||||||
)
|
|
||||||
PRODUCT_MANUAL_WORKFLOW_IDS = (
|
|
||||||
"product.video_upload",
|
"product.video_upload",
|
||||||
"product.jd_video_upload",
|
"product.jd_video_upload",
|
||||||
)
|
)
|
||||||
|
PRODUCT_MANUAL_WORKFLOW_IDS = ()
|
||||||
PRODUCT_WORKFLOW_IDS = PRODUCT_SCHEDULED_WORKFLOW_IDS + PRODUCT_MANUAL_WORKFLOW_IDS
|
PRODUCT_WORKFLOW_IDS = PRODUCT_SCHEDULED_WORKFLOW_IDS + PRODUCT_MANUAL_WORKFLOW_IDS
|
||||||
PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60
|
PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60
|
||||||
PRODUCT_RESOURCE = "module:product_commerce"
|
PRODUCT_RESOURCE = "module:product_commerce"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from scrapling.fetchers import DynamicFetcher
|
|||||||
try:
|
try:
|
||||||
from . import collect_erp_yesterday_metrics as erp
|
from . import collect_erp_yesterday_metrics as erp
|
||||||
from .collect_retry_utils import retry_step
|
from .collect_retry_utils import retry_step
|
||||||
from .config.style_config_loader import StyleConfigLoader
|
from .config.style_config_loader import StyleConfigLoader, has_complete_style_identity
|
||||||
from .db import (
|
from .db import (
|
||||||
get_conn,
|
get_conn,
|
||||||
init_schema,
|
init_schema,
|
||||||
@@ -35,7 +35,7 @@ try:
|
|||||||
except ImportError: # Direct script execution from the module root.
|
except ImportError: # Direct script execution from the module root.
|
||||||
import collect_erp_yesterday_metrics as erp
|
import collect_erp_yesterday_metrics as erp
|
||||||
from collect_retry_utils import retry_step
|
from collect_retry_utils import retry_step
|
||||||
from config.style_config_loader import StyleConfigLoader
|
from config.style_config_loader import StyleConfigLoader, has_complete_style_identity
|
||||||
from db import get_conn, init_schema, upsert_erp_all_shop_style_daily_metrics
|
from db import get_conn, init_schema, upsert_erp_all_shop_style_daily_metrics
|
||||||
from erp_login_product_analysis import (
|
from erp_login_product_analysis import (
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
@@ -80,14 +80,15 @@ def build_style_plans(payload: Mapping[str, Any]) -> tuple[StylePlan, ...]:
|
|||||||
code_owners: dict[str, str] = {}
|
code_owners: dict[str, str] = {}
|
||||||
for raw_name, raw_config in (payload.get("styles") or {}).items():
|
for raw_name, raw_config in (payload.get("styles") or {}).items():
|
||||||
style_name = str(raw_name).strip()
|
style_name = str(raw_name).strip()
|
||||||
|
config = raw_config or {}
|
||||||
codes = tuple(
|
codes = tuple(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(code).strip()
|
str(code).strip()
|
||||||
for code in (raw_config or {}).get("erp_codes") or ()
|
for code in config.get("erp_codes") or ()
|
||||||
if str(code).strip()
|
if str(code).strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not style_name or not codes:
|
if not style_name or not codes or not has_complete_style_identity(config):
|
||||||
continue
|
continue
|
||||||
for code in codes:
|
for code in codes:
|
||||||
previous = code_owners.setdefault(code, style_name)
|
previous = code_owners.setdefault(code, style_name)
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ def load_styles(path: Path) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def merge_styles_with_dynamic_config(styles: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def merge_styles_with_dynamic_config(styles: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""[已废弃] 飞书为唯一来源,本地 styles_input.json 退为兜底。保留仅为兼容旧调用。"""
|
"""[已废弃] 产品生命进程是 ERP 身份唯一来源;保留仅为兼容旧调用。"""
|
||||||
return styles
|
return styles
|
||||||
|
|
||||||
|
|
||||||
@@ -182,14 +182,21 @@ def load_erp_styles(
|
|||||||
loader: StyleConfigLoader | None,
|
loader: StyleConfigLoader | None,
|
||||||
fallback_path: Path,
|
fallback_path: Path,
|
||||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
"""飞书为唯一来源,本地 styles_input.json 仅在飞书读取失败时回退。
|
"""只从产品生命进程读取 ERP 采集款式,身份源不可用时失败闭环。
|
||||||
|
|
||||||
返回 (styles, skip_report):
|
返回 (styles, skip_report):
|
||||||
styles = [{"style_name", "erp_style_codes", "brand"}]
|
styles = [{"style_name", "erp_style_codes", "brand"}]
|
||||||
skip_report = {"source": "lark"|"fallback_json", "collectable": [...], "skipped": [{style, reason}], "brand_map": {...}}
|
skip_report = {"source": "product_lifecycle"|"lifecycle_unavailable", ...}
|
||||||
brand 字段让 collect_in_page 按品牌选店铺 (光影行星旗下店铺 vs ozko旗舰店)。
|
brand 字段让 collect_in_page 按品牌选店铺 (光影行星旗下店铺 vs ozko旗舰店)。
|
||||||
"""
|
"""
|
||||||
if loader is not None:
|
del fallback_path # 保留参数以兼容旧调用,商品身份不再从本地文件回退。
|
||||||
|
if loader is None:
|
||||||
|
return [], {
|
||||||
|
"source": "lifecycle_unavailable",
|
||||||
|
"collectable": [],
|
||||||
|
"skipped": [{"style": "*", "reason": "产品生命进程身份源不可用"}],
|
||||||
|
"brand_map": {},
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
data = loader.get_erp_daily_styles()
|
data = loader.get_erp_daily_styles()
|
||||||
payload = loader.load()
|
payload = loader.load()
|
||||||
@@ -203,43 +210,39 @@ def load_erp_styles(
|
|||||||
"brand": brand_map.get(name) or DEFAULT_BRAND,
|
"brand": brand_map.get(name) or DEFAULT_BRAND,
|
||||||
})
|
})
|
||||||
return styles, {
|
return styles, {
|
||||||
"source": "lark",
|
"source": "product_lifecycle",
|
||||||
"collectable": data["collectable"],
|
"collectable": data["collectable"],
|
||||||
"skipped": data["skipped"],
|
"skipped": data["skipped"],
|
||||||
"brand_map": brand_map,
|
"brand_map": brand_map,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[WARN] 飞书读取失败,回退本地 {fallback_path}: {exc}")
|
print(f"[FAIL] 产品生命进程身份源读取失败,不启动 ERP 采集: {exc}")
|
||||||
|
return [], {
|
||||||
styles = load_styles(fallback_path)
|
"source": "lifecycle_unavailable",
|
||||||
for s in styles:
|
|
||||||
s.setdefault("brand", DEFAULT_BRAND)
|
|
||||||
return styles, {
|
|
||||||
"source": "fallback_json",
|
|
||||||
"collectable": [],
|
"collectable": [],
|
||||||
"skipped": [],
|
"skipped": [{"style": "*", "reason": "产品生命进程身份源不可用"}],
|
||||||
"brand_map": {s["style_name"]: s.get("brand", DEFAULT_BRAND) for s in styles},
|
"brand_map": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_skip_summary(skip_report: dict[str, Any]) -> None:
|
def print_skip_summary(skip_report: dict[str, Any]) -> None:
|
||||||
src = skip_report.get("source", "?")
|
src = skip_report.get("source", "?")
|
||||||
if not skip_report.get("skipped"):
|
if not skip_report.get("skipped"):
|
||||||
if src == "lark":
|
if src == "product_lifecycle":
|
||||||
print(f"[ERP] 来源={src}, 采集 {len(skip_report['collectable'])} 款, 跳过 0 款")
|
print(f"[ERP] 来源={src}, 采集 {len(skip_report['collectable'])} 款, 跳过 0 款")
|
||||||
else:
|
else:
|
||||||
print(f"[ERP] 来源={src}, 跳过清单未生成 (回退路径不带 reason)")
|
print(f"[ERP] 来源={src}, 跳过清单未生成 (回退路径不带 reason)")
|
||||||
return
|
return
|
||||||
print(
|
print(
|
||||||
f"[ERP] 来源={src}, 采集 {len(skip_report['collectable'])} 款, "
|
f"[ERP] 来源={src}, 采集 {len(skip_report['collectable'])} 款, "
|
||||||
f"跳过 {len(skip_report['skipped'])} 款 (缺 ERP 编码 / TM 分组 ID / 飞书表地址)"
|
f"跳过 {len(skip_report['skipped'])} 款 (缺 ERP 编码 / 平台商品编码 / 飞书表地址)"
|
||||||
)
|
)
|
||||||
for s in skip_report["skipped"]:
|
for s in skip_report["skipped"]:
|
||||||
print(f" - {s['style']}: {s['reason']}")
|
print(f" - {s['style']}: {s['reason']}")
|
||||||
|
|
||||||
|
|
||||||
def write_erp_skip_report(skip_report: dict[str, Any], date_str: str) -> Path | None:
|
def write_erp_skip_report(skip_report: dict[str, Any], date_str: str) -> Path | None:
|
||||||
if skip_report.get("source") != "lark":
|
if skip_report.get("source") not in {"product_lifecycle", "lifecycle_unavailable"}:
|
||||||
return None
|
return None
|
||||||
SKIP_REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
SKIP_REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
out_path = SKIP_REPORT_DIR / f"erp_skipped_{date_str}.json"
|
out_path = SKIP_REPORT_DIR / f"erp_skipped_{date_str}.json"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Mapping
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from gyxx_flow.adapters.workflow_config import runtime_workflow_configs
|
from gyxx_flow.adapters.workflow_config import runtime_workflow_configs
|
||||||
@@ -56,6 +56,31 @@ PLATFORM_MAP = {
|
|||||||
"抖音": "dy",
|
"抖音": "dy",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PRODUCT_ID_FIELDS = {
|
||||||
|
"tm": "item_ids",
|
||||||
|
"jd": "spus",
|
||||||
|
"jd_self": "skus",
|
||||||
|
"dy": "item_ids",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_complete_style_identity(
|
||||||
|
cfg: Mapping[str, Any], platform: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""判断款式是否具备商品采集所需的统一身份。"""
|
||||||
|
|
||||||
|
if not cfg.get("erp_codes"):
|
||||||
|
return False
|
||||||
|
if not any(
|
||||||
|
bool(cfg.get(name, {}).get(ids_key))
|
||||||
|
for name, ids_key in PRODUCT_ID_FIELDS.items()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if platform is None:
|
||||||
|
return True
|
||||||
|
ids_key = PRODUCT_ID_FIELDS.get(platform)
|
||||||
|
return bool(ids_key and cfg.get(platform, {}).get(ids_key))
|
||||||
|
|
||||||
|
|
||||||
class StyleConfigError(Exception):
|
class StyleConfigError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -528,11 +553,11 @@ class StyleConfigLoader:
|
|||||||
return sorted(self.load()["styles"].keys())
|
return sorted(self.load()["styles"].keys())
|
||||||
|
|
||||||
def get_tm_style_id_map(self) -> dict[str, list[str]]:
|
def get_tm_style_id_map(self) -> dict[str, list[str]]:
|
||||||
"""每日天猫采集列表:_aggregate 已保证满足四条件(erp/款式/item_ids/销量表)。"""
|
"""天猫商品 ID 映射;只有完整身份的款式才进入采集。"""
|
||||||
return {
|
return {
|
||||||
name: cfg["tm"]["item_ids"]
|
name: cfg["tm"]["item_ids"]
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg["tm"]["item_ids"]
|
if self._has_complete_identity(cfg, "tm")
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_tm_persona_bitable_map(self) -> dict[str, dict[str, str]]:
|
def get_tm_persona_bitable_map(self) -> dict[str, dict[str, str]]:
|
||||||
@@ -540,7 +565,7 @@ class StyleConfigLoader:
|
|||||||
只返回同时有 tm item_ids 和 persona_bitable 的款式(三者同时存在才采)。"""
|
只返回同时有 tm item_ids 和 persona_bitable 的款式(三者同时存在才采)。"""
|
||||||
result: dict[str, dict[str, str]] = {}
|
result: dict[str, dict[str, str]] = {}
|
||||||
for name, cfg in self.load()["styles"].items():
|
for name, cfg in self.load()["styles"].items():
|
||||||
if not cfg["tm"]["item_ids"]:
|
if not self._has_complete_identity(cfg, "tm"):
|
||||||
continue
|
continue
|
||||||
persona = cfg.get("persona_bitable") or {}
|
persona = cfg.get("persona_bitable") or {}
|
||||||
if persona.get("base_token") and persona.get("table_id"):
|
if persona.get("base_token") and persona.get("table_id"):
|
||||||
@@ -556,7 +581,7 @@ class StyleConfigLoader:
|
|||||||
return {
|
return {
|
||||||
name: cfg.get("brand", "")
|
name: cfg.get("brand", "")
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg["tm"]["item_ids"]
|
if self._has_complete_identity(cfg, "tm")
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_jd_spu_groups(self) -> list[dict]:
|
def get_jd_spu_groups(self) -> list[dict]:
|
||||||
@@ -564,7 +589,7 @@ class StyleConfigLoader:
|
|||||||
return [
|
return [
|
||||||
{"style": name, "spus": cfg["jd"]["spus"]}
|
{"style": name, "spus": cfg["jd"]["spus"]}
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg["jd"]["spus"]
|
if self._has_complete_identity(cfg, "jd")
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_dy_product_groups(self) -> dict[str, list[str]]:
|
def get_dy_product_groups(self) -> dict[str, list[str]]:
|
||||||
@@ -572,7 +597,7 @@ class StyleConfigLoader:
|
|||||||
return {
|
return {
|
||||||
name: cfg["dy"]["item_ids"]
|
name: cfg["dy"]["item_ids"]
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg["dy"]["item_ids"]
|
if self._has_complete_identity(cfg, "dy")
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_dy_persona_bitable_map(self) -> dict[str, dict[str, str]]:
|
def get_dy_persona_bitable_map(self) -> dict[str, dict[str, str]]:
|
||||||
@@ -580,7 +605,7 @@ class StyleConfigLoader:
|
|||||||
只返回同时有 dy item_ids 和 dy_persona_bitable 的款式。"""
|
只返回同时有 dy item_ids 和 dy_persona_bitable 的款式。"""
|
||||||
result: dict[str, dict[str, str]] = {}
|
result: dict[str, dict[str, str]] = {}
|
||||||
for name, cfg in self.load()["styles"].items():
|
for name, cfg in self.load()["styles"].items():
|
||||||
if not cfg["dy"]["item_ids"]:
|
if not self._has_complete_identity(cfg, "dy"):
|
||||||
continue
|
continue
|
||||||
persona = cfg.get("dy_persona_bitable") or {}
|
persona = cfg.get("dy_persona_bitable") or {}
|
||||||
if persona.get("base_token") and persona.get("table_id"):
|
if persona.get("base_token") and persona.get("table_id"):
|
||||||
@@ -596,7 +621,7 @@ class StyleConfigLoader:
|
|||||||
只返回同时有 jd spus 和 jd_persona_bitable 的款式。"""
|
只返回同时有 jd spus 和 jd_persona_bitable 的款式。"""
|
||||||
result: dict[str, dict[str, str]] = {}
|
result: dict[str, dict[str, str]] = {}
|
||||||
for name, cfg in self.load()["styles"].items():
|
for name, cfg in self.load()["styles"].items():
|
||||||
if not cfg["jd"]["spus"]:
|
if not self._has_complete_identity(cfg, "jd"):
|
||||||
continue
|
continue
|
||||||
persona = cfg.get("jd_persona_bitable") or {}
|
persona = cfg.get("jd_persona_bitable") or {}
|
||||||
if persona.get("base_token") and persona.get("table_id"):
|
if persona.get("base_token") and persona.get("table_id"):
|
||||||
@@ -611,6 +636,8 @@ class StyleConfigLoader:
|
|||||||
"""款式 -> 平台单品分析目标表。"""
|
"""款式 -> 平台单品分析目标表。"""
|
||||||
result: dict[str, dict[str, str]] = {}
|
result: dict[str, dict[str, str]] = {}
|
||||||
for name, cfg in self.load()["styles"].items():
|
for name, cfg in self.load()["styles"].items():
|
||||||
|
if not self._has_complete_identity(cfg):
|
||||||
|
continue
|
||||||
target = cfg.get("style_analysis_bitable") or {}
|
target = cfg.get("style_analysis_bitable") or {}
|
||||||
if target.get("base_token") and target.get("table_id"):
|
if target.get("base_token") and target.get("table_id"):
|
||||||
result[name] = {
|
result[name] = {
|
||||||
@@ -625,20 +652,22 @@ class StyleConfigLoader:
|
|||||||
return [
|
return [
|
||||||
{"style_name": name, "skus": cfg["jd_self"]["skus"]}
|
{"style_name": name, "skus": cfg["jd_self"]["skus"]}
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg["jd_self"]["skus"]
|
if self._has_complete_identity(cfg, "jd_self")
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_erp_styles_input(self) -> list[dict]:
|
def get_erp_styles_input(self) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{"style_name": name, "erp_style_codes": cfg["erp_codes"]}
|
{"style_name": name, "erp_style_codes": cfg["erp_codes"]}
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg["erp_codes"]
|
if self._has_complete_identity(cfg)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_bitable_style_map(self) -> dict:
|
def get_bitable_style_map(self) -> dict:
|
||||||
"""生成与 bitable_style_map.json 兼容的结构。"""
|
"""生成与 bitable_style_map.json 兼容的结构。"""
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
for name, cfg in self.load()["styles"].items():
|
for name, cfg in self.load()["styles"].items():
|
||||||
|
if not self._has_complete_identity(cfg):
|
||||||
|
continue
|
||||||
sales = cfg.get("sales_bitable") or {}
|
sales = cfg.get("sales_bitable") or {}
|
||||||
if not sales.get("base_token") or not sales.get("table_id"):
|
if not sales.get("base_token") or not sales.get("table_id"):
|
||||||
continue
|
continue
|
||||||
@@ -665,6 +694,8 @@ class StyleConfigLoader:
|
|||||||
"""生成与 bitable_main_image_map.json 兼容的结构。"""
|
"""生成与 bitable_main_image_map.json 兼容的结构。"""
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
for name, cfg in self.load()["styles"].items():
|
for name, cfg in self.load()["styles"].items():
|
||||||
|
if not self._has_complete_identity(cfg, "tm"):
|
||||||
|
continue
|
||||||
main = cfg.get("main_image_bitable") or {}
|
main = cfg.get("main_image_bitable") or {}
|
||||||
if not main.get("base_token") or not main.get("table_id"):
|
if not main.get("base_token") or not main.get("table_id"):
|
||||||
continue
|
continue
|
||||||
@@ -686,38 +717,55 @@ class StyleConfigLoader:
|
|||||||
cfg.get("sales_bitable", {}).get("table_id")
|
cfg.get("sales_bitable", {}).get("table_id")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_any_product_id(cfg: dict) -> bool:
|
||||||
|
return any(
|
||||||
|
bool(cfg.get(platform, {}).get(ids_key))
|
||||||
|
for platform, ids_key in PRODUCT_ID_FIELDS.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_complete_identity(self, cfg: dict, platform: str | None = None) -> bool:
|
||||||
|
"""商品采集统一门:ERP 款式编码 + 至少一个平台商品编码。"""
|
||||||
|
return has_complete_style_identity(cfg, platform)
|
||||||
|
|
||||||
|
def _identity_missing(self, cfg: dict) -> list[str]:
|
||||||
|
missing: list[str] = []
|
||||||
|
if not cfg.get("erp_codes"):
|
||||||
|
missing.append("ERP款式编码")
|
||||||
|
if not self._has_any_product_id(cfg):
|
||||||
|
missing.append("平台商品编码")
|
||||||
|
return missing
|
||||||
|
|
||||||
def get_daily_styles(self) -> dict:
|
def get_daily_styles(self) -> dict:
|
||||||
"""每日采集门 (v2.2):
|
"""每日商品采集门:ERP 编码、销量目标和平台商品编码必须完整。
|
||||||
- 款式级门: erp_codes ∩ sales_bitable 都非空 (从任意记录收集)
|
|
||||||
- 平台级门: 该平台分组 item_ids 非空
|
|
||||||
|
|
||||||
返回 {tm/jd/dy: {collectable: [...], skipped: [{style, reason}]}}。
|
返回 {tm/jd/dy: {collectable: [...], skipped: [{style, reason}]}}。
|
||||||
skipped 仅记录「款式级门通过、平台级缺 IDs」的款式;款式级失败不计入 skipped
|
incomplete style 永远不会进入 collectable,并记录具体缺失字段。
|
||||||
(因为该款式没有任何平台可采)。
|
|
||||||
"""
|
"""
|
||||||
payload = self.load()
|
payload = self.load()
|
||||||
result = {p: {"collectable": [], "skipped": []} for p in ("tm", "jd", "dy")}
|
result = {p: {"collectable": [], "skipped": []} for p in ("tm", "jd", "dy")}
|
||||||
for name, cfg in payload["styles"].items():
|
for name, cfg in payload["styles"].items():
|
||||||
style_ok = bool(cfg.get("erp_codes")) and self._cfg_has_sales_bitable(cfg)
|
identity_missing = self._identity_missing(cfg)
|
||||||
if not style_ok:
|
|
||||||
continue # 款式级失败,无任何平台可采
|
|
||||||
for plat in ("tm", "jd", "dy"):
|
for plat in ("tm", "jd", "dy"):
|
||||||
ids_key = "spus" if plat == "jd" else "item_ids"
|
ids_key = "spus" if plat == "jd" else "item_ids"
|
||||||
if cfg.get(plat, {}).get(ids_key):
|
missing = list(identity_missing)
|
||||||
result[plat]["collectable"].append(name)
|
if not self._cfg_has_sales_bitable(cfg):
|
||||||
else:
|
missing.append("销量目标地址")
|
||||||
|
if not cfg.get(plat, {}).get(ids_key):
|
||||||
|
missing.append(f"{plat}商品编码")
|
||||||
|
if missing:
|
||||||
result[plat]["skipped"].append(
|
result[plat]["skipped"].append(
|
||||||
{"style": name, "reason": f"缺 {plat} 商品链接ID"}
|
{"style": name, "reason": "缺 " + "/".join(dict.fromkeys(missing))}
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
result[plat]["collectable"].append(name)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_erp_daily_styles(self) -> dict:
|
def get_erp_daily_styles(self) -> dict:
|
||||||
"""ERP daily 门:款式 + ERP 编码 + TM 分组有 item_ids + 飞书销量地址 都要有。
|
"""ERP daily 门:ERP 编码、至少一个平台商品编码和销量目标都要有。
|
||||||
|
|
||||||
返回 {collectable, skipped, brand_map: {style: brand}}。
|
返回 {collectable, skipped, brand_map: {style: brand}}。
|
||||||
ERP 一次跑覆盖 jd/dy/tm 三平台(用同一组 erp_codes 查三个店铺),
|
ERP 一次跑覆盖 jd/dy/tm 三个平台,平台商品编码只用于确认款式已完成商品身份配置。
|
||||||
所以 jd/dy 没商品 ID 也能跑,但 TM 分组必须有 item_ids
|
|
||||||
(飞书表格里 TM 分组是「基础分组」, 款式只要在 TM 上架就算款式存在)。
|
|
||||||
brand_map 给 collect_erp_yesterday_metrics.py 按品牌选店铺
|
brand_map 给 collect_erp_yesterday_metrics.py 按品牌选店铺
|
||||||
(光影行星旗下店铺 vs ozko旗舰店)。
|
(光影行星旗下店铺 vs ozko旗舰店)。
|
||||||
"""
|
"""
|
||||||
@@ -726,36 +774,31 @@ class StyleConfigLoader:
|
|||||||
skipped: list[dict] = []
|
skipped: list[dict] = []
|
||||||
brand_map: dict[str, str] = {}
|
brand_map: dict[str, str] = {}
|
||||||
for name, cfg in payload["styles"].items():
|
for name, cfg in payload["styles"].items():
|
||||||
has_tm = bool(cfg.get("tm", {}).get("item_ids"))
|
missing = self._identity_missing(cfg)
|
||||||
if cfg.get("erp_codes") and has_tm and self._cfg_has_sales_bitable(cfg):
|
if not self._cfg_has_sales_bitable(cfg):
|
||||||
|
missing.append("销量目标地址")
|
||||||
|
if not missing:
|
||||||
collectable.append(name)
|
collectable.append(name)
|
||||||
brand_map[name] = cfg.get("brand") or "光影行星"
|
brand_map[name] = cfg.get("brand") or "光影行星"
|
||||||
else:
|
else:
|
||||||
miss = []
|
skipped.append({"style": name, "reason": "缺 " + "/".join(dict.fromkeys(missing))})
|
||||||
if not cfg.get("erp_codes"):
|
|
||||||
miss.append("ERP款式编码")
|
|
||||||
if not has_tm:
|
|
||||||
miss.append("天猫分组商品ID")
|
|
||||||
if not self._cfg_has_sales_bitable(cfg):
|
|
||||||
miss.append("飞书多维表格地址")
|
|
||||||
skipped.append({"style": name, "reason": "缺 " + "/".join(miss)})
|
|
||||||
return {"collectable": collectable, "skipped": skipped, "brand_map": brand_map}
|
return {"collectable": collectable, "skipped": skipped, "brand_map": brand_map}
|
||||||
|
|
||||||
def get_jd_self_styles(self) -> list[str]:
|
def get_jd_self_styles(self) -> list[str]:
|
||||||
"""京东自营周汇总门:仅 SKU 非空即可 (用户确认保持现状)。"""
|
"""京东自营周汇总门:ERP 编码和京东自营 SKU 都要有。"""
|
||||||
return [
|
return [
|
||||||
name
|
name
|
||||||
for name, cfg in self.load()["styles"].items()
|
for name, cfg in self.load()["styles"].items()
|
||||||
if cfg.get("jd_self", {}).get("skus")
|
if self._has_complete_identity(cfg, "jd_self")
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_main_image_styles(self) -> list[str]:
|
def get_main_image_styles(self) -> list[str]:
|
||||||
"""主图门:天猫分组 + 主图飞书多维表格地址 同时存在。"""
|
"""天猫主图门:完整身份、天猫商品编码和主图目标地址同时存在。"""
|
||||||
payload = self.load()
|
payload = self.load()
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
for name, cfg in payload["styles"].items():
|
for name, cfg in payload["styles"].items():
|
||||||
main = cfg.get("main_image_bitable") or {}
|
main = cfg.get("main_image_bitable") or {}
|
||||||
if main.get("base_token") and main.get("table_id"):
|
if self._has_complete_identity(cfg, "tm") and main.get("base_token") and main.get("table_id"):
|
||||||
result.append(name)
|
result.append(name)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
写: check_nine_day_decline.py (去重)
|
写: check_nine_day_decline.py (去重)
|
||||||
读: check_nine_day_decline.py._already_notified
|
读: check_nine_day_decline.py._already_notified
|
||||||
dim_style
|
dim_style
|
||||||
写: db/sync_dim_style.py (人工触发,全量扫 4 个数据源)
|
写: db/sync_dim_style.py (人工触发,投影产品生命进程身份)
|
||||||
读: bitable 同步 / 业务查询
|
读: bitable 同步 / 业务查询
|
||||||
fact_platform_xlsx
|
fact_platform_xlsx
|
||||||
写: 暂无 caller (db/__init__.py.upsert_platform_xlsx_rows 已写好,import 脚本待补)
|
写: 暂无 caller (db/__init__.py.upsert_platform_xlsx_rows 已写好,import 脚本待补)
|
||||||
|
|||||||
@@ -4,13 +4,10 @@
|
|||||||
-- 设计原则: schema 与 data/ 目录下的 JSON 产物 1:1 映射;upsert 幂等
|
-- 设计原则: schema 与 data/ 目录下的 JSON 产物 1:1 映射;upsert 幂等
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
-- 款式维度表:维护每款的 (各平台 SPU/ID + 京东自营 SKU + ERP 编码 + 飞书表地址)
|
-- 款式维度表:维护每款的 (各平台商品 ID + 京东自营 SKU + ERP 编码 + 飞书表地址)
|
||||||
-- 数据源:
|
-- 身份主来源:产品生命进程 cmt_styles.platform_product_ids / erp_style_codes,
|
||||||
-- jd_self_skus ← jd_self_inventory_sales_collector.STYLE_SKU_GROUPS
|
-- 由 workflow_config.runtime_records() 统一投影;运行时不再从各采集器、JSON 产物反向拼接身份。
|
||||||
-- jd_spus/dy_spus/tm_spus ← data/<plat>/<款>_<日期>/<款>_<日期>.json 的"匹配SPU"
|
-- bitable_weekly_* 仍由 bitable_style_map.json 派生;bitable_sku_master_* 仍由飞书 SKU 主表人工维护。
|
||||||
-- erp_codes ← daily_style_metrics.erp_style_codes (自动聚)
|
|
||||||
-- bitable_weekly_* ← bitable_style_map.json (每款周报表地址)
|
|
||||||
-- bitable_sku_master_* ← 飞书 SKU 主表(人工维护;表 base_token + table_id 填这里)
|
|
||||||
CREATE TABLE IF NOT EXISTS dim_style (
|
CREATE TABLE IF NOT EXISTS dim_style (
|
||||||
style_name TEXT PRIMARY KEY,
|
style_name TEXT PRIMARY KEY,
|
||||||
jd_spus TEXT[], -- 京东 POP 商品 SPU
|
jd_spus TEXT[], -- 京东 POP 商品 SPU
|
||||||
@@ -226,7 +223,7 @@ ALTER TABLE dim_style ADD COLUMN IF NOT EXISTS bitable_sku_master_table TEXT;
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
-- dim_style
|
-- dim_style
|
||||||
COMMENT ON TABLE dim_style IS '款式维度表 — 维护每款 (各平台 SPU/ID + 京东自营 SKU + ERP 编码 + 飞书表地址 + 品类/上架日期/备注)。数据源: jd_self_skus、jd/dy/tm_spus、erp_codes、bitable_style_map.json、飞书 SKU 主表 (人工维护)。';
|
COMMENT ON TABLE dim_style IS '款式维度表 — 维护每款 (各平台商品 ID + 京东自营 SKU + ERP 编码 + 飞书表地址 + 品类/上架日期/备注)。身份投影自产品生命进程,周报地址与 SKU 主表仍按各自来源维护。';
|
||||||
COMMENT ON COLUMN dim_style.style_name IS '款式名 (主键),与 PRODUCT_STYLE_GROUPS 的 key 对齐';
|
COMMENT ON COLUMN dim_style.style_name IS '款式名 (主键),与 PRODUCT_STYLE_GROUPS 的 key 对齐';
|
||||||
COMMENT ON COLUMN dim_style.jd_spus IS '京东 POP 商品 SPU 列表';
|
COMMENT ON COLUMN dim_style.jd_spus IS '京东 POP 商品 SPU 列表';
|
||||||
COMMENT ON COLUMN dim_style.dy_spus IS '抖音商品 SPU 列表';
|
COMMENT ON COLUMN dim_style.dy_spus IS '抖音商品 SPU 列表';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""同步 dim_style:4 个数据源自动 upsert。
|
"""同步 dim_style:4 个数据源自动 upsert。
|
||||||
|
|
||||||
数据源:
|
数据源:
|
||||||
1) bitable_style_map.json -> bitable_weekly_url/base/table
|
产品生命进程 + 动态配置运行时聚合 -> 商品编码、ERP 编码和飞书目标地址
|
||||||
2) jd_self_inventory_sales_collector.STYLE_SKU_GROUPS -> jd_self_skus
|
|
||||||
3) data/<plat>/<款>_<最近日期>/<款>_<最近日期>.json -> jd_spus / dy_spus / tm_spus
|
历史文件、采集产物和硬编码仅保留在本文件的兼容函数中,不再作为同步入口,
|
||||||
4) daily_style_metrics.erp_style_codes -> erp_codes
|
避免人工修改生命进程后被旧脚本反向覆盖。
|
||||||
|
|
||||||
SKU 主表的飞书地址 (bitable_sku_master_*) 由人工维护,不在自动同步范围。
|
SKU 主表的飞书地址 (bitable_sku_master_*) 由人工维护,不在自动同步范围。
|
||||||
|
|
||||||
@@ -25,7 +25,9 @@ from typing import Any, Iterable
|
|||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT.parents[2]))
|
||||||
from runtime_paths import RAW_DATA_ROOT, runtime_script
|
from runtime_paths import RAW_DATA_ROOT, runtime_script
|
||||||
|
from gyxx_flow.adapters.workflow_config import runtime_workflow_configs # noqa: E402
|
||||||
|
|
||||||
from db import get_conn # noqa: E402
|
from db import get_conn # noqa: E402
|
||||||
|
|
||||||
@@ -258,6 +260,63 @@ def _merge_records(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def load_lifecycle_records() -> list[dict[str, Any]]:
|
||||||
|
"""读取生命进程身份与动态目标的统一运行时视图。"""
|
||||||
|
|
||||||
|
return runtime_workflow_configs()
|
||||||
|
|
||||||
|
|
||||||
|
def _lifecycle_to_dim_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
platform_fields = {
|
||||||
|
"天猫": "tm_spus",
|
||||||
|
"京东旗舰店": "jd_spus",
|
||||||
|
"京东": "jd_spus",
|
||||||
|
"京东自营": "jd_self_skus",
|
||||||
|
"抖音": "dy_spus",
|
||||||
|
}
|
||||||
|
for record in records:
|
||||||
|
name = str(record.get("style_name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
row = grouped.setdefault(
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
"style_name": name,
|
||||||
|
"jd_spus": [],
|
||||||
|
"dy_spus": [],
|
||||||
|
"tm_spus": [],
|
||||||
|
"jd_self_skus": [],
|
||||||
|
"erp_codes": [],
|
||||||
|
"bitable_weekly_url": "",
|
||||||
|
"bitable_weekly_base": "",
|
||||||
|
"bitable_weekly_table": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for code in record.get("erp_codes") or []:
|
||||||
|
code = str(code).strip()
|
||||||
|
if code and code not in row["erp_codes"]:
|
||||||
|
row["erp_codes"].append(code)
|
||||||
|
target_url = str(record.get("sales_bitable_url") or "").strip()
|
||||||
|
if target_url and not row["bitable_weekly_url"]:
|
||||||
|
row["bitable_weekly_url"] = target_url
|
||||||
|
match = re.search(
|
||||||
|
r"/base/([A-Za-z0-9]+)\?[^\s]*table=([A-Za-z0-9]+)",
|
||||||
|
target_url,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
row["bitable_weekly_base"] = match.group(1)
|
||||||
|
row["bitable_weekly_table"] = match.group(2)
|
||||||
|
field = platform_fields.get(str(record.get("platform") or "").strip())
|
||||||
|
if not field:
|
||||||
|
continue
|
||||||
|
for product_id in record.get("item_ids") or []:
|
||||||
|
product_id = str(product_id).strip()
|
||||||
|
if product_id and product_id not in row[field]:
|
||||||
|
row[field].append(product_id)
|
||||||
|
return list(grouped.values())
|
||||||
|
|
||||||
|
|
||||||
def upsert_dim_style(conn, records: list[dict]) -> int:
|
def upsert_dim_style(conn, records: list[dict]) -> int:
|
||||||
if not records:
|
if not records:
|
||||||
return 0
|
return 0
|
||||||
@@ -311,21 +370,10 @@ def main():
|
|||||||
ap.add_argument("--dry-run", action="store_true", help="扫描不入库")
|
ap.add_argument("--dry-run", action="store_true", help="扫描不入库")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
weekly = load_bitable_weekly()
|
runtime_records = load_lifecycle_records()
|
||||||
self_skus = load_jd_self_skus()
|
records = _lifecycle_to_dim_rows(runtime_records)
|
||||||
spus_map = load_platform_spus()
|
|
||||||
erp_map = load_erp_codes()
|
|
||||||
|
|
||||||
# 款式名全集:合并 4 个数据源
|
print(f"[INFO] 数据源: product_lifecycle_runtime={len(runtime_records)} 条")
|
||||||
style_names: set[str] = set()
|
|
||||||
style_names.update(weekly.keys())
|
|
||||||
style_names.update(self_skus.keys())
|
|
||||||
style_names.update(spus_map.keys())
|
|
||||||
style_names.update(erp_map.keys())
|
|
||||||
|
|
||||||
records = _merge_records(sorted(style_names), weekly, self_skus, spus_map, erp_map)
|
|
||||||
|
|
||||||
print(f"[INFO] 数据源: weekly={len(weekly)} self_skus={len(self_skus)} spus={len(spus_map)} erp={len(erp_map)}")
|
|
||||||
print(f"[INFO] 合并款式: {len(records)}")
|
print(f"[INFO] 合并款式: {len(records)}")
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
for r in records[:5]:
|
for r in records[:5]:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def safe_name(value):
|
|||||||
|
|
||||||
|
|
||||||
def load_style_meta():
|
def load_style_meta():
|
||||||
"""优先从飞书读取 ERP 编码,失败则回退 styles_input.json。"""
|
"""只从产品生命进程读取 ERP 编码;不使用本地旧配置回退。"""
|
||||||
result = {}
|
result = {}
|
||||||
try:
|
try:
|
||||||
for item in get_erp_styles_input():
|
for item in get_erp_styles_input():
|
||||||
@@ -35,19 +35,7 @@ def load_style_meta():
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[WARN] 动态读取 ERP 编码失败,使用本地文件回退: {exc}")
|
raise RuntimeError("产品生命进程身份源不可用,拒绝读取 styles_input.json") from exc
|
||||||
|
|
||||||
if not STYLE_INPUT.exists():
|
|
||||||
return {}
|
|
||||||
items = read_json(STYLE_INPUT)
|
|
||||||
for item in items:
|
|
||||||
style = item.get("style_name") or item.get("款式") or item.get("style")
|
|
||||||
if not style:
|
|
||||||
continue
|
|
||||||
codes = item.get("erp_style_codes") or item.get("erp款式编码") or item.get("codes") or []
|
|
||||||
if isinstance(codes, str):
|
|
||||||
codes = [x.strip() for x in codes.split(",") if x.strip()]
|
|
||||||
result[style] = {"erp_style_codes": codes}
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from runtime_paths import (
|
|||||||
vendor_source_root,
|
vendor_source_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
from gyxx_flow.accounts import _looks_like_login_url
|
from gyxx_flow.adapters.browser import looks_like_login_url as _looks_like_login_url
|
||||||
from gyxx_flow.adapters.scrapling import ScraplingBrowser
|
from gyxx_flow.adapters.scrapling import ScraplingBrowser
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
|||||||
@@ -49,49 +49,15 @@ from jd_peer_product_data_collector import ( # noqa: E402
|
|||||||
INVENTORY_URL = "https://ppzh.jd.com/inventoryweb/brand/view/supplychainAnalysis/inventoryDetail.html"
|
INVENTORY_URL = "https://ppzh.jd.com/inventoryweb/brand/view/supplychainAnalysis/inventoryDetail.html"
|
||||||
DEFAULT_OUTPUT_DIR = RAW_DATA_ROOT / "jd自营库存销量数据"
|
DEFAULT_OUTPUT_DIR = RAW_DATA_ROOT / "jd自营库存销量数据"
|
||||||
|
|
||||||
# 保留硬编码作为灾备回退;运行时优先从飞书多维表格读取。
|
|
||||||
_STATIC_STYLE_SKU_GROUPS = [
|
|
||||||
{"style_name": "盖亚斜挎", "skus": ["100044949353", "100087299891"]},
|
|
||||||
{"style_name": "极星pro", "skus": ["100117005088", "100100271235"]},
|
|
||||||
{"style_name": "盖亚微单", "skus": ["100108144308", "100108144324"]},
|
|
||||||
{"style_name": "盖世m1", "skus": ["100191634938"]},
|
|
||||||
{
|
|
||||||
"style_name": "星迹2",
|
|
||||||
"skus": [
|
|
||||||
"100108144306",
|
|
||||||
"100150785348",
|
|
||||||
"100108144260",
|
|
||||||
"100100271371",
|
|
||||||
"100241562067",
|
|
||||||
"100241562047",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"style_name": "星云2", "skus": ["100160619963", "100160619965", "100248422862"]},
|
|
||||||
{
|
|
||||||
"style_name": "宙斯",
|
|
||||||
"skus": ["100221530889", "100306128160", "100306128166", "100255125975", "100239269869"],
|
|
||||||
},
|
|
||||||
{"style_name": "阿波罗x1", "skus": ["100058323820", "100058323830", "100058916832", "100058916818"]},
|
|
||||||
{"style_name": "逐星", "skus": ["100165451136", "100165451142"]},
|
|
||||||
{"style_name": "星云mini", "skus": ["100215176791", "100215176793"]},
|
|
||||||
{"style_name": "晨星2", "skus": ["100287829888", "100287829866"]},
|
|
||||||
{"style_name": "极星双肩", "skus": ["100247532920", "100247532928"]},
|
|
||||||
{"style_name": "凌云air", "skus": ["100291530148", "100218450063"]},
|
|
||||||
{"style_name": "觅光", "skus": ["100241563697"]},
|
|
||||||
{"style_name": "云卷2", "skus": ["100329997886"]},
|
|
||||||
{"style_name": "拓界mode", "skus": ["100251533359", "100251533369"]},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_style_sku_groups():
|
def _get_style_sku_groups():
|
||||||
"""优先从飞书读取京东自营 SKU 分组,失败则回退硬编码。"""
|
"""从产品生命进程读取完整身份的京东自营 SKU 分组,失败则不采集。"""
|
||||||
try:
|
try:
|
||||||
groups = get_jd_self_sku_groups()
|
groups = get_jd_self_sku_groups()
|
||||||
if groups:
|
if groups:
|
||||||
return groups
|
return groups
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[WARN] 动态读取京东自营 SKU 配置失败,使用硬编码回退: {exc}")
|
print(f"[FAIL] 产品生命进程身份源不可用,跳过京东自营采集: {exc}")
|
||||||
return _STATIC_STYLE_SKU_GROUPS
|
return []
|
||||||
|
|
||||||
|
|
||||||
STYLE_SKU_GROUPS = _get_style_sku_groups()
|
STYLE_SKU_GROUPS = _get_style_sku_groups()
|
||||||
@@ -101,7 +67,7 @@ def _print_preflight() -> None:
|
|||||||
"""启动期轻量打印:京东自营周汇总分组里今日待采款式数。"""
|
"""启动期轻量打印:京东自营周汇总分组里今日待采款式数。"""
|
||||||
try:
|
try:
|
||||||
styles = get_jd_self_styles()
|
styles = get_jd_self_styles()
|
||||||
print(f"[JD_SELF] 飞书表格 周汇总待采 {len(styles)} 款 (仅要求 SKU 非空)")
|
print(f"[JD_SELF] 产品生命进程 周汇总待采 {len(styles)} 款 (要求 ERP + 京东自营 SKU)")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[WARN] 飞书读取失败, 跳过 preflight: {exc}")
|
print(f"[WARN] 飞书读取失败, 跳过 preflight: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ try:
|
|||||||
)
|
)
|
||||||
from .config.style_config_loader import (
|
from .config.style_config_loader import (
|
||||||
StyleConfigLoader,
|
StyleConfigLoader,
|
||||||
|
has_complete_style_identity,
|
||||||
)
|
)
|
||||||
from .lark_cli_runtime import run_lark_cli
|
from .lark_cli_runtime import run_lark_cli
|
||||||
except ImportError: # Direct script execution from the module root.
|
except ImportError: # Direct script execution from the module root.
|
||||||
@@ -36,6 +37,7 @@ except ImportError: # Direct script execution from the module root.
|
|||||||
)
|
)
|
||||||
from config.style_config_loader import (
|
from config.style_config_loader import (
|
||||||
StyleConfigLoader,
|
StyleConfigLoader,
|
||||||
|
has_complete_style_identity,
|
||||||
)
|
)
|
||||||
from lark_cli_runtime import run_lark_cli
|
from lark_cli_runtime import run_lark_cli
|
||||||
|
|
||||||
@@ -328,7 +330,7 @@ def plan_erp_styles(
|
|||||||
str(code).strip() for code in raw_codes if str(code).strip()
|
str(code).strip() for code in raw_codes if str(code).strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not codes:
|
if not codes or not has_complete_style_identity(style_config):
|
||||||
missing.append(target.product_name)
|
missing.append(target.product_name)
|
||||||
continue
|
continue
|
||||||
key = normalized_name(style_name)
|
key = normalized_name(style_name)
|
||||||
@@ -402,7 +404,7 @@ def require_complete_erp_mappings(missing_products: Sequence[str]) -> None:
|
|||||||
if not missing:
|
if not missing:
|
||||||
return
|
return
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"以下产品在飞书款式配置中缺少款式映射或 ERP 款式编码,"
|
"以下产品在产品生命进程中缺少款式映射、ERP 款式编码或平台商品编码,"
|
||||||
"已停止正式写入且目标表未清空: " + "、".join(missing)
|
"已停止正式写入且目标表未清空: " + "、".join(missing)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ _BATCH_FAILURE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
_DRAFT_COUNT_RE = re.compile(r"草稿\s*[((]\s*(?P<count>\d+)\s*[))]")
|
_DRAFT_COUNT_RE = re.compile(r"草稿\s*[((]\s*(?P<count>\d+)\s*[))]")
|
||||||
_DRAFT_EMPTY_MARKERS = ("暂无数据", "数据为空", "暂无商品", "没有符合条件的商品")
|
_DRAFT_EMPTY_MARKERS = ("暂无数据", "数据为空", "暂无商品", "没有符合条件的商品")
|
||||||
|
_ANNOUNCEMENT_MARKERS = ("公告", "通知")
|
||||||
|
_ANNOUNCEMENT_DISMISS_LABELS = ("我已阅读完成", "关闭")
|
||||||
_EDITOR_CLOSE_SELECTORS = (
|
_EDITOR_CLOSE_SELECTORS = (
|
||||||
"button[aria-label='关闭此对话框']",
|
"button[aria-label='关闭此对话框']",
|
||||||
"[role='button'][aria-label='关闭此对话框']",
|
"[role='button'][aria-label='关闭此对话框']",
|
||||||
@@ -627,6 +629,58 @@ def _visible_locator(context: Any, selectors: Iterable[str]) -> Any | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dismiss_blocking_announcements(page: Any) -> bool:
|
||||||
|
"""Close a visible Tmall announcement dialog that blocks page actions.
|
||||||
|
|
||||||
|
The old-shop activity page can show an announcement overlay after the
|
||||||
|
account identity has already loaded. The underlying ``商品批量导入``
|
||||||
|
button remains present, but a normal click is intercepted by the dialog.
|
||||||
|
Only dialogs whose own text contains an announcement marker are touched;
|
||||||
|
business dialogs are left alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dismissed = False
|
||||||
|
for context in _page_contexts(page):
|
||||||
|
get_by_role = getattr(context, "get_by_role", None)
|
||||||
|
if not callable(get_by_role):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dialogs = get_by_role("dialog")
|
||||||
|
count = min(dialogs.count(), 12)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for index in range(count):
|
||||||
|
dialog = dialogs.nth(index)
|
||||||
|
try:
|
||||||
|
if not dialog.is_visible():
|
||||||
|
continue
|
||||||
|
dialog_text = _normalized_page_text(
|
||||||
|
dialog.inner_text(timeout=2_000)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not any(marker in dialog_text for marker in _ANNOUNCEMENT_MARKERS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates: list[Any] = []
|
||||||
|
for label in _ANNOUNCEMENT_DISMISS_LABELS:
|
||||||
|
candidates.extend(_visible_text_candidates(dialog, label))
|
||||||
|
if not candidates:
|
||||||
|
close = _visible_locator(dialog, _EDITOR_CLOSE_SELECTORS)
|
||||||
|
if close is not None:
|
||||||
|
candidates.append(close)
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
candidate.click(timeout=8_000)
|
||||||
|
dismissed = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if dismissed:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _find_password_login_form(
|
def _find_password_login_form(
|
||||||
page: Any,
|
page: Any,
|
||||||
*,
|
*,
|
||||||
@@ -860,6 +914,8 @@ def _open_batch_import(
|
|||||||
) -> _BatchImportStatus | None:
|
) -> _BatchImportStatus | None:
|
||||||
"""Open the import dialog, failing closed on an active prior operation."""
|
"""Open the import dialog, failing closed on an active prior operation."""
|
||||||
|
|
||||||
|
_dismiss_blocking_announcements(page)
|
||||||
|
|
||||||
# The account marker is rendered before the 最近操作 panel on this SPA.
|
# The account marker is rendered before the 最近操作 panel on this SPA.
|
||||||
# Capture the panel after it has had a short chance to hydrate; otherwise a
|
# Capture the panel after it has had a short chance to hydrate; otherwise a
|
||||||
# previous terminal summary can appear only after the dialog is opened and
|
# previous terminal summary can appear only after the dialog is opened and
|
||||||
@@ -886,6 +942,7 @@ def _open_batch_import(
|
|||||||
|
|
||||||
deadline = time.monotonic() + 20
|
deadline = time.monotonic() + 20
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
|
_dismiss_blocking_announcements(page)
|
||||||
if _click_text(page, ("商品批量导入",)):
|
if _click_text(page, ("商品批量导入",)):
|
||||||
return previous
|
return previous
|
||||||
current = _latest_batch_import_status(_page_text(page))
|
current = _latest_batch_import_status(_page_text(page))
|
||||||
@@ -993,26 +1050,28 @@ def _return_to_draft_list(page: Any, entry: BaibuEntry) -> None:
|
|||||||
"""Leave the current editor and wait until the draft list is usable.
|
"""Leave the current editor and wait until the draft list is usable.
|
||||||
|
|
||||||
Tmall keeps the editor open after ``下一步`` saves a product. The next
|
Tmall keeps the editor open after ``下一步`` saves a product. The next
|
||||||
product must be selected from the draft list, so explicitly navigate back
|
product must be selected from the draft list, so close the editor first
|
||||||
before the loop reads the draft count. The visible 草稿 tab is preferred;
|
and only then navigate back before the loop reads the draft count. This
|
||||||
closing the editor and browser history are fallbacks for the two page
|
prevents a click on the 草稿 tab or the next 完善商品 action from being
|
||||||
variants used by the old and new seller consoles.
|
intercepted by the still-open drawer. Browser history remains a fallback
|
||||||
|
for the two page variants used by the old and new seller consoles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
deadline = time.monotonic() + _DRAFT_NAVIGATION_TIMEOUT_SECONDS
|
deadline = time.monotonic() + _DRAFT_NAVIGATION_TIMEOUT_SECONDS
|
||||||
close_attempted = False
|
close_attempted = False
|
||||||
back_attempted = False
|
back_attempted = False
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if _click_draft_tab(page) and _draft_list_is_ready(page):
|
|
||||||
return
|
|
||||||
|
|
||||||
if not close_attempted:
|
if not close_attempted:
|
||||||
close_attempted = True
|
close_attempted = True
|
||||||
if _close_editor(page):
|
if _close_editor(page):
|
||||||
# Let the drawer close animation finish before trying the
|
# Let the drawer close animation finish before trying the
|
||||||
# tab again or falling back to browser history.
|
# draft list or falling back to browser history.
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
continue
|
if _draft_list_is_ready(page):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _click_draft_tab(page) and _draft_list_is_ready(page):
|
||||||
|
return
|
||||||
|
|
||||||
if not back_attempted:
|
if not back_attempted:
|
||||||
back_attempted = True
|
back_attempted = True
|
||||||
|
|||||||
@@ -1291,14 +1291,14 @@
|
|||||||
const platforms = Array.isArray(record.platforms) ? record.platforms : [];
|
const platforms = Array.isArray(record.platforms) ? record.platforms : [];
|
||||||
const enabledPlatforms = platforms.filter((platform) => platform.enabled !== false);
|
const enabledPlatforms = platforms.filter((platform) => platform.enabled !== false);
|
||||||
const itemCount = enabledPlatforms.reduce((total, platform) => total + (Array.isArray(platform.item_ids) ? platform.item_ids.length : 0), 0);
|
const itemCount = enabledPlatforms.reduce((total, platform) => total + (Array.isArray(platform.item_ids) ? platform.item_ids.length : 0), 0);
|
||||||
const platformRows = enabledPlatforms.slice(0, 4).map((platform) => `<span class="config-platform-chip"><b>${esc(platform.platform || "未填写平台")}</b><small>${Array.isArray(platform.item_ids) && platform.item_ids.length ? `${platform.item_ids.length} 个 ID` : "待补 ID"}</small></span>`).join("");
|
const platformRows = enabledPlatforms.slice(0, 4).map((platform) => `<span class="config-platform-chip"><b>${esc(platform.platform || "未填写平台")}</b><small>${Array.isArray(platform.item_ids) && platform.item_ids.length ? `${platform.item_ids.length} 个兼容 ID` : "由生命进程维护"}</small></span>`).join("");
|
||||||
const morePlatforms = enabledPlatforms.length > 4 ? `<span class="config-platform-chip config-platform-chip--more">+${enabledPlatforms.length - 4}</span>` : "";
|
const morePlatforms = enabledPlatforms.length > 4 ? `<span class="config-platform-chip config-platform-chip--more">+${enabledPlatforms.length - 4}</span>` : "";
|
||||||
const destinationNames = dynamicConfigDestinations(record).filter((item) => item.enabled && item.url).slice(0, 3).map((item) => `<span>${esc(item.label)}</span>`).join("");
|
const destinationNames = dynamicConfigDestinations(record).filter((item) => item.enabled && item.url).slice(0, 3).map((item) => `<span>${esc(item.label)}</span>`).join("");
|
||||||
const moreTargets = targetCount > 3 ? `<span class="config-target-more">+${targetCount - 3}</span>` : "";
|
const moreTargets = targetCount > 3 ? `<span class="config-target-more">+${targetCount - 3}</span>` : "";
|
||||||
return `<article class="dynamic-config-record" role="listitem" data-config-id="${Number(record.id)}">
|
return `<article class="dynamic-config-record" role="listitem" data-config-id="${Number(record.id)}">
|
||||||
<div class="dynamic-config-record__identity" data-label="款式身份"><span class="dynamic-config-record__avatar">${esc(configInitial(style))}</span><div><strong>${esc(style)}</strong><small>${esc(brand)}${record.note ? ` · ${esc(record.note)}` : ""}</small><span class="dynamic-config-record__id">配置 #${Number(record.id)}</span></div></div>
|
<div class="dynamic-config-record__identity" data-label="款式身份"><span class="dynamic-config-record__avatar">${esc(configInitial(style))}</span><div><strong>${esc(style)}</strong><small>${esc(brand)}${record.note ? ` · ${esc(record.note)}` : ""}</small><span class="dynamic-config-record__id">配置 #${Number(record.id)}</span></div></div>
|
||||||
<div class="dynamic-config-record__platforms" data-label="平台商品"><div class="dynamic-config-record__metric"><strong>${enabledPlatforms.length}</strong><span>个平台 · ${itemCount} 个商品 ID</span></div><div class="config-platform-list">${platformRows || '<span class="muted">暂无平台映射</span>'}${morePlatforms}</div></div>
|
<div class="dynamic-config-record__platforms" data-label="平台目标"><div class="dynamic-config-record__metric"><strong>${enabledPlatforms.length}</strong><span>个平台 · ${itemCount} 个兼容 ID</span></div><div class="config-platform-list">${platformRows || '<span class="muted">暂无平台目标</span>'}${morePlatforms}</div></div>
|
||||||
<div class="dynamic-config-record__targets" data-label="ERP / 业务目标"><div class="dynamic-config-record__erp"><span>ERP</span>${configCodeList(record.erp_codes)}</div><div class="config-target-list">${destinationNames || '<span class="muted">尚未配置目标地址</span>'}${moreTargets}</div></div>
|
<div class="dynamic-config-record__targets" data-label="业务目标"><div class="dynamic-config-record__erp"><span>身份</span><span class="muted">产品生命进程</span></div><div class="config-target-list">${destinationNames || '<span class="muted">尚未配置目标地址</span>'}${moreTargets}</div></div>
|
||||||
<div class="dynamic-config-record__status" data-label="状态"><span class="config-state-pill config-state-pill--${record.enabled ? "enabled" : "disabled"}">${record.enabled ? "启用" : "停用"}</span><small>${targetCount} 个目标已接入</small></div>
|
<div class="dynamic-config-record__status" data-label="状态"><span class="config-state-pill config-state-pill--${record.enabled ? "enabled" : "disabled"}">${record.enabled ? "启用" : "停用"}</span><small>${targetCount} 个目标已接入</small></div>
|
||||||
<div class="dynamic-config-record__action"><button class="dynamic-config-edit" type="button" data-action="edit-dynamic-config" data-config-id="${Number(record.id)}">编辑配置<span aria-hidden="true">→</span></button></div>
|
<div class="dynamic-config-record__action"><button class="dynamic-config-edit" type="button" data-action="edit-dynamic-config" data-config-id="${Number(record.id)}">编辑配置<span aria-hidden="true">→</span></button></div>
|
||||||
</article>`;
|
</article>`;
|
||||||
@@ -1335,9 +1335,9 @@
|
|||||||
function dynamicConfigPlatformRow(platform = {}) {
|
function dynamicConfigPlatformRow(platform = {}) {
|
||||||
const enabled = platform.enabled !== false;
|
const enabled = platform.enabled !== false;
|
||||||
return `<article class="dynamic-config-platform-row" data-platform-row>
|
return `<article class="dynamic-config-platform-row" data-platform-row>
|
||||||
<div class="dynamic-config-platform-row__header"><span class="dynamic-config-platform-row__mark" aria-hidden="true">台</span><div><strong data-platform-preview>${esc(platform.platform || "新平台")}</strong><small>平台级商品身份</small></div><label class="dynamic-config-platform-toggle"><input data-platform-field="enabled" type="checkbox" ${enabled ? "checked" : ""}><span>启用</span></label><button class="dynamic-config-platform-remove" data-action="remove-platform" type="button" aria-label="删除平台">×</button></div>
|
<div class="dynamic-config-platform-row__header"><span class="dynamic-config-platform-row__mark" aria-hidden="true">台</span><div><strong data-platform-preview>${esc(platform.platform || "新平台")}</strong><small>平台画像与启停</small></div><label class="dynamic-config-platform-toggle"><input data-platform-field="enabled" type="checkbox" ${enabled ? "checked" : ""}><span>启用</span></label><button class="dynamic-config-platform-remove" data-action="remove-platform" type="button" aria-label="删除平台">×</button></div>
|
||||||
<div class="dynamic-config-platform-row__fields"><label class="field"><span>平台名称 <i>必填</i></span><input data-platform-field="platform" list="config-platform-options" maxlength="80" value="${esc(platform.platform || "")}" placeholder="例如:天猫" required></label>
|
<div class="dynamic-config-platform-row__fields"><label class="field"><span>平台名称 <i>必填</i></span><input data-platform-field="platform" list="config-platform-options" maxlength="80" value="${esc(platform.platform || "")}" placeholder="例如:天猫" required></label>
|
||||||
<label class="field"><span>商品 ID / SPU / SKU <small>多个用逗号或换行分隔</small></span><textarea data-platform-field="item_ids" rows="2" placeholder="例如:1074059145982">${esc((platform.item_ids || []).join(", "))}</textarea></label>
|
<label class="field"><span>商品 ID / SPU / SKU <small>只读,前往产品生命进程维护</small></span><textarea data-platform-field="item_ids" rows="2" placeholder="由产品生命进程提供" readonly>${esc((platform.item_ids || []).join(", "))}</textarea></label>
|
||||||
<label class="field field--full"><span>本平台人群画像表 <small>不采集画像可留空</small></span><input data-platform-field="persona_bitable_url" type="url" value="${esc(platform.persona_bitable_url || "")}" placeholder="https://…"></label></div>
|
<label class="field field--full"><span>本平台人群画像表 <small>不采集画像可留空</small></span><input data-platform-field="persona_bitable_url" type="url" value="${esc(platform.persona_bitable_url || "")}" placeholder="https://…"></label></div>
|
||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="module-nav__item sidebar-config__item" id="dynamic-config-nav-button" type="button" data-view="dynamic-configs" aria-controls="dynamic-config-view">
|
<button class="module-nav__item sidebar-config__item" id="dynamic-config-nav-button" type="button" data-view="dynamic-configs" aria-controls="dynamic-config-view">
|
||||||
<span class="module-nav__glyph sidebar-config__glyph dynamic-config-nav__glyph" aria-hidden="true">配</span>
|
<span class="module-nav__glyph sidebar-config__glyph dynamic-config-nav__glyph" aria-hidden="true">配</span>
|
||||||
<span class="module-nav__copy"><strong>款式平台配置</strong><small>ERP、平台商品 ID 与目标表</small></span>
|
<span class="module-nav__copy"><strong>款式目标配置</strong><small>业务目标、画像表与启停</small></span>
|
||||||
<span class="module-nav__count" id="dynamic-config-nav-count">…</span>
|
<span class="module-nav__count" id="dynamic-config-nav-count">…</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
<div class="dynamic-config-header__copy">
|
<div class="dynamic-config-header__copy">
|
||||||
<span class="eyebrow">STYLE CONFIGURATION WORKSPACE</span>
|
<span class="eyebrow">STYLE CONFIGURATION WORKSPACE</span>
|
||||||
<h1 id="dynamic-config-title">款式配置中心</h1>
|
<h1 id="dynamic-config-title">款式配置中心</h1>
|
||||||
<p>一款一条配置,集中维护 ERP、飞书业务目标和各平台商品身份。</p>
|
<p>一款一条配置,集中维护飞书业务目标、平台画像和启停;商品身份请在产品生命进程维护。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dynamic-config-header__actions">
|
<div class="dynamic-config-header__actions">
|
||||||
<div class="dynamic-config-source-note"><span class="config-source-dot" aria-hidden="true"></span><span><small>配置来源</small><strong>云端 PostgreSQL</strong></span></div>
|
<div class="dynamic-config-source-note"><span class="config-source-dot" aria-hidden="true"></span><span><small>配置来源</small><strong>云端 PostgreSQL</strong></span></div>
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
|
|
||||||
<section class="dynamic-config-source-bar" aria-label="款式配置读取范围">
|
<section class="dynamic-config-source-bar" aria-label="款式配置读取范围">
|
||||||
<div class="dynamic-config-source-bar__lead"><span class="config-source-icon" aria-hidden="true">↗</span><div><strong>统一配置,按款式生效</strong><p>新启动的工作流会读取这里的最新版本。</p></div></div>
|
<div class="dynamic-config-source-bar__lead"><span class="config-source-icon" aria-hidden="true">↗</span><div><strong>统一配置,按款式生效</strong><p>新启动的工作流会读取这里的最新版本。</p></div></div>
|
||||||
<div class="dynamic-config-source-bar__items"><span><b>01</b>ERP 款式编码</span><span><b>02</b>业务目标地址</span><span><b>03</b>平台商品身份</span></div>
|
<div class="dynamic-config-source-bar__items"><span><b>01</b>业务目标地址</span><span><b>02</b>平台画像表</span><span><b>03</b>启停状态</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="dynamic-config-summary" aria-label="配置统计">
|
<section class="dynamic-config-summary" aria-label="配置统计">
|
||||||
@@ -386,7 +386,7 @@
|
|||||||
<div class="dynamic-config-feedback" id="dynamic-config-feedback" role="status" aria-live="polite" hidden></div>
|
<div class="dynamic-config-feedback" id="dynamic-config-feedback" role="status" aria-live="polite" hidden></div>
|
||||||
<div class="dynamic-config-table-wrap">
|
<div class="dynamic-config-table-wrap">
|
||||||
<div class="dynamic-config-table" role="list" aria-label="款式配置列表">
|
<div class="dynamic-config-table" role="list" aria-label="款式配置列表">
|
||||||
<div class="dynamic-config-table-head" aria-hidden="true"><span>款式身份</span><span>平台商品</span><span>ERP / 业务目标</span><span>状态</span><span>操作</span></div>
|
<div class="dynamic-config-table-head" aria-hidden="true"><span>款式身份</span><span>平台目标</span><span>业务目标</span><span>状态</span><span>操作</span></div>
|
||||||
<div id="dynamic-config-table-body"><div class="dynamic-config-loading">正在读取云端配置…</div></div>
|
<div id="dynamic-config-table-body"><div class="dynamic-config-loading">正在读取云端配置…</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +560,7 @@
|
|||||||
<div class="overlay__backdrop" data-close="dynamic-config" aria-hidden="true"></div>
|
<div class="overlay__backdrop" data-close="dynamic-config" aria-hidden="true"></div>
|
||||||
<section class="modal dynamic-config-modal" id="dynamic-config-modal" role="dialog" aria-modal="true" aria-labelledby="dynamic-config-form-title" aria-hidden="true">
|
<section class="modal dynamic-config-modal" id="dynamic-config-modal" role="dialog" aria-modal="true" aria-labelledby="dynamic-config-form-title" aria-hidden="true">
|
||||||
<header class="modal__header dynamic-config-modal__header">
|
<header class="modal__header dynamic-config-modal__header">
|
||||||
<div class="dynamic-config-modal__heading"><div class="dynamic-config-modal__eyebrow"><span class="config-modal-mark" aria-hidden="true">款</span><span class="eyebrow">STYLE CONFIGURATION</span></div><h2 id="dynamic-config-form-title">新增款式</h2><p id="dynamic-config-form-subtitle">一次维护款式公共配置与全部平台商品身份。</p></div>
|
<div class="dynamic-config-modal__heading"><div class="dynamic-config-modal__eyebrow"><span class="config-modal-mark" aria-hidden="true">款</span><span class="eyebrow">STYLE CONFIGURATION</span></div><h2 id="dynamic-config-form-title">新增目标配置</h2><p id="dynamic-config-form-subtitle">身份在产品生命进程维护;这里配置业务目标与平台画像。</p></div>
|
||||||
<div class="dynamic-config-modal__header-actions"><span class="dynamic-config-revision" id="dynamic-config-editor-revision">新配置</span><button class="icon-button" id="dynamic-config-form-close" type="button" aria-label="关闭配置编辑">×</button></div>
|
<div class="dynamic-config-modal__header-actions"><span class="dynamic-config-revision" id="dynamic-config-editor-revision">新配置</span><button class="icon-button" id="dynamic-config-form-close" type="button" aria-label="关闭配置编辑">×</button></div>
|
||||||
</header>
|
</header>
|
||||||
<form id="dynamic-config-form" novalidate>
|
<form id="dynamic-config-form" novalidate>
|
||||||
@@ -569,20 +569,20 @@
|
|||||||
<aside class="dynamic-config-form-rail" aria-label="编辑页导航">
|
<aside class="dynamic-config-form-rail" aria-label="编辑页导航">
|
||||||
<div class="dynamic-config-form-identity"><span class="dynamic-config-editor-avatar" id="dynamic-config-editor-avatar">款</span><div><small>当前款式</small><strong id="dynamic-config-editor-style">新款式</strong><span class="dynamic-config-form-identity__status" id="dynamic-config-editor-status">准备创建</span><span class="dynamic-config-form-identity__summary" id="dynamic-config-editor-summary">新配置 · 1 个平台 · 1 个目标</span></div></div>
|
<div class="dynamic-config-form-identity"><span class="dynamic-config-editor-avatar" id="dynamic-config-editor-avatar">款</span><div><small>当前款式</small><strong id="dynamic-config-editor-style">新款式</strong><span class="dynamic-config-form-identity__status" id="dynamic-config-editor-status">准备创建</span><span class="dynamic-config-form-identity__summary" id="dynamic-config-editor-summary">新配置 · 1 个平台 · 1 个目标</span></div></div>
|
||||||
<nav class="dynamic-config-form-nav" aria-label="配置分区">
|
<nav class="dynamic-config-form-nav" aria-label="配置分区">
|
||||||
<button type="button" data-config-section-nav="identity" class="is-active"><span>01</span><strong>基础信息</strong><small>款式与 ERP 编码</small></button>
|
<button type="button" data-config-section-nav="identity" class="is-active"><span>01</span><strong>基础信息</strong><small>款式与状态</small></button>
|
||||||
<button type="button" data-config-section-nav="destinations"><span>02</span><strong>业务目标</strong><small>可扩展飞书表格地址</small></button>
|
<button type="button" data-config-section-nav="destinations"><span>02</span><strong>业务目标</strong><small>可扩展飞书表格地址</small></button>
|
||||||
<button type="button" data-config-section-nav="platforms"><span>03</span><strong>平台映射</strong><small>商品 ID 与画像表</small></button>
|
<button type="button" data-config-section-nav="platforms"><span>03</span><strong>平台目标</strong><small>画像表与启停</small></button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="dynamic-config-form-rail__tip"><span aria-hidden="true">✦</span><div><strong>为下一条逻辑留位置</strong><p>新分析逻辑只需新增一条业务目标,使用稳定的逻辑标识读取对应地址。</p></div></div>
|
<div class="dynamic-config-form-rail__tip"><span aria-hidden="true">✦</span><div><strong>为下一条逻辑留位置</strong><p>新分析逻辑只需新增一条业务目标,使用稳定的逻辑标识读取对应地址。</p></div></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="modal__body dynamic-config-form-body">
|
<div class="modal__body dynamic-config-form-body">
|
||||||
<p class="form-error" id="dynamic-config-form-error" role="alert" hidden></p>
|
<p class="form-error" id="dynamic-config-form-error" role="alert" hidden></p>
|
||||||
<section class="dynamic-config-form-section" id="dynamic-config-identity-section" data-config-section="identity" aria-labelledby="dynamic-config-identity-title">
|
<section class="dynamic-config-form-section" id="dynamic-config-identity-section" data-config-section="identity" aria-labelledby="dynamic-config-identity-title">
|
||||||
<header><span>01</span><div><h3 id="dynamic-config-identity-title">款式基本信息</h3><p>ERP 编码和款式身份只维护一次,所有平台共享。</p></div></header>
|
<header><span>01</span><div><h3 id="dynamic-config-identity-title">款式基本信息</h3><p>款式身份只在产品生命进程维护;这里仅关联目标配置。</p></div></header>
|
||||||
<div class="dynamic-config-form-grid">
|
<div class="dynamic-config-form-grid">
|
||||||
<label class="field"><span>款式名称 <i>必填</i></span><input id="config-style-name" maxlength="120" placeholder="例如:云栖" required></label>
|
<label class="field"><span>款式名称 <i>必填</i></span><input id="config-style-name" maxlength="120" placeholder="例如:云栖" required></label>
|
||||||
<label class="field"><span>品牌</span><input id="config-brand" maxlength="120" placeholder="例如:光影行星"></label>
|
<label class="field"><span>品牌</span><input id="config-brand" maxlength="120" placeholder="例如:光影行星"></label>
|
||||||
<label class="field field--full"><span>ERP 款式编码 <small>多个编码用逗号或换行分隔</small></span><textarea id="config-erp-codes" rows="2" placeholder="例如:10521, 10522"></textarea></label>
|
<label class="field field--full"><span>ERP 款式编码 <small>只读,前往产品生命进程维护</small></span><textarea id="config-erp-codes" rows="2" placeholder="由产品生命进程提供" readonly></textarea></label>
|
||||||
<label class="field dynamic-config-enabled-field"><span><strong>参与工作流</strong><small>停用后保留记录,但整款不再被工作流解析。</small></span><input id="config-enabled" type="checkbox" checked><i aria-hidden="true"></i></label>
|
<label class="field dynamic-config-enabled-field"><span><strong>参与工作流</strong><small>停用后保留记录,但整款不再被工作流解析。</small></span><input id="config-enabled" type="checkbox" checked><i aria-hidden="true"></i></label>
|
||||||
<label class="field field--full"><span>备注</span><input id="config-note" maxlength="500" placeholder="记录这款的业务说明、特殊规则或负责人"></label>
|
<label class="field field--full"><span>备注</span><input id="config-note" maxlength="500" placeholder="记录这款的业务说明、特殊规则或负责人"></label>
|
||||||
<input id="config-style-content" type="hidden">
|
<input id="config-style-content" type="hidden">
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="dynamic-config-form-section dynamic-config-platform-section" id="dynamic-config-platforms-section" data-config-section="platforms" aria-labelledby="dynamic-config-platforms-title">
|
<section class="dynamic-config-form-section dynamic-config-platform-section" id="dynamic-config-platforms-section" data-config-section="platforms" aria-labelledby="dynamic-config-platforms-title">
|
||||||
<header><span>03</span><div><h3 id="dynamic-config-platforms-title">平台商品身份</h3><p>一个平台一行:填写商品 ID;需要画像采集时再填本平台人群画像表。</p></div><button class="button button--secondary" id="dynamic-config-add-platform" type="button"><span aria-hidden="true">+</span>添加平台</button></header>
|
<header><span>03</span><div><h3 id="dynamic-config-platforms-title">平台目标</h3><p>商品编码由产品生命进程提供;这里维护平台画像表和启停。</p></div><button class="button button--secondary" id="dynamic-config-add-platform" type="button"><span aria-hidden="true">+</span>添加平台</button></header>
|
||||||
<div class="dynamic-config-platform-editor" id="dynamic-config-platform-editor"></div>
|
<div class="dynamic-config-platform-editor" id="dynamic-config-platform-editor"></div>
|
||||||
<datalist id="config-platform-options"><option value="天猫"><option value="京东旗舰店"><option value="京东自营"><option value="抖音"><option value="拼多多"><option value="拼多多光影行星GYXX箱包专卖店"><option value="拼多多光影行星箱包官方旗舰店"><option value="拼多多光影行星官方旗舰店"></datalist>
|
<datalist id="config-platform-options"><option value="天猫"><option value="京东旗舰店"><option value="京东自营"><option value="抖音"><option value="拼多多"><option value="拼多多光影行星GYXX箱包专卖店"><option value="拼多多光影行星箱包官方旗舰店"><option value="拼多多光影行星官方旗舰店"></datalist>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -32,6 +32,45 @@ def test_buyin_source_uses_doudian_market_and_cookie_scope():
|
|||||||
assert xingtu.BUSINESS_COOKIE_DOMAINS == ("jinritemai.com",)
|
assert xingtu.BUSINESS_COOKIE_DOMAINS == ("jinritemai.com",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_buyin_search_stays_on_market_route_instead_of_clicking_global_nav():
|
||||||
|
xingtu.configure_source("buyin")
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class Locator:
|
||||||
|
def __init__(self, count):
|
||||||
|
self._count = count
|
||||||
|
self.first = self
|
||||||
|
|
||||||
|
def count(self):
|
||||||
|
return self._count
|
||||||
|
|
||||||
|
def wait_for(self, **kwargs):
|
||||||
|
events.append(("wait", kwargs))
|
||||||
|
|
||||||
|
class Page:
|
||||||
|
url = "https://www.douyinec.com/"
|
||||||
|
|
||||||
|
def locator(self, selector):
|
||||||
|
events.append(("locator", selector))
|
||||||
|
return Locator(0)
|
||||||
|
|
||||||
|
def goto(self, url, **kwargs):
|
||||||
|
events.append(("goto", url, kwargs))
|
||||||
|
|
||||||
|
def get_by_text(self, *_args, **_kwargs):
|
||||||
|
raise AssertionError("Buyin must not click the global 找达人 navigation")
|
||||||
|
|
||||||
|
xingtu.select_nickname_search(Page())
|
||||||
|
|
||||||
|
assert events[0] == (
|
||||||
|
"locator",
|
||||||
|
f"{xingtu.BUYIN_SEARCH_INPUT_SELECTOR}:visible",
|
||||||
|
)
|
||||||
|
assert events[1][0] == "goto"
|
||||||
|
assert events[1][1] == xingtu.MARKET_URL
|
||||||
|
assert events[2][0] == "wait"
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_creates_cookie_parent_directory(tmp_path, monkeypatch):
|
def test_save_state_creates_cookie_parent_directory(tmp_path, monkeypatch):
|
||||||
cookie_file = tmp_path / "nested" / "cookies.json"
|
cookie_file = tmp_path / "nested" / "cookies.json"
|
||||||
monkeypatch.setattr(xingtu, "DATA_DIR", tmp_path / "raw")
|
monkeypatch.setattr(xingtu, "DATA_DIR", tmp_path / "raw")
|
||||||
|
|||||||
@@ -36,13 +36,29 @@ def test_style_plan_rejects_one_erp_code_owned_by_two_styles() -> None:
|
|||||||
backfill.build_style_plans(
|
backfill.build_style_plans(
|
||||||
{
|
{
|
||||||
"styles": {
|
"styles": {
|
||||||
"款式甲": {"erp_codes": ["10416"]},
|
"款式甲": {"erp_codes": ["10416"], "tm": {"item_ids": ["tm-1"]}},
|
||||||
"款式乙": {"erp_codes": ["10416"]},
|
"款式乙": {"erp_codes": ["10416"], "tm": {"item_ids": ["tm-2"]}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_plan_skips_erp_only_style_until_product_id_is_added() -> None:
|
||||||
|
plans = backfill.build_style_plans(
|
||||||
|
{
|
||||||
|
"styles": {
|
||||||
|
"研发款": {"erp_codes": ["ERP-ONLY"]},
|
||||||
|
"已上架款": {
|
||||||
|
"erp_codes": ["ERP-READY"],
|
||||||
|
"jd": {"spus": ["JD-1"]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plans == (backfill.StylePlan("已上架款", ("ERP-READY",)),)
|
||||||
|
|
||||||
|
|
||||||
def test_all_shop_filter_selects_every_shop_and_batches_all_codes() -> None:
|
def test_all_shop_filter_selects_every_shop_and_batches_all_codes() -> None:
|
||||||
class Frame:
|
class Frame:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|||||||
@@ -66,5 +66,64 @@ def test_database_row_uses_explicit_style_name_not_style_content() -> None:
|
|||||||
"dy_persona_bitable_url": "",
|
"dy_persona_bitable_url": "",
|
||||||
"jd_persona_bitable_url": "",
|
"jd_persona_bitable_url": "",
|
||||||
"style_analysis_bitable_url": "",
|
"style_analysis_bitable_url": "",
|
||||||
|
"destinations": [],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_erp_only_style_is_skipped_by_every_product_collection_gate() -> None:
|
||||||
|
loader = StyleConfigLoader(
|
||||||
|
records_provider=lambda: [
|
||||||
|
{
|
||||||
|
"platform": "天猫",
|
||||||
|
"style_name": "研发款",
|
||||||
|
"erp_codes": ["ERP-ONLY"],
|
||||||
|
"item_ids": [],
|
||||||
|
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
|
||||||
|
"main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
daily = loader.get_daily_styles()
|
||||||
|
erp_daily = loader.get_erp_daily_styles()
|
||||||
|
|
||||||
|
assert daily["tm"]["collectable"] == []
|
||||||
|
assert daily["tm"]["skipped"] == [
|
||||||
|
{"style": "研发款", "reason": "缺 平台商品编码/tm商品编码"}
|
||||||
|
]
|
||||||
|
assert erp_daily["collectable"] == []
|
||||||
|
assert erp_daily["skipped"] == [
|
||||||
|
{"style": "研发款", "reason": "缺 平台商品编码"}
|
||||||
|
]
|
||||||
|
assert loader.get_erp_styles_input() == []
|
||||||
|
assert loader.get_main_image_styles() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_identity_allows_only_configured_platforms() -> None:
|
||||||
|
loader = StyleConfigLoader(
|
||||||
|
records_provider=lambda: [
|
||||||
|
{
|
||||||
|
"platform": "天猫",
|
||||||
|
"style_name": "完整款",
|
||||||
|
"erp_codes": ["ERP-1"],
|
||||||
|
"item_ids": ["TM-1"],
|
||||||
|
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
|
||||||
|
"main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "京东旗舰店",
|
||||||
|
"style_name": "完整款",
|
||||||
|
"erp_codes": [],
|
||||||
|
"item_ids": ["JD-1"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
daily = loader.get_daily_styles()
|
||||||
|
|
||||||
|
assert daily["tm"]["collectable"] == ["完整款"]
|
||||||
|
assert daily["jd"]["collectable"] == ["完整款"]
|
||||||
|
assert daily["dy"]["collectable"] == []
|
||||||
|
assert loader.get_erp_daily_styles()["collectable"] == ["完整款"]
|
||||||
|
assert loader.get_main_image_styles() == ["完整款"]
|
||||||
|
|||||||
@@ -210,8 +210,11 @@ def test_erp_style_plan_uses_style_base_codes_and_aliases() -> None:
|
|||||||
ProductTarget("云栖相机双肩包", 200),
|
ProductTarget("云栖相机双肩包", 200),
|
||||||
]
|
]
|
||||||
styles = {
|
styles = {
|
||||||
"盖世m1": {"erp_codes": ["10416", "10455", "10416"]},
|
"盖世m1": {
|
||||||
"星迹2": {"erp_codes": ["10504", "10394"]},
|
"erp_codes": ["10416", "10455", "10416"],
|
||||||
|
"tm": {"item_ids": ["tm-1"]},
|
||||||
|
},
|
||||||
|
"星迹2": {"erp_codes": ["10504", "10394"], "tm": {"item_ids": ["tm-2"]}},
|
||||||
"云栖": {"erp_codes": []},
|
"云栖": {"erp_codes": []},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,83 @@ def test_upload_template_supports_tmall_dialog_import_button(
|
|||||||
assert chooser.files == str(template)
|
assert chooser.files == str(template)
|
||||||
|
|
||||||
|
|
||||||
|
class _AnnouncementDialog:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.dismiss = _LoginLocator()
|
||||||
|
|
||||||
|
def is_visible(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def inner_text(self, **_kwargs) -> str:
|
||||||
|
return "公告 26年天猫活动通知"
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def nth(self, _index: int):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def get_by_role(self, role: str, *, name: str, exact: bool):
|
||||||
|
if role == "button" and name == "我已阅读完成" and exact:
|
||||||
|
return self.dismiss
|
||||||
|
return _LoginLocator(visible=False)
|
||||||
|
|
||||||
|
def get_by_text(self, _label: str, *, exact: bool):
|
||||||
|
return _LoginLocator(visible=False)
|
||||||
|
|
||||||
|
def locator(self, _selector: str):
|
||||||
|
return _LoginLocator(visible=False)
|
||||||
|
|
||||||
|
|
||||||
|
class _AnnouncementPage:
|
||||||
|
frames: list[object] = []
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.dialog = _AnnouncementDialog()
|
||||||
|
|
||||||
|
def get_by_role(self, role: str, **_kwargs):
|
||||||
|
if role == "dialog":
|
||||||
|
return self.dialog
|
||||||
|
return _LoginLocator(visible=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dismiss_blocking_announcements_closes_only_announcement_dialog() -> None:
|
||||||
|
page = _AnnouncementPage()
|
||||||
|
|
||||||
|
assert baibu._dismiss_blocking_announcements(page) is True
|
||||||
|
assert page.dialog.dismiss.click_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_batch_import_dismisses_announcement_before_click(monkeypatch) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
page = SimpleNamespace()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
baibu,
|
||||||
|
"_page_text",
|
||||||
|
lambda _page: (
|
||||||
|
"您于2026-09-03 15:00:51执行了商品批量导入操作,"
|
||||||
|
"总数量2件,已成功2件,失败0件,待查看0件。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
baibu,
|
||||||
|
"_dismiss_blocking_announcements",
|
||||||
|
lambda _page: events.append("dismiss") or True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
baibu,
|
||||||
|
"_click_text",
|
||||||
|
lambda _page, _labels: events.append("click") or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
baibu._open_batch_import(page, _entry())
|
||||||
|
|
||||||
|
assert events[:2] == ["dismiss", "dismiss"]
|
||||||
|
assert events[2:] == ["click"]
|
||||||
|
|
||||||
|
|
||||||
def test_open_batch_import_refuses_an_active_previous_operation(monkeypatch) -> None:
|
def test_open_batch_import_refuses_an_active_previous_operation(monkeypatch) -> None:
|
||||||
page = SimpleNamespace()
|
page = SimpleNamespace()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -538,6 +615,33 @@ def test_return_to_draft_list_falls_back_to_browser_history(monkeypatch) -> None
|
|||||||
assert events == ["draft", "back"]
|
assert events == ["draft", "back"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_to_draft_list_closes_editor_before_clicking_draft_tab(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
baibu,
|
||||||
|
"_close_editor",
|
||||||
|
lambda _page: events.append("close") or True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
baibu,
|
||||||
|
"_draft_list_is_ready",
|
||||||
|
lambda _page: events.append("ready") or True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
baibu,
|
||||||
|
"_click_draft_tab",
|
||||||
|
lambda _page: pytest.fail("关闭编辑器后列表已就绪时不得重复点草稿"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||||
|
|
||||||
|
baibu._return_to_draft_list(SimpleNamespace(), _entry())
|
||||||
|
|
||||||
|
assert events == ["close", "ready"]
|
||||||
|
|
||||||
|
|
||||||
def test_tmall_config_enables_draft_completion_for_only_old_all_3c() -> None:
|
def test_tmall_config_enables_draft_completion_for_only_old_all_3c() -> None:
|
||||||
old_all_3c = baibu._load_entry("old_all_3c")
|
old_all_3c = baibu._load_entry("old_all_3c")
|
||||||
old_all_bag = baibu._load_entry("old_all_bag")
|
old_all_bag = baibu._load_entry("old_all_bag")
|
||||||
|
|||||||
+23
-18
@@ -11,15 +11,15 @@ from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_maps_27_project_scheduled_tasks() -> None:
|
def test_catalog_maps_29_project_scheduled_tasks() -> None:
|
||||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||||
scheduled = catalog.scheduled_workflows()
|
scheduled = catalog.scheduled_workflows()
|
||||||
|
|
||||||
assert len(scheduled) == 27
|
assert len(scheduled) == 29
|
||||||
assert len({item.source_task_name for item in scheduled}) == 27
|
assert len({item.source_task_name for item in scheduled}) == 29
|
||||||
assert Counter(item.module for item in scheduled) == {
|
assert Counter(item.module for item in scheduled) == {
|
||||||
"content_marketing": 9,
|
"content_marketing": 9,
|
||||||
"product_commerce": 10,
|
"product_commerce": 12,
|
||||||
"shop_intelligence": 4,
|
"shop_intelligence": 4,
|
||||||
"supply_chain": 4,
|
"supply_chain": 4,
|
||||||
}
|
}
|
||||||
@@ -30,10 +30,7 @@ def test_catalog_schedules_douyin_price_appeal() -> None:
|
|||||||
schedule = catalog.schedule_for("shop.douyin_price_appeal")
|
schedule = catalog.schedule_for("shop.douyin_price_appeal")
|
||||||
|
|
||||||
assert len(catalog.workflows) == 29
|
assert len(catalog.workflows) == 29
|
||||||
assert tuple(item.workflow_id for item in catalog.manual_workflows()) == (
|
assert catalog.manual_workflows() == ()
|
||||||
"product.video_upload",
|
|
||||||
"product.jd_video_upload",
|
|
||||||
)
|
|
||||||
assert "shop.douyin_price_appeal" in {
|
assert "shop.douyin_price_appeal" in {
|
||||||
schedule.workflow_id for schedule in catalog.schedules
|
schedule.workflow_id for schedule in catalog.schedules
|
||||||
}
|
}
|
||||||
@@ -42,13 +39,13 @@ def test_catalog_schedules_douyin_price_appeal() -> None:
|
|||||||
assert schedule.effective_times == ("08:00", "16:00", "22:00")
|
assert schedule.effective_times == ("08:00", "16:00", "22:00")
|
||||||
|
|
||||||
|
|
||||||
def test_tmall_video_upload_is_an_idempotent_store_selected_manual_workflow() -> None:
|
def test_tmall_video_upload_is_an_idempotent_store_selected_scheduled_workflow() -> None:
|
||||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||||
workflow = next(
|
workflow = next(
|
||||||
item for item in catalog.workflows if item.workflow_id == "product.video_upload"
|
item for item in catalog.workflows if item.workflow_id == "product.video_upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert workflow.trigger == "manual"
|
assert workflow.trigger == "scheduled"
|
||||||
assert [step.step_id for step in workflow.steps] == ["upload"]
|
assert [step.step_id for step in workflow.steps] == ["upload"]
|
||||||
step = workflow.steps[0]
|
step = workflow.steps[0]
|
||||||
assert step.entry == "upload_video_to_guanghe.py"
|
assert step.entry == "upload_video_to_guanghe.py"
|
||||||
@@ -60,11 +57,15 @@ def test_tmall_video_upload_is_an_idempotent_store_selected_manual_workflow() ->
|
|||||||
assert step.timeout_seconds == 21600
|
assert step.timeout_seconds == 21600
|
||||||
assert workflow.topic_config is not None
|
assert workflow.topic_config is not None
|
||||||
assert workflow.topic_config.default_keyword == "我的夏日焕新清单"
|
assert workflow.topic_config.default_keyword == "我的夏日焕新清单"
|
||||||
with pytest.raises(KeyError):
|
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||||
catalog.schedule_for(workflow.workflow_id)
|
assert schedule.kind == "interval_days"
|
||||||
|
assert schedule.at == "14:00"
|
||||||
|
assert schedule.every_days == 3
|
||||||
|
assert schedule.anchor_date == "2026-09-04"
|
||||||
|
assert schedule.enabled is True
|
||||||
|
|
||||||
|
|
||||||
def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
|
def test_jd_video_upload_is_an_idempotent_scheduled_workflow() -> None:
|
||||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||||
workflow = next(
|
workflow = next(
|
||||||
item
|
item
|
||||||
@@ -72,7 +73,7 @@ def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
|
|||||||
if item.workflow_id == "product.jd_video_upload"
|
if item.workflow_id == "product.jd_video_upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert workflow.trigger == "manual"
|
assert workflow.trigger == "scheduled"
|
||||||
assert [step.step_id for step in workflow.steps] == ["upload"]
|
assert [step.step_id for step in workflow.steps] == ["upload"]
|
||||||
step = workflow.steps[0]
|
step = workflow.steps[0]
|
||||||
assert step.entry == "upload_video_to_jd.py"
|
assert step.entry == "upload_video_to_jd.py"
|
||||||
@@ -84,8 +85,12 @@ def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
|
|||||||
)
|
)
|
||||||
assert step.replay_policy == "idempotent"
|
assert step.replay_policy == "idempotent"
|
||||||
assert step.timeout_seconds == 21600
|
assert step.timeout_seconds == 21600
|
||||||
with pytest.raises(KeyError):
|
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||||
catalog.schedule_for(workflow.workflow_id)
|
assert schedule.kind == "interval_days"
|
||||||
|
assert schedule.at == "14:00"
|
||||||
|
assert schedule.every_days == 3
|
||||||
|
assert schedule.anchor_date == "2026-09-04"
|
||||||
|
assert schedule.enabled is True
|
||||||
|
|
||||||
|
|
||||||
def test_tmall_baibu_apply_is_a_six_step_hourly_workflow() -> None:
|
def test_tmall_baibu_apply_is_a_six_step_hourly_workflow() -> None:
|
||||||
@@ -181,13 +186,13 @@ def test_every_scheduled_workflow_has_one_valid_asia_shanghai_schedule() -> None
|
|||||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||||
|
|
||||||
assert catalog.timezone == "Asia/Shanghai"
|
assert catalog.timezone == "Asia/Shanghai"
|
||||||
assert len(catalog.schedules) == 27
|
assert len(catalog.schedules) == 29
|
||||||
for workflow in catalog.scheduled_workflows():
|
for workflow in catalog.scheduled_workflows():
|
||||||
schedule = catalog.schedule_for(workflow.workflow_id)
|
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||||
assert schedule.workflow_id == workflow.workflow_id
|
assert schedule.workflow_id == workflow.workflow_id
|
||||||
assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"}
|
assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"}
|
||||||
assert schedule.at.count(":") == 1
|
assert schedule.at.count(":") == 1
|
||||||
assert sum(schedule.enabled for schedule in catalog.schedules) == 23
|
assert sum(schedule.enabled for schedule in catalog.schedules) == 25
|
||||||
|
|
||||||
|
|
||||||
def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -> None:
|
def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -> None:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def test_default_cli_registry_exposes_all_project_scheduled_workflows(
|
|||||||
scheduled_ids = {
|
scheduled_ids = {
|
||||||
entry.workflow_id for entry in registry.catalog.scheduled_workflows()
|
entry.workflow_id for entry in registry.catalog.scheduled_workflows()
|
||||||
}
|
}
|
||||||
assert len(scheduled_ids) == 27
|
assert len(scheduled_ids) == 29
|
||||||
assert scheduled_ids == {
|
assert scheduled_ids == {
|
||||||
schedule.workflow_id for schedule in registry.catalog.schedules
|
schedule.workflow_id for schedule in registry.catalog.schedules
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-1
@@ -546,7 +546,7 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths(
|
|||||||
assert failed["last_run"]["mode"] == "execute"
|
assert failed["last_run"]["mode"] == "execute"
|
||||||
assert failed["last_run"]["error"] == "critical steps failed: collect"
|
assert failed["last_run"]["error"] == "critical steps failed: collect"
|
||||||
assert all(step["name"] and step["description"] for step in failed["steps"])
|
assert all(step["name"] and step["description"] for step in failed["steps"])
|
||||||
assert {step["replay_policy"] for step in failed["steps"]} == {"guarded"}
|
assert {step["replay_policy"] for step in failed["steps"]} == {"repeatable"}
|
||||||
notes_master = next(
|
notes_master = next(
|
||||||
item
|
item
|
||||||
for item in initial["workflows"]
|
for item in initial["workflows"]
|
||||||
@@ -3271,3 +3271,149 @@ def test_scheduler_reload_catalog_runs_before_each_tick(tmp_path: Path) -> None:
|
|||||||
assert scheduler.tick(now) == []
|
assert scheduler.tick(now) == []
|
||||||
assert scheduler.catalog is disabled_catalog
|
assert scheduler.catalog is disabled_catalog
|
||||||
assert load_calls == [due_catalog, disabled_catalog]
|
assert load_calls == [due_catalog, disabled_catalog]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_detail_returns_a_single_workflow(
|
||||||
|
console_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
service = WorkflowConsoleService(
|
||||||
|
console_settings,
|
||||||
|
launcher=FakeConsoleLauncher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = service.workflow_detail("content.metrics.daily")
|
||||||
|
|
||||||
|
assert detail["workflow"]["id"] == "content.metrics.daily"
|
||||||
|
assert detail["workflow"]["module"] == "content_marketing"
|
||||||
|
|
||||||
|
with pytest.raises(ConsoleNotFoundError):
|
||||||
|
service.workflow_detail("content.unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_detail_returns_journal_steps(
|
||||||
|
console_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
run_id = _index_parallel_main_image_run(console_settings)
|
||||||
|
service = WorkflowConsoleService(
|
||||||
|
console_settings,
|
||||||
|
launcher=FakeConsoleLauncher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = service.run_detail("product.main_image.weekly", run_id)
|
||||||
|
|
||||||
|
assert detail["workflow_id"] == "product.main_image.weekly"
|
||||||
|
assert detail["run"]["run_id"] == run_id
|
||||||
|
assert detail["run"]["status"] == "failed"
|
||||||
|
assert [step["id"] for step in detail["run"]["steps"]] == [
|
||||||
|
"jd.attempt-1",
|
||||||
|
"tmall.attempt-1",
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ConsoleNotFoundError):
|
||||||
|
service.run_detail("content.metrics.daily", run_id)
|
||||||
|
with pytest.raises(ConsoleNotFoundError):
|
||||||
|
service.run_detail("product.main_image.weekly", "missing-run")
|
||||||
|
with pytest.raises(ConsoleNotFoundError):
|
||||||
|
service.run_detail("product.main_image.weekly", "../escape")
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_diagnosis_bundles_trace_and_sanitized_log_tails(
|
||||||
|
console_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
run_id = _index_parallel_main_image_run(console_settings)
|
||||||
|
layout = DataLayout(console_settings.data_root)
|
||||||
|
log_dir = layout.log_dir("product.main_image.weekly", "2026-08-02")
|
||||||
|
secret = "hunter" + "2"
|
||||||
|
(log_dir / "jd.attempt-1.log").write_text(
|
||||||
|
"采集开始\npass" + "word=" + secret + "\n浏览器崩溃\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(log_dir / "empty.log").write_text("", encoding="utf-8")
|
||||||
|
service = WorkflowConsoleService(
|
||||||
|
console_settings,
|
||||||
|
launcher=FakeConsoleLauncher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
diagnosis = service.run_diagnosis("product.main_image.weekly", run_id)
|
||||||
|
|
||||||
|
assert diagnosis["workflow_id"] == "product.main_image.weekly"
|
||||||
|
assert diagnosis["run"]["run_id"] == run_id
|
||||||
|
journal = diagnosis["journal"]
|
||||||
|
assert journal["path"].endswith("/run.json")
|
||||||
|
assert journal["trace"]["paths"]["log"].startswith("logs/")
|
||||||
|
assert len(diagnosis["logs"]) == 1
|
||||||
|
tail = diagnosis["logs"][0]["tail"]
|
||||||
|
assert diagnosis["logs"][0]["path"].endswith("jd.attempt-1.log")
|
||||||
|
assert "浏览器崩溃" in tail
|
||||||
|
assert secret not in tail
|
||||||
|
assert "[REDACTED]" in tail
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_diagnosis_handles_a_missing_journal(
|
||||||
|
console_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
context = RunContext.create(
|
||||||
|
"product.main_image.weekly",
|
||||||
|
"2026-08-02",
|
||||||
|
now=datetime(2026, 8, 2, 3, 4, 5, tzinfo=timezone.utc),
|
||||||
|
random_suffix="broken1",
|
||||||
|
)
|
||||||
|
journal = RunJournal.create(DataLayout(console_settings.data_root), context)
|
||||||
|
journal.finalize("failed", error="launch failed")
|
||||||
|
RunIndex(console_settings.data_root).index_journal(journal)
|
||||||
|
journal.path.unlink()
|
||||||
|
service = WorkflowConsoleService(
|
||||||
|
console_settings,
|
||||||
|
launcher=FakeConsoleLauncher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
diagnosis = service.run_diagnosis("product.main_image.weekly", context.run_id)
|
||||||
|
|
||||||
|
assert diagnosis["journal"] == {"path": None, "mode": None, "trace": None}
|
||||||
|
assert diagnosis["logs"] == []
|
||||||
|
assert diagnosis["run"]["status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_workflow_detail_and_diagnosis_routes(
|
||||||
|
console_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
run_id = _index_parallel_main_image_run(console_settings)
|
||||||
|
with _running_server(console_settings, FakeConsoleLauncher()) as server:
|
||||||
|
status, headers, content = _http_request(
|
||||||
|
server, "GET", "/api/workflows/product.main_image.weekly"
|
||||||
|
)
|
||||||
|
assert status == HTTPStatus.OK
|
||||||
|
_assert_security_headers(headers)
|
||||||
|
payload = json.loads(content.decode("utf-8"))
|
||||||
|
assert payload["workflow"]["id"] == "product.main_image.weekly"
|
||||||
|
|
||||||
|
status, _, _ = _http_request(server, "GET", "/api/workflows/unknown")
|
||||||
|
assert status == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
status, headers, content = _http_request(
|
||||||
|
server,
|
||||||
|
"GET",
|
||||||
|
f"/api/workflows/product.main_image.weekly/runs/{run_id}",
|
||||||
|
)
|
||||||
|
assert status == HTTPStatus.OK
|
||||||
|
_assert_security_headers(headers)
|
||||||
|
payload = json.loads(content.decode("utf-8"))
|
||||||
|
assert payload["run"]["run_id"] == run_id
|
||||||
|
|
||||||
|
status, _, content = _http_request(
|
||||||
|
server,
|
||||||
|
"GET",
|
||||||
|
f"/api/workflows/product.main_image.weekly/runs/{run_id}/diagnosis",
|
||||||
|
)
|
||||||
|
assert status == HTTPStatus.OK
|
||||||
|
payload = json.loads(content.decode("utf-8"))
|
||||||
|
assert payload["workflow_id"] == "product.main_image.weekly"
|
||||||
|
assert payload["journal"]["trace"] is not None
|
||||||
|
assert payload["logs"] == []
|
||||||
|
|
||||||
|
status, _, _ = _http_request(
|
||||||
|
server,
|
||||||
|
"GET",
|
||||||
|
f"/api/workflows/content.metrics.daily/runs/{run_id}",
|
||||||
|
)
|
||||||
|
assert status == HTTPStatus.NOT_FOUND
|
||||||
|
|||||||
@@ -24,11 +24,8 @@ def test_product_module_registers_scheduled_and_manual_entries() -> None:
|
|||||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||||
)
|
)
|
||||||
assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS
|
assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS
|
||||||
assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 10
|
assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 12
|
||||||
assert PRODUCT_MANUAL_WORKFLOW_IDS == (
|
assert PRODUCT_MANUAL_WORKFLOW_IDS == ()
|
||||||
"product.video_upload",
|
|
||||||
"product.jd_video_upload",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tmall_baibu_import_mode_filters_hyperlink_steps_and_rewires_dependencies() -> None:
|
def test_tmall_baibu_import_mode_filters_hyperlink_steps_and_rewires_dependencies() -> None:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def test_catalog_and_scripts_work_after_project_and_data_roots_move(
|
|||||||
|
|
||||||
assert workflows.returncode == 0, workflows.stderr
|
assert workflows.returncode == 0, workflows.stderr
|
||||||
assert scripts.returncode == 0, scripts.stderr
|
assert scripts.returncode == 0, scripts.stderr
|
||||||
assert len(json.loads(workflows.stdout)) == 27
|
assert len(json.loads(workflows.stdout)) == 29
|
||||||
commands = json.loads(scripts.stdout)
|
commands = json.loads(scripts.stdout)
|
||||||
assert len(commands) == 41
|
assert len(commands) == 41
|
||||||
assert len({row["command_id"] for row in commands}) == 41
|
assert len({row["command_id"] for row in commands}) == 41
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def test_python_scheduler_status_is_project_owned_and_cross_platform(tmp_path: P
|
|||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
payload = json.loads(output.getvalue())
|
payload = json.loads(output.getvalue())
|
||||||
assert payload["timezone"] == "Asia/Shanghai"
|
assert payload["timezone"] == "Asia/Shanghai"
|
||||||
assert payload["scheduled_count"] == 27
|
assert payload["scheduled_count"] == 29
|
||||||
assert payload["state"]["schema_version"] == 1
|
assert payload["state"]["schema_version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ def test_misfire_older_than_grace_is_not_launched(tmp_path: Path) -> None:
|
|||||||
def test_configured_schedules_disable_cookie_refresh_job() -> None:
|
def test_configured_schedules_disable_cookie_refresh_job() -> None:
|
||||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||||
|
|
||||||
assert len(catalog.schedules) == 27
|
assert len(catalog.schedules) == 29
|
||||||
assert [
|
assert [
|
||||||
schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled
|
schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled
|
||||||
] == [
|
] == [
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None:
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
workflow_entries.add(f"{workflow['module']}:{entry}")
|
workflow_entries.add(f"{workflow['module']}:{entry}")
|
||||||
|
|
||||||
assert scheduled_workflows == 27
|
assert scheduled_workflows == 29
|
||||||
assert manual_workflows == 2
|
assert manual_workflows == 0
|
||||||
assert len(workflow_entries) == 44
|
assert len(workflow_entries) == 44
|
||||||
assert len(catalog.scripts) == 49
|
assert len(catalog.scripts) == 49
|
||||||
assert workflow_entries < set(catalog.script_ids)
|
assert workflow_entries < set(catalog.script_ids)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ def test_deployment_documents_one_scheduler_and_external_data_root() -> None:
|
|||||||
assert "GYXX_DATA_ROOT" in guide
|
assert "GYXX_DATA_ROOT" in guide
|
||||||
assert "/var/lib/gyxx-flow" in guide
|
assert "/var/lib/gyxx-flow" in guide
|
||||||
assert "Windows Task Scheduler" in guide
|
assert "Windows Task Scheduler" in guide
|
||||||
assert len(payload["schedules"]) == 27
|
assert len(payload["schedules"]) == 29
|
||||||
assert sum(item.get("enabled", True) for item in payload["schedules"]) == 23
|
assert sum(item.get("enabled", True) for item in payload["schedules"]) == 25
|
||||||
|
|
||||||
|
|
||||||
def test_three_canonical_documents_cover_architecture_deployment_and_operations() -> None:
|
def test_three_canonical_documents_cover_architecture_deployment_and_operations() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from gyxx_flow.migration.sync_feishu_style_identity import (
|
||||||
|
StyleIdentity,
|
||||||
|
_merge_target_row,
|
||||||
|
build_sync_plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(record_id: str, **config):
|
||||||
|
return {"source_record_id": record_id, "config": config}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sync_plan_groups_platform_rows_and_deduplicates_ids():
|
||||||
|
styles, report = build_sync_plan(
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
"tm-1",
|
||||||
|
style_name=" 极星pro ",
|
||||||
|
brand="光影行星",
|
||||||
|
erp_codes="10398,10398",
|
||||||
|
platform="天猫",
|
||||||
|
item_ids="TM-1,TM-2",
|
||||||
|
),
|
||||||
|
_row(
|
||||||
|
"jd-1",
|
||||||
|
style_name="极星pro",
|
||||||
|
brand="光影行星",
|
||||||
|
erp_codes=["10398"],
|
||||||
|
platform="京东旗舰店",
|
||||||
|
item_ids=["JD-1", "JD-1"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [style.name for style in styles] == ["极星pro"]
|
||||||
|
assert styles[0].erp_codes == ["10398"]
|
||||||
|
assert styles[0].platform_product_ids == {
|
||||||
|
"tm": ["TM-1", "TM-2"],
|
||||||
|
"jd": ["JD-1"],
|
||||||
|
"jd_self": [],
|
||||||
|
"dy": [],
|
||||||
|
}
|
||||||
|
assert report["collectable_styles"] == ["极星pro"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sync_plan_reports_unknown_platform_and_blank_style():
|
||||||
|
styles, report = build_sync_plan(
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
"pdd-1",
|
||||||
|
style_name="宙斯",
|
||||||
|
brand="光影行星",
|
||||||
|
erp_codes="10489",
|
||||||
|
platform="拼多多",
|
||||||
|
item_ids="PDD-1",
|
||||||
|
),
|
||||||
|
_row(
|
||||||
|
"blank-1",
|
||||||
|
style_name="",
|
||||||
|
brand="",
|
||||||
|
erp_codes="",
|
||||||
|
platform="天猫",
|
||||||
|
item_ids="",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [style.name for style in styles] == ["宙斯"]
|
||||||
|
assert report["unknown_platform_rows"] == [
|
||||||
|
{
|
||||||
|
"style": "宙斯",
|
||||||
|
"platform": "拼多多",
|
||||||
|
"item_count": 1,
|
||||||
|
"record_id": "pdd-1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert report["skipped_rows"] == [
|
||||||
|
{"record_id": "blank-1", "reason": "款式名称为空"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_target_row_does_not_erase_existing_identity_with_blank_source():
|
||||||
|
style = StyleIdentity(name="宙斯")
|
||||||
|
merged = _merge_target_row(
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
"name": "宙斯",
|
||||||
|
"brand": "光影行星",
|
||||||
|
"erp_style_codes": ["10489"],
|
||||||
|
"platform_product_ids": {
|
||||||
|
"天猫": ["OLD-TM"],
|
||||||
|
"tm": ["TM-1"],
|
||||||
|
"jd": ["JD-1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert merged["brand"] == "光影行星"
|
||||||
|
assert merged["erp_style_codes"] == ["10489"]
|
||||||
|
assert merged["platform_product_ids"]["tm"] == ["TM-1"]
|
||||||
|
assert merged["platform_product_ids"]["jd"] == ["JD-1"]
|
||||||
|
assert merged["platform_product_ids"]["jd_self"] == []
|
||||||
|
assert merged["platform_product_ids"]["dy"] == []
|
||||||
@@ -11,6 +11,7 @@ import pytest
|
|||||||
from gyxx_flow.adapters.workflow_config import (
|
from gyxx_flow.adapters.workflow_config import (
|
||||||
PostgresWorkflowConfigStore,
|
PostgresWorkflowConfigStore,
|
||||||
WorkflowConfigConflictError,
|
WorkflowConfigConflictError,
|
||||||
|
merge_lifecycle_style_records,
|
||||||
normalize_config,
|
normalize_config,
|
||||||
normalize_style_config,
|
normalize_style_config,
|
||||||
)
|
)
|
||||||
@@ -160,6 +161,107 @@ def test_normalize_style_config_owns_erp_once_and_platform_ids_per_child() -> No
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_records_use_lifecycle_identity_and_keep_dynamic_targets() -> None:
|
||||||
|
dynamic_records = [
|
||||||
|
{
|
||||||
|
"style_name": "款式甲",
|
||||||
|
"brand": "旧品牌",
|
||||||
|
"erp_codes": ["OLD-ERP"],
|
||||||
|
"platform": "天猫",
|
||||||
|
"item_ids": ["OLD-TM"],
|
||||||
|
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
|
||||||
|
"tm_persona_bitable_url": "tm-persona",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
lifecycle_rows = [
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "款式甲",
|
||||||
|
"brand": "光影行星",
|
||||||
|
"erp_style_codes": ["NEW-ERP"],
|
||||||
|
"platform_product_ids": {
|
||||||
|
"tm": ["NEW-TM"],
|
||||||
|
"jd": ["NEW-JD"],
|
||||||
|
"jd_self": [],
|
||||||
|
"dy": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
records = merge_lifecycle_style_records(dynamic_records, lifecycle_rows)
|
||||||
|
|
||||||
|
by_platform = {row["platform"]: row for row in records}
|
||||||
|
assert by_platform["天猫"]["item_ids"] == ["NEW-TM"]
|
||||||
|
assert by_platform["京东旗舰店"]["item_ids"] == ["NEW-JD"]
|
||||||
|
assert by_platform["京东自营"]["item_ids"] == []
|
||||||
|
assert by_platform["抖音"]["item_ids"] == []
|
||||||
|
assert all(row["erp_codes"] == ["NEW-ERP"] for row in records)
|
||||||
|
assert by_platform["天猫"]["sales_bitable_url"].endswith("table=tblSales")
|
||||||
|
assert by_platform["天猫"]["tm_persona_bitable_url"] == "tm-persona"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_records_empty_lifecycle_ids_clear_legacy_platform_ids() -> None:
|
||||||
|
records = merge_lifecycle_style_records(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"style_name": "研发款",
|
||||||
|
"erp_codes": ["ERP-1"],
|
||||||
|
"platform": "天猫",
|
||||||
|
"item_ids": ["LEGACY-TM"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "研发款",
|
||||||
|
"erp_style_codes": ["ERP-1"],
|
||||||
|
"platform_product_ids": {
|
||||||
|
"tm": [],
|
||||||
|
"jd": [],
|
||||||
|
"jd_self": [],
|
||||||
|
"dy": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(not row["item_ids"] for row in records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_merge_respects_disabled_dynamic_platform() -> None:
|
||||||
|
records = merge_lifecycle_style_records(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"style_name": "款式甲",
|
||||||
|
"platform": "天猫",
|
||||||
|
"item_ids": ["TM-1"],
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"style_name": "款式甲",
|
||||||
|
"platform": "京东旗舰店",
|
||||||
|
"item_ids": ["JD-1"],
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "款式甲",
|
||||||
|
"erp_style_codes": ["ERP-1"],
|
||||||
|
"platform_product_ids": {
|
||||||
|
"tm": ["TM-1"],
|
||||||
|
"jd": ["JD-1"],
|
||||||
|
"jd_self": [],
|
||||||
|
"dy": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {row["platform"] for row in records} == {"京东旗舰店", "京东自营", "抖音"}
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_style_config_supports_extensible_business_targets() -> None:
|
def test_normalize_style_config_supports_extensible_business_targets() -> None:
|
||||||
payload = _payload()
|
payload = _payload()
|
||||||
payload["destinations"] = [
|
payload["destinations"] = [
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# GYXX 智能工作台(deepseek-harness 集成)
|
||||||
|
|
||||||
|
以 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`,DeepSeek 智能体底座)
|
||||||
|
为 agent 运行时,把 gyxx-flow 的现有工作流**以插件形式**扩展进 dsh Web UI,形成一个
|
||||||
|
可监控、可启动、可智能诊断/修复工作流的智能工作台。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────── dsh Web UI(DeepSeek 智能体)────────────────────────┐
|
||||||
|
│ 侧边栏「工作流」面板 ───────────────┐ │
|
||||||
|
│ (插件客户端包 shell.overlay 抽屉) │ 对话:选中工作流后提问 / 诊断 / 修复 │
|
||||||
|
└─────────┬────────────────────────────┴───────────────────▲─────────────────┘
|
||||||
|
│ 本机回环桥接 127.0.0.1:8790 │ 7 个工作流工具
|
||||||
|
┌─────────▼─────────────────────────────────────────────────┴─────────────────┐
|
||||||
|
│ gyxx-workbench 宿主插件(workbench/plugin/gyxx-workbench.mjs) │
|
||||||
|
│ 工具注册 · 系统提示词 · 失败监控器 · 桥接服务 · 会话创建 │
|
||||||
|
└─────────┬────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ HTTP(只读 + 受控写)
|
||||||
|
┌─────────▼─────────────────────────────────────────┐
|
||||||
|
│ gyxx console(127.0.0.1:8765,gyxx-flow 现有控制台) │
|
||||||
|
│ /api/overview · /api/workflows/* · /api/dynamic-configs │
|
||||||
|
└─────────┬─────────────────────────────────────────┘
|
||||||
|
┌─────────▼─────────────────────────────────────────┐
|
||||||
|
│ gyxx-flow 工作流引擎(LangGraph)· 调度器 · RunJournal │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
前置条件:Python 3.12 + `uv sync` 已完成;Node.js 22.19+;一个 DeepSeek API Key
|
||||||
|
(`DEEPSEEK_API_KEY`,dsh 自身要求)。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows:渲染补丁、拉起控制台(注入云端凭据)、启动 dsh Web UI
|
||||||
|
powershell -File workbench\bin\start-workbench.ps1
|
||||||
|
|
||||||
|
# 控制台凭据文件不是默认路径时
|
||||||
|
powershell -File workbench\bin\start-workbench.ps1 -ConsoleEnvFile <你的.env 路径>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
bash workbench/bin/start-workbench.sh [console_env_file]
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后打开 dsh Web UI(默认 <http://127.0.0.1:3080>):
|
||||||
|
|
||||||
|
- 侧边栏底部出现「工作流」按钮 → 打开工作流面板;
|
||||||
|
- 面板按模块分组列出全部调度工作流(状态点:绿=成功 / 红=失败 / 蓝=运行中 / 灰=未运行);
|
||||||
|
- **选中任意工作流**后可直接:
|
||||||
|
- 「提问」:带着该工作流上下文创建智能体会话,自由提问;
|
||||||
|
- 「诊断」:自动获取最近一次失败运行的诊断包(步骤、脱敏日志)并输出根因报告;
|
||||||
|
- 「修复」:智能体先诊断再给修复方案,**任何正式执行/调度修改必须先经你确认**;
|
||||||
|
- 「试运行 / 正式运行 / 停止」:对应控制台的受控执行语义(试运行无外部副作用)。
|
||||||
|
|
||||||
|
降级方案:若 dsh 客户端包因版本差异未能加载,直接打开桥接服务自带的独立面板
|
||||||
|
<http://127.0.0.1:8790/>,功能与侧边栏面板一致(零依赖页面)。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
workbench/
|
||||||
|
cordis.template.yml # dsh 组合补丁模板(启动脚本渲染出 cordis.local.yml)
|
||||||
|
bin/
|
||||||
|
start-workbench.ps1 # Windows 一键启动
|
||||||
|
start-workbench.sh # Linux/macOS 一键启动
|
||||||
|
plugin/ # dsh 插件包(@gyxx/dsh-plugin-gyxx-workbench)
|
||||||
|
package.json # 含 dsh.client 声明(浏览器包发现契约)
|
||||||
|
gyxx-workbench.mjs # 宿主插件:工具 / 提示词 / 监控 / 桥接(零运行时依赖)
|
||||||
|
client/
|
||||||
|
src/ # 侧边栏面板源码(React,打包时 react 外置)
|
||||||
|
standalone.html # 降级独立面板(桥接服务直接托管)
|
||||||
|
scripts/build-client.mjs
|
||||||
|
lib/client.js # 已构建的浏览器包(随仓库提交,改源码后需重建)
|
||||||
|
tests/ # node --test 冒烟测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 智能体工具清单
|
||||||
|
|
||||||
|
| 工具 | 说明 | 副作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gyxx_workflow_list` | 全部工作流及调度、最近运行状态 | 无 |
|
||||||
|
| `gyxx_workflow_detail` | 单工作流定义(步骤/依赖/重放策略/调度) | 无 |
|
||||||
|
| `gyxx_workflow_runs` | 最近运行历史(步骤级状态、退出码、脱敏错误) | 无 |
|
||||||
|
| `gyxx_workflow_diagnose` | 一次运行的完整诊断包(journal 路径 + 脱敏日志尾部) | 无 |
|
||||||
|
| `gyxx_workflow_trigger` | 触发运行;默认试运行 | 试运行无副作用;正式执行需 `execute=true` + `confirmed=true` |
|
||||||
|
| `gyxx_workflow_cancel` | 停止手动/定时运行 | 有(停止进程) |
|
||||||
|
| `gyxx_schedule_update` | 启用/停用/改调度(读-改-写,带版本校验) | 有(改 `config/schedules.json`) |
|
||||||
|
|
||||||
|
## 安全模型
|
||||||
|
|
||||||
|
- 宿主插件与控制台都只绑定 `127.0.0.1`;桥接服务的写操作要求 `x-gyxx-workbench: 1`
|
||||||
|
自定义头 + `application/json`,拒绝跨站表单提交;CORS 仅回环来源。
|
||||||
|
- 控制台自身的守卫不变:正式执行仍需 `confirmed=true`,写操作仍需
|
||||||
|
`X-GYXX-Console: 1` 与同源检查;令牌模式(`GYXX_CONSOLE_TOKEN`)对插件同样生效。
|
||||||
|
- 系统提示词固化「诊断 → 方案 → 用户确认 → 执行」的修复顺序,禁止智能体跳过确认。
|
||||||
|
- 日志经控制台脱敏管道(口令/Token/URL 凭据打码)后才进入对话上下文。
|
||||||
|
|
||||||
|
## 失败监控
|
||||||
|
|
||||||
|
宿主插件每 30s(`pollIntervalMs`)轮询 `/api/overview`:某工作流出现**新的**失败运行时
|
||||||
|
→ 面板右下角弹出告警 toast(可一键「立即诊断」);`autoDiagnose: true` 时还会自动创建
|
||||||
|
诊断会话。告警去重以 `workflow_id + run_id` 为准,恢复成功后重置。
|
||||||
|
|
||||||
|
## 配置项(cordis.local.yml → config)
|
||||||
|
|
||||||
|
| 键 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `consoleBaseUrl` | `http://127.0.0.1:8765`(或环境变量 `GYXX_CONSOLE_URL`) | gyxx 控制台地址 |
|
||||||
|
| `consoleToken` | 环境变量 `GYXX_CONSOLE_TOKEN` | 控制台访问令牌(控制台以令牌模式运行时必填) |
|
||||||
|
| `bridgeHost` / `bridgePort` | `127.0.0.1` / `8790` | 桥接服务监听地址 |
|
||||||
|
| `pollIntervalMs` | `30000` | 失败监控轮询间隔 |
|
||||||
|
| `autoDiagnose` | `false` | 发现失败时自动创建诊断会话 |
|
||||||
|
| `projectRoot` | dsh 进程 cwd | 新建智能体会话的工作目录 |
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重建客户端包(修改 client/src 后必须执行并提交 lib/client.js)
|
||||||
|
cd workbench/plugin && npm install && npm run build
|
||||||
|
|
||||||
|
# 宿主插件冒烟测试(不需要 dsh / gyxx console)
|
||||||
|
cd workbench/plugin && npm test
|
||||||
|
|
||||||
|
# Python 侧端点测试
|
||||||
|
uv run pytest tests/test_console.py -k "workflow_detail or run_detail or diagnosis"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖的 dsh 扩展点(上游契约)
|
||||||
|
|
||||||
|
- `ctx.tools.register()` 原始 JSON-Schema 工具定义(cookbook: extension-cookbook)
|
||||||
|
- `ctx.systemPrompt.section()` 系统提示词段
|
||||||
|
- cordis.yml `--patch` 组合覆盖(`apps/cli/src/args.ts`,npm 版同样支持)
|
||||||
|
- `dsh.client` package.json 声明 → 客户端包发现(`packages/client/modules`)
|
||||||
|
- 插槽:`sidebar.footer.action`(list,追加)、`shell.overlay`(list,追加)
|
||||||
|
- `ctx.agents.create()` + `agent.followup()` 编程式会话
|
||||||
|
|
||||||
|
dsh 处于 developer preview,扩展点可能变化;升级 dsh 后若面板消失,先检查
|
||||||
|
浏览器控制台模块加载错误,再核对上述插槽名是否仍存在于
|
||||||
|
`packages/client/ui-layout` / `ui-sidebar` 的 SlotMap。
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# GYXX 智能工作台一键启动(Windows PowerShell)
|
||||||
|
# 用法:
|
||||||
|
# powershell -File workbench\bin\start-workbench.ps1
|
||||||
|
# powershell -File workbench\bin\start-workbench.ps1 -ConsoleEnvFile D:\product-collector-analyze-flow\.env
|
||||||
|
#
|
||||||
|
# 行为:
|
||||||
|
# 1. 渲染 workbench/cordis.local.yml(插件绝对路径)
|
||||||
|
# 2. 若 8765 控制台未运行,则以 --env-file 注入云端凭据启动 gyxx console
|
||||||
|
# 3. 启动 dsh Web UI 并应用工作台补丁(优先 $env:DSH_HOME 源码目录,其次 npx @deepseek-ai/dsh)
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ConsoleEnvFile = 'D:\product-collector-analyze-flow\.env',
|
||||||
|
[int]$ConsolePort = 8765,
|
||||||
|
[int]$BridgePort = 8790,
|
||||||
|
[switch]$SkipConsole
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
$WorkbenchDir = Join-Path $RepoRoot 'workbench'
|
||||||
|
$LocalYml = Join-Path $WorkbenchDir 'cordis.local.yml'
|
||||||
|
|
||||||
|
# 1) 渲染 cordis.local.yml
|
||||||
|
$template = Get-Content (Join-Path $WorkbenchDir 'cordis.template.yml') -Raw -Encoding UTF8
|
||||||
|
$workbenchDirPosix = ($WorkbenchDir -replace '\\', '/')
|
||||||
|
$projectRootPosix = ($RepoRoot -replace '\\', '/')
|
||||||
|
# Windows 上 dsh 的 ESM 加载器要求 file:// URL
|
||||||
|
$workbenchModuleUrl = 'file:///' + $workbenchDirPosix
|
||||||
|
$rendered = $template.Replace('__WORKBENCH_DIR__', $workbenchModuleUrl).Replace('__PROJECT_ROOT__', $projectRootPosix)
|
||||||
|
$rendered = $rendered -replace 'bridgePort: 8790', "bridgePort: $BridgePort"
|
||||||
|
$rendered = $rendered -replace "consoleBaseUrl: 'http://127.0.0.1:8765'", "consoleBaseUrl: 'http://127.0.0.1:$ConsolePort'"
|
||||||
|
[System.IO.File]::WriteAllText($LocalYml, $rendered, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
Write-Host "[workbench] 已生成 $LocalYml"
|
||||||
|
|
||||||
|
# 2) 确保 gyxx console 在运行
|
||||||
|
if (-not $SkipConsole) {
|
||||||
|
$consoleUp = $false
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$ConsolePort/api/overview" -TimeoutSec 3
|
||||||
|
$consoleUp = $true
|
||||||
|
} catch { $consoleUp = $false }
|
||||||
|
|
||||||
|
if ($consoleUp) {
|
||||||
|
Write-Host "[workbench] gyxx console 已在 http://127.0.0.1:$ConsolePort 运行"
|
||||||
|
} else {
|
||||||
|
if (-not (Test-Path $ConsoleEnvFile)) {
|
||||||
|
Write-Warning "缺少 $ConsoleEnvFile —— 正式执行将无法注入云端凭据(AGENTS.md 约定)。"
|
||||||
|
}
|
||||||
|
$consoleArgs = @('run', 'gyxx', 'console', '--port', "$ConsolePort", '--env-file', $ConsoleEnvFile)
|
||||||
|
Write-Host "[workbench] 启动 gyxx console: uv $($consoleArgs -join ' ')"
|
||||||
|
Start-Process -FilePath 'uv' -ArgumentList $consoleArgs -WorkingDirectory $RepoRoot -WindowStyle Minimized
|
||||||
|
$deadline = (Get-Date).AddSeconds(30)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$ConsolePort/api/overview" -TimeoutSec 2
|
||||||
|
$consoleUp = $true
|
||||||
|
} catch { $consoleUp = $false }
|
||||||
|
} until ($consoleUp -or (Get-Date) -gt $deadline)
|
||||||
|
if (-not $consoleUp) { Write-Warning 'gyxx console 启动超时,工作台仍可启动但工具会提示控制台离线。' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3) 启动 dsh Web UI
|
||||||
|
if ($env:DSH_HOME -and (Test-Path (Join-Path $env:DSH_HOME 'package.json'))) {
|
||||||
|
Write-Host "[workbench] 使用源码版 dsh: $($env:DSH_HOME)"
|
||||||
|
Push-Location $env:DSH_HOME
|
||||||
|
try {
|
||||||
|
& pnpm dsh web --patch $LocalYml
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host '[workbench] 使用 npm 版 dsh(npx @deepseek-ai/dsh)'
|
||||||
|
& npx -y '@deepseek-ai/dsh' web --patch $LocalYml
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# GYXX 智能工作台一键启动(Linux/macOS)
|
||||||
|
# 用法:bash workbench/bin/start-workbench.sh [console_env_file]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
WORKBENCH_DIR="$REPO_ROOT/workbench"
|
||||||
|
LOCAL_YML="$WORKBENCH_DIR/cordis.local.yml"
|
||||||
|
CONSOLE_ENV_FILE="${1:-${GYXX_CONSOLE_ENV_FILE:-/etc/gyxx/flow.env}}"
|
||||||
|
CONSOLE_PORT="${GYXX_CONSOLE_PORT:-8765}"
|
||||||
|
BRIDGE_PORT="${GYXX_WB_BRIDGE_PORT:-8790}"
|
||||||
|
|
||||||
|
# 1) 渲染 cordis.local.yml(插件路径必须为绝对路径)
|
||||||
|
# dsh 的 ESM 加载器要求 file:// URL(Windows 上必须,POSIX 上同样可用)
|
||||||
|
sed \
|
||||||
|
-e "s|__WORKBENCH_DIR__|file://$WORKBENCH_DIR|g" \
|
||||||
|
-e "s|__PROJECT_ROOT__|$REPO_ROOT|g" \
|
||||||
|
-e "s|bridgePort: 8790|bridgePort: $BRIDGE_PORT|g" \
|
||||||
|
-e "s|http://127.0.0.1:8765|http://127.0.0.1:$CONSOLE_PORT|g" \
|
||||||
|
"$WORKBENCH_DIR/cordis.template.yml" > "$LOCAL_YML"
|
||||||
|
echo "[workbench] 已生成 $LOCAL_YML"
|
||||||
|
|
||||||
|
# 2) 确保 gyxx console 在运行
|
||||||
|
if ! curl -fsS -m 3 "http://127.0.0.1:$CONSOLE_PORT/api/overview" >/dev/null 2>&1; then
|
||||||
|
if [ ! -f "$CONSOLE_ENV_FILE" ]; then
|
||||||
|
echo "[workbench] 警告:缺少 $CONSOLE_ENV_FILE —— 正式执行将无法注入云端凭据(AGENTS.md 约定)。" >&2
|
||||||
|
fi
|
||||||
|
echo "[workbench] 启动 gyxx console(端口 $CONSOLE_PORT)"
|
||||||
|
(cd "$REPO_ROOT" && nohup uv run gyxx console --port "$CONSOLE_PORT" --env-file "$CONSOLE_ENV_FILE" >/dev/null 2>&1 &)
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
sleep 1
|
||||||
|
if curl -fsS -m 2 "http://127.0.0.1:$CONSOLE_PORT/api/overview" >/dev/null 2>&1; then break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) 启动 dsh Web UI
|
||||||
|
if [ -n "${DSH_HOME:-}" ] && [ -f "$DSH_HOME/package.json" ]; then
|
||||||
|
echo "[workbench] 使用源码版 dsh: $DSH_HOME"
|
||||||
|
(cd "$DSH_HOME" && pnpm dsh web --patch "$LOCAL_YML")
|
||||||
|
else
|
||||||
|
echo "[workbench] 使用 npm 版 dsh(npx @deepseek-ai/dsh)"
|
||||||
|
npx -y @deepseek-ai/dsh web --patch "$LOCAL_YML"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# GYXX 智能工作台 —— dsh Web 组合补丁(模板)。
|
||||||
|
# 启动脚本会把 __WORKBENCH_DIR__ / __PROJECT_ROOT__ 渲染为绝对路径后生成
|
||||||
|
# cordis.local.yml,再通过 `dsh web --patch workbench/cordis.local.yml` 加载。
|
||||||
|
# 不要直接手工使用本文件:dsh 要求插件路径为绝对路径(Windows 上必须是 file:// URL)。
|
||||||
|
|
||||||
|
- insert:
|
||||||
|
- id: gyxx-workbench
|
||||||
|
name: '__WORKBENCH_DIR__/plugin/gyxx-workbench.mjs'
|
||||||
|
config:
|
||||||
|
# gyxx 控制台 HTTP API(gyxx console,默认 8765 端口)
|
||||||
|
consoleBaseUrl: 'http://127.0.0.1:8765'
|
||||||
|
# 访问令牌:缺省读取进程环境变量 GYXX_CONSOLE_TOKEN
|
||||||
|
# consoleToken: ''
|
||||||
|
# 侧边栏面板桥接服务(本机回环)
|
||||||
|
bridgeHost: '127.0.0.1'
|
||||||
|
bridgePort: 8790
|
||||||
|
# 失败监控轮询间隔(毫秒)
|
||||||
|
pollIntervalMs: 30000
|
||||||
|
# 发现失败时是否自动创建诊断会话(默认 false,只在面板弹告警)
|
||||||
|
autoDiagnose: false
|
||||||
|
# 智能体会话的工作目录(数据根目录下的日志可用文件工具直接查看)
|
||||||
|
projectRoot: '__PROJECT_ROOT__'
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
|
||||||
|
|
||||||
|
import { useSyncExternalStore, useEffect, useCallback } from 'react'
|
||||||
|
import { getState, subscribe, update, pushToast } from './store.js'
|
||||||
|
import { api } from './api.js'
|
||||||
|
|
||||||
|
function useWorkbench() {
|
||||||
|
return useSyncExternalStore(subscribe, getState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
success: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
running: '运行中',
|
||||||
|
cancelled: '已停止',
|
||||||
|
none: '未运行',
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(workflow) {
|
||||||
|
if (workflow.active_run) return 'running'
|
||||||
|
return workflow.last_run?.status ?? 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value)
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds == null) return ''
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshWorkflows() {
|
||||||
|
update({ loading: true, error: null })
|
||||||
|
try {
|
||||||
|
const payload = await api.listWorkflows()
|
||||||
|
update({
|
||||||
|
loading: false,
|
||||||
|
workflows: payload.workflows ?? [],
|
||||||
|
summary: payload.summary ?? null,
|
||||||
|
generatedAt: payload.generated_at ?? null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
update({ loading: false, error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectWorkflow(workflowId) {
|
||||||
|
update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] })
|
||||||
|
try {
|
||||||
|
const [detail, runs] = await Promise.all([
|
||||||
|
api.workflowDetail(workflowId).catch(() => null),
|
||||||
|
api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })),
|
||||||
|
])
|
||||||
|
update({ detailLoading: false, detail, runs: runs.runs ?? [] })
|
||||||
|
} catch (error) {
|
||||||
|
update({ detailLoading: false, error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDiagnosis(workflowId, runId) {
|
||||||
|
update({ diagnosisLoading: true })
|
||||||
|
try {
|
||||||
|
const payload = await api.diagnosis(workflowId, runId)
|
||||||
|
update({ diagnosisLoading: false, diagnosis: payload })
|
||||||
|
} catch (error) {
|
||||||
|
update({ diagnosisLoading: false })
|
||||||
|
pushToast({ kind: 'error', text: `诊断数据加载失败:${error.message}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAsk(action) {
|
||||||
|
const s = getState()
|
||||||
|
if (!s.selectedId) return
|
||||||
|
const question = s.question.trim()
|
||||||
|
try {
|
||||||
|
const payload = await api.ask({
|
||||||
|
workflow_id: s.selectedId,
|
||||||
|
action,
|
||||||
|
question,
|
||||||
|
})
|
||||||
|
const label = { ask: '提问', diagnose: '诊断', repair: '修复' }[action] ?? '会话'
|
||||||
|
pushToast({
|
||||||
|
kind: 'ok',
|
||||||
|
text: `已创建${label}会话(${String(payload.session_id).slice(0, 18)}…),请在左侧会话列表中查看`,
|
||||||
|
})
|
||||||
|
update({ question: '' })
|
||||||
|
} catch (error) {
|
||||||
|
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doTrigger(execute) {
|
||||||
|
const s = getState()
|
||||||
|
if (!s.selectedId) return
|
||||||
|
if (execute) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`正式执行 ${s.selectedId}(业务日期 ${s.businessDate})会对外部系统产生真实写入。确认继续?`,
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.trigger({
|
||||||
|
workflow_id: s.selectedId,
|
||||||
|
business_date: s.businessDate,
|
||||||
|
execute,
|
||||||
|
confirmed: execute,
|
||||||
|
})
|
||||||
|
pushToast({ kind: 'ok', text: execute ? '已发起正式执行' : '已发起试运行(无外部副作用)' })
|
||||||
|
setTimeout(refreshWorkflows, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
pushToast({ kind: 'error', text: `启动失败:${error.message}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCancel() {
|
||||||
|
const s = getState()
|
||||||
|
const active = s.detail?.workflow?.active_run
|
||||||
|
if (!s.selectedId) return
|
||||||
|
try {
|
||||||
|
if (active?.operation_id) {
|
||||||
|
await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id })
|
||||||
|
} else {
|
||||||
|
await api.cancel({ workflow_id: s.selectedId, scheduled: true })
|
||||||
|
}
|
||||||
|
pushToast({ kind: 'ok', text: '已发送停止指令' })
|
||||||
|
setTimeout(refreshWorkflows, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
pushToast({ kind: 'error', text: `停止失败:${error.message}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryPills({ summary, workflows }) {
|
||||||
|
const failed = workflows.filter((w) => statusOf(w) === 'failed').length
|
||||||
|
const running = workflows.filter((w) => w.active_run).length
|
||||||
|
return (
|
||||||
|
<div className="gyxxwb-pills">
|
||||||
|
<span className="gyxxwb-pill">共 {summary?.total ?? workflows.length}</span>
|
||||||
|
{running > 0 && <span className="gyxxwb-pill gyxxwb-pill-running">运行中 {running}</span>}
|
||||||
|
{failed > 0 && <span className="gyxxwb-pill gyxxwb-pill-failed">失败 {failed}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowRow({ workflow, selected, onSelect }) {
|
||||||
|
const status = statusOf(workflow)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`gyxxwb-row ${selected ? 'gyxxwb-row-selected' : ''}`}
|
||||||
|
onClick={() => onSelect(workflow.id)}
|
||||||
|
>
|
||||||
|
<span className={`gyxxwb-dot gyxxwb-dot-${status}`} />
|
||||||
|
<span className="gyxxwb-row-main">
|
||||||
|
<span className="gyxxwb-row-name">{workflow.name}</span>
|
||||||
|
<span className="gyxxwb-row-id">{workflow.id}</span>
|
||||||
|
</span>
|
||||||
|
<span className="gyxxwb-row-meta">
|
||||||
|
{workflow.last_run ? formatTime(workflow.last_run.ended_at ?? workflow.last_run.started_at) : '—'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunList({ runs, onDiagnose }) {
|
||||||
|
if (!runs.length) return <div className="gyxxwb-empty">暂无运行记录</div>
|
||||||
|
return (
|
||||||
|
<div className="gyxxwb-runs">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={run.run_id}
|
||||||
|
className="gyxxwb-run"
|
||||||
|
onClick={() => onDiagnose(run.run_id)}
|
||||||
|
title="点击查看诊断详情"
|
||||||
|
>
|
||||||
|
<span className={`gyxxwb-dot gyxxwb-dot-${run.status}`} />
|
||||||
|
<span>{run.business_date}</span>
|
||||||
|
<span className="gyxxwb-run-mode">{run.mode === 'execute' ? '正式' : '试运行'}</span>
|
||||||
|
<span>{formatDuration(run.duration_seconds)}</span>
|
||||||
|
{run.error && <span className="gyxxwb-run-error">{run.error.slice(0, 60)}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosisView({ diagnosis, loading }) {
|
||||||
|
if (loading) return <div className="gyxxwb-empty">正在加载诊断数据…</div>
|
||||||
|
if (!diagnosis) return null
|
||||||
|
if (diagnosis.message) return <div className="gyxxwb-empty">{diagnosis.message}</div>
|
||||||
|
const run = diagnosis.run ?? {}
|
||||||
|
const steps = Array.isArray(run.steps) ? run.steps : []
|
||||||
|
const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []
|
||||||
|
return (
|
||||||
|
<div className="gyxxwb-diagnosis">
|
||||||
|
<div className="gyxxwb-diagnosis-head">
|
||||||
|
诊断:{run.run_id}({run.status})
|
||||||
|
</div>
|
||||||
|
{steps
|
||||||
|
.filter((step) => step.status === 'failed')
|
||||||
|
.map((step) => (
|
||||||
|
<div key={step.id} className="gyxxwb-step-fail">
|
||||||
|
<div className="gyxxwb-step-id">
|
||||||
|
✗ {step.id}(退出码 {step.exit_code ?? '—'})
|
||||||
|
</div>
|
||||||
|
{step.error && <pre className="gyxxwb-pre">{step.error}</pre>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={log.path} className="gyxxwb-log">
|
||||||
|
<div className="gyxxwb-log-path">{log.path}</div>
|
||||||
|
<pre className="gyxxwb-pre">{log.tail}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!steps.some((s) => s.status === 'failed') && !logs.length && (
|
||||||
|
<div className="gyxxwb-empty">该运行没有失败步骤或日志</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowPanel() {
|
||||||
|
const s = useWorkbench()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!s.open) return undefined
|
||||||
|
refreshWorkflows()
|
||||||
|
const timer = setInterval(refreshWorkflows, 10_000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [s.open])
|
||||||
|
|
||||||
|
const onSelect = useCallback((workflowId) => {
|
||||||
|
selectWorkflow(workflowId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const groups = []
|
||||||
|
const byModule = new Map()
|
||||||
|
for (const workflow of s.workflows) {
|
||||||
|
const key = workflow.module_label ?? workflow.module
|
||||||
|
if (!byModule.has(key)) byModule.set(key, [])
|
||||||
|
byModule.get(key).push(workflow)
|
||||||
|
}
|
||||||
|
for (const [label, items] of byModule) groups.push({ label, items })
|
||||||
|
|
||||||
|
const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`gyxxwb-panel ${s.open ? 'gyxxwb-panel-open' : ''}`}>
|
||||||
|
<div className="gyxxwb-panel-head">
|
||||||
|
<span className="gyxxwb-title">GYXX 工作流</span>
|
||||||
|
<SummaryPills summary={s.summary} workflows={s.workflows} />
|
||||||
|
<button type="button" className="gyxxwb-icon-btn" title="刷新" onClick={refreshWorkflows}>
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gyxxwb-icon-btn"
|
||||||
|
title="关闭"
|
||||||
|
onClick={() => update({ open: false })}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{s.error && (
|
||||||
|
<div className="gyxxwb-error">
|
||||||
|
无法连接工作台桥接服务:{s.error}
|
||||||
|
<br />
|
||||||
|
请确认 dsh 已通过 workbench/cordis.yml 启动。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="gyxxwb-panel-body">
|
||||||
|
<div className="gyxxwb-list">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label} className="gyxxwb-group">
|
||||||
|
<div className="gyxxwb-group-label">{group.label}</div>
|
||||||
|
{group.items.map((workflow) => (
|
||||||
|
<WorkflowRow
|
||||||
|
key={workflow.id}
|
||||||
|
workflow={workflow}
|
||||||
|
selected={workflow.id === s.selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!s.loading && !s.workflows.length && !s.error && (
|
||||||
|
<div className="gyxxwb-empty">未发现工作流</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="gyxxwb-detail">
|
||||||
|
<div className="gyxxwb-detail-title">
|
||||||
|
{selected.name}
|
||||||
|
<span className={`gyxxwb-status gyxxwb-status-${statusOf(selected)}`}>
|
||||||
|
{STATUS_LABELS[statusOf(selected)]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="gyxxwb-detail-sub">
|
||||||
|
{selected.id} · 调度{' '}
|
||||||
|
{selected.schedule
|
||||||
|
? `${selected.schedule.enabled ? '启用' : '停用'} ${
|
||||||
|
Array.isArray(selected.schedule.at)
|
||||||
|
? selected.schedule.at.join('/')
|
||||||
|
: (selected.schedule.at ?? '')
|
||||||
|
}`
|
||||||
|
: '无'}
|
||||||
|
{selected.next_run_at ? ` · 下次 ${formatTime(selected.next_run_at)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="gyxxwb-actions">
|
||||||
|
<button type="button" onClick={() => doAsk('diagnose')}>诊断</button>
|
||||||
|
<button type="button" onClick={() => doAsk('repair')}>修复</button>
|
||||||
|
<button type="button" onClick={() => doTrigger(false)}>试运行</button>
|
||||||
|
<button type="button" className="gyxxwb-danger" onClick={() => doTrigger(true)}>
|
||||||
|
正式运行
|
||||||
|
</button>
|
||||||
|
{selected.active_run && (
|
||||||
|
<button type="button" className="gyxxwb-danger" onClick={doCancel}>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="gyxxwb-ask">
|
||||||
|
<input
|
||||||
|
value={s.question}
|
||||||
|
placeholder="就这个工作流提问,例如:昨天为什么失败?"
|
||||||
|
onChange={(event) => update({ question: event.target.value })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') doAsk('ask')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => doAsk('ask')}>提问</button>
|
||||||
|
</div>
|
||||||
|
<div className="gyxxwb-date-row">
|
||||||
|
业务日期{' '}
|
||||||
|
<input
|
||||||
|
value={s.businessDate}
|
||||||
|
onChange={(event) => update({ businessDate: event.target.value })}
|
||||||
|
pattern="\d{4}-\d{2}-\d{2}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="gyxxwb-section-label">最近运行</div>
|
||||||
|
<RunList runs={s.runs} onDiagnose={(runId) => loadDiagnosis(selected.id, runId)} />
|
||||||
|
<DiagnosisView diagnosis={s.diagnosis} loading={s.diagnosisLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** 失败告警浮层:轮询桥接告警,新失败弹出 toast,可一键发起诊断会话。 */
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
import { getState, subscribe, update, dismissToast, pushToast } from './store.js'
|
||||||
|
import { api } from './api.js'
|
||||||
|
|
||||||
|
function useWorkbench() {
|
||||||
|
return useSyncExternalStore(subscribe, getState)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toasts() {
|
||||||
|
const s = useWorkbench()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false
|
||||||
|
let timer = null
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const payload = await api.alerts()
|
||||||
|
const fresh = (payload.alerts ?? []).filter(
|
||||||
|
(alert) => !alert.seen && !getState().seenAlertIds[alert.id],
|
||||||
|
)
|
||||||
|
if (fresh.length) {
|
||||||
|
const seen = { ...getState().seenAlertIds }
|
||||||
|
for (const alert of fresh) {
|
||||||
|
seen[alert.id] = true
|
||||||
|
pushToast({ kind: 'failed', text: `工作流「${alert.name}」运行失败`, alert })
|
||||||
|
}
|
||||||
|
update({ seenAlertIds: seen })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 桥接离线时静默
|
||||||
|
}
|
||||||
|
if (!stopped) timer = setTimeout(poll, 30_000)
|
||||||
|
}
|
||||||
|
timer = setTimeout(poll, 8_000)
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!s.toasts.length) return null
|
||||||
|
return (
|
||||||
|
<div className="gyxxwb-toasts">
|
||||||
|
{s.toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className={`gyxxwb-toast gyxxwb-toast-${toast.kind}`}>
|
||||||
|
<span className="gyxxwb-toast-text">{toast.text}</span>
|
||||||
|
{toast.alert && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.ask({ workflow_id: toast.alert.workflow_id, action: 'diagnose' })
|
||||||
|
await api.markAlertSeen(toast.alert.id).catch(() => {})
|
||||||
|
pushToast({ kind: 'ok', text: '已创建诊断会话,请在左侧会话列表查看' })
|
||||||
|
} catch (error) {
|
||||||
|
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
|
||||||
|
}
|
||||||
|
dismissToast(toast.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即诊断
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" title="关闭" onClick={() => dismissToast(toast.id)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/** 工作台桥接服务客户端:所有数据经由 gyxx-workbench 宿主插件的本机回环桥接。 */
|
||||||
|
|
||||||
|
export function bridgeBase() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage?.getItem('gyxx.wb.bridge')
|
||||||
|
if (saved) return saved
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用时使用默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'http://127.0.0.1:8790'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const response = await fetch(`${bridgeBase()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
...(body !== undefined
|
||||||
|
? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = await response.json()
|
||||||
|
} catch {
|
||||||
|
payload = { ok: false, error: `桥接服务返回了非 JSON 内容(HTTP ${response.status})` }
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error ?? `桥接服务错误(HTTP ${response.status})`)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listWorkflows: () => request('GET', '/bridge/workflows'),
|
||||||
|
workflowDetail: (workflowId) =>
|
||||||
|
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}`),
|
||||||
|
workflowRuns: (workflowId, limit = 6) =>
|
||||||
|
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`),
|
||||||
|
diagnosis: (workflowId, runId) =>
|
||||||
|
request(
|
||||||
|
'GET',
|
||||||
|
`/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ''}`,
|
||||||
|
),
|
||||||
|
alerts: () => request('GET', '/bridge/alerts'),
|
||||||
|
markAlertSeen: (id) => request('POST', '/bridge/alerts/seen', { id }),
|
||||||
|
trigger: (payload) => request('POST', '/bridge/trigger', payload),
|
||||||
|
cancel: (payload) => request('POST', '/bridge/cancel', payload),
|
||||||
|
ask: (payload) => request('POST', '/bridge/ask', payload),
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* GYXX 工作台客户端插件入口。
|
||||||
|
* 由 esbuild 打包为 CJS 并通过 window.__ModuleLoader__ 注册(见 scripts/build-client.mjs)。
|
||||||
|
* 运行时只依赖 dsh 平台共享模块表中的 react。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createElement, useSyncExternalStore } from 'react'
|
||||||
|
import { WorkflowPanel, refreshWorkflows } from './Panel.jsx'
|
||||||
|
import { Toasts } from './Toasts.jsx'
|
||||||
|
import { getState, subscribe, update } from './store.js'
|
||||||
|
import stylesText from './styles.css'
|
||||||
|
|
||||||
|
export const inject = ['slots']
|
||||||
|
|
||||||
|
function useWorkbench() {
|
||||||
|
return useSyncExternalStore(subscribe, getState)
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (document.getElementById('gyxxwb-styles')) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = 'gyxxwb-styles'
|
||||||
|
style.textContent = stylesText
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧边栏底部动作:工作流面板开关 + 聚合状态点。 */
|
||||||
|
function WorkbenchFooterAction() {
|
||||||
|
const s = useWorkbench()
|
||||||
|
const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length
|
||||||
|
const running = s.workflows.filter((w) => w.active_run).length
|
||||||
|
const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success'
|
||||||
|
return createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`,
|
||||||
|
title: 'GYXX 工作流工作台',
|
||||||
|
onClick: () => {
|
||||||
|
const next = !getState().open
|
||||||
|
update({ open: next })
|
||||||
|
if (next) refreshWorkflows()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),
|
||||||
|
createElement('span', null, '工作流'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖层根:左侧抽屉面板 + 失败 toast。 */
|
||||||
|
function WorkbenchOverlay() {
|
||||||
|
return createElement(
|
||||||
|
'div',
|
||||||
|
{ className: 'gyxxwb-overlay' },
|
||||||
|
createElement(WorkflowPanel, null),
|
||||||
|
createElement(Toasts, null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apply(ctx) {
|
||||||
|
injectStyles()
|
||||||
|
// 插槽须等宿主声明后才能注册:slots.inject 会等待座位声明(顺序安全),
|
||||||
|
// 直接 register 在我们先于 ui-sidebar/ui-layout 加载时会报“slot is not declared”。
|
||||||
|
ctx.effect(
|
||||||
|
() =>
|
||||||
|
ctx.slots.inject('sidebar.footer.action', () =>
|
||||||
|
ctx.slots.register(
|
||||||
|
{ name: 'sidebar.footer.action', id: 'gyxx-workbench.footer' },
|
||||||
|
WorkbenchFooterAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'gyxx-workbench: footer action',
|
||||||
|
)
|
||||||
|
ctx.effect(
|
||||||
|
() =>
|
||||||
|
ctx.slots.inject('shell.overlay', () =>
|
||||||
|
ctx.slots.register(
|
||||||
|
{ name: 'shell.overlay', id: 'gyxx-workbench.overlay' },
|
||||||
|
WorkbenchOverlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'gyxx-workbench: overlay',
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* GYXX 工作台面板 —— 极简外部存储(避免向客户端包引入额外依赖)。
|
||||||
|
* React 18 的 useSyncExternalStore 直接订阅。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const listeners = new Set()
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
generatedAt: null,
|
||||||
|
summary: null,
|
||||||
|
workflows: [],
|
||||||
|
selectedId: null,
|
||||||
|
detail: null,
|
||||||
|
detailLoading: false,
|
||||||
|
runs: [],
|
||||||
|
diagnosis: null,
|
||||||
|
diagnosisLoading: false,
|
||||||
|
question: '',
|
||||||
|
businessDate: defaultBusinessDate(),
|
||||||
|
alerts: [],
|
||||||
|
seenAlertIds: {},
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultBusinessDate() {
|
||||||
|
const now = new Date()
|
||||||
|
now.setDate(now.getDate() - 1)
|
||||||
|
const pad = (value) => String(value).padStart(2, '0')
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getState() {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function update(patch) {
|
||||||
|
state = { ...state, ...patch }
|
||||||
|
for (const listener of listeners) listener()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushToast(toast) {
|
||||||
|
const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
update({ toasts: [...state.toasts, { id, ...toast }] })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissToast(id) {
|
||||||
|
update({ toasts: state.toasts.filter((toast) => toast.id !== id) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/* GYXX 工作台面板样式 —— 以文本形式内联进客户端包,运行时注入 <style>。 */
|
||||||
|
|
||||||
|
.gyxxwb-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 70;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
|
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 264px;
|
||||||
|
width: 380px;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
border-right: 1px solid #e4e4e7;
|
||||||
|
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
|
||||||
|
pointer-events: auto;
|
||||||
|
color: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-panel-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e4e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
color: #52525b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-pill-failed {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-pill-running {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #71717a;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-icon-btn:hover {
|
||||||
|
background: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-error {
|
||||||
|
margin: 10px 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-group-label {
|
||||||
|
padding: 10px 12px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row:hover {
|
||||||
|
background: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row-selected {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row-name {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-row-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d4d4d8;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-dot-success {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-dot-failed {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-dot-running {
|
||||||
|
background: #2563eb;
|
||||||
|
animation: gyxxwb-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-dot-cancelled,
|
||||||
|
.gyxxwb-dot-none {
|
||||||
|
background: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gyxxwb-pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-detail {
|
||||||
|
border-top: 1px solid #e4e4e7;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-detail-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-status {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-status-failed {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-status-running {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-status-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-detail-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #71717a;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-actions button,
|
||||||
|
.gyxxwb-ask button {
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-actions button:hover,
|
||||||
|
.gyxxwb-ask button:hover {
|
||||||
|
background: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-actions .gyxxwb-danger {
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-ask {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-ask input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-date-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #71717a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-date-row input {
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-section-label {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-run {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-run:hover {
|
||||||
|
background: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-run-mode {
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-run-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-diagnosis {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-diagnosis-head {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-step-fail {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-step-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b91c1c;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-log {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-log-path {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #71717a;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-pre {
|
||||||
|
font-size: 11px;
|
||||||
|
background: #18181b;
|
||||||
|
color: #e4e4e7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-empty {
|
||||||
|
padding: 16px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-toasts {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: auto;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #18181b;
|
||||||
|
color: #fafafa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-toast button {
|
||||||
|
border: 1px solid #3f3f46;
|
||||||
|
background: transparent;
|
||||||
|
color: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-toast-ok {
|
||||||
|
background: #14532d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-toast-error,
|
||||||
|
.gyxxwb-toast-failed {
|
||||||
|
background: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-footer-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gyxxwb-footer-btn:hover,
|
||||||
|
.gyxxwb-footer-btn-active {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.gyxxwb-panel {
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.gyxxwb-panel {
|
||||||
|
background: #18181b;
|
||||||
|
color: #fafafa;
|
||||||
|
border-right-color: #3f3f46;
|
||||||
|
}
|
||||||
|
.gyxxwb-row:hover {
|
||||||
|
background: #27272a;
|
||||||
|
}
|
||||||
|
.gyxxwb-row-selected {
|
||||||
|
background: #312e81;
|
||||||
|
}
|
||||||
|
.gyxxwb-detail {
|
||||||
|
background: #1f1f22;
|
||||||
|
border-top-color: #3f3f46;
|
||||||
|
}
|
||||||
|
.gyxxwb-actions button,
|
||||||
|
.gyxxwb-ask button {
|
||||||
|
background: #27272a;
|
||||||
|
border-color: #3f3f46;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
.gyxxwb-ask input,
|
||||||
|
.gyxxwb-date-row input {
|
||||||
|
background: #27272a;
|
||||||
|
border-color: #3f3f46;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
.gyxxwb-pill {
|
||||||
|
background: #27272a;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
.gyxxwb-footer-btn:hover,
|
||||||
|
.gyxxwb-footer-btn-active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>GYXX 智能工作台</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
|
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: #f4f4f5; color: #18181b;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
||||||
|
background: #fff; border-bottom: 1px solid #e4e4e7; position: sticky; top: 0; z-index: 2;
|
||||||
|
}
|
||||||
|
h1 { font-size: 15px; margin: 0; }
|
||||||
|
.pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: #f4f4f5; color: #52525b; border: 1px solid #e4e4e7; }
|
||||||
|
.pill-failed { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
||||||
|
.pill-running { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
||||||
|
main { display: flex; gap: 0; min-height: calc(100vh - 49px); }
|
||||||
|
#list { width: 320px; background: #fff; border-right: 1px solid #e4e4e7; overflow-y: auto; }
|
||||||
|
#detail { flex: 1; padding: 16px; overflow-y: auto; }
|
||||||
|
.group-label { padding: 10px 12px 4px; font-size: 12px; color: #71717a; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; border: none; background: none; cursor: pointer; text-align: left; }
|
||||||
|
.row:hover { background: #f4f4f5; }
|
||||||
|
.row.selected { background: #eef2ff; }
|
||||||
|
.row .name { font-size: 13px; display: block; }
|
||||||
|
.row .id { font-size: 11px; color: #a1a1aa; display: block; }
|
||||||
|
.row .meta { font-size: 11px; color: #71717a; margin-left: auto; }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: #a1a1aa; flex: none; }
|
||||||
|
.dot-success { background: #16a34a; } .dot-failed { background: #dc2626; }
|
||||||
|
.dot-running { background: #2563eb; animation: pulse 1.2s infinite; }
|
||||||
|
@keyframes pulse { 50% { opacity: .35; } }
|
||||||
|
button.act { border: 1px solid #e4e4e7; background: #fff; border-radius: 6px; font-size: 12px; padding: 5px 12px; cursor: pointer; margin-right: 6px; }
|
||||||
|
button.act:hover { background: #f4f4f5; }
|
||||||
|
button.danger { border-color: #fecaca; color: #b91c1c; }
|
||||||
|
input { border: 1px solid #e4e4e7; border-radius: 6px; padding: 6px 8px; font-size: 12px; }
|
||||||
|
pre { background: #18181b; color: #e4e4e7; border-radius: 8px; padding: 10px; font-size: 11px; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.muted { color: #71717a; font-size: 12px; }
|
||||||
|
.err { color: #b91c1c; font-size: 12px; }
|
||||||
|
.card { background: #fff; border: 1px solid #e4e4e7; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||||
|
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 9; }
|
||||||
|
.toast { background: #18181b; color: #fafafa; border-radius: 10px; padding: 10px 12px; font-size: 12px; max-width: 340px; }
|
||||||
|
.toast.ok { background: #14532d; } .toast.err { background: #7f1d1d; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: #09090b; color: #fafafa; }
|
||||||
|
header, #list, .card { background: #18181b; border-color: #3f3f46; }
|
||||||
|
.row:hover { background: #27272a; } .row.selected { background: #312e81; }
|
||||||
|
button.act, input { background: #27272a; border-color: #3f3f46; color: #fafafa; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>GYXX 智能工作台</h1>
|
||||||
|
<span id="pills"></span>
|
||||||
|
<span class="muted" style="margin-left:auto" id="generated"></span>
|
||||||
|
<button class="act" onclick="loadWorkflows()">刷新</button>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="list"></div>
|
||||||
|
<div id="detail"><p class="muted" style="padding:16px">在左侧选择一个工作流,可提问、诊断、修复或启动。</p></div>
|
||||||
|
</main>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
<script>
|
||||||
|
const $ = (sel) => document.querySelector(sel)
|
||||||
|
const state = { workflows: [], selected: null, detail: null, runs: [], seenAlerts: {} }
|
||||||
|
|
||||||
|
function toast(text, kind) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'toast ' + (kind || '')
|
||||||
|
el.textContent = text
|
||||||
|
$('#toasts').appendChild(el)
|
||||||
|
setTimeout(() => el.remove(), 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req(method, path, body) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers: body !== undefined ? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' } : {},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const payload = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status))
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(w) { return w.active_run ? 'running' : (w.last_run && w.last_run.status) || 'none' }
|
||||||
|
function fmtTime(v) {
|
||||||
|
if (!v) return '—'
|
||||||
|
const d = new Date(v); if (isNaN(d)) return v
|
||||||
|
const p = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])) }
|
||||||
|
|
||||||
|
async function loadWorkflows() {
|
||||||
|
try {
|
||||||
|
const payload = await req('GET', '/bridge/workflows')
|
||||||
|
state.workflows = payload.workflows || []
|
||||||
|
$('#generated').textContent = payload.generated_at ? ('更新于 ' + fmtTime(payload.generated_at)) : ''
|
||||||
|
renderList()
|
||||||
|
} catch (e) {
|
||||||
|
$('#list').innerHTML = '<p class="err" style="padding:12px">无法连接桥接服务:' + esc(e.message) + '<br>请确认 gyxx console 与 dsh workbench 插件已启动。</p>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const failed = state.workflows.filter((w) => statusOf(w) === 'failed').length
|
||||||
|
const running = state.workflows.filter((w) => w.active_run).length
|
||||||
|
$('#pills').innerHTML =
|
||||||
|
`<span class="pill">共 ${state.workflows.length}</span>` +
|
||||||
|
(running ? `<span class="pill pill-running">运行中 ${running}</span>` : '') +
|
||||||
|
(failed ? `<span class="pill pill-failed">失败 ${failed}</span>` : '')
|
||||||
|
const byModule = new Map()
|
||||||
|
for (const w of state.workflows) {
|
||||||
|
const key = w.module_label || w.module
|
||||||
|
if (!byModule.has(key)) byModule.set(key, [])
|
||||||
|
byModule.get(key).push(w)
|
||||||
|
}
|
||||||
|
let html = ''
|
||||||
|
for (const [label, items] of byModule) {
|
||||||
|
html += `<div class="group-label">${esc(label)}</div>`
|
||||||
|
for (const w of items) {
|
||||||
|
const st = statusOf(w)
|
||||||
|
html += `<button class="row ${state.selected === w.id ? 'selected' : ''}" onclick="select('${esc(w.id)}')">
|
||||||
|
<span class="dot dot-${st}"></span>
|
||||||
|
<span><span class="name">${esc(w.name)}</span><span class="id">${esc(w.id)}</span></span>
|
||||||
|
<span class="meta">${w.last_run ? fmtTime(w.last_run.ended_at || w.last_run.started_at) : '—'}</span>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#list').innerHTML = html || '<p class="muted" style="padding:12px">未发现工作流</p>'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function select(id) {
|
||||||
|
state.selected = id
|
||||||
|
renderList()
|
||||||
|
$('#detail').innerHTML = '<p class="muted" style="padding:16px">加载中…</p>'
|
||||||
|
try {
|
||||||
|
const [detail, runs] = await Promise.all([
|
||||||
|
req('GET', '/bridge/workflows/' + encodeURIComponent(id)).catch(() => null),
|
||||||
|
req('GET', '/bridge/workflows/' + encodeURIComponent(id) + '/runs?limit=6').catch(() => ({ runs: [] })),
|
||||||
|
])
|
||||||
|
state.detail = detail
|
||||||
|
state.runs = runs.runs || []
|
||||||
|
renderDetail()
|
||||||
|
} catch (e) {
|
||||||
|
$('#detail').innerHTML = '<p class="err" style="padding:16px">' + esc(e.message) + '</p>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail() {
|
||||||
|
const w = state.workflows.find((x) => x.id === state.selected)
|
||||||
|
if (!w) return
|
||||||
|
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const p = (n) => String(n).padStart(2, '0')
|
||||||
|
const defaultDate = `${yesterday.getFullYear()}-${p(yesterday.getMonth()+1)}-${p(yesterday.getDate())}`
|
||||||
|
const sched = w.schedule
|
||||||
|
let html = `<div class="card">
|
||||||
|
<h2 style="font-size:15px;margin:0 0 4px">${esc(w.name)}</h2>
|
||||||
|
<div class="muted">${esc(w.id)} · 调度 ${sched ? (sched.enabled ? '启用' : '停用') + ' ' + (Array.isArray(sched.at) ? sched.at.join('/') : sched.at || '') : '无'}</div>
|
||||||
|
<div style="margin:10px 0">
|
||||||
|
<button class="act" onclick="ask('diagnose')">诊断</button>
|
||||||
|
<button class="act" onclick="ask('repair')">修复</button>
|
||||||
|
<button class="act" onclick="triggerRun(false)">试运行</button>
|
||||||
|
<button class="act danger" onclick="triggerRun(true)">正式运行</button>
|
||||||
|
${w.active_run ? '<button class="act danger" onclick="cancelRun()">停止</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||||
|
<input id="q" style="flex:1" placeholder="就这个工作流提问,例如:昨天为什么失败?" />
|
||||||
|
<button class="act" onclick="ask('ask')">提问</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted">业务日期 <input id="bd" value="${defaultDate}" style="width:110px" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="muted" style="margin-bottom:6px">最近运行(点击查看诊断)</div>`
|
||||||
|
if (!state.runs.length) html += '<div class="muted">暂无运行记录</div>'
|
||||||
|
for (const r of state.runs) {
|
||||||
|
html += `<button class="row" onclick="diagnose('${esc(r.run_id)}')">
|
||||||
|
<span class="dot dot-${r.status}"></span><span>${esc(r.business_date)}</span>
|
||||||
|
<span class="muted">${r.mode === 'execute' ? '正式' : '试运行'}</span>
|
||||||
|
${r.error ? `<span class="err">${esc(r.error.slice(0, 80))}</span>` : ''}
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
html += '</div><div id="diag"></div>'
|
||||||
|
$('#detail').innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diagnose(runId) {
|
||||||
|
$('#diag').innerHTML = '<div class="card muted">加载诊断数据…</div>'
|
||||||
|
try {
|
||||||
|
const d = await req('GET', '/bridge/workflows/' + encodeURIComponent(state.selected) + '/diagnosis?run_id=' + encodeURIComponent(runId))
|
||||||
|
let html = '<div class="card"><div class="muted">诊断:' + esc(runId) + '</div>'
|
||||||
|
const steps = (d.run && d.run.steps) || []
|
||||||
|
for (const s of steps.filter((x) => x.status === 'failed')) {
|
||||||
|
html += `<div class="err">✗ ${esc(s.id)}(退出码 ${s.exit_code == null ? '—' : s.exit_code})</div>${s.error ? '<pre>' + esc(s.error) + '</pre>' : ''}`
|
||||||
|
}
|
||||||
|
for (const log of d.logs || []) {
|
||||||
|
html += `<div class="muted" style="margin-top:8px">${esc(log.path)}</div><pre>${esc(log.tail)}</pre>`
|
||||||
|
}
|
||||||
|
if (!steps.some((x) => x.status === 'failed') && !(d.logs || []).length) html += '<div class="muted">该运行没有失败步骤或日志</div>'
|
||||||
|
$('#diag').innerHTML = html + '</div>'
|
||||||
|
} catch (e) {
|
||||||
|
$('#diag').innerHTML = '<div class="card err">' + esc(e.message) + '</div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(action) {
|
||||||
|
const question = ($('#q') && $('#q').value) || ''
|
||||||
|
try {
|
||||||
|
const payload = await req('POST', '/bridge/ask', { workflow_id: state.selected, action, question })
|
||||||
|
toast('已创建会话(' + String(payload.session_id).slice(0, 24) + '…),请在 dsh 侧边栏会话列表中查看', 'ok')
|
||||||
|
} catch (e) { toast('创建会话失败:' + e.message, 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerRun(execute) {
|
||||||
|
const businessDate = ($('#bd') && $('#bd').value) || ''
|
||||||
|
if (execute && !confirm(`正式执行 ${state.selected}(业务日期 ${businessDate})会产生真实外部写入。确认继续?`)) return
|
||||||
|
try {
|
||||||
|
await req('POST', '/bridge/trigger', { workflow_id: state.selected, business_date: businessDate, execute, confirmed: execute })
|
||||||
|
toast(execute ? '已发起正式执行' : '已发起试运行(无外部副作用)', 'ok')
|
||||||
|
setTimeout(loadWorkflows, 1500)
|
||||||
|
} catch (e) { toast('启动失败:' + e.message, 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRun() {
|
||||||
|
const w = state.workflows.find((x) => x.id === state.selected)
|
||||||
|
const body = w && w.active_run && w.active_run.operation_id
|
||||||
|
? { workflow_id: state.selected, operation_id: w.active_run.operation_id }
|
||||||
|
: { workflow_id: state.selected, scheduled: true }
|
||||||
|
try {
|
||||||
|
await req('POST', '/bridge/cancel', body)
|
||||||
|
toast('已发送停止指令', 'ok')
|
||||||
|
setTimeout(loadWorkflows, 1500)
|
||||||
|
} catch (e) { toast('停止失败:' + e.message, 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAlerts() {
|
||||||
|
try {
|
||||||
|
const payload = await req('GET', '/bridge/alerts')
|
||||||
|
for (const alert of payload.alerts || []) {
|
||||||
|
if (alert.seen || state.seenAlerts[alert.id]) continue
|
||||||
|
state.seenAlerts[alert.id] = true
|
||||||
|
toast('工作流「' + alert.name + '」运行失败,可在 dsh 中发起诊断', 'err')
|
||||||
|
req('POST', '/bridge/alerts/seen', { id: alert.id }).catch(() => {})
|
||||||
|
}
|
||||||
|
} catch (e) { /* 桥接离线时静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWorkflows()
|
||||||
|
setInterval(loadWorkflows, 15000)
|
||||||
|
setInterval(pollAlerts, 30000)
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,937 @@
|
|||||||
|
/**
|
||||||
|
* GYXX 智能工作台 — deepseek-harness 宿主插件。
|
||||||
|
*
|
||||||
|
* 通过 gyxx-flow 控制台 HTTP API(默认 http://127.0.0.1:8765)把工作流
|
||||||
|
* 暴露为 DeepSeek 智能体工具,并提供:
|
||||||
|
* - 7 个模型可调用的工作流工具(查询 / 诊断 / 触发 / 停止 / 定时配置)
|
||||||
|
* - 中文系统提示词(安全契约:默认试运行,正式执行必须用户确认)
|
||||||
|
* - 失败监控器(轮询 overview,发现新失败时发出告警,可选自动开诊断会话)
|
||||||
|
* - 本机回环桥接服务(默认 127.0.0.1:8790),供侧边栏面板读取数据、
|
||||||
|
* 发起「提问 / 诊断 / 修复」会话;同时内置一个零依赖的独立面板页面
|
||||||
|
* (GET /)作为 dsh 客户端插件加载失败时的降级 UI。
|
||||||
|
*
|
||||||
|
* 本文件零运行时依赖(只用 Node 内置模块),经 cordis.yml --patch 以绝对
|
||||||
|
* 路径插入 dsh Web 组合即可运行。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
export const name = 'gyxx-workbench'
|
||||||
|
export const inject = ['tools', 'systemPrompt']
|
||||||
|
|
||||||
|
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const MODULE_LABELS = {
|
||||||
|
content_marketing: '内容营销',
|
||||||
|
product_commerce: '商品经营',
|
||||||
|
shop_intelligence: '店铺情报',
|
||||||
|
supply_chain: '供应链',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 控制台 HTTP 客户端 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
class ConsoleApiError extends Error {
|
||||||
|
constructor(status, message) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ConsoleApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConsoleClient(config) {
|
||||||
|
const base = new URL(config.consoleBaseUrl)
|
||||||
|
return async function consoleApi(method, path, body, options = {}) {
|
||||||
|
const headers = { accept: 'application/json' }
|
||||||
|
if (config.consoleToken) headers.authorization = `Bearer ${config.consoleToken}`
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers['content-type'] = 'application/json'
|
||||||
|
headers['x-gyxx-console'] = '1'
|
||||||
|
}
|
||||||
|
if (options.ifMatch) headers['if-match'] = options.ifMatch
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(new URL(path, base), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: options.signal,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') throw error
|
||||||
|
throw new ConsoleApiError(
|
||||||
|
0,
|
||||||
|
`无法连接 gyxx 控制台 ${base.origin},请先启动 gyxx console(${error?.message ?? error})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
payload = { raw: text }
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
payload && typeof payload.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: `控制台返回 HTTP ${response.status}`
|
||||||
|
throw new ConsoleApiError(response.status, message)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 数据压缩:把控制台载荷裁剪为适合模型阅读的体积 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function compactRun(run) {
|
||||||
|
if (!run) return null
|
||||||
|
const compact = {
|
||||||
|
run_id: run.run_id,
|
||||||
|
business_date: run.business_date,
|
||||||
|
mode: run.mode,
|
||||||
|
shadow: run.shadow,
|
||||||
|
status: run.status,
|
||||||
|
started_at: run.started_at,
|
||||||
|
ended_at: run.ended_at,
|
||||||
|
duration_seconds: run.duration_seconds,
|
||||||
|
error: run.error,
|
||||||
|
}
|
||||||
|
if (Array.isArray(run.steps)) {
|
||||||
|
compact.steps = run.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
status: step.status,
|
||||||
|
exit_code: step.exit_code,
|
||||||
|
error: step.error,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return compact
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactWorkflow(item) {
|
||||||
|
const schedule = item.schedule ?? null
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
module: item.module,
|
||||||
|
module_label: MODULE_LABELS[item.module] ?? item.module,
|
||||||
|
trigger: item.trigger,
|
||||||
|
registered: item.registered,
|
||||||
|
note: item.note ?? null,
|
||||||
|
schedule: schedule
|
||||||
|
? {
|
||||||
|
enabled: schedule.enabled,
|
||||||
|
kind: schedule.kind,
|
||||||
|
at: schedule.at,
|
||||||
|
raw: schedule,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
next_run_at: item.next_run_at ?? null,
|
||||||
|
last_run: compactRun(item.last_run),
|
||||||
|
active_run: item.active_run
|
||||||
|
? {
|
||||||
|
operation_id: item.active_run.operation_id,
|
||||||
|
business_date: item.active_run.business_date,
|
||||||
|
mode: item.active_run.mode,
|
||||||
|
started_at: item.active_run.started_at,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactDetail(payload) {
|
||||||
|
const workflow = payload.workflow ?? {}
|
||||||
|
const steps = Array.isArray(workflow.steps)
|
||||||
|
? workflow.steps.map((step) => ({
|
||||||
|
id: step.id,
|
||||||
|
name: step.name,
|
||||||
|
description: step.description,
|
||||||
|
entry: step.entry,
|
||||||
|
timeout_seconds: step.timeout_seconds,
|
||||||
|
replay_policy: step.replay_policy,
|
||||||
|
depends_on: step.depends_on,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
workflow: { ...compactWorkflow(workflow), steps },
|
||||||
|
schedule_revision: payload.schedule_revision ?? null,
|
||||||
|
warnings: payload.warnings ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 系统提示词 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_TEXT = `\
|
||||||
|
你是 GYXX 智能工作台的运维智能体。gyxx-flow 是一套 LangGraph 工作流编排系统,
|
||||||
|
包含四个业务模块:内容营销(content_marketing)、商品经营(product_commerce)、
|
||||||
|
店铺情报(shop_intelligence)、供应链(supply_chain)。每个工作流由若干步骤组成,
|
||||||
|
运行日志(RunJournal)与运行索引位于数据根目录下。
|
||||||
|
|
||||||
|
你可以使用以下工具(数据来自本机 gyxx 控制台 HTTP API):
|
||||||
|
- gyxx_workflow_list:列出全部工作流及其调度、最近一次运行状态。
|
||||||
|
- gyxx_workflow_detail:查看单个工作流的定义、步骤与调度详情。
|
||||||
|
- gyxx_workflow_runs:查看某工作流的最近运行历史(含每个步骤的退出码与错误)。
|
||||||
|
- gyxx_workflow_diagnose:获取一次运行的完整诊断包(日志尾部已脱敏)。
|
||||||
|
- gyxx_workflow_trigger:触发一次运行。默认 execute=false 为试运行(无副作用)。
|
||||||
|
- gyxx_workflow_cancel:停止正在运行的手动执行或定时执行。
|
||||||
|
- gyxx_schedule_update:启用/停用或修改定时调度(读-改-写,需要 schedule_revision)。
|
||||||
|
|
||||||
|
安全契约(必须遵守):
|
||||||
|
1. 默认只做只读分析与试运行。execute=true 的正式执行会对外部系统
|
||||||
|
(PostgreSQL、飞书、电商平台)产生真实写入,只有在用户于对话中明确确认后,
|
||||||
|
才能把 confirmed=true 一并传入;否则工具会拒绝。
|
||||||
|
2. 修复工作流时遵循「诊断 → 定位根因 → 给出方案 → 用户确认 → 执行修复」的顺序,
|
||||||
|
不得跳过确认直接修改调度或正式重跑。
|
||||||
|
3. 常见修复手段:登录态失效(提示用户运行 gyxx accounts login/sync)、
|
||||||
|
幂等冲突(force_refresh 需真实执行且谨慎)、参数错误(改动态配置或调度)、
|
||||||
|
临时性失败(先试运行验证再正式重跑)。
|
||||||
|
4. 诊断结果用中文输出,包含:失败步骤、根因推测、建议操作、风险说明。
|
||||||
|
|
||||||
|
当用户从工作台侧边栏选中工作流发起提问/诊断/修复时,会话的第一条消息会
|
||||||
|
带有工作流上下文,请直接开始分析,不要反问用户基本信息。`
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 工具定义 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const JSON_OUTPUT = {
|
||||||
|
schema: { type: 'object' },
|
||||||
|
render: (_args, value) => [
|
||||||
|
{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolErrorResult(error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof ConsoleApiError ? error.message : `工具执行失败:${error?.message ?? error}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineWorkflowTools(ctx, consoleApi) {
|
||||||
|
const register = (definition) => ctx.tools.register(definition)
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_workflow_list',
|
||||||
|
description:
|
||||||
|
'列出 GYXX 全部调度工作流:名称、模块、调度时间、最近一次运行状态与进行中的执行。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
module: {
|
||||||
|
type: 'string',
|
||||||
|
enum: Object.keys(MODULE_LABELS),
|
||||||
|
description: '按业务模块过滤,缺省返回全部',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['failed', 'running', 'success', 'none'],
|
||||||
|
description: '按最近一次运行状态过滤(none = 从未运行)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
try {
|
||||||
|
const overview = await consoleApi('GET', '/api/overview', undefined, {
|
||||||
|
signal: exec?.signal,
|
||||||
|
})
|
||||||
|
let workflows = (overview.workflows ?? []).map(compactWorkflow)
|
||||||
|
if (args?.module) workflows = workflows.filter((w) => w.module === args.module)
|
||||||
|
if (args?.status) {
|
||||||
|
workflows = workflows.filter((w) => {
|
||||||
|
const status = w.active_run ? 'running' : (w.last_run?.status ?? 'none')
|
||||||
|
return args.status === 'running' ? status === 'running' || w.active_run : status === args.status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { ok: true, summary: overview.summary ?? null, workflows }
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_workflow_detail',
|
||||||
|
description: '查看单个工作流的完整定义:步骤、调度、依赖、重放策略与最近运行。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workflow_id: { type: 'string', description: '工作流 ID,例如 content.metrics.daily' },
|
||||||
|
},
|
||||||
|
required: ['workflow_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
try {
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}`,
|
||||||
|
undefined,
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
return { ok: true, ...compactDetail(payload) }
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_workflow_runs',
|
||||||
|
description: '查看某工作流最近的运行历史(含步骤级状态、退出码与脱敏错误)。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workflow_id: { type: 'string' },
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 20, description: '默认 8,最大 20' },
|
||||||
|
},
|
||||||
|
required: ['workflow_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
try {
|
||||||
|
const limit = args?.limit ?? 8
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs?limit=${limit}`,
|
||||||
|
undefined,
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
workflow_id: payload.workflow_id,
|
||||||
|
runs: (payload.runs ?? []).map(compactRun),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_workflow_diagnose',
|
||||||
|
description:
|
||||||
|
'获取一次运行的完整诊断包:运行状态、步骤明细、journal 追踪路径与脱敏日志尾部。' +
|
||||||
|
'不传 run_id 时自动选择最近一次失败的运行(无失败则最近一次运行)。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workflow_id: { type: 'string' },
|
||||||
|
run_id: { type: 'string', description: '缺省时自动选择最近一次失败运行' },
|
||||||
|
},
|
||||||
|
required: ['workflow_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
try {
|
||||||
|
let runId = args?.run_id
|
||||||
|
if (!runId) {
|
||||||
|
const runs = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs?limit=8`,
|
||||||
|
undefined,
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
const list = runs.runs ?? []
|
||||||
|
const failed = list.find((run) => run.status === 'failed')
|
||||||
|
const chosen = failed ?? list[0]
|
||||||
|
if (!chosen) {
|
||||||
|
return { ok: true, message: '该工作流还没有任何运行记录', workflow_id: args.workflow_id }
|
||||||
|
}
|
||||||
|
runId = chosen.run_id
|
||||||
|
}
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs/${encodeURIComponent(runId)}/diagnosis`,
|
||||||
|
undefined,
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
return { ok: true, ...payload, run: compactRun(payload.run) }
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_workflow_trigger',
|
||||||
|
description:
|
||||||
|
'触发工作流运行。默认 execute=false 为试运行(演练,无外部副作用);' +
|
||||||
|
'正式执行必须 execute=true 且 confirmed=true(只有在用户明确确认后才允许)。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workflow_id: { type: 'string' },
|
||||||
|
business_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: '业务日期 YYYY-MM-DD' },
|
||||||
|
execute: { type: 'boolean', description: '缺省 false = 试运行' },
|
||||||
|
confirmed: { type: 'boolean', description: '正式执行确认标记,须先征得用户确认' },
|
||||||
|
shadow: { type: 'boolean', description: '影子模式(对比旧链路),缺省 false' },
|
||||||
|
force_refresh: { type: 'boolean', description: '忽略幂等跳过强制重采(需正式执行)' },
|
||||||
|
},
|
||||||
|
required: ['workflow_id', 'business_date'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
const execute = args?.execute === true
|
||||||
|
const confirmed = args?.confirmed === true
|
||||||
|
if (execute && !confirmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'正式执行(execute=true)会对外部系统产生真实写入。请先用中文向用户说明将执行的操作与影响,' +
|
||||||
|
'在用户明确确认后,再以 confirmed=true 重新调用本工具。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'POST',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs`,
|
||||||
|
{
|
||||||
|
business_date: args.business_date,
|
||||||
|
execute,
|
||||||
|
shadow: args?.shadow === true,
|
||||||
|
confirmed,
|
||||||
|
force_refresh: args?.force_refresh === true,
|
||||||
|
},
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
return { ok: true, launched: payload }
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_workflow_cancel',
|
||||||
|
description: '停止某工作流正在进行的执行:手动运行需要 operation_id;定时运行用 scheduled=true。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workflow_id: { type: 'string' },
|
||||||
|
operation_id: { type: 'string', description: '手动运行的操作 ID(active_run.operation_id)' },
|
||||||
|
scheduled: { type: 'boolean', description: 'true 表示停止调度器正在执行的运行' },
|
||||||
|
},
|
||||||
|
required: ['workflow_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
try {
|
||||||
|
if (args?.scheduled === true) {
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'DELETE',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/scheduled-run`,
|
||||||
|
{},
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
return { ok: true, result: payload }
|
||||||
|
}
|
||||||
|
if (typeof args?.operation_id !== 'string' || !args.operation_id) {
|
||||||
|
return { ok: false, error: '停止手动运行必须提供 operation_id(见 active_run.operation_id)' }
|
||||||
|
}
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'DELETE',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/runs/${encodeURIComponent(args.operation_id)}`,
|
||||||
|
{},
|
||||||
|
{ signal: exec?.signal },
|
||||||
|
)
|
||||||
|
return { ok: true, result: payload }
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'gyxx_schedule_update',
|
||||||
|
description:
|
||||||
|
'修改工作流的定时调度(启用/停用、启动时间、周期)。采用读-改-写:' +
|
||||||
|
'先用 gyxx_workflow_detail 拿到 schedule 与 schedule_revision,再把完整调度对象' +
|
||||||
|
'与 revision 一并提交。修改前必须向用户说明并获得确认。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workflow_id: { type: 'string' },
|
||||||
|
schedule_revision: { type: 'string', description: 'gyxx_workflow_detail 返回的版本号' },
|
||||||
|
kind: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'interval_days'] },
|
||||||
|
at: {
|
||||||
|
description: '启动时间,HH:MM 字符串或字符串数组',
|
||||||
|
anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }],
|
||||||
|
},
|
||||||
|
enabled: { type: 'boolean' },
|
||||||
|
days: { type: 'array', items: { type: 'string' }, description: 'weekly 时的星期列表' },
|
||||||
|
day_of_month: { type: 'integer', description: 'monthly 时的日期' },
|
||||||
|
every_days: { type: 'integer', description: 'interval_days 时的间隔天数' },
|
||||||
|
business_date_offset_days: { type: 'integer', description: '业务日期偏移,缺省 0' },
|
||||||
|
},
|
||||||
|
required: ['workflow_id', 'schedule_revision', 'kind', 'at'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
output: JSON_OUTPUT,
|
||||||
|
async execute(args, exec) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
kind: args.kind,
|
||||||
|
at: args.at,
|
||||||
|
enabled: args?.enabled ?? true,
|
||||||
|
business_date_offset_days: args?.business_date_offset_days ?? 0,
|
||||||
|
}
|
||||||
|
for (const key of ['days', 'day_of_month', 'every_days']) {
|
||||||
|
if (args?.[key] !== undefined) body[key] = args[key]
|
||||||
|
}
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'PUT',
|
||||||
|
`/api/workflows/${encodeURIComponent(args.workflow_id)}/schedule`,
|
||||||
|
body,
|
||||||
|
{ signal: exec?.signal, ifMatch: args.schedule_revision },
|
||||||
|
)
|
||||||
|
return { ok: true, result: payload }
|
||||||
|
} catch (error) {
|
||||||
|
return toolErrorResult(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 提问 / 诊断 / 修复会话 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function buildSessionPrompt(action, workflow, question) {
|
||||||
|
const label = `${workflow.id}(${workflow.name ?? workflow.id})`
|
||||||
|
const last = workflow.last_run
|
||||||
|
const context = last
|
||||||
|
? `最近一次运行:${last.status},业务日期 ${last.business_date},run_id=${last.run_id}` +
|
||||||
|
(last.error ? `,错误:${last.error}` : '')
|
||||||
|
: '该工作流暂无运行记录'
|
||||||
|
if (action === 'diagnose') {
|
||||||
|
return (
|
||||||
|
`【工作台诊断请求】请诊断工作流 ${label}。\n${context}\n` +
|
||||||
|
'请调用 gyxx_workflow_diagnose 获取最近一次失败运行的诊断包,' +
|
||||||
|
'按「失败步骤 → 根因推测 → 建议修复 → 风险说明」输出中文诊断报告。'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === 'repair') {
|
||||||
|
return (
|
||||||
|
`【工作台修复请求】请修复工作流 ${label}。\n${context}\n` +
|
||||||
|
'先用 gyxx_workflow_diagnose 定位失败根因,给出候选修复方案并逐条说明影响;' +
|
||||||
|
'任何正式执行或调度修改都必须先征得我的确认(遵守安全契约),确认后再执行。'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`【工作台提问】工作流 ${label}。\n${context}\n` +
|
||||||
|
`我的问题:${question ?? '这个工作流现在状态如何?'}\n` +
|
||||||
|
'请结合 gyxx_workflow_detail / gyxx_workflow_runs / gyxx_workflow_diagnose 回答。'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 桥接服务(供侧边栏面板使用) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function isLoopbackOrigin(origin) {
|
||||||
|
if (!origin) return null
|
||||||
|
try {
|
||||||
|
const url = new URL(origin)
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||||
|
if (['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) return origin
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let size = 0
|
||||||
|
const chunks = []
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > 64 * 1024) {
|
||||||
|
reject(new Error('请求正文过大'))
|
||||||
|
req.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
req.on('end', () => {
|
||||||
|
if (chunks.length === 0) return resolve({})
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
||||||
|
} catch {
|
||||||
|
reject(new Error('请求 JSON 无效'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBridgeServer(ctx, config, consoleApi, alerts, openChatSession) {
|
||||||
|
const standalonePage = () => {
|
||||||
|
try {
|
||||||
|
return readFileSync(join(PLUGIN_DIR, 'client', 'standalone.html'))
|
||||||
|
} catch {
|
||||||
|
return Buffer.from('<h1>gyxx-workbench bridge</h1>', 'utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendJson = (req, res, status, payload) => {
|
||||||
|
const origin = isLoopbackOrigin(req.headers.origin)
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
res.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
...(origin ? { 'access-control-allow-origin': origin, vary: 'origin' } : {}),
|
||||||
|
})
|
||||||
|
res.end(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`)
|
||||||
|
const path = url.pathname
|
||||||
|
const origin = isLoopbackOrigin(req.headers.origin)
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204, {
|
||||||
|
...(origin
|
||||||
|
? {
|
||||||
|
'access-control-allow-origin': origin,
|
||||||
|
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
||||||
|
'access-control-allow-headers': 'content-type,x-gyxx-workbench',
|
||||||
|
'access-control-max-age': '3600',
|
||||||
|
vary: 'origin',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && path === '/') {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
||||||
|
res.end(standalonePage())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/healthz') {
|
||||||
|
sendJson(req, res, 200, { ok: true, name })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/bridge/workflows') {
|
||||||
|
const overview = await consoleApi('GET', '/api/overview')
|
||||||
|
sendJson(req, res, 200, {
|
||||||
|
ok: true,
|
||||||
|
summary: overview.summary ?? null,
|
||||||
|
generated_at: overview.generated_at ?? null,
|
||||||
|
workflows: (overview.workflows ?? []).map(compactWorkflow),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detailMatch = /^\/bridge\/workflows\/([^/]+)$/.exec(path)
|
||||||
|
if (req.method === 'GET' && detailMatch) {
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(decodeURIComponent(detailMatch[1]))}`,
|
||||||
|
)
|
||||||
|
sendJson(req, res, 200, { ok: true, ...compactDetail(payload) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const runsMatch = /^\/bridge\/workflows\/([^/]+)\/runs$/.exec(path)
|
||||||
|
if (req.method === 'GET' && runsMatch) {
|
||||||
|
const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 8, 1), 20)
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(decodeURIComponent(runsMatch[1]))}/runs?limit=${limit}`,
|
||||||
|
)
|
||||||
|
sendJson(req, res, 200, {
|
||||||
|
ok: true,
|
||||||
|
workflow_id: payload.workflow_id,
|
||||||
|
runs: (payload.runs ?? []).map(compactRun),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const diagnosisMatch = /^\/bridge\/workflows\/([^/]+)\/diagnosis$/.exec(path)
|
||||||
|
if (req.method === 'GET' && diagnosisMatch) {
|
||||||
|
const workflowId = decodeURIComponent(diagnosisMatch[1])
|
||||||
|
let runId = url.searchParams.get('run_id') || ''
|
||||||
|
if (!runId) {
|
||||||
|
const runs = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(workflowId)}/runs?limit=8`,
|
||||||
|
)
|
||||||
|
const list = runs.runs ?? []
|
||||||
|
const chosen = list.find((run) => run.status === 'failed') ?? list[0]
|
||||||
|
if (!chosen) {
|
||||||
|
sendJson(req, res, 200, { ok: true, message: '该工作流暂无运行记录', logs: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runId = chosen.run_id
|
||||||
|
}
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'GET',
|
||||||
|
`/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(runId)}/diagnosis`,
|
||||||
|
)
|
||||||
|
sendJson(req, res, 200, { ok: true, ...payload })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/bridge/alerts') {
|
||||||
|
sendJson(req, res, 200, { ok: true, alerts: alerts.list() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 以下为写操作:要求自定义头,防跨站表单提交 ——
|
||||||
|
if (req.method === 'POST' && (path === '/bridge/trigger' || path === '/bridge/cancel' || path === '/bridge/ask' || path === '/bridge/alerts/seen')) {
|
||||||
|
if (req.headers['x-gyxx-workbench'] !== '1') {
|
||||||
|
sendJson(req, res, 403, { ok: false, error: '缺少 x-gyxx-workbench 写操作标识' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const contentType = String(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase()
|
||||||
|
if (contentType !== 'application/json') {
|
||||||
|
sendJson(req, res, 415, { ok: false, error: '写操作只接受 application/json' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body = await readJsonBody(req)
|
||||||
|
|
||||||
|
if (path === '/bridge/trigger') {
|
||||||
|
const execute = body.execute === true
|
||||||
|
const confirmed = body.confirmed === true
|
||||||
|
if (execute && !confirmed) {
|
||||||
|
sendJson(req, res, 400, { ok: false, error: '正式执行必须 confirmed=true' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'POST',
|
||||||
|
`/api/workflows/${encodeURIComponent(String(body.workflow_id ?? ''))}/runs`,
|
||||||
|
{
|
||||||
|
business_date: String(body.business_date ?? ''),
|
||||||
|
execute,
|
||||||
|
shadow: body.shadow === true,
|
||||||
|
confirmed,
|
||||||
|
force_refresh: body.force_refresh === true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sendJson(req, res, 200, { ok: true, launched: payload })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === '/bridge/cancel') {
|
||||||
|
const workflowId = String(body.workflow_id ?? '')
|
||||||
|
if (body.scheduled === true) {
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'DELETE',
|
||||||
|
`/api/workflows/${encodeURIComponent(workflowId)}/scheduled-run`,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
sendJson(req, res, 200, { ok: true, result: payload })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const operationId = String(body.operation_id ?? '')
|
||||||
|
if (!operationId) {
|
||||||
|
sendJson(req, res, 400, { ok: false, error: '缺少 operation_id' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = await consoleApi(
|
||||||
|
'DELETE',
|
||||||
|
`/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(operationId)}`,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
sendJson(req, res, 200, { ok: true, result: payload })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === '/bridge/alerts/seen') {
|
||||||
|
alerts.markSeen(typeof body.id === 'string' ? body.id : null)
|
||||||
|
sendJson(req, res, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// /bridge/ask —— 创建工作流上下文会话
|
||||||
|
const workflowId = String(body.workflow_id ?? '')
|
||||||
|
const action = ['ask', 'diagnose', 'repair'].includes(body.action) ? body.action : 'ask'
|
||||||
|
let workflow
|
||||||
|
try {
|
||||||
|
const detail = await consoleApi('GET', `/api/workflows/${encodeURIComponent(workflowId)}`)
|
||||||
|
workflow = compactWorkflow(detail.workflow ?? {})
|
||||||
|
} catch {
|
||||||
|
workflow = { id: workflowId, name: workflowId, last_run: null }
|
||||||
|
}
|
||||||
|
const prompt = buildSessionPrompt(action, workflow, typeof body.question === 'string' ? body.question : '')
|
||||||
|
const sessionId = await openChatSession(prompt)
|
||||||
|
if (!sessionId) {
|
||||||
|
sendJson(req, res, 503, {
|
||||||
|
ok: false,
|
||||||
|
error: '当前 dsh 组合不支持编程式会话创建,请在对话中直接提问',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJson(req, res, 200, { ok: true, session_id: sessionId, action })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(req, res, 404, { ok: false, error: '接口不存在' })
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof ConsoleApiError && error.status ? error.status : 500
|
||||||
|
sendJson(req, res, status, {
|
||||||
|
ok: false,
|
||||||
|
error: error?.message ?? '桥接服务内部错误',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* 插件入口 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function apply(ctx, rawConfig) {
|
||||||
|
const config = {
|
||||||
|
consoleBaseUrl: process.env.GYXX_CONSOLE_URL ?? 'http://127.0.0.1:8765',
|
||||||
|
consoleToken: process.env.GYXX_CONSOLE_TOKEN ?? '',
|
||||||
|
bridgeHost: '127.0.0.1',
|
||||||
|
bridgePort: 8790,
|
||||||
|
pollIntervalMs: 30_000,
|
||||||
|
autoDiagnose: false,
|
||||||
|
projectRoot: process.cwd(),
|
||||||
|
...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}),
|
||||||
|
}
|
||||||
|
const consoleApi = makeConsoleClient(config)
|
||||||
|
|
||||||
|
// 1) 智能体工具
|
||||||
|
defineWorkflowTools(ctx, consoleApi)
|
||||||
|
|
||||||
|
// 2) 系统提示词
|
||||||
|
ctx.systemPrompt.section({
|
||||||
|
name: 'gyxx-workbench',
|
||||||
|
order: 700,
|
||||||
|
text: SYSTEM_PROMPT_TEXT,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3) 失败监控器 + 告警缓存
|
||||||
|
const state = {
|
||||||
|
seen: new Map(),
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
const alerts = {
|
||||||
|
list: () => state.items.map((item) => ({ ...item })),
|
||||||
|
markSeen(id) {
|
||||||
|
if (id === null) state.items.forEach((item) => (item.seen = true))
|
||||||
|
else {
|
||||||
|
const target = state.items.find((item) => item.id === id)
|
||||||
|
if (target) target.seen = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
push(alert) {
|
||||||
|
state.items.unshift(alert)
|
||||||
|
if (state.items.length > 50) state.items.length = 50
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChatSession = async (prompt) => {
|
||||||
|
const agents = typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents
|
||||||
|
if (!agents || typeof agents.create !== 'function') return null
|
||||||
|
const sessionId = `gyxx-wb-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`
|
||||||
|
const handle = await agents.create({
|
||||||
|
sessionId,
|
||||||
|
meta: { cwd: config.projectRoot, origin: 'gyxx-workbench', isSeeded: true },
|
||||||
|
})
|
||||||
|
handle.agent.followup({
|
||||||
|
id: randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: prompt }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
})
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.effect(() => {
|
||||||
|
let stopped = false
|
||||||
|
let timer = null
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const overview = await consoleApi('GET', '/api/overview')
|
||||||
|
for (const item of overview.workflows ?? []) {
|
||||||
|
const last = item.last_run
|
||||||
|
if (!last || last.status !== 'failed') {
|
||||||
|
if (last && last.status !== 'failed') state.seen.delete(item.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (state.seen.get(item.id) === last.run_id) continue
|
||||||
|
state.seen.set(item.id, last.run_id)
|
||||||
|
const alert = {
|
||||||
|
id: randomUUID(),
|
||||||
|
kind: 'workflow-failed',
|
||||||
|
workflow_id: item.id,
|
||||||
|
name: item.name ?? item.id,
|
||||||
|
run_id: last.run_id,
|
||||||
|
business_date: last.business_date ?? null,
|
||||||
|
error: last.error ?? null,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
seen: false,
|
||||||
|
}
|
||||||
|
alerts.push(alert)
|
||||||
|
try {
|
||||||
|
ctx.emit('gyxx-workbench/alert', alert)
|
||||||
|
} catch {
|
||||||
|
// 自定义事件在某些组合上不可用时忽略
|
||||||
|
}
|
||||||
|
if (config.autoDiagnose) {
|
||||||
|
const prompt = buildSessionPrompt('diagnose', compactWorkflow(item), '')
|
||||||
|
openChatSession(prompt)?.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 控制台离线时静默,下一轮继续
|
||||||
|
}
|
||||||
|
if (!stopped) timer = setTimeout(poll, config.pollIntervalMs)
|
||||||
|
}
|
||||||
|
timer = setTimeout(poll, 5_000)
|
||||||
|
return () => {
|
||||||
|
stopped = true
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, 'gyxx-workbench: monitor')
|
||||||
|
|
||||||
|
// 4) 桥接服务(侧边栏面板数据 + 会话创建 + 降级页面)
|
||||||
|
ctx.effect(async () => {
|
||||||
|
const server = createBridgeServer(ctx, config, consoleApi, alerts, openChatSession)
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject)
|
||||||
|
server.listen(config.bridgePort, config.bridgeHost, resolve)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// 端口占用等问题不应阻断插件加载(工具与监控仍可用)
|
||||||
|
ctx.logger?.warn?.(
|
||||||
|
`[gyxx-workbench] 桥接服务启动失败(${error?.message ?? error}),侧边栏面板不可用`,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
server.close()
|
||||||
|
} catch {
|
||||||
|
// 忽略关闭异常
|
||||||
|
}
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
const address = server.address()
|
||||||
|
ctx.logger?.info?.(
|
||||||
|
`[gyxx-workbench] 桥接服务已启动: http://${config.bridgeHost}:${address?.port ?? config.bridgePort}/`,
|
||||||
|
)
|
||||||
|
return () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
server.close(() => resolve())
|
||||||
|
})
|
||||||
|
}, 'gyxx-workbench: bridge')
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Generated
+481
@@ -0,0 +1,481 @@
|
|||||||
|
{
|
||||||
|
"name": "@gyxx/dsh-plugin-gyxx-workbench",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@gyxx/dsh-plugin-gyxx-workbench",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.24.2",
|
||||||
|
"@esbuild/android-arm": "0.24.2",
|
||||||
|
"@esbuild/android-arm64": "0.24.2",
|
||||||
|
"@esbuild/android-x64": "0.24.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.24.2",
|
||||||
|
"@esbuild/darwin-x64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.24.2",
|
||||||
|
"@esbuild/linux-arm": "0.24.2",
|
||||||
|
"@esbuild/linux-arm64": "0.24.2",
|
||||||
|
"@esbuild/linux-ia32": "0.24.2",
|
||||||
|
"@esbuild/linux-loong64": "0.24.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.24.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.24.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.24.2",
|
||||||
|
"@esbuild/linux-s390x": "0.24.2",
|
||||||
|
"@esbuild/linux-x64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/sunos-x64": "0.24.2",
|
||||||
|
"@esbuild/win32-arm64": "0.24.2",
|
||||||
|
"@esbuild/win32-ia32": "0.24.2",
|
||||||
|
"@esbuild/win32-x64": "0.24.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@gyxx/dsh-plugin-gyxx-workbench",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "GYXX 智能工作台:把 gyxx-flow 工作流暴露为 deepseek-harness 智能体工具与侧边栏面板",
|
||||||
|
"type": "module",
|
||||||
|
"main": "gyxx-workbench.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": "./gyxx-workbench.mjs",
|
||||||
|
"./client": "./lib/client.js",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"dsh": {
|
||||||
|
"client": {
|
||||||
|
"platform": "web",
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-renderer",
|
||||||
|
"@deepseek-ai/dsh-client-ui-layout",
|
||||||
|
"@deepseek-ai/dsh-client-locale"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/build-client.mjs",
|
||||||
|
"test": "node --test \"tests/**/*.test.mjs\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 构建客户端包:esbuild(CJS / browser / 平台模块外置)+ dsh 模块加载器包装。
|
||||||
|
* 包装协议与 dsh 官方 tsdown 产物一致:
|
||||||
|
* window.__ModuleLoader__.load({ id, factory: (require) => { ... return module.exports } })
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const PKG_ID = '@gyxx/dsh-plugin-gyxx-workbench'
|
||||||
|
|
||||||
|
// dsh 平台共享模块表(packages/client/web/src/platform.ts 的 PLATFORM_MODULES)
|
||||||
|
const PLATFORM_EXTERNALS = [
|
||||||
|
'react',
|
||||||
|
'react/jsx-runtime',
|
||||||
|
'react-dom',
|
||||||
|
'react-dom/client',
|
||||||
|
'@deepseek-ai/cordis',
|
||||||
|
'@deepseek-ai/dsh-client-store',
|
||||||
|
'@deepseek-ai/dsh-client-ui-slots',
|
||||||
|
'@deepseek-ai/dsh-client-ui-primitives',
|
||||||
|
]
|
||||||
|
|
||||||
|
await build({
|
||||||
|
absWorkingDir: root,
|
||||||
|
entryPoints: ['client/src/index.jsx'],
|
||||||
|
bundle: true,
|
||||||
|
format: 'cjs',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2020',
|
||||||
|
outfile: 'lib/client.js',
|
||||||
|
external: PLATFORM_EXTERNALS,
|
||||||
|
loader: { '.css': 'text' },
|
||||||
|
jsx: 'automatic',
|
||||||
|
sourcemap: true,
|
||||||
|
minify: false,
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
// dsh 浏览器加载器只给 factory 传 require(返回值即模块导出),
|
||||||
|
// CJS 的 module/exports 垫片必须由包体自带(与官方 tsdown 产物一致)。
|
||||||
|
js: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PKG_ID)}, factory: (require) => {\nvar module = { exports: {} };\nvar exports = module.exports;`,
|
||||||
|
},
|
||||||
|
footer: { js: 'return module.exports; } });' },
|
||||||
|
logLevel: 'info',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('built lib/client.js')
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* gyxx-workbench 宿主插件冒烟测试:
|
||||||
|
* - apply() 能注册 7 个工具与系统提示词
|
||||||
|
* - 工具在控制台离线时返回结构化错误(不抛出)
|
||||||
|
* - 桥接服务能代理控制台 API 并为提问创建会话
|
||||||
|
* 运行:node --test tests/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
import * as plugin from '../gyxx-workbench.mjs'
|
||||||
|
|
||||||
|
function makeCtx() {
|
||||||
|
const registered = { tools: [], sections: [], effects: [], emissions: [] }
|
||||||
|
const agents = {
|
||||||
|
calls: [],
|
||||||
|
async create(options) {
|
||||||
|
agents.calls.push(options)
|
||||||
|
return {
|
||||||
|
agent: {
|
||||||
|
followups: [],
|
||||||
|
followup(message) {
|
||||||
|
this.followups.push(message)
|
||||||
|
agents.calls[agents.calls.length - 1].followup = message
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const ctx = {
|
||||||
|
tools: { register: (def) => registered.tools.push(def) },
|
||||||
|
systemPrompt: { section: (section) => registered.sections.push(section) },
|
||||||
|
effect(fn) {
|
||||||
|
registered.effects.push(fn)
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
emit: (event, payload) => registered.emissions.push([event, payload]),
|
||||||
|
get: (key) => (key === 'agents' ? agents : undefined),
|
||||||
|
logger: { info() {} },
|
||||||
|
}
|
||||||
|
return { ctx, registered, agents }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('apply 注册 7 个工作流工具与系统提示词', () => {
|
||||||
|
const { ctx, registered } = makeCtx()
|
||||||
|
plugin.apply(ctx, { bridgePort: 0, pollIntervalMs: 60_000 })
|
||||||
|
const names = registered.tools.map((tool) => tool.name).sort()
|
||||||
|
assert.deepEqual(names, [
|
||||||
|
'gyxx_schedule_update',
|
||||||
|
'gyxx_workflow_cancel',
|
||||||
|
'gyxx_workflow_detail',
|
||||||
|
'gyxx_workflow_diagnose',
|
||||||
|
'gyxx_workflow_list',
|
||||||
|
'gyxx_workflow_runs',
|
||||||
|
'gyxx_workflow_trigger',
|
||||||
|
])
|
||||||
|
assert.equal(registered.sections.length, 1)
|
||||||
|
assert.equal(registered.sections[0].name, 'gyxx-workbench')
|
||||||
|
assert.match(registered.sections[0].text, /安全契约/)
|
||||||
|
// 监控器 + 桥接服务两个 effect
|
||||||
|
assert.equal(registered.effects.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('工具在控制台离线时返回结构化错误而非抛出', async () => {
|
||||||
|
const { ctx, registered } = makeCtx()
|
||||||
|
plugin.apply(ctx, {
|
||||||
|
consoleBaseUrl: 'http://127.0.0.1:1',
|
||||||
|
bridgePort: 0,
|
||||||
|
pollIntervalMs: 60_000,
|
||||||
|
})
|
||||||
|
const listTool = registered.tools.find((tool) => tool.name === 'gyxx_workflow_list')
|
||||||
|
const result = await listTool.execute({})
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.error, /无法连接 gyxx 控制台/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('正式执行缺少 confirmed 时工具直接拒绝', async () => {
|
||||||
|
const { ctx, registered } = makeCtx()
|
||||||
|
plugin.apply(ctx, { consoleBaseUrl: 'http://127.0.0.1:1', bridgePort: 0 })
|
||||||
|
const trigger = registered.tools.find((tool) => tool.name === 'gyxx_workflow_trigger')
|
||||||
|
const result = await trigger.execute({
|
||||||
|
workflow_id: 'content.metrics.daily',
|
||||||
|
business_date: '2026-08-01',
|
||||||
|
execute: true,
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.match(result.error, /确认/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('桥接服务代理控制台 API 并通过 /bridge/ask 创建会话', async (t) => {
|
||||||
|
// 1) 模拟 gyxx 控制台
|
||||||
|
const consoleServer = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/api/overview') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
workflows: [
|
||||||
|
{
|
||||||
|
id: 'content.metrics.daily',
|
||||||
|
name: '内容指标日报',
|
||||||
|
module: 'content_marketing',
|
||||||
|
trigger: 'scheduled',
|
||||||
|
registered: true,
|
||||||
|
schedule: { enabled: true, kind: 'daily', at: '09:00' },
|
||||||
|
last_run: {
|
||||||
|
run_id: 'run-1',
|
||||||
|
business_date: '2026-08-01',
|
||||||
|
mode: 'execute',
|
||||||
|
shadow: false,
|
||||||
|
status: 'failed',
|
||||||
|
started_at: '2026-08-01T01:00:00+00:00',
|
||||||
|
ended_at: '2026-08-01T01:05:00+00:00',
|
||||||
|
error: 'step failed',
|
||||||
|
step_counts: { failed: 1 },
|
||||||
|
},
|
||||||
|
active_run: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: { total: 1 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (req.url === '/api/workflows/content.metrics.daily') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ workflow: { id: 'content.metrics.daily', name: '内容指标日报' } }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.writeHead(404, { 'content-type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: '页面或接口不存在' }))
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => consoleServer.listen(0, '127.0.0.1', resolve))
|
||||||
|
t.after(() => consoleServer.close())
|
||||||
|
const consolePort = consoleServer.address().port
|
||||||
|
|
||||||
|
// 2) 以 mock ctx 加载插件并手动启动桥接 effect
|
||||||
|
const { ctx, registered, agents } = makeCtx()
|
||||||
|
plugin.apply(ctx, {
|
||||||
|
consoleBaseUrl: `http://127.0.0.1:${consolePort}`,
|
||||||
|
bridgePort: 0,
|
||||||
|
pollIntervalMs: 600_000,
|
||||||
|
})
|
||||||
|
const bridgeEffect = registered.effects[1]
|
||||||
|
const dispose = await bridgeEffect()
|
||||||
|
t.after(() => dispose())
|
||||||
|
|
||||||
|
// 桥接端口由 effect 内部 listen 决定,通过日志无法读取——改为直接请求
|
||||||
|
// 控制台代理行为经工具路径验证;这里验证 ask 会话创建逻辑。
|
||||||
|
const listTool = registered.tools.find((tool) => tool.name === 'gyxx_workflow_list')
|
||||||
|
const list = await listTool.execute({})
|
||||||
|
assert.equal(list.ok, true)
|
||||||
|
assert.equal(list.workflows.length, 1)
|
||||||
|
assert.equal(list.workflows[0].last_run.status, 'failed')
|
||||||
|
assert.equal(list.workflows[0].module_label, '内容营销')
|
||||||
|
|
||||||
|
// diagnose:自动选择最近失败运行(控制台无该路由时应返回结构化错误)
|
||||||
|
const diagnose = registered.tools.find((tool) => tool.name === 'gyxx_workflow_diagnose')
|
||||||
|
const missing = await diagnose.execute({ workflow_id: 'content.metrics.daily' })
|
||||||
|
assert.equal(missing.ok, false)
|
||||||
|
assert.match(missing.error, /页面或接口不存在|HTTP 404/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user