Initial commit

This commit is contained in:
chenpei 2026-03-04 13:25:48 +08:00
commit 90236eaf4c
169 changed files with 25610 additions and 0 deletions

View File

0
,out) PY Normal file
View File

BIN
.DS_Store vendored Normal file

Binary file not shown.

152
AGENTS.remote.live.md Normal file
View File

@ -0,0 +1,152 @@
# AGENTS.md - OpenClaw Workspace Rules
## 1) Core Behavior
- Execute first, explain later. Do not throw generic blockers to the user.
- Before asking user for help, try at least 2 autonomous recoveries.
- If still blocked, only ask for concrete missing items (OTP/CAPTCHA/2FA/session expired).
- Do not fabricate data. Every conclusion must come from tool results.
## 2) Routing Priority (Highest to Lowest)
### A. Xiaohongshu Tasks (override all market routes)
Trigger keywords: 小红书 / 红书 / xhs / 笔记 / 种草 / 舆情
Required sequence:
1. `exec: xhs-api health`
2. `exec: xhs-api login-status`
3. If not logged in: `exec: xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`
4. Then use:
- `xhs-api search "<keyword>"`
- `xhs-api detail <id> <xsecToken> true`
### B. Sales / Aftersale / Refund / Performance Tasks (SQL only)
Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 / 库存 / 缺货 / 补货 / 安全库存
Hard rules:
- Must route to `company-sales-db-analysis`.
- Do not use yuce website for sales/aftersale tasks.
- Do not use web_search/web_fetch for these tasks.
- Must run at least one `exec` data query before final response.
- Do not call `tts` unless user explicitly asks voice output.
Mandatory first checks (once per turn):
1. `exec: company-db health`
2. Query by SQL templates in `/root/.openclaw/workspace/sqlserver/`
Template mapping:
- 店铺周报 -> `shop_weekly_report.sql`
- SKU结构 -> `sku_structure_report.sql`
- 产品信息/库存预警(SKU/成本/材质/预览图/库存/发货件数/缺货天数) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`
- 全量SKU缺货预警明细 -> `exec: stock-risk-alerts --days 30 --limit 500`
- 全量SKU缺货预警推送飞书(结构化字段) -> `exec: stock-risk-to-feishu --days 30 --top 100`
- 商品ID查SKU清单 -> `exec: product-skus-by-id "<商品id>" --limit 1000`
Product info output minimum fields:
- SKU编码, SKU名称, 成本价, 材质, 预览图地址, 当前库存, 日均发货件数, 预计缺货天数, 补货建议
- 推荐补充:SPU/货号/分类/供应商/尺寸/重量/SKU状态
- 若存在物流数据,追加 一区/二区/三区 运费汇总(近30天)
- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)
- 若涉及库存/补货,必须追加:近30天在售商品ID数量(按渠道)+ 商品ID对应店铺名/运营人 + 采购/工厂/仓库信息
- 库存预警(强制):
- 必须输出 `estimated_stockout_days` 与 `replenishment_lead_days(=10)`
- 当 `estimated_stockout_days <= 10` 时,结论必须标记 `补货紧急`
- 退款原因/售后 -> `after_sale_360_report.sql`
- 费比异常 -> `fee_ratio_alert.sql`
- 物流时效 -> `logistics_sla_report.sql`
- 运费效率 -> `freight_efficiency_report.sql`
- 客服热点 -> `customer_service_hotspot_report.sql`
- 客服体验/话术分析(可指定时间段) -> `exec: customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`
- 输出需包含:客服能力总分与等级(S/A/B/C/D)
- 安装服务 -> `install_service_quality_report.sql`
- 链接目标覆盖 -> `link_target_coverage_report.sql`
Aftersale special rule:
- Do not analyze only one table.
- Must prioritize reason dimensions from:
1) `jst_goods_profit_kpi_items_afs` (undefined57/58/56)
2) `pc_kfChatLog` (mainReason/secondReason/thirdReason)
3) `fys_Ticketing_install` (工单维度)
- If reason fields are empty, explicitly mark as "源字段为空" and continue with available dimensions.
Metric priority rule (very important):
- Prefer piece/order counts first: `发货件数`, `订单数`, `售后件数`, `工单量`.
- Amount metrics are secondary and optional.
- Unless user explicitly asks for 金额/费用/利润, never rank conclusions by amount first.
Output format for sales/aftersale:
1. 件数与订单核心结论
2. 原因结构(一级/二级/三级)
3. 趋势与异常
4. 金额补充(可选)
5. 可执行建议
6. 若为客服体验/话术任务,必须补充“对话案例 + 推荐回复话术”
7. 若为客服体验/话术任务,必须补充“客服能力评分(按客服名字输出四维度+总分+等级)”
### C. Market Analysis Tasks (non-sales)
Trigger keywords: 市场分析 / 行业分析 / 类目分析 / 竞品分析 / 趋势分析
Hard rules:
- Only allow domain `hq.yucekj.com` / `yucekj.com`.
- Use `exec` with `yuce-browser` only.
- Never use browser `search` action.
- Never use web_search/web_fetch for this route.
- If login/CAPTCHA blocks, ask user for that exact step only.
### D. General Online Search Tasks
Trigger keywords: 在线搜索 / 搜一下 / 查资料 / 最新消息 / 新闻 / 政策更新
Preferred execution:
1. First use `exec: web-search "<query>" --count 8` (stable path; includes Brave+Bing fallback).
2. Optional: if user explicitly asks for Brave-only, use `web_search`.
3. Summarize with source links, and mark date/time context when content is time-sensitive.
## 3) Security and Privacy
- Do not expose raw data source endpoints in user-facing replies.
- Mask domain/IP/api endpoints in final messages when possible.
- Exception: when reporting Feishu push result, must show the actual Feishu table link (do not mask `*.feishu.cn`).
- Never leak secrets from env files.
## 4) Reply Quality Rules
- Send concise final answer; no long tool transcript dump.
- If task may take time, send one short processing hint first.
- Do not repeat the same result twice in one turn.
- If a request fails, include: what was attempted, what failed, and next actionable fix.
## 5) Feishu Delivery Rules
Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接
Default target (pinned):
- default_link: https://acnuoqgha8fk.feishu.cn/wiki/RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI
- default_app_token: SZ4ebCKU3a9OdfspetxcSeg1n4g
- default_table_id: tblzuMy85WiRAUX5
Required behavior:
- Never auto-push to Feishu unless the user explicitly asks in the current turn.
- If current turn does not contain Feishu push intent, do analysis only and return in chat.
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
- For stock-risk/inventory warning reports, do NOT write a long text blob into one cell; must push structured columns/rows (summary row + detail rows), preferably via `exec: stock-risk-to-feishu --days 30 --top 100`.
- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload.
- If user does not provide a link in current turn, ALWAYS use the pinned default target above.
- Do NOT ask user for Feishu link again unless user explicitly asks to switch to another table.
- In final reply, always include:
1) push status (success/failed)
2) target sheet link (actual link used)
3) written record_id (or updated count)
- If write partially fails, return succeeded records and failed reason list separately.
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
## 6) Workspace Paths
- SQL templates: `/root/.openclaw/workspace/sqlserver/`
- SQL catalog: `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
- Router skill: `/root/.openclaw/skills/web-task-router/SKILL.md`
- Tool guide: `/root/.openclaw/workspace/TOOLS.md`
- Site config: `/root/.openclaw/workspace/websites.yaml`

101
README_SQL_TEMPLATES.md Normal file
View File

@ -0,0 +1,101 @@
# 销售分析常用 SQL 模板包
数据源:`JSDataMiddlePlatform`(SQLServer)
执行方式:`company-db run-file <sql文件路径>`
默认分析口径:
- 主口径优先:`发货件数 / 售后件数 / 订单数`
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
## 模板列表
1. `shop_weekly_report.sql`
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
- 默认时间:上一个自然周
2. `sku_structure_report.sql`
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
- 默认时间:最近30天
3. `refund_reason_report.sql`
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
- 默认时间:最近30天
4. `fee_ratio_alert.sql`
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
- 默认时间:最近14天
5. `logistics_sla_report.sql`
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
- 默认时间:最近30天
6. `freight_efficiency_report.sql`
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
- 默认时间:最近30天
7. `install_service_quality_report.sql`
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
- 默认时间:最近90天
8. `customer_service_hotspot_report.sql`
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
- 默认时间:最近30天
9. `link_target_coverage_report.sql`
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
- 默认时间:在线链接全量
10. `after_sale_360_report.sql`
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
- 默认时间:上一个自然周
11. `product-info`(CLI辅助命令)
- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存缺货预警
- 命令:`product-info "<sku或关键词>" --days 30 --limit 20`
- 输出:
- 结果集1:产品基础信息(SKU名称、成本、材质、预览图等)
- 结果集2:最近N天出货/售后件数
- 结果集3:最近N天店铺分布(件数)
- 结果集4:最近N天一区/二区/三区运费汇总
- 结果集5:最近N天省份运费TOP
- 结果集6:最近N天售后总览
- 结果集7:最近N天售后类型/结果矩阵
- 结果集8:最近N天售后原因TOP
- 结果集9:最近N天每日总发货件数(按SKU)
- 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)
- 结果集11:近30天在售商品ID数量(按店铺渠道)
- 结果集12:商品ID对应店铺名/渠道/运营人(通知清单)
- 结果集13:缺货风险通知清单(采购/工厂/仓库/店铺/运营)
12. `product-skus-by-id`(CLI辅助命令)
- 用途:按商品ID查询该ID下所有SKU编码与SKU名称
- 命令:`product-skus-by-id "<商品id>" --limit 1000`
13. `customer_service_experience_report.sql`
- 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势)
- 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`)
14. `customer-service-analysis`(CLI辅助命令)
- 用途:按指定时间段做客服维度的用户反馈体验/话术分析
- 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
15. `sku_stock_risk_report.sql`
- 用途:按SKU编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天)
- 默认时间:最近30天(模板顶部可改 `@lookback_days`)
- 参数:修改模板顶部 `@sku` 即可
## 运行示例
```bash
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/sku_stock_risk_report.sql
product-info "CJ019317N1097V01" --days 30 --limit 20
customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达"
```
## 注意事项
- 所有模板默认过滤 `配件` 和 `特殊单` 标签。
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
- 库存预警建议:`estimated_stockout_days <= 10` 视为补货紧急。

View File

@ -0,0 +1,113 @@
# 销售分析常用 SQL 模板包
数据源:`JSDataMiddlePlatform`(SQLServer)
执行方式:`company-db run-file <sql文件路径>`
默认分析口径:
- 主口径优先:`发货件数 / 售后件数 / 订单数`
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
## 模板列表
1. `shop_weekly_report.sql`
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
- 默认时间:上一个自然周
2. `sku_structure_report.sql`
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
- 默认时间:最近30天
3. `refund_reason_report.sql`
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
- 默认时间:最近30天
4. `fee_ratio_alert.sql`
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
- 默认时间:最近14天
5. `logistics_sla_report.sql`
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
- 默认时间:最近30天
6. `freight_efficiency_report.sql`
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
- 默认时间:最近30天
7. `install_service_quality_report.sql`
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
- 默认时间:最近90天
8. `customer_service_hotspot_report.sql`
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
- 默认时间:最近30天
9. `link_target_coverage_report.sql`
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
- 默认时间:在线链接全量
10. `after_sale_360_report.sql`
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
- 默认时间:上一个自然周
11. `product-info`(CLI辅助命令)
- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存缺货预警
- 命令:`product-info "<sku或关键词>" --days 30 --limit 20`
- 输出:
- 结果集1:产品基础信息(SKU名称、成本、材质、预览图等)
- 结果集2:最近N天出货/售后件数
- 结果集3:最近N天店铺分布(件数)
- 结果集4:最近N天一区/二区/三区运费汇总
- 结果集5:最近N天省份运费TOP
- 结果集6:最近N天售后总览
- 结果集7:最近N天售后类型/结果矩阵
- 结果集8:最近N天售后原因TOP
- 结果集9:最近N天每日总发货件数(按SKU)
- 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)
- 结果集11:近30天在售商品ID数量(按店铺渠道)
- 结果集12:商品ID对应店铺名/渠道/运营人(通知清单)
- 结果集13:缺货风险通知清单(采购/工厂/仓库/店铺/运营)
12. `product-skus-by-id`(CLI辅助命令)
- 用途:按商品ID查询该ID下所有SKU编码与SKU名称
- 命令:`product-skus-by-id "<商品id>" --limit 1000`
13. `customer_service_experience_report.sql`
- 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势)
- 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`)
14. `customer-service-analysis`(CLI辅助命令)
- 用途:按指定时间段做客服维度的用户反馈体验/话术分析
- 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
15. `sku_stock_risk_report.sql`
- 用途:按SKU编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天)
- 默认时间:最近30天(模板顶部可改 `@lookback_days`)
- 参数:修改模板顶部 `@sku` 即可
16. `stock-risk-alerts`(CLI辅助命令)
- 用途:查询近30天所有在售SKU的缺货预警明细(SKU编码维度)
- 命令:`stock-risk-alerts --days 30 --limit 500`
- 输出:
- 结果集1:全量在售SKU/风险SKU汇总
- 结果集2:风险明细(预计缺货天数、补货建议、采购、工厂、仓库、运营、渠道/店铺)
- 常用参数:
- `--lead-days 10`(补货提前期)
- `--warn-days 15`(预警阈值)
- `--include-safe`(包含安全SKU)
## 运行示例
```bash
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/sku_stock_risk_report.sql
product-info "CJ019317N1097V01" --days 30 --limit 20
stock-risk-alerts --days 30 --limit 500
customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达"
```
## 注意事项
- 所有模板默认过滤 `配件` 和 `特殊单` 标签。
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
- 库存预警建议:`estimated_stockout_days <= 10` 视为补货紧急。

90
TOOLS.remote.live.md Normal file
View File

@ -0,0 +1,90 @@
# TOOLS.md - Local Notes
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
## What Goes Here
Things like:
- Camera names and locations
- SSH hosts and aliases
- Preferred voices for TTS
- Speaker/room names
- Device nicknames
- Anything environment-specific
## Examples
```markdown
### Cameras
- living-room → Main area, 180° wide angle
- front-door → Entrance, motion-triggered
### SSH
- home-server → 192.168.1.100, user: admin
### TTS
- Preferred voice: "Nova" (warm, slightly British)
- Default speaker: Kitchen HomePod
```
## Why Separate?
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
---
Add whatever helps you do your job. This is your cheat sheet.
## Web Routing Preset (Auto)
- Site registry file: `/root/.openclaw/workspace/websites.yaml`
- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`.
- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`.
- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping.
- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow.
- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`).
- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback.
- If task does not need login, use `public-search` or direct public site access.
- If OTP/CAPTCHA appears, pause and ask user in chat, then continue.
- Never operate outside `allowed_domains`.
## Product Info Helper
- Command: `product-info "<sku或关键词>" --days 30 --limit 20`
- Returns:
1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)
2) Last N days shipped/aftersale counts
3) Last N days shop distribution by shipped piece count
4) Last N days daily shipped pieces by SKU
5) Stock risk estimation (`available_qty`, `avg_daily_shipped_30d`, `estimated_stockout_days`, `suggest_replenish_qty_to_10d`)
6) Last 30d sold product-id counts by shop channel
7) Product-id to shop/operator mapping (for notification)
8) Risk-only contact list (`purchaser_name`, `factory_name`, `warehouse_name`, `operator_name`)
- Data source: SQLServer `JSDataMiddlePlatform`
- Command: `product-skus-by-id "<商品id>" --limit 1000`
- Returns all SKU codes and SKU names under the given product ID (style_code/product_no/spu/shopproid).
- `product-info` now also returns aftersale insight for matched SKU:
- aftersale summary, type/result matrix, reason TOP (last N days)
- Command: `stock-risk-alerts --days 30 --limit 500`
- Purpose: Query all selling SKUs with potential stockout warning details.
- Output:
1) summary counters (`sold_sku_count_30d`, `stock_risk_sku_count`, `urgent_sku_count`, `warning_sku_count`)
2) detailed risk list with `sku_id`, `estimated_stockout_days`, `suggest_replenish_qty_to_lead_days`, `purchaser_name`, `factory_name`, `warehouse_name`, `operator_name`
- Command: `stock-risk-to-feishu --days 30 --top 100`
- Purpose: Push stock risk report to Feishu in structured rows (not one-cell long text).
- Output:
- summary record + detail records, with batch id and Feishu link
- columns include `SKU编码/SKU名称/风险等级/预计缺货天数/建议补货件数/采购/运营/工厂/仓库/渠道/店铺`
## Customer Service Experience Helper
- Command: `customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
- Purpose: Analyze customer feedback experience + service wording quality in a date range.
- Output: overall quality, reason structure, shop comparison score, wording coverage, risk phrase hits, daily trend, dialogue cases, capability score by agent name (S/A/B/C/D).
- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.

Binary file not shown.

650
bot.bridge.live.py Executable file
View File

@ -0,0 +1,650 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import logging
import os
import re
import subprocess
import time
import urllib.parse
from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage
from dingtalk_stream import Credential, DingTalkStreamClient
MARKET_HINT_KEYWORDS = (
"市场分析",
"市场数据分析",
"行业分析",
"类目分析",
"竞品分析",
"趋势分析",
)
SALES_HINT_KEYWORDS = (
"销售数据",
"销售报表",
"销售看板",
"公司销售",
"业绩报表",
"业绩数据",
"费比监控",
)
SENSITIVE_SALES_KEYWORDS = (
"销售",
"业绩",
"售后",
"退款",
"店铺",
"发货",
"工单",
"费比",
)
EXPLICIT_AMOUNT_KEYWORDS = (
"金额",
"费用",
"收入",
"利润",
"成本",
"客单价",
"gmv",
"销售额",
"退款额",
"毛利",
)
URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE)
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b")
DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE)
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE)
def bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
class OpenClawDingTalkBot(ChatbotHandler):
def __init__(self, logger: logging.Logger):
super().__init__()
self.logger = logger
self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600"))
self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180"))
self.prefix = os.getenv("BOT_PREFIX", "/oc").strip()
self.require_prefix = bool_env("REQUIRE_PREFIX", False)
self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500"))
self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk")
self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True)
self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True)
self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300"))
self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120"))
self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions")
self.unmask_url_hosts = {
h.strip().lower()
for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",")
if h.strip()
}
self._recent_event_ts = {}
self.processing_hint_text = os.getenv(
"PROCESSING_HINT_TEXT",
"已收到,正在处理你的需求,请稍等1-3分钟。",
).strip()
self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower()
self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip()
self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip()
self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip()
self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip()
self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip()
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
if text.startswith(self.prefix):
return True
if msg.is_in_at_list:
return True
return not self.require_prefix
def _normalize_prompt(self, text: str) -> str:
text = text.strip()
if self.prefix and text.startswith(self.prefix):
text = text[len(self.prefix):].strip()
text = re.sub(r"^@\S+\s*", "", text).strip()
return text
def _session_id(self, msg: ChatbotMessage) -> str:
conversation_id = (msg.conversation_id or "default").replace(" ", "_")
base_session = f"{self.session_prefix}-{conversation_id}"
session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl")
if self.session_max_lines > 0 and os.path.exists(session_file):
try:
with open(session_file, "rb") as fh:
line_count = sum(1 for _ in fh)
if line_count >= self.session_max_lines:
rotate_suffix = time.strftime("%Y%m%d%H%M")
rotated = f"{base_session}-{rotate_suffix}"
self.logger.info(
"session auto-rotated: base=%s lines=%s rotated=%s",
base_session,
line_count,
rotated,
)
return rotated
except Exception:
self.logger.exception("failed to inspect session file: %s", session_file)
return base_session
def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str:
if getattr(msg, "message_id", None):
return f"msg:{msg.message_id}"
data = callback.data if isinstance(callback.data, dict) else {}
for key in ("msgId", "messageId", "eventId", "processQueryKey"):
value = data.get(key)
if value:
return f"data:{key}:{value}"
headers = getattr(callback, "headers", {}) or {}
for key in ("messageId", "eventId", "processQueryKey"):
value = headers.get(key)
if value:
return f"hdr:{key}:{value}"
raw = "|".join(
[
str(getattr(msg, "conversation_id", "") or ""),
str(getattr(msg, "sender_id", "") or ""),
str(getattr(msg, "sender_staff_id", "") or ""),
str(getattr(msg, "create_at", "") or ""),
prompt[:200],
]
)
return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _seen_or_mark_event(self, key: str) -> bool:
now = time.time()
expire_before = now - max(self.dedupe_ttl_seconds, 30)
stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before]
for stale_key in stale_keys:
self._recent_event_ts.pop(stale_key, None)
if key in self._recent_event_ts:
return True
self._recent_event_ts[key] = now
return False
def _is_market_prompt(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt)
if any(k in normalized for k in SALES_HINT_KEYWORDS):
return False
if "电脑桌" in normalized:
return True
if any(k in normalized for k in MARKET_HINT_KEYWORDS):
return True
if "市场" in normalized and "分析" in normalized:
return True
return False
def _is_sales_prompt(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt).lower()
return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS)
def _explicit_amount_requested(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt).lower()
return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS)
def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str:
if not self._is_sales_prompt(prompt):
return answer
if self._explicit_amount_requested(prompt):
return answer
filtered_lines = []
removed = 0
for line in answer.splitlines():
if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE):
removed += 1
continue
filtered_lines.append(line)
cleaned = "\n".join(filtered_lines).strip()
if removed > 0:
note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)"
if cleaned:
cleaned = f"{cleaned}\n\n{note}"
else:
cleaned = note
return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。"
def _extract_market_subject(self, prompt: str) -> str:
normalized = prompt.strip()
def clean_subject(raw: str) -> str:
v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'")
v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v)
v = re.sub(r"(的|情况|数据|分析)+$", "", v)
return v.strip()
def pick(raw: str):
v = clean_subject(raw)
if len(v) < 2:
return None
if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}:
return None
return v
if "电脑桌" in normalized:
return "电脑桌"
m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(
r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)",
normalized,
)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(
r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)",
normalized,
)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
if m:
v = pick(m.group(1))
if v:
return v
return "电脑桌"
def _sanitize_answer(self, answer: str) -> str:
if not self.hide_source_urls:
return answer
keep_map = {}
def _keep_or_mask_url(match):
raw = match.group(0)
try:
host = (urllib.parse.urlparse(raw).hostname or "").lower()
except Exception:
host = ""
if host and (
host in self.unmask_url_hosts
or host == "feishu.cn"
or host.endswith(".feishu.cn")
):
token = f"__KEEP_URL_{len(keep_map)}__"
keep_map[token] = raw
return token
return "[内部数据源地址已脱敏]"
out = URL_RE.sub(_keep_or_mask_url, answer)
out = IP_RE.sub("[内部地址已脱敏]", out)
out = DOMAIN_RE.sub("[内部域名已脱敏]", out)
# 保底脱敏(防止某些格式未被正则覆盖)
for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"):
out = out.replace(token, "[内部数据源已脱敏]")
for token, raw in keep_map.items():
out = out.replace(token, raw)
return out
def _run_market_brief(self, subject: str) -> str:
cmd = [self.market_brief_bin, subject]
self.logger.info("market-brief call: subject=%s", subject)
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.market_brief_timeout,
)
output = (proc.stdout or "").strip()
if proc.returncode != 0:
err = (proc.stderr or output or "market brief error").strip()
return f"市场分析执行失败: {err[:500]}"
if not output:
return "市场分析执行完成,但未返回文本结果。"
return output
def _run_openclaw(self, session_id: str, prompt: str) -> str:
cmd = [
self.openclaw_bin,
"agent",
"--session-id",
session_id,
"--message",
prompt,
"--json",
"--timeout",
str(self.openclaw_timeout),
]
self.logger.info("openclaw call: session=%s", session_id)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.openclaw_timeout + 20,
)
except subprocess.TimeoutExpired:
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw error").strip()
return f"OpenClaw 调用失败: {err[:500]}"
try:
raw_stdout = (proc.stdout or "").strip()
decoder = json.JSONDecoder()
data = None
parse_error = None
try:
data, _ = decoder.raw_decode(raw_stdout)
except Exception as exc:
parse_error = exc
idx = raw_stdout.find("{")
while idx != -1:
candidate = raw_stdout[idx:]
try:
data, _ = decoder.raw_decode(candidate)
break
except Exception:
idx = raw_stdout.find("{", idx + 1)
if data is None:
raise parse_error
payloads = data.get("result", {}).get("payloads", [])
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
answer = "\n".join(t for t in text_items if t).strip()
if not answer:
answer = "任务已执行,但未返回文本结果。"
if self._is_market_prompt(prompt) and (
"无法直接从页面抓取" in answer
or "需要登录后的操作" in answer
or "工具版本不支持点击交互" in answer
):
subject = self._extract_market_subject(prompt)
fallback = self._run_market_brief(subject)
return f"已切换为市场分析直连模式({subject}):\n\n{fallback}"
return answer
except Exception as exc:
self.logger.exception("parse openclaw output failed")
raw = (proc.stdout or "").strip()
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
def _is_image_url(self, raw_url: str) -> bool:
try:
parsed = urllib.parse.urlparse(raw_url)
except Exception:
return False
host = (parsed.hostname or "").lower()
path = (parsed.path or "").lower()
if IMAGE_EXT_RE.search(path):
return True
if host.startswith("img.") or "image" in host:
return True
return False
def _extract_image_urls(self, text: str, limit: int = 5) -> list:
urls = []
seen = set()
for match in URL_RE.finditer(text or ""):
raw = match.group(0).rstrip(".,;!?")
if not self._is_image_url(raw):
continue
if raw in seen:
continue
seen.add(raw)
urls.append(raw)
if len(urls) >= limit:
break
return urls
def _remove_image_urls(self, text: str, urls: list) -> str:
out = text
for u in urls:
out = out.replace(u, "")
out = re.sub(r"\n{3,}", "\n\n", out)
return out.strip()
def _build_action_buttons(self) -> list:
if not self.action_card_btn_url:
return []
return [
{
"text": self.action_card_btn_text or "打开链接",
"url": self.action_card_btn_url,
"iosUrl": self.action_card_btn_url,
"color": "gray",
}
]
def _is_card_reply_ok(self, reply_obj) -> bool:
if reply_obj is None:
return False
card_instance_id = getattr(reply_obj, "card_instance_id", None)
if isinstance(card_instance_id, str):
return bool(card_instance_id.strip())
return bool(card_instance_id)
def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool:
payload = (text or "").strip() or "任务已执行,但未返回文本结果。"
if prefer_card and self.reply_style == "action_card":
image_urls = self._extract_image_urls(payload, limit=5)
if image_urls:
slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)]
markdown = self._remove_image_urls(payload, image_urls)
if not markdown:
markdown = "已整理相关图片如下:"
try:
card = self.reply_carousel_card(
incoming_message=msg,
markdown=markdown,
image_slider=slider,
button_text=self.action_card_btn_text or "查看",
title=self.action_card_title,
logo=self.action_card_logo,
)
if self._is_card_reply_ok(card):
return True
self.logger.warning("carousel card reply returned empty id, fallback to action card/text")
except Exception:
self.logger.exception("carousel card reply failed, fallback to action card")
markdown = payload
if self.action_card_title:
markdown = f"### {self.action_card_title}\n\n{payload}"
try:
card = self.reply_markdown_button(
incoming_message=msg,
markdown=markdown,
button_list=self._build_action_buttons(),
tips=self.action_card_tips,
title=self.action_card_title,
logo=self.action_card_logo,
)
if self._is_card_reply_ok(card):
return True
self.logger.warning("action card reply returned empty id, fallback to text")
except Exception:
self.logger.exception("action card reply failed, fallback to text")
resp = self.reply_text(payload, msg)
if resp is None:
self.logger.error("text reply failed")
return False
return True
async def process(self, callback: CallbackMessage):
try:
msg = ChatbotMessage.from_dict(callback.data)
except Exception:
self.logger.exception("invalid callback payload")
return AckMessage.STATUS_BAD_REQUEST, "bad payload"
text = ""
if msg.text and getattr(msg.text, "content", None):
text = msg.text.content.strip()
else:
text_list = msg.get_text_list() or []
text = text_list[0].strip() if text_list else ""
if not text:
return AckMessage.STATUS_OK, "ignored"
if not self._should_handle(msg, text):
return AckMessage.STATUS_OK, "ignored"
prompt = self._normalize_prompt(text)
if not prompt:
self._reply_message(
msg,
f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩",
prefer_card=False,
)
return AckMessage.STATUS_OK, "ok"
event_key = self._event_key(callback, msg, prompt)
if self._seen_or_mark_event(event_key):
self.logger.info(
"duplicate callback ignored: key=%s conversation=%s sender=%s",
event_key,
getattr(msg, "conversation_id", ""),
getattr(msg, "sender_id", ""),
)
return AckMessage.STATUS_OK, "duplicate_ignored"
if self.send_processing_hint:
hint = self.processing_hint_text or "已收到,正在处理,请稍等。"
try:
self.reply_text(hint, msg)
except Exception:
self.logger.exception("failed to send processing hint")
session_id = self._session_id(msg)
if self._is_market_prompt(prompt):
subject = self._extract_market_subject(prompt)
answer = self._run_market_brief(subject)
else:
answer = self._run_openclaw(session_id, prompt)
answer = self._sanitize_answer(answer)
answer = self._mask_sensitive_amounts(prompt, answer)
if len(answer) > self.reply_max_chars:
answer = answer[: self.reply_max_chars] + "\n...(已截断)"
try:
sent = self._reply_message(msg, answer, prefer_card=True)
if sent:
self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer))
else:
self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer))
except Exception:
self.logger.exception("failed to send final reply: session=%s", session_id)
return AckMessage.STATUS_OK, "ok"
def build_client(logger: logging.Logger) -> DingTalkStreamClient:
client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip()
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip()
if not client_id or not client_secret:
raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置")
credential = Credential(client_id, client_secret)
client = DingTalkStreamClient(credential, logger=logger)
bot = OpenClawDingTalkBot(logger)
client.register_callback_handler(ChatbotMessage.TOPIC, bot)
if bool_env("REGISTER_DELEGATE_TOPIC", False):
client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot)
logger.info("delegate topic enabled")
else:
logger.info("delegate topic disabled (default)")
return client
def self_check(logger: logging.Logger) -> int:
required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
missing = [k for k in required if not os.getenv(k)]
if missing:
logger.error("missing env: %s", ", ".join(missing))
return 2
openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True)
if proc.returncode != 0:
logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip())
return 3
market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
if not os.path.exists(market_brief_bin):
logger.error("market brief bin not found: %s", market_brief_bin)
return 4
logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip())
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge")
parser.add_argument("--check", action="store_true", help="run checks and exit")
args = parser.parse_args()
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("openclaw-dingtalk-bridge")
if args.check:
return self_check(logger)
client = build_client(logger)
logger.info("bridge started, listening DingTalk stream")
client.start_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

627
bot.py.restore Executable file
View File

@ -0,0 +1,627 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import logging
import os
import re
import subprocess
import time
import urllib.parse
from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage
from dingtalk_stream import Credential, DingTalkStreamClient
MARKET_HINT_KEYWORDS = (
"市场分析",
"市场数据分析",
"行业分析",
"类目分析",
"竞品分析",
"趋势分析",
)
SALES_HINT_KEYWORDS = (
"销售数据",
"销售报表",
"销售看板",
"公司销售",
"业绩报表",
"业绩数据",
"费比监控",
)
SENSITIVE_SALES_KEYWORDS = (
"销售",
"业绩",
"售后",
"退款",
"店铺",
"发货",
"工单",
"费比",
)
EXPLICIT_AMOUNT_KEYWORDS = (
"金额",
"费用",
"收入",
"利润",
"成本",
"客单价",
"gmv",
"销售额",
"退款额",
"毛利",
)
URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE)
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b")
DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE)
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE)
def bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
class OpenClawDingTalkBot(ChatbotHandler):
def __init__(self, logger: logging.Logger):
super().__init__()
self.logger = logger
self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600"))
self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180"))
self.prefix = os.getenv("BOT_PREFIX", "/oc").strip()
self.require_prefix = bool_env("REQUIRE_PREFIX", False)
self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500"))
self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk")
self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True)
self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True)
self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300"))
self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120"))
self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions")
self.unmask_url_hosts = {
h.strip().lower()
for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",")
if h.strip()
}
self._recent_event_ts = {}
self.processing_hint_text = os.getenv(
"PROCESSING_HINT_TEXT",
"已收到,正在处理你的需求,请稍等1-3分钟。",
).strip()
self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower()
self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip()
self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip()
self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip()
self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip()
self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip()
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
if text.startswith(self.prefix):
return True
if msg.is_in_at_list:
return True
return not self.require_prefix
def _normalize_prompt(self, text: str) -> str:
text = text.strip()
if self.prefix and text.startswith(self.prefix):
text = text[len(self.prefix):].strip()
text = re.sub(r"^@\S+\s*", "", text).strip()
return text
def _session_id(self, msg: ChatbotMessage) -> str:
conversation_id = (msg.conversation_id or "default").replace(" ", "_")
base_session = f"{self.session_prefix}-{conversation_id}"
session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl")
if self.session_max_lines > 0 and os.path.exists(session_file):
try:
with open(session_file, "rb") as fh:
line_count = sum(1 for _ in fh)
if line_count >= self.session_max_lines:
rotate_suffix = time.strftime("%Y%m%d%H%M")
rotated = f"{base_session}-{rotate_suffix}"
self.logger.info(
"session auto-rotated: base=%s lines=%s rotated=%s",
base_session,
line_count,
rotated,
)
return rotated
except Exception:
self.logger.exception("failed to inspect session file: %s", session_file)
return base_session
def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str:
if getattr(msg, "message_id", None):
return f"msg:{msg.message_id}"
data = callback.data if isinstance(callback.data, dict) else {}
for key in ("msgId", "messageId", "eventId", "processQueryKey"):
value = data.get(key)
if value:
return f"data:{key}:{value}"
headers = getattr(callback, "headers", {}) or {}
for key in ("messageId", "eventId", "processQueryKey"):
value = headers.get(key)
if value:
return f"hdr:{key}:{value}"
raw = "|".join(
[
str(getattr(msg, "conversation_id", "") or ""),
str(getattr(msg, "sender_id", "") or ""),
str(getattr(msg, "sender_staff_id", "") or ""),
str(getattr(msg, "create_at", "") or ""),
prompt[:200],
]
)
return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _seen_or_mark_event(self, key: str) -> bool:
now = time.time()
expire_before = now - max(self.dedupe_ttl_seconds, 30)
stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before]
for stale_key in stale_keys:
self._recent_event_ts.pop(stale_key, None)
if key in self._recent_event_ts:
return True
self._recent_event_ts[key] = now
return False
def _is_market_prompt(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt)
if any(k in normalized for k in SALES_HINT_KEYWORDS):
return False
if "电脑桌" in normalized:
return True
if any(k in normalized for k in MARKET_HINT_KEYWORDS):
return True
if "市场" in normalized and "分析" in normalized:
return True
return False
def _is_sales_prompt(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt).lower()
return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS)
def _explicit_amount_requested(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt).lower()
return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS)
def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str:
if not self._is_sales_prompt(prompt):
return answer
if self._explicit_amount_requested(prompt):
return answer
filtered_lines = []
removed = 0
for line in answer.splitlines():
if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE):
removed += 1
continue
filtered_lines.append(line)
cleaned = "\n".join(filtered_lines).strip()
if removed > 0:
note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)"
if cleaned:
cleaned = f"{cleaned}\n\n{note}"
else:
cleaned = note
return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。"
def _extract_market_subject(self, prompt: str) -> str:
normalized = prompt.strip()
def clean_subject(raw: str) -> str:
v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'")
v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v)
v = re.sub(r"(的|情况|数据|分析)+$", "", v)
return v.strip()
def pick(raw: str):
v = clean_subject(raw)
if len(v) < 2:
return None
if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}:
return None
return v
if "电脑桌" in normalized:
return "电脑桌"
m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(
r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)",
normalized,
)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(
r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)",
normalized,
)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
if m:
v = pick(m.group(1))
if v:
return v
return "电脑桌"
def _sanitize_answer(self, answer: str) -> str:
if not self.hide_source_urls:
return answer
keep_map = {}
def _keep_or_mask_url(match):
raw = match.group(0)
try:
host = (urllib.parse.urlparse(raw).hostname or "").lower()
except Exception:
host = ""
if host and host in self.unmask_url_hosts:
token = f"__KEEP_URL_{len(keep_map)}__"
keep_map[token] = raw
return token
return "[内部数据源地址已脱敏]"
out = URL_RE.sub(_keep_or_mask_url, answer)
out = IP_RE.sub("[内部地址已脱敏]", out)
out = DOMAIN_RE.sub("[内部域名已脱敏]", out)
# 保底脱敏(防止某些格式未被正则覆盖)
for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"):
out = out.replace(token, "[内部数据源已脱敏]")
for token, raw in keep_map.items():
out = out.replace(token, raw)
return out
def _run_market_brief(self, subject: str) -> str:
cmd = [self.market_brief_bin, subject]
self.logger.info("market-brief call: subject=%s", subject)
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.market_brief_timeout,
)
output = (proc.stdout or "").strip()
if proc.returncode != 0:
err = (proc.stderr or output or "market brief error").strip()
return f"市场分析执行失败: {err[:500]}"
if not output:
return "市场分析执行完成,但未返回文本结果。"
return output
def _run_openclaw(self, session_id: str, prompt: str) -> str:
cmd = [
self.openclaw_bin,
"agent",
"--session-id",
session_id,
"--message",
prompt,
"--json",
"--timeout",
str(self.openclaw_timeout),
]
self.logger.info("openclaw call: session=%s", session_id)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.openclaw_timeout + 20,
)
except subprocess.TimeoutExpired:
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw error").strip()
return f"OpenClaw 调用失败: {err[:500]}"
try:
raw_stdout = (proc.stdout or "").strip()
decoder = json.JSONDecoder()
data = None
parse_error = None
try:
data, _ = decoder.raw_decode(raw_stdout)
except Exception as exc:
parse_error = exc
idx = raw_stdout.find("{")
while idx != -1:
candidate = raw_stdout[idx:]
try:
data, _ = decoder.raw_decode(candidate)
break
except Exception:
idx = raw_stdout.find("{", idx + 1)
if data is None:
raise parse_error
payloads = data.get("result", {}).get("payloads", [])
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
answer = "\n".join(t for t in text_items if t).strip()
if not answer:
answer = "任务已执行,但未返回文本结果。"
if self._is_market_prompt(prompt) and (
"无法直接从页面抓取" in answer
or "需要登录后的操作" in answer
or "工具版本不支持点击交互" in answer
):
subject = self._extract_market_subject(prompt)
fallback = self._run_market_brief(subject)
return f"已切换为市场分析直连模式({subject}):\n\n{fallback}"
return answer
except Exception as exc:
self.logger.exception("parse openclaw output failed")
raw = (proc.stdout or "").strip()
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
def _is_image_url(self, raw_url: str) -> bool:
try:
parsed = urllib.parse.urlparse(raw_url)
except Exception:
return False
host = (parsed.hostname or "").lower()
path = (parsed.path or "").lower()
if IMAGE_EXT_RE.search(path):
return True
if host.startswith("img.") or "image" in host:
return True
return False
def _extract_image_urls(self, text: str, limit: int = 5) -> list:
urls = []
seen = set()
for match in URL_RE.finditer(text or ""):
raw = match.group(0).rstrip(".,;!?")
if not self._is_image_url(raw):
continue
if raw in seen:
continue
seen.add(raw)
urls.append(raw)
if len(urls) >= limit:
break
return urls
def _remove_image_urls(self, text: str, urls: list) -> str:
out = text
for u in urls:
out = out.replace(u, "")
out = re.sub(r"\n{3,}", "\n\n", out)
return out.strip()
def _build_action_buttons(self) -> list:
if not self.action_card_btn_url:
return []
return [
{
"text": self.action_card_btn_text or "打开链接",
"url": self.action_card_btn_url,
"iosUrl": self.action_card_btn_url,
"color": "gray",
}
]
def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True):
payload = (text or "").strip() or "任务已执行,但未返回文本结果。"
if prefer_card and self.reply_style == "action_card":
image_urls = self._extract_image_urls(payload, limit=5)
if image_urls:
slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)]
markdown = self._remove_image_urls(payload, image_urls)
if not markdown:
markdown = "已整理相关图片如下:"
try:
self.reply_carousel_card(
incoming_message=msg,
markdown=markdown,
image_slider=slider,
button_text=self.action_card_btn_text or "查看",
title=self.action_card_title,
logo=self.action_card_logo,
)
return
except Exception:
self.logger.exception("carousel card reply failed, fallback to action card")
markdown = payload
if self.action_card_title:
markdown = f"### {self.action_card_title}\n\n{payload}"
try:
self.reply_markdown_button(
incoming_message=msg,
markdown=markdown,
button_list=self._build_action_buttons(),
tips=self.action_card_tips,
title=self.action_card_title,
logo=self.action_card_logo,
)
return
except Exception:
self.logger.exception("action card reply failed, fallback to text")
self.reply_text(payload, msg)
async def process(self, callback: CallbackMessage):
try:
msg = ChatbotMessage.from_dict(callback.data)
except Exception:
self.logger.exception("invalid callback payload")
return AckMessage.STATUS_BAD_REQUEST, "bad payload"
text = ""
if msg.text and getattr(msg.text, "content", None):
text = msg.text.content.strip()
else:
text_list = msg.get_text_list() or []
text = text_list[0].strip() if text_list else ""
if not text:
return AckMessage.STATUS_OK, "ignored"
if not self._should_handle(msg, text):
return AckMessage.STATUS_OK, "ignored"
prompt = self._normalize_prompt(text)
if not prompt:
self._reply_message(
msg,
f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩",
prefer_card=False,
)
return AckMessage.STATUS_OK, "ok"
event_key = self._event_key(callback, msg, prompt)
if self._seen_or_mark_event(event_key):
self.logger.info(
"duplicate callback ignored: key=%s conversation=%s sender=%s",
event_key,
getattr(msg, "conversation_id", ""),
getattr(msg, "sender_id", ""),
)
return AckMessage.STATUS_OK, "duplicate_ignored"
if self.send_processing_hint:
hint = self.processing_hint_text or "已收到,正在处理,请稍等。"
try:
self.reply_text(hint, msg)
except Exception:
self.logger.exception("failed to send processing hint")
session_id = self._session_id(msg)
if self._is_market_prompt(prompt):
subject = self._extract_market_subject(prompt)
answer = self._run_market_brief(subject)
else:
answer = self._run_openclaw(session_id, prompt)
answer = self._sanitize_answer(answer)
answer = self._mask_sensitive_amounts(prompt, answer)
if len(answer) > self.reply_max_chars:
answer = answer[: self.reply_max_chars] + "\n...(已截断)"
try:
self._reply_message(msg, answer, prefer_card=True)
self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer))
except Exception:
self.logger.exception("failed to send final reply: session=%s", session_id)
return AckMessage.STATUS_OK, "ok"
def build_client(logger: logging.Logger) -> DingTalkStreamClient:
client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip()
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip()
if not client_id or not client_secret:
raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置")
credential = Credential(client_id, client_secret)
client = DingTalkStreamClient(credential, logger=logger)
bot = OpenClawDingTalkBot(logger)
client.register_callback_handler(ChatbotMessage.TOPIC, bot)
if bool_env("REGISTER_DELEGATE_TOPIC", False):
client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot)
logger.info("delegate topic enabled")
else:
logger.info("delegate topic disabled (default)")
return client
def self_check(logger: logging.Logger) -> int:
required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
missing = [k for k in required if not os.getenv(k)]
if missing:
logger.error("missing env: %s", ", ".join(missing))
return 2
openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True)
if proc.returncode != 0:
logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip())
return 3
market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
if not os.path.exists(market_brief_bin):
logger.error("market brief bin not found: %s", market_brief_bin)
return 4
logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip())
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge")
parser.add_argument("--check", action="store_true", help="run checks and exit")
args = parser.parse_args()
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("openclaw-dingtalk-bridge")
if args.check:
return self_check(logger)
client = build_client(logger)
logger.info("bridge started, listening DingTalk stream")
client.start_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,26 @@
from pathlib import Path
p = Path('/root/.openclaw/workspace/AGENTS.md')
text = p.read_text(encoding='utf-8')
start = text.find('## 5) Feishu Delivery Rules\n')
ws = text.find('## 5) Workspace Paths\n')
if start == -1 or ws == -1 or ws < start:
raise SystemExit('section markers not found')
new_section = '''## 5) Feishu Delivery Rules
Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接
Required behavior:
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
- In final reply, always include:
1) push status (success/failed)
2) target sheet link (the user-provided wiki/base URL)
3) written record_id (or updated count)
- If write partially fails, return succeeded records and failed reason list separately.
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
'''
text = text[:start] + new_section + text[ws:]
text = text.replace('## 5) Workspace Paths\n', '## 6) Workspace Paths\n', 1)
p.write_text(text, encoding='utf-8')
print('fixed', p)

View File

@ -0,0 +1,51 @@
from pathlib import Path
p=Path('/root/.openclaw/workspace/websites.yaml')
lines=p.read_text(encoding='utf-8').splitlines()
# Ensure hard route keywords contain inventory terms
hard_start=None
hard_end=None
for i,l in enumerate(lines):
if l.strip()=='- when_keywords:' and i>0 and 'hard_route_rules:' in lines[i-1]:
# first rule, skip
pass
for i,l in enumerate(lines):
if l.strip()=='- when_keywords:' and i+1 < len(lines) and lines[i+1].strip()=='- 销售数据':
hard_start=i
# find force_site_id line
for j in range(i+1,len(lines)):
if 'force_site_id: company-sales-db-analysis' in lines[j]:
hard_end=j
break
break
if hard_start is not None and hard_end is not None:
existing=set(l.strip().lstrip('-').strip() for l in lines[hard_start:hard_end] if l.strip().startswith('- '))
need=['库存','缺货','补货','安全库存']
insert_pos=hard_end
for kw in need:
if kw not in existing:
lines.insert(insert_pos, ' - '+kw)
insert_pos+=1
# Ensure site keywords contain inventory terms
site_kw_start=None
site_kw_end=None
for i,l in enumerate(lines):
if l.strip()=='keywords:' and i>0 and lines[i-1].strip()=='- 物流与安装协同分析':
site_kw_start=i
for j in range(i+1,len(lines)):
if lines[j].strip()=='login_required: false':
site_kw_end=j
break
break
if site_kw_start is not None and site_kw_end is not None:
existing=set(l.strip().lstrip('-').strip() for l in lines[site_kw_start:site_kw_end] if l.strip().startswith('- '))
need=['库存','缺货','补货','安全库存']
insert_pos=site_kw_end
for kw in need:
if kw not in existing:
lines.insert(insert_pos, ' - '+kw)
insert_pos+=1
p.write_text('\n'.join(lines)+'\n',encoding='utf-8')
print('updated',p)

View File

@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBpDCCAUmgAwIBAgIQANEkK1m3v+0fBES3/FsNCzAKBggqhkjOPQQDAjAwMS4w
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X
DTI2MDIwODAyMTExMloXDTM1MTIxODAyMTExMlowMDEuMCwGA1UEAxMlQ2FkZHkg
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
SM49AwEHA0IABA6AXOtkhlMavSBG9ZKAhVl6cqcjYfdSGZRNZweHSTGaWFUyp+2z
kcYEXLNFPL7M8TpDfSBOPKM1P4vIdfBjJhKjRTBDMA4GA1UdDwEB/wQEAwIBBjAS
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSaSmQ0hDEj0WAnSVCtZ80wsWii
WDAKBggqhkjOPQQDAgNJADBGAiEAyv1iBqcTKsMWTPYOjbGkeyYXCLIlIbEWznSx
VRKhL6gCIQDVW8oVdtANrvqtwpJNnVvOeXO43JeOT9+Qx56z2UZK6A==
-----END CERTIFICATE-----

893
openclaw-log-observer Executable file
View File

@ -0,0 +1,893 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Dict, List, Optional, Tuple
DEFAULT_APP_TOKEN = "GC2Mb4TXwad8ZusStBecLNg8nCe"
DEFAULT_TABLE_LINK = "https://acnuoqgha8fk.feishu.cn/base/GC2Mb4TXwad8ZusStBecLNg8nCe"
OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
REPORT_DIR = Path("/var/log/openclaw/log-observer")
REQUIRED_FIELDS = [
"记录类型",
"批次ID",
"巡检时间",
"日志范围(分钟)",
"日志总行数",
"OpenClaw调用次数",
"成功回复次数",
"未完成会话数",
"错误总数",
"网络错误数",
"解析错误数",
"重复回调数",
"问题类型",
"会话ID",
"问题摘要",
"建议动作",
"自动回放状态",
"自动回放会话",
"自动回放摘要",
"状态",
]
ISSUE_PATTERNS = {
"openclaw_call_failed": re.compile(r"OpenClaw 调用失败|openclaw call timeout|OpenClaw 调用超时", re.I),
"parse_failed": re.compile(r"结果解析失败|parse openclaw output failed", re.I),
"network_error": re.compile(r"network exception|keepalive ping timeout|timed out during opening handshake", re.I),
"reply_failed": re.compile(r"reply failed: session=|failed to send final reply", re.I),
"traceback": re.compile(r"Traceback \(most recent call last\)", re.I),
}
DIALOG_PATTERNS = {
"dialog_tool_noise": re.compile(
r"NO TOOLBeenCalled|without a preceding text-to-speech|tts\s*Completed|结果解析失败|原始输出:",
re.I,
),
"dialog_blocked_response": re.compile(
r"权限不足|未登录|无法直接从页面抓取|需要登录后的操作|工具版本不支持点击交互|device identity required|网关令牌缺失|需要配对",
re.I,
),
"dialog_meta_leak": re.compile(r"\[plugins\]|Registered\s+\w+\s+tool|runId\"\s*:\s*\"", re.I),
}
CALL_RE = re.compile(r"openclaw call: session=([^\s]+)")
REPLY_RE = re.compile(r"reply sent: session=([^\s]+)")
DUP_RE = re.compile(r"duplicate callback ignored", re.I)
TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+")
SESSION_RE = re.compile(r"session=([^\s,]+)")
def _run(cmd: List[str], timeout: int = 120) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict:
data = None
req_headers = dict(headers)
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json; charset=utf-8"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e
except Exception as e:
raise RuntimeError(f"request failed {method} {url}: {e}") from e
def _load_feishu_app() -> Tuple[str, str]:
app_id = os.getenv("FEISHU_APP_ID", "").strip()
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
if app_id and app_secret:
return app_id, app_secret
cfg_path = Path(OPENCLAW_CONFIG)
if not cfg_path.exists():
raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)")
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
ch = (cfg.get("channels") or {}).get("feishu") or {}
app_id = (ch.get("appId") or "").strip()
app_secret = (ch.get("appSecret") or "").strip()
if not app_id or not app_secret:
raise RuntimeError("feishu appId/appSecret is empty in openclaw.json")
return app_id, app_secret
def _tenant_token(app_id: str, app_secret: str) -> str:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
res = _http_json("POST", url, headers={}, payload={"app_id": app_id, "app_secret": app_secret})
if res.get("code") != 0:
raise RuntimeError(f"feishu auth failed: {res}")
token = res.get("tenant_access_token")
if not token:
raise RuntimeError("feishu auth succeeded but tenant_access_token missing")
return token
def _create_table(token: str, app_token: str, table_name: str) -> str:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
res = _http_json(
"POST",
url,
headers={"Authorization": f"Bearer {token}"},
payload={"table": {"name": table_name[:100]}},
)
if res.get("code") != 0:
raise RuntimeError(f"create table failed ({table_name}): {res}")
data = res.get("data") or {}
table_id = data.get("table_id") or (data.get("table") or {}).get("table_id") or ""
if not table_id:
raise RuntimeError(f"create table succeeded but table_id missing: {res}")
return table_id
def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields?page_size=500"
res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"})
if res.get("code") != 0:
raise RuntimeError(f"list fields failed: {res}")
out: Dict[str, str] = {}
for it in ((res.get("data") or {}).get("items") or []):
name = it.get("field_name")
fid = it.get("field_id")
if name and fid:
out[name] = fid
return out
def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
res = _http_json(
"POST",
url,
headers={"Authorization": f"Bearer {token}"},
payload={"field_name": field_name, "type": 1},
)
if res.get("code") != 0:
raise RuntimeError(f"create field failed ({field_name}): {res}")
def _ensure_fields(token: str, app_token: str, table_id: str) -> None:
fields = _list_fields(token, app_token, table_id)
for name in REQUIRED_FIELDS:
if name not in fields:
_create_field(token, app_token, table_id, name)
time.sleep(0.3)
def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> None:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
res = _http_json(
"POST",
url,
headers={"Authorization": f"Bearer {token}"},
payload={"fields": fields},
)
if res.get("code") != 0:
raise RuntimeError(f"create record failed: {res}")
def _build_table_link(base_link: str, app_token: str, table_id: str) -> str:
if not base_link:
return f"https://acnuoqgha8fk.feishu.cn/base/{app_token}?table={table_id}"
parsed = urllib.parse.urlsplit(base_link)
query_pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query_pairs = [(k, v) for (k, v) in query_pairs if k.lower() not in {"table", "view"}]
query_pairs.append(("table", table_id))
query = urllib.parse.urlencode(query_pairs)
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment))
def _extract_text_from_openclaw(stdout: str) -> str:
raw = (stdout or "").strip()
if not raw:
return ""
decoder = json.JSONDecoder()
data = None
idx = raw.find("{")
while idx != -1:
try:
data, _ = decoder.raw_decode(raw[idx:])
break
except Exception:
idx = raw.find("{", idx + 1)
if not isinstance(data, dict):
return ""
payloads = ((data.get("result") or {}).get("payloads") or [])
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
return "\n".join(t for t in text_items if t).strip()
def _collect_logs(service: str, since_minutes: int) -> List[str]:
cmd = [
"journalctl",
"-u",
service,
"--since",
f"{since_minutes} min ago",
"--no-pager",
"-o",
"short-iso",
]
proc = _run(cmd)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "journalctl failed").strip()
raise RuntimeError(f"collect logs failed: {err}")
return [line.rstrip("\n") for line in (proc.stdout or "").splitlines() if line.strip()]
def _extract_session(line: str) -> str:
for regex in (CALL_RE, REPLY_RE, SESSION_RE):
m = regex.search(line)
if m:
return m.group(1)
return ""
def _analyze_logs(lines: List[str]) -> Dict:
call_count = 0
reply_count = 0
duplicate_count = 0
issue_counts = {k: 0 for k in ISSUE_PATTERNS}
calls_by_session: Dict[str, int] = {}
replies_by_session: Dict[str, int] = {}
issues: List[Dict] = []
for line in lines:
m = CALL_RE.search(line)
if m:
call_count += 1
session = m.group(1)
calls_by_session[session] = calls_by_session.get(session, 0) + 1
m = REPLY_RE.search(line)
if m:
reply_count += 1
session = m.group(1)
replies_by_session[session] = replies_by_session.get(session, 0) + 1
if DUP_RE.search(line):
duplicate_count += 1
for issue_type, patt in ISSUE_PATTERNS.items():
if patt.search(line):
issue_counts[issue_type] += 1
ts_match = TS_RE.match(line)
issues.append(
{
"type": issue_type,
"timestamp": ts_match.group(1) if ts_match else "",
"line": line[-600:],
"session": _extract_session(line),
}
)
break
unresolved_sessions = []
for session, cnt in calls_by_session.items():
rc = replies_by_session.get(session, 0)
if cnt > rc:
unresolved_sessions.append({"session": session, "pending_calls": cnt - rc})
unresolved_sessions = sorted(unresolved_sessions, key=lambda x: (-x["pending_calls"], x["session"]))
return {
"line_count": len(lines),
"call_count": call_count,
"reply_count": reply_count,
"duplicate_count": duplicate_count,
"issue_counts": issue_counts,
"issues": issues,
"unresolved_sessions": unresolved_sessions,
}
def _collect_session_ids(lines: List[str]) -> List[str]:
seen = set()
out = []
for line in lines:
sid = _extract_session(line)
if sid and sid not in seen:
seen.add(sid)
out.append(sid)
return out
def _parse_iso_utc(value: str) -> Optional[dt.datetime]:
if not value:
return None
try:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
parsed = dt.datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt.timezone.utc)
return parsed.astimezone(dt.timezone.utc)
except Exception:
return None
def _extract_content_text(content) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") != "text":
continue
text = item.get("text")
if text:
parts.append(str(text))
return "\n".join(parts).strip()
return ""
def _normalize_text(text: str) -> str:
return re.sub(r"[\s,,。.!!??::~~`'\"\-_/\\]+", "", text or "").lower()
def _dialog_issue(
issue_type: str,
session_id: str,
timestamp: str,
user_text: str,
assistant_text: str,
) -> Dict:
user_short = (user_text or "").replace("\n", " ")[:120]
assistant_short = (assistant_text or "").replace("\n", " ")[:320]
line = f"user={user_short} | assistant={assistant_short}"
return {
"type": issue_type,
"timestamp": timestamp,
"line": line,
"session": session_id,
}
def _analyze_dialog_anomalies(lines: List[str], since_minutes: int) -> Dict:
session_ids = _collect_session_ids(lines)
cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=since_minutes)
issue_counts = {k: 0 for k in DIALOG_PATTERNS}
issue_counts["dialog_repeat_reply"] = 0
issue_counts["dialog_large_code_dump"] = 0
issues: List[Dict] = []
inspected = 0
for session_id in session_ids:
session_file = SESSIONS_DIR / f"{session_id}.jsonl"
if not session_file.exists() or not session_file.is_file():
continue
inspected += 1
last_user_text = ""
last_assistant_norm = ""
try:
with session_file.open("r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if obj.get("type") != "message":
continue
msg = obj.get("message") or {}
role = (msg.get("role") or "").lower()
content_text = _extract_content_text(msg.get("content"))
if not content_text:
continue
ts_raw = obj.get("timestamp") or ""
ts_dt = _parse_iso_utc(ts_raw)
if ts_dt and ts_dt < cutoff:
continue
ts_text = ts_raw or ""
if role == "user":
last_user_text = content_text
continue
if role != "assistant":
continue
normalized = _normalize_text(content_text)
for issue_type, patt in DIALOG_PATTERNS.items():
if patt.search(content_text):
issue_counts[issue_type] += 1
issues.append(_dialog_issue(issue_type, session_id, ts_text, last_user_text, content_text))
break
if normalized and last_assistant_norm and normalized == last_assistant_norm and len(normalized) >= 24:
issue_counts["dialog_repeat_reply"] += 1
issues.append(_dialog_issue("dialog_repeat_reply", session_id, ts_text, last_user_text, content_text))
last_assistant_norm = normalized
if "```" in content_text:
code_line_count = sum(1 for ln in content_text.splitlines() if ln.strip())
if code_line_count >= 24:
issue_counts["dialog_large_code_dump"] += 1
issues.append(_dialog_issue("dialog_large_code_dump", session_id, ts_text, last_user_text, content_text))
except Exception:
issues.append(
{
"type": "dialog_session_parse_failed",
"timestamp": dt.datetime.now().isoformat(),
"line": f"session file parse failed: {session_file}",
"session": session_id,
}
)
return {
"dialog_issue_counts": issue_counts,
"dialog_issues": issues,
"dialog_inspected_sessions": inspected,
}
def _last_user_prompt(session_id: str) -> str:
p = SESSIONS_DIR / f"{session_id}.jsonl"
if not p.exists() or p.stat().st_size == 0:
return ""
prompt = ""
try:
with p.open("r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if obj.get("type") != "message":
continue
message = obj.get("message") or {}
role = (message.get("role") or "").lower()
if role != "user":
continue
content = _extract_content_text(message.get("content"))
if content:
prompt = content
except Exception:
return ""
return prompt[:1200]
def _is_safe_readonly_prompt(prompt: str) -> bool:
if not prompt:
return False
lowered = prompt.lower()
dangerous = ["删除", "drop ", "truncate", "update ", "insert ", "修改", "推送", "发送", "写入", "支付", "转账"]
if any(k in lowered for k in dangerous):
return False
read_only = ["查询", "分析", "检查", "统计", "汇总", "搜索", "报表", "售后", "库存", "sku", "店铺"]
return any(k in lowered for k in read_only)
def _openclaw_replay(prompt: str, timeout: int = 240) -> Tuple[bool, str, str]:
replay_session = f"observer-replay-{int(time.time())}-{hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:6]}"
cmd = [
"/usr/bin/openclaw",
"agent",
"--session-id",
replay_session,
"--message",
prompt,
"--json",
"--timeout",
str(timeout),
]
proc = _run(cmd, timeout=timeout + 20)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw replay failed").strip()
return False, replay_session, err[:300]
text = _extract_text_from_openclaw(proc.stdout or "")
if not text:
text = (proc.stdout or "")[:300]
ok = "调用失败" not in text and "超时" not in text and "解析失败" not in text
summary = text.replace("\n", " ")[:260]
return ok, replay_session, summary
def _total_issue_count(analysis: Dict) -> int:
base = sum((analysis.get("issue_counts") or {}).values())
dialog = sum((analysis.get("dialog_issue_counts") or {}).values())
return base + dialog
def _openclaw_observer_comment(analysis: Dict, top_lines: List[str], timeout: int = 180) -> str:
payload = {
"summary": {
"line_count": analysis["line_count"],
"call_count": analysis["call_count"],
"reply_count": analysis["reply_count"],
"duplicate_count": analysis["duplicate_count"],
"issue_counts": analysis["issue_counts"],
"dialog_issue_counts": analysis.get("dialog_issue_counts", {}),
"dialog_inspected_sessions": analysis.get("dialog_inspected_sessions", 0),
"unresolved_sessions": analysis["unresolved_sessions"][:10],
},
"samples": top_lines[:20],
}
prompt = (
"你是日志巡检分身。根据以下 JSON 生成简短巡检结论,输出三段:"
"\n1) 主要问题(最多3条)"
"\n2) 对主机器人的修复建议(最多5条,必须可执行)"
"\n3) 优先级最高的一条修复动作"
"\n请重点覆盖对话返回异常(如无关输出、工具噪音、重复回复)。"
"\n请用中文、短句、不要废话。\n\n"
+ json.dumps(payload, ensure_ascii=False)
)
cmd = [
"/usr/bin/openclaw",
"agent",
"--session-id",
"log-observer-main",
"--message",
prompt,
"--json",
"--timeout",
str(timeout),
]
proc = _run(cmd, timeout=timeout + 20)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw observer failed").strip()
return f"分身分析失败: {err[:300]}"
text = _extract_text_from_openclaw(proc.stdout or "")
return text or "分身分析完成,但未返回文本。"
def _recommendation(issue_type: str) -> str:
mapping = {
"network_error": "检查网络抖动与 websocket keepalive,建议提高重连退避并增加心跳监控。",
"parse_failed": "统一 OpenClaw 输出解析入口,先剥离插件日志再解码 JSON。",
"openclaw_call_failed": "检查模型/API可用性与超时阈值,必要时降级到本地简答模式。",
"reply_failed": "检查 ActionCard 权限,失败时强制文本兜底避免无回复。",
"traceback": "排查异常栈并加防护,避免同类异常重复触发。",
"dialog_tool_noise": "在回复前增加输出清洗,拦截 tool 元信息/tts 噪音文本。",
"dialog_blocked_response": "对登录/权限失败加自动重试和明确下一步,不直接抛给用户。",
"dialog_meta_leak": "过滤插件注册/运行元数据,用户侧只保留结论。",
"dialog_repeat_reply": "对同会话相同答复加内容去重,避免重复发送。",
"dialog_large_code_dump": "非代码任务启用摘要模式,限制长代码块直接外发。",
"dialog_session_parse_failed": "检查 session 文件格式和异常记录,保证可解析。",
}
return mapping.get(issue_type, "检查对应日志并补充防护与重试策略。")
def _write_report(batch_id: str, since_minutes: int, analysis: Dict, observer_text: str, replay_rows: List[Dict], lines: List[str]) -> Path:
REPORT_DIR.mkdir(parents=True, exist_ok=True)
report_path = REPORT_DIR / f"{batch_id}.md"
issues_top = analysis["issues"][:16]
unresolved_top = analysis["unresolved_sessions"][:12]
dialog_top = (analysis.get("dialog_issues") or [])[:16]
out = []
out.append(f"# 日志巡检报告 {batch_id}")
out.append("")
out.append(f"- 巡检时间: {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
out.append(f"- 日志范围: 最近 {since_minutes} 分钟")
out.append(f"- 日志总行数: {analysis['line_count']}")
out.append(f"- OpenClaw调用/成功回复: {analysis['call_count']} / {analysis['reply_count']}")
out.append(f"- 未完成会话: {len(analysis['unresolved_sessions'])}")
out.append(f"- 错误总数: {_total_issue_count(analysis)}")
out.append(f"- 对话巡检会话数: {analysis.get('dialog_inspected_sessions', 0)}")
out.append(f"- 对话内容异常数: {sum((analysis.get('dialog_issue_counts') or {}).values())}")
out.append("")
out.append("## 分身结论")
out.append("")
out.append(observer_text.strip() or "无")
out.append("")
out.append("## 问题明细")
out.append("")
if not issues_top:
out.append("- 无错误问题")
else:
for idx, row in enumerate(issues_top, start=1):
out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}")
out.append(f" - 时间: {row.get('timestamp') or '-'}")
out.append(f" - 日志: {row.get('line')}")
out.append(f" - 建议: {_recommendation(row.get('type') or '')}")
out.append("")
out.append("## 对话内容异常")
out.append("")
if not dialog_top:
out.append("- 无")
else:
for idx, row in enumerate(dialog_top, start=1):
out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}")
out.append(f" - 时间: {row.get('timestamp') or '-'}")
out.append(f" - 内容: {row.get('line')}")
out.append(f" - 建议: {_recommendation(row.get('type') or '')}")
out.append("")
out.append("## 未完成会话")
out.append("")
if not unresolved_top:
out.append("- 无")
else:
for row in unresolved_top:
out.append(f"- {row['session']}: pending_calls={row['pending_calls']}")
out.append("")
out.append("## 自动回放")
out.append("")
if not replay_rows:
out.append("- 未执行")
else:
for row in replay_rows:
out.append(f"- {row['session']} -> {row['status']} ({row['replay_session']}) {row['summary']}")
out.append("")
out.append("## 日志样本")
out.append("")
for line in lines[-40:]:
out.append(f"- {line[-800:]}")
report_path.write_text("\n".join(out) + "\n", encoding="utf-8")
latest = REPORT_DIR / "latest.md"
latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8")
return report_path
def _push_to_feishu(
batch_id: str,
since_minutes: int,
analysis: Dict,
observer_text: str,
replay_rows: List[Dict],
app_token: str,
base_link: str,
shared_table: bool,
) -> Tuple[str, str]:
app_id, app_secret = _load_feishu_app()
token = _tenant_token(app_id, app_secret)
table_name = f"日志巡检-{dt.datetime.now().strftime('%m%d-%H%M')}"
table_id = ""
if shared_table:
parsed = urllib.parse.urlsplit(base_link or "")
query = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
table_id = query.get("table", "").strip()
if not table_id:
raise RuntimeError("shared-table 模式下未提供 table_id")
else:
table_id = _create_table(token, app_token, table_name)
_ensure_fields(token, app_token, table_id)
now_text = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_create_record(
token,
app_token,
table_id,
{
"记录类型": "summary",
"批次ID": batch_id,
"巡检时间": now_text,
"日志范围(分钟)": str(since_minutes),
"日志总行数": str(analysis["line_count"]),
"OpenClaw调用次数": str(analysis["call_count"]),
"成功回复次数": str(analysis["reply_count"]),
"未完成会话数": str(len(analysis["unresolved_sessions"])),
"错误总数": str(_total_issue_count(analysis)),
"网络错误数": str(analysis["issue_counts"].get("network_error", 0)),
"解析错误数": str(analysis["issue_counts"].get("parse_failed", 0)),
"重复回调数": str(analysis["duplicate_count"]),
"问题类型": "巡检汇总",
"会话ID": "-",
"问题摘要": (
observer_text[:5000]
+ "\n\n"
+ f"对话巡检会话数={analysis.get('dialog_inspected_sessions', 0)}, 对话内容异常数={sum((analysis.get('dialog_issue_counts') or {}).values())}"
)[:8000],
"建议动作": "按优先级逐条修复,修复后观察下一周期是否下降。",
"自动回放状态": "已执行" if replay_rows else "未执行",
"自动回放会话": str(len(replay_rows)),
"自动回放摘要": "; ".join([f"{r['session']}:{r['status']}" for r in replay_rows])[:8000],
"状态": "ok",
},
)
for row in analysis["issues"][:40]:
_create_record(
token,
app_token,
table_id,
{
"记录类型": "issue",
"批次ID": batch_id,
"巡检时间": row.get("timestamp") or now_text,
"日志范围(分钟)": str(since_minutes),
"日志总行数": "",
"OpenClaw调用次数": "",
"成功回复次数": "",
"未完成会话数": "",
"错误总数": "",
"网络错误数": "",
"解析错误数": "",
"重复回调数": "",
"问题类型": row.get("type") or "",
"会话ID": row.get("session") or "",
"问题摘要": row.get("line", "")[:8000],
"建议动作": _recommendation(row.get("type") or ""),
"自动回放状态": "",
"自动回放会话": "",
"自动回放摘要": "",
"状态": "pending",
},
)
for row in replay_rows[:20]:
_create_record(
token,
app_token,
table_id,
{
"记录类型": "replay",
"批次ID": batch_id,
"巡检时间": now_text,
"日志范围(分钟)": str(since_minutes),
"日志总行数": "",
"OpenClaw调用次数": "",
"成功回复次数": "",
"未完成会话数": "",
"错误总数": "",
"网络错误数": "",
"解析错误数": "",
"重复回调数": "",
"问题类型": "auto-replay",
"会话ID": row.get("session") or "",
"问题摘要": row.get("prompt", "")[:8000],
"建议动作": "若回放成功,检查主机器人同类问题是否已恢复。",
"自动回放状态": row.get("status", ""),
"自动回放会话": row.get("replay_session", ""),
"自动回放摘要": row.get("summary", "")[:8000],
"状态": "ok" if row.get("status") == "success" else "failed",
},
)
table_link = _build_table_link(base_link, app_token, table_id)
return table_id, table_link
def main() -> int:
parser = argparse.ArgumentParser(description="OpenClaw log observer")
parser.add_argument("--service", default="openclaw-dingtalk-bridge.service")
parser.add_argument("--since-minutes", type=int, default=60)
parser.add_argument("--push-feishu", action="store_true")
parser.add_argument("--shared-table", action="store_true")
parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN)
parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK)
parser.add_argument("--replay-failures", action="store_true")
parser.add_argument("--replay-limit", type=int, default=2)
args = parser.parse_args()
since_minutes = max(5, min(args.since_minutes, 24 * 60))
batch_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
try:
lines = _collect_logs(args.service, since_minutes)
analysis = _analyze_logs(lines)
dialog_result = _analyze_dialog_anomalies(lines, since_minutes)
analysis.update(dialog_result)
analysis["issues"].extend(dialog_result.get("dialog_issues", []))
except Exception as e:
print("STATUS=error")
print(f"ERROR={e}")
return 2
issue_lines = [row["line"] for row in analysis["issues"][:20]]
observer_text = _openclaw_observer_comment(analysis, issue_lines)
replay_rows: List[Dict] = []
if args.replay_failures:
for item in analysis["unresolved_sessions"][: max(0, args.replay_limit)]:
session_id = item["session"]
prompt = _last_user_prompt(session_id)
if not _is_safe_readonly_prompt(prompt):
replay_rows.append(
{
"session": session_id,
"prompt": prompt[:200],
"status": "skipped",
"replay_session": "",
"summary": "prompt为空或非只读任务,跳过",
}
)
continue
ok, replay_session, summary = _openclaw_replay(prompt)
replay_rows.append(
{
"session": session_id,
"prompt": prompt[:500],
"status": "success" if ok else "failed",
"replay_session": replay_session,
"summary": summary,
}
)
report_path = _write_report(batch_id, since_minutes, analysis, observer_text, replay_rows, lines)
table_id = ""
table_link = ""
if args.push_feishu:
try:
table_id, table_link = _push_to_feishu(
batch_id=batch_id,
since_minutes=since_minutes,
analysis=analysis,
observer_text=observer_text,
replay_rows=replay_rows,
app_token=args.app_token,
base_link=args.table_link,
shared_table=args.shared_table,
)
except Exception as e:
print("STATUS=partial")
print(f"ERROR=push feishu failed: {e}")
print(f"BATCH_ID={batch_id}")
print(f"REPORT_PATH={report_path}")
print(f"LINE_COUNT={analysis['line_count']}")
print(f"ISSUE_COUNT={_total_issue_count(analysis)}")
print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}")
return 1
print("STATUS=ok")
print(f"BATCH_ID={batch_id}")
print(f"REPORT_PATH={report_path}")
print(f"LINE_COUNT={analysis['line_count']}")
print(f"CALL_COUNT={analysis['call_count']}")
print(f"REPLY_COUNT={analysis['reply_count']}")
print(f"ISSUE_COUNT={_total_issue_count(analysis)}")
print(f"NETWORK_ERRORS={analysis['issue_counts'].get('network_error', 0)}")
print(f"PARSE_ERRORS={analysis['issue_counts'].get('parse_failed', 0)}")
print(f"DUPLICATE_CALLBACKS={analysis['duplicate_count']}")
print(f"DIALOG_INSPECTED_SESSIONS={analysis.get('dialog_inspected_sessions', 0)}")
print(f"DIALOG_ANOMALIES={sum((analysis.get('dialog_issue_counts') or {}).values())}")
print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}")
print(f"REPLAY_ROWS={len(replay_rows)}")
if table_id:
print(f"TABLE_ID={table_id}")
if table_link:
print(f"TABLE_LINK={table_link}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

83
patch_bot_run_openclaw.py Normal file
View File

@ -0,0 +1,83 @@
from pathlib import Path
path = Path('/opt/openclaw-dingtalk-bridge/bot.py')
text = path.read_text(encoding='utf-8')
start_marker = ' def _run_openclaw(self, session_id: str, prompt: str) -> str:\n'
end_marker = '\n def _is_image_url(self, raw_url: str) -> bool:\n'
start = text.find(start_marker)
end = text.find(end_marker, start)
if start == -1 or end == -1:
raise SystemExit('failed to locate _run_openclaw boundaries')
replacement = ''' def _run_openclaw(self, session_id: str, prompt: str) -> str:
cmd = [
self.openclaw_bin,
"agent",
"--session-id",
session_id,
"--message",
prompt,
"--json",
"--timeout",
str(self.openclaw_timeout),
]
self.logger.info("openclaw call: session=%s", session_id)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.openclaw_timeout + 20,
)
except subprocess.TimeoutExpired:
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw error").strip()
return f"OpenClaw 调用失败: {err[:500]}"
try:
raw_stdout = (proc.stdout or "").strip()
decoder = json.JSONDecoder()
data = None
parse_error = None
try:
data, _ = decoder.raw_decode(raw_stdout)
except Exception as exc:
parse_error = exc
idx = raw_stdout.find("{")
while idx != -1:
candidate = raw_stdout[idx:]
try:
data, _ = decoder.raw_decode(candidate)
break
except Exception:
idx = raw_stdout.find("{", idx + 1)
if data is None:
raise parse_error
payloads = data.get("result", {}).get("payloads", [])
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
answer = "\\n".join(t for t in text_items if t).strip()
if not answer:
answer = "任务已执行,但未返回文本结果。"
if self._is_market_prompt(prompt) and (
"无法直接从页面抓取" in answer
or "需要登录后的操作" in answer
or "工具版本不支持点击交互" in answer
):
subject = self._extract_market_subject(prompt)
fallback = self._run_market_brief(subject)
return f"已切换为市场分析直连模式({subject}):\\n\\n{fallback}"
return answer
except Exception as exc:
self.logger.exception("parse openclaw output failed")
raw = (proc.stdout or "").strip()
return f"结果解析失败: {exc}.\\n原始输出: {raw[:500]}"
'''
new_text = text[:start] + replacement + text[end:]
path.write_text(new_text, encoding='utf-8')
print('patched', path)

821
product-info Executable file
View File

@ -0,0 +1,821 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
def _sql_escape(value: str) -> str:
return value.replace("'", "''")
def build_sql(keyword: str, days: int, limit: int) -> str:
kw = _sql_escape(keyword.strip())
return f"""
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @kw nvarchar(100) = N'{kw}';
DECLARE @days int = {days};
DECLARE @limit int = {limit};
DECLARE @start_time datetime2 = DATEADD(day, -@days, GETDATE());
DECLARE @end_time datetime2 = GETDATE();
DECLARE @has_exact bit = 0;
IF EXISTS (SELECT 1 FROM dbo.jst_sku WITH (NOLOCK) WHERE sku_id = @kw)
SET @has_exact = 1;
IF OBJECT_ID('tempdb..#matched_sku') IS NOT NULL
DROP TABLE #matched_sku;
SELECT TOP (300)
s.sku_id,
s.i_id AS style_code,
s.other_1 AS spu,
s.name AS sku_name,
s.short_name,
s.properties_value,
s.color,
s.item_type,
s.category,
s.brand,
s.vc_name,
s.supplier_name,
s.supplier_sku_id,
s.sku_code,
s.sale_price,
s.cost_price,
s.purchase_price,
s.market_price,
s.weight,
s.l,
s.w,
s.h,
s.unit,
s.labels,
s.other_2 AS board_type,
s.other_10 AS board_material,
s.other_3 AS default_warehouse,
s.other_5 AS sku_status,
s.other_7 AS product_no,
s.pic AS preview_pic_url,
s.pic_big AS preview_pic_big_url,
s.created,
s.modified,
CASE
WHEN s.sku_id = @kw THEN 0
WHEN s.i_id = @kw THEN 1
WHEN s.other_1 = @kw THEN 2
WHEN s.other_7 = @kw THEN 3
WHEN s.sku_id LIKE @kw + '%' THEN 4
WHEN s.name LIKE @kw + '%' THEN 5
ELSE 9
END AS match_rank
INTO #matched_sku
FROM dbo.jst_sku AS s WITH (NOLOCK)
WHERE ISNULL(NULLIF(@kw, ''), '') <> ''
AND (
(@has_exact = 1 AND s.sku_id = @kw)
OR (
@has_exact = 0
AND (
s.sku_id LIKE '%' + @kw + '%'
OR s.i_id LIKE '%' + @kw + '%'
OR s.other_1 LIKE '%' + @kw + '%'
OR s.name LIKE '%' + @kw + '%'
OR s.short_name LIKE '%' + @kw + '%'
OR s.other_7 LIKE '%' + @kw + '%'
)
)
)
ORDER BY
CASE
WHEN s.sku_id = @kw THEN 0
WHEN s.i_id = @kw THEN 1
WHEN s.other_1 = @kw THEN 2
WHEN s.other_7 = @kw THEN 3
WHEN s.sku_id LIKE @kw + '%' THEN 4
WHEN s.name LIKE @kw + '%' THEN 5
ELSE 9
END,
s.modified DESC;
-- 1) 产品基础信息(含SKU名称/成本/材质/预览图 + 扩展字段)
SELECT TOP (@limit)
sku_id,
sku_name,
short_name,
style_code,
spu,
product_no,
sku_code,
properties_value,
color,
item_type,
category,
brand,
supplier_name,
supplier_sku_id,
cost_price,
purchase_price,
sale_price,
market_price,
board_type,
board_material,
default_warehouse,
sku_status,
labels,
weight,
l,
w,
h,
unit,
preview_pic_url,
preview_pic_big_url,
created,
modified
FROM #matched_sku
ORDER BY match_rank ASC, modified DESC;
IF OBJECT_ID('tempdb..#afs') IS NOT NULL
DROP TABLE #afs;
SELECT
NULLIF(a.undefined10, '') AS sku_id,
ISNULL(NULLIF(a.undefined36, ''), 'unknown') AS sku_name,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
NULLIF(a.undefined2, '') AS order_id,
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined37, '')) AS pre_ship_apply_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined40, '')) AS pre_ship_return_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined46, '')) AS post_ship_apply_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined49, '')) AS post_ship_return_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount
INTO #afs
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE a.createdAt >= @start_time
AND a.createdAt < @end_time
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'
AND NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku);
IF OBJECT_ID('tempdb..#afs_orders') IS NOT NULL
DROP TABLE #afs_orders;
SELECT DISTINCT order_id
INTO #afs_orders
FROM #afs
WHERE ISNULL(order_id, '') <> '';
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
DROP TABLE #chat_latest;
WITH chat_rank AS (
SELECT
NULLIF(c.orderNo, '') AS order_id,
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
c.createdAt,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(c.orderNo, '')
ORDER BY c.createdAt DESC, c.id DESC
) AS rn
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
INNER JOIN #afs_orders AS o
ON o.order_id = NULLIF(c.orderNo, '')
)
SELECT
order_id,
main_reason,
second_reason,
third_reason
INTO #chat_latest
FROM chat_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#afs_enriched') IS NOT NULL
DROP TABLE #afs_enriched;
SELECT
af.*,
CASE
WHEN ISNULL(NULLIF(af.native_reason, ''), '') <> '' THEN af.native_reason
WHEN ISNULL(NULLIF(cl.main_reason, ''), '') <> '' THEN cl.main_reason
ELSE '未标注'
END AS reason_l1,
CASE
WHEN ISNULL(NULLIF(cl.second_reason, ''), '') <> '' THEN cl.second_reason
ELSE '未标注'
END AS reason_l2,
CASE
WHEN ISNULL(NULLIF(cl.third_reason, ''), '') <> '' THEN cl.third_reason
ELSE '未标注'
END AS reason_l3,
CASE
WHEN ISNULL(NULLIF(af.native_reason, ''), '') <> '' THEN 'afs_reason'
WHEN ISNULL(NULLIF(cl.main_reason, ''), '') <> '' THEN 'chat_main_reason'
ELSE 'unknown'
END AS reason_source
INTO #afs_enriched
FROM #afs AS af
LEFT JOIN #chat_latest AS cl
ON cl.order_id = af.order_id;
-- 2) 最近N天产品出货/售后件数(件数优先)
SELECT TOP (@limit)
ae.sku_id,
MAX(COALESCE(ms.sku_name, ae.sku_name)) AS sku_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT ae.order_id) AS order_count,
SUM(ISNULL(ae.order_qty, 0)) AS order_qty_sum,
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
FROM #afs_enriched AS ae
LEFT JOIN #matched_sku AS ms
ON ms.sku_id = ae.sku_id
GROUP BY ae.sku_id
ORDER BY shipped_qty_sum DESC, order_count DESC;
-- 3) 最近N天该产品的店铺分布(件数)
SELECT TOP (@limit)
ae.shop_name,
COUNT(DISTINCT ae.order_id) AS order_count,
SUM(ISNULL(ae.order_qty, 0)) AS order_qty_sum,
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
FROM #afs_enriched AS ae
GROUP BY ae.shop_name
ORDER BY shipped_qty_sum DESC, order_count DESC;
IF OBJECT_ID('tempdb..#freight') IS NOT NULL
DROP TABLE #freight;
SELECT
NULLIF(f.sku_id, '') AS sku_id,
NULLIF(f.so_id, '') AS so_id,
ISNULL(NULLIF(f.receiver_state, ''), '未知') AS receiver_state,
CASE
WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','')
IN ('上海','江苏','浙江','安徽') THEN '一区'
WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','')
IN ('北京','天津','河北','山东','河南','湖北','湖南','江西','福建','广东') THEN '二区'
WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','')
IN ('辽宁','吉林','黑龙江','山西','陕西','四川','重庆','贵州','云南','广西','海南','内蒙古','甘肃','宁夏','青海','新疆','西藏') THEN '三区'
ELSE '未分区'
END AS freight_zone,
ISNULL(NULLIF(f.expressName, ''), 'unknown') AS express_name,
TRY_CONVERT(decimal(18, 2), f.freightCost) AS freight_cost,
TRY_CONVERT(decimal(18, 2), f.weight) AS freight_weight,
f.send_date
INTO #freight
FROM dbo.fys_jstFreightCostDetail AS f WITH (NOLOCK)
WHERE f.send_date >= @start_time
AND f.send_date < @end_time
AND ISNULL(NULLIF(f.sku_id, ''), '') <> ''
AND NULLIF(f.sku_id, '') IN (SELECT sku_id FROM #matched_sku);
-- 4) 最近N天一区/二区/三区运费(按SKU)
SELECT TOP (@limit)
fr.sku_id,
fr.freight_zone,
COUNT(*) AS freight_record_count,
COUNT(DISTINCT fr.so_id) AS order_count,
SUM(ISNULL(fr.freight_cost, 0)) AS freight_cost_sum,
AVG(ISNULL(fr.freight_cost, 0)) AS avg_freight_cost,
AVG(ISNULL(fr.freight_weight, 0)) AS avg_weight
FROM #freight AS fr
GROUP BY fr.sku_id, fr.freight_zone
ORDER BY
CASE fr.freight_zone WHEN '一区' THEN 1 WHEN '二区' THEN 2 WHEN '三区' THEN 3 ELSE 9 END,
order_count DESC;
-- 5) 最近N天省份运费TOP(按SKU)
SELECT TOP (@limit)
fr.sku_id,
fr.receiver_state,
COUNT(DISTINCT fr.so_id) AS order_count,
SUM(ISNULL(fr.freight_cost, 0)) AS freight_cost_sum,
AVG(ISNULL(fr.freight_cost, 0)) AS avg_freight_cost,
AVG(ISNULL(fr.freight_weight, 0)) AS avg_weight
FROM #freight AS fr
GROUP BY fr.sku_id, fr.receiver_state
ORDER BY order_count DESC, freight_cost_sum DESC;
-- 6) 最近N天售后总览(该SKU)
SELECT TOP (@limit)
ae.sku_id,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT ae.order_id) AS afs_order_count,
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(ae.pre_ship_apply_qty, 0) + ISNULL(ae.post_ship_apply_qty, 0)) AS apply_qty_sum,
SUM(ISNULL(ae.pre_ship_return_qty, 0) + ISNULL(ae.post_ship_return_qty, 0)) AS return_qty_sum,
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count,
SUM(ISNULL(ae.refund_amount, 0)) AS refund_amount_sum
FROM #afs_enriched AS ae
GROUP BY ae.sku_id
ORDER BY afs_record_count DESC;
-- 7) 最近N天售后类型/结果矩阵(该SKU)
SELECT TOP (@limit)
ae.sku_id,
ae.afs_type,
ae.afs_result,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT ae.order_id) AS order_count,
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
FROM #afs_enriched AS ae
GROUP BY ae.sku_id, ae.afs_type, ae.afs_result
ORDER BY afs_record_count DESC, order_count DESC;
-- 8) 最近N天售后原因TOP(该SKU)
SELECT TOP (@limit)
ae.sku_id,
ae.reason_l1,
ae.reason_l2,
ae.reason_l3,
ae.reason_source,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT ae.order_id) AS order_count,
SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum,
SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count
FROM #afs_enriched AS ae
GROUP BY ae.sku_id, ae.reason_l1, ae.reason_l2, ae.reason_l3, ae.reason_source
ORDER BY afs_record_count DESC, order_count DESC;
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
DROP TABLE #inv_latest;
WITH inv_rank AS (
SELECT
NULLIF(i.sku_id, '') AS sku_id,
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
i.wms_co_id,
i.name AS inventory_note,
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(i.sku_id, '')
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
) AS rn
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #matched_sku)
)
SELECT
sku_id,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
virtual_qty,
defective_qty,
purchase_qty,
return_qty,
in_qty,
min_qty,
max_qty,
wms_co_id,
inventory_note,
inventory_snapshot_time,
CASE
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
THEN CAST(0 AS decimal(18, 2))
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
END AS available_qty
INTO #inv_latest
FROM inv_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
DROP TABLE #ship_base;
SELECT
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined12, '') AS product_id,
NULLIF(a.undefined11, '') AS style_code_from_afs,
NULLIF(a.undefined96, '') AS product_no_from_afs,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道')
END AS shop_channel,
ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty
INTO #ship_base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku)
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#ship_daily') IS NOT NULL
DROP TABLE #ship_daily;
SELECT
sb.sku_id,
CAST(sb.ship_dt AS date) AS ship_date,
COUNT(DISTINCT sb.order_id) AS order_count,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum
INTO #ship_daily
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id, CAST(sb.ship_dt AS date);
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
DROP TABLE #ship_stats;
SELECT
sd.sku_id,
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -7, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_7d,
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -30, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_30d,
MAX(sd.ship_date) AS last_ship_date
INTO #ship_stats
FROM #ship_daily AS sd
GROUP BY sd.sku_id;
IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL
DROP TABLE #sku_owner;
SELECT
ms.sku_id,
COALESCE(
NULLIF(pc_exact.purchaser, ''),
NULLIF(pc_style.purchaser, ''),
NULLIF(pc_base.purchaser, ''),
'未分配'
) AS purchaser_name,
COALESCE(
NULLIF(po_exact.oper, ''),
NULLIF(po_style.oper, ''),
NULLIF(po_base.oper, ''),
NULLIF(pc_exact.oper, ''),
NULLIF(pc_style.oper, ''),
NULLIF(pc_base.oper, ''),
'未分配'
) AS spu_oper_name
INTO #sku_owner
FROM #matched_sku AS ms
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = NULLIF(ms.spu, '')
ORDER BY c.Id DESC
) AS pc_exact
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = NULLIF(ms.style_code, '')
ORDER BY c.Id DESC
) AS pc_style
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = CASE
WHEN CHARINDEX('K', ISNULL(ms.spu, '')) > 0
THEN LEFT(ms.spu, CHARINDEX('K', ms.spu) - 1)
ELSE NULL
END
ORDER BY c.Id DESC
) AS pc_base
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = NULLIF(ms.spu, '')
ORDER BY o.Id DESC
) AS po_exact
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = NULLIF(ms.style_code, '')
ORDER BY o.Id DESC
) AS po_style
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = CASE
WHEN CHARINDEX('K', ISNULL(ms.spu, '')) > 0
THEN LEFT(ms.spu, CHARINDEX('K', ms.spu) - 1)
ELSE NULL
END
ORDER BY o.Id DESC
) AS po_base;
IF OBJECT_ID('tempdb..#tm_oper') IS NOT NULL
DROP TABLE #tm_oper;
WITH tm_rank AS (
SELECT
NULLIF(t.shopproid, '') AS product_id,
ISNULL(NULLIF(t.shopname, ''), 'unknown') AS shop_name,
NULLIF(t.oper, '') AS oper_name,
NULLIF(t.dept, '') AS dept_name,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(t.shopproid, ''), ISNULL(NULLIF(t.shopname, ''), 'unknown')
ORDER BY t.Id DESC
) AS rn
FROM dbo.op_TargetManagement AS t WITH (NOLOCK)
WHERE NULLIF(t.shopproid, '') IS NOT NULL
)
SELECT
product_id,
shop_name,
oper_name,
dept_name
INTO #tm_oper
FROM tm_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#tm_oper_pid') IS NOT NULL
DROP TABLE #tm_oper_pid;
SELECT
product_id,
MAX(oper_name) AS oper_name,
MAX(dept_name) AS dept_name
INTO #tm_oper_pid
FROM #tm_oper
GROUP BY product_id;
IF OBJECT_ID('tempdb..#product_id_contact') IS NOT NULL
DROP TABLE #product_id_contact;
SELECT
sb.sku_id,
ms.sku_name,
ISNULL(NULLIF(sb.product_id, ''), '(未标注)') AS product_id,
sb.shop_channel,
sb.shop_name,
COALESCE(
NULLIF(tm_shop.oper_name, ''),
NULLIF(tm_pid.oper_name, ''),
NULLIF(ow.spu_oper_name, ''),
'未分配'
) AS operator_name,
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name,
COALESCE(NULLIF(ms.supplier_name, ''), '未标注') AS factory_name,
COALESCE(NULLIF(sb.ship_warehouse_name, ''), NULLIF(ms.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name,
COUNT(DISTINCT sb.order_id) AS order_count_30d,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d
INTO #product_id_contact
FROM #ship_base AS sb
INNER JOIN #matched_sku AS ms
ON ms.sku_id = sb.sku_id
LEFT JOIN #sku_owner AS ow
ON ow.sku_id = sb.sku_id
LEFT JOIN #tm_oper AS tm_shop
ON tm_shop.product_id = NULLIF(sb.product_id, '')
AND tm_shop.shop_name = sb.shop_name
LEFT JOIN #tm_oper_pid AS tm_pid
ON tm_pid.product_id = NULLIF(sb.product_id, '')
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = sb.sku_id
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
AND ISNULL(NULLIF(sb.product_id, ''), '') <> ''
GROUP BY
sb.sku_id,
ms.sku_name,
ISNULL(NULLIF(sb.product_id, ''), '(未标注)'),
sb.shop_channel,
sb.shop_name,
COALESCE(
NULLIF(tm_shop.oper_name, ''),
NULLIF(tm_pid.oper_name, ''),
NULLIF(ow.spu_oper_name, ''),
'未分配'
),
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配'),
COALESCE(NULLIF(ms.supplier_name, ''), '未标注'),
COALESCE(NULLIF(sb.ship_warehouse_name, ''), NULLIF(ms.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注');
IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL
DROP TABLE #risk_eval;
SELECT
ms.sku_id,
ms.sku_name,
inv.stock_qty,
inv.order_lock_qty,
inv.pick_lock_qty,
inv.lock_qty,
inv.available_qty,
inv.virtual_qty,
inv.defective_qty,
inv.purchase_qty,
inv.return_qty,
inv.in_qty,
inv.min_qty,
inv.max_qty,
inv.wms_co_id,
inv.inventory_snapshot_time,
ss.last_ship_date,
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
END AS decimal(18, 2)
) AS estimated_stockout_days,
10 AS replenishment_lead_days,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * 10 THEN 0
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * 10) - inv.available_qty
END AS decimal(18, 2)
) AS suggest_replenish_qty_to_10d,
CASE
WHEN inv.sku_id IS NULL THEN '无库存快照'
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 10 THEN '补货紧急(<=10天)'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 15 THEN '补货预警(10-15天)'
ELSE '库存安全'
END AS stock_risk_level,
CASE
WHEN inv.sku_id IS NULL THEN 0
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty <= 0 THEN 1
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 15 THEN 1
ELSE 0
END AS has_stock_risk
INTO #risk_eval
FROM #matched_sku AS ms
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = ms.sku_id
LEFT JOIN #ship_stats AS ss
ON ss.sku_id = ms.sku_id;
-- 9) 最近N天每日总发货件数(按SKU)
SELECT TOP (@limit)
sd.sku_id,
ms.sku_name,
sd.ship_date,
sd.order_count,
sd.shipped_qty_sum
FROM #ship_daily AS sd
LEFT JOIN #matched_sku AS ms
ON ms.sku_id = sd.sku_id
ORDER BY sd.ship_date DESC, sd.shipped_qty_sum DESC;
-- 10) 库存风险预估(按SKU,补货安全线=10天)
SELECT TOP (@limit)
re.sku_id,
re.sku_name,
re.stock_qty,
re.order_lock_qty,
re.pick_lock_qty,
re.lock_qty,
re.available_qty,
re.virtual_qty,
re.defective_qty,
re.purchase_qty,
re.return_qty,
re.in_qty,
re.min_qty,
re.max_qty,
re.wms_co_id,
re.inventory_snapshot_time,
re.last_ship_date,
re.avg_daily_shipped_7d,
re.avg_daily_shipped_30d,
re.estimated_stockout_days,
re.replenishment_lead_days,
re.suggest_replenish_qty_to_10d,
re.stock_risk_level
FROM #risk_eval AS re
ORDER BY
CASE
WHEN re.stock_qty IS NULL THEN 9
WHEN re.avg_daily_shipped_30d <= 0 THEN 8
WHEN re.available_qty <= 0 THEN 1
WHEN re.estimated_stockout_days <= 10 THEN 2
WHEN re.estimated_stockout_days <= 15 THEN 3
ELSE 4
END,
re.estimated_stockout_days ASC,
re.sku_id ASC;
-- 11) 近30天在售商品ID数量(按店铺渠道)
SELECT TOP (@limit)
sb.sku_id,
ms.sku_name,
sb.shop_channel,
COUNT(DISTINCT sb.product_id) AS sold_product_id_count_30d,
COUNT(DISTINCT sb.shop_name) AS shop_count_30d,
COUNT(DISTINCT sb.order_id) AS order_count_30d,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d
FROM #ship_base AS sb
INNER JOIN #matched_sku AS ms
ON ms.sku_id = sb.sku_id
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
AND ISNULL(NULLIF(sb.product_id, ''), '') <> ''
GROUP BY sb.sku_id, ms.sku_name, sb.shop_channel
ORDER BY sold_product_id_count_30d DESC, shipped_qty_sum_30d DESC, sb.shop_channel ASC;
-- 12) 商品ID对应店铺/渠道/运营人(用于通知)
SELECT TOP (@limit)
pic.sku_id,
pic.sku_name,
pic.product_id,
pic.shop_channel,
pic.shop_name,
pic.operator_name,
pic.purchaser_name,
pic.factory_name,
pic.warehouse_name,
pic.order_count_30d,
pic.shipped_qty_sum_30d
FROM #product_id_contact AS pic
ORDER BY pic.shipped_qty_sum_30d DESC, pic.order_count_30d DESC, pic.product_id ASC;
-- 13) 缺货风险通知清单(带采购/工厂/仓库/店铺/运营)
SELECT TOP (@limit)
re.sku_id,
re.sku_name,
re.stock_risk_level,
re.available_qty,
re.avg_daily_shipped_30d,
re.estimated_stockout_days,
re.suggest_replenish_qty_to_10d,
pic.product_id,
pic.shop_channel,
pic.shop_name,
pic.operator_name,
pic.purchaser_name,
pic.factory_name,
pic.warehouse_name,
pic.shipped_qty_sum_30d
FROM #risk_eval AS re
LEFT JOIN #product_id_contact AS pic
ON pic.sku_id = re.sku_id
WHERE re.has_stock_risk = 1
ORDER BY
CASE
WHEN re.available_qty <= 0 THEN 1
WHEN re.estimated_stockout_days <= 10 THEN 2
WHEN re.estimated_stockout_days <= 15 THEN 3
ELSE 9
END,
re.estimated_stockout_days ASC,
pic.shipped_qty_sum_30d DESC;
"""
def main() -> int:
parser = argparse.ArgumentParser(description="Company product info lookup (SQLServer)")
parser.add_argument("keyword", help="SKU/SPU/款式/产品名关键词")
parser.add_argument("--days", type=int, default=30, help="lookback window, default 30")
parser.add_argument("--limit", type=int, default=20, help="max rows per result set")
args = parser.parse_args()
kw = args.keyword.strip()
if not kw:
print("keyword is required", file=sys.stderr)
return 2
days = max(1, min(args.days, 365))
limit = max(1, min(args.limit, 100))
sql = build_sql(kw, days, limit)
cmd = ["company-db", "query", "--sql", sql]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "company-db failed").strip()
print(err, file=sys.stderr)
return proc.returncode
print(proc.stdout.rstrip())
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,404 @@
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
/*
Template: 商品ID推广费用周报(2026可用)
目标: 费用 + 发货件数 + 缺货风险 + 负责人
默认窗口: 上一个自然周 [week_start, week_end)
*/
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
DECLARE @lookback_30d datetime2 = DATEADD(day, -30, @week_end);
DECLARE @lead_days int = 10;
DECLARE @warn_days int = 15;
IF OBJECT_ID('tempdb..#base_week') IS NOT NULL
DROP TABLE #base_week;
WITH src AS (
SELECT
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined12, '') AS product_id,
NULLIF(a.undefined10, '') AS sku_id,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
ELSE 'unknown'
END AS shop_channel,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined97, '')) AS promo_fee_8001201,
(
ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined108, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined109, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined110, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined111, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined112, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined113, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined114, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined115, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined116, '')), 0)
+ ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined117, '')), 0)
) AS commission_fee_sum,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
)
SELECT
order_id,
product_id,
sku_id,
shop_name,
shop_channel,
ship_dt,
shipped_qty,
ISNULL(promo_fee_8001201, 0) AS promo_fee_8001201,
ISNULL(commission_fee_sum, 0) AS commission_fee_sum
INTO #base_week
FROM src
WHERE ship_dt >= @week_start
AND ship_dt < @week_end
AND ISNULL(product_id, '') <> ''
AND ISNULL(sku_id, '') <> ''
AND order_labels NOT LIKE '%配件%'
AND order_labels NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#ship_30d') IS NOT NULL
DROP TABLE #ship_30d;
WITH src_30d AS (
SELECT
NULLIF(a.undefined12, '') AS product_id,
NULLIF(a.undefined10, '') AS sku_id,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
)
SELECT
product_id,
sku_id,
shop_name,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_30d
INTO #ship_30d
FROM src_30d
WHERE ship_dt >= @lookback_30d
AND ship_dt < @week_end
AND ISNULL(product_id, '') <> ''
AND ISNULL(sku_id, '') <> ''
AND order_labels NOT LIKE '%配件%'
AND order_labels NOT LIKE '%特殊单%'
GROUP BY product_id, sku_id, shop_name;
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
DROP TABLE #inv_latest;
WITH inv_rank AS (
SELECT
NULLIF(i.sku_id, '') AS sku_id,
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
i.name AS inventory_note,
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(i.sku_id, '')
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
) AS rn
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
WHERE NULLIF(i.sku_id, '') IN (SELECT DISTINCT sku_id FROM #base_week)
)
SELECT
sku_id,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
inventory_note,
inventory_snapshot_time,
CASE
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
THEN CAST(0 AS decimal(18, 2))
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
END AS available_qty
INTO #inv_latest
FROM inv_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#sku_risk') IS NOT NULL
DROP TABLE #sku_risk;
SELECT
s.product_id,
s.shop_name,
s.sku_id,
s.shipped_qty_30d,
CAST(ISNULL(s.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
inv.available_qty,
CAST(
CASE
WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN NULL
ELSE inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)
END AS decimal(18, 2)
) AS estimated_stockout_days,
CAST(
CASE
WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty >= (ISNULL(s.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
ELSE ((ISNULL(s.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
END AS decimal(18, 2)
) AS suggest_replenish_qty_to_10d,
CASE
WHEN inv.sku_id IS NULL THEN '无库存快照'
WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
WHEN (inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
WHEN (inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)'
ELSE '库存安全'
END AS stock_risk_level
INTO #sku_risk
FROM #ship_30d AS s
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = s.sku_id;
IF OBJECT_ID('tempdb..#owner_pick') IS NOT NULL
DROP TABLE #owner_pick;
WITH owner_src AS (
SELECT
bw.product_id,
bw.shop_name,
bw.sku_id,
SUM(ISNULL(bw.shipped_qty, 0)) AS week_shipped_qty,
NULLIF(js.other_1, '') AS spu,
NULLIF(js.i_id, '') AS style_code,
NULLIF(tm.oper, '') AS tm_oper,
NULLIF(opm.oper, '') AS opm_oper,
NULLIF(spu_cfg.oper, '') AS spu_cfg_oper,
NULLIF(spu_cfg.purchaser, '') AS purchaser
FROM #base_week AS bw
LEFT JOIN dbo.jst_sku AS js WITH (NOLOCK)
ON js.sku_id = bw.sku_id
LEFT JOIN dbo.op_TargetManagement AS tm WITH (NOLOCK)
ON NULLIF(tm.shopproid, '') = bw.product_id
AND ISNULL(NULLIF(tm.shopname, ''), '') = ISNULL(NULLIF(bw.shop_name, ''), '')
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') IN (
NULLIF(js.other_1, ''),
NULLIF(js.i_id, ''),
CASE
WHEN CHARINDEX('K', ISNULL(js.other_1, '')) > 0
THEN LEFT(js.other_1, CHARINDEX('K', js.other_1) - 1)
ELSE NULL
END
)
ORDER BY o.Id DESC
) AS opm
OUTER APPLY (
SELECT TOP 1 c.oper, c.purchaser
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') IN (
NULLIF(js.other_1, ''),
NULLIF(js.i_id, ''),
CASE
WHEN CHARINDEX('K', ISNULL(js.other_1, '')) > 0
THEN LEFT(js.other_1, CHARINDEX('K', js.other_1) - 1)
ELSE NULL
END
)
ORDER BY c.Id DESC
) AS spu_cfg
GROUP BY
bw.product_id,
bw.shop_name,
bw.sku_id,
NULLIF(js.other_1, ''),
NULLIF(js.i_id, ''),
NULLIF(tm.oper, ''),
NULLIF(opm.oper, ''),
NULLIF(spu_cfg.oper, ''),
NULLIF(spu_cfg.purchaser, '')
), owner_rank AS (
SELECT
product_id,
shop_name,
COALESCE(tm_oper, opm_oper, spu_cfg_oper, '未分配') AS operator_name,
COALESCE(purchaser, '未分配') AS purchaser_name,
ROW_NUMBER() OVER (
PARTITION BY product_id, shop_name
ORDER BY week_shipped_qty DESC, sku_id ASC
) AS rn
FROM owner_src
)
SELECT
product_id,
shop_name,
operator_name,
purchaser_name
INTO #owner_pick
FROM owner_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#risk_agg') IS NOT NULL
DROP TABLE #risk_agg;
SELECT
product_id,
shop_name,
COUNT(DISTINCT CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN sku_id END) AS urgent_risk_sku_count,
COUNT(DISTINCT CASE WHEN stock_risk_level LIKE '补货预警%' THEN sku_id END) AS warning_risk_sku_count,
MIN(estimated_stockout_days) AS min_estimated_stockout_days,
SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN ISNULL(suggest_replenish_qty_to_10d, 0) ELSE 0 END) AS suggest_replenish_qty_10d
INTO #risk_agg
FROM #sku_risk
GROUP BY product_id, shop_name;
IF OBJECT_ID('tempdb..#product_week') IS NOT NULL
DROP TABLE #product_week;
SELECT
bw.product_id,
bw.shop_channel,
bw.shop_name,
COUNT(DISTINCT bw.sku_id) AS sku_count,
COUNT(DISTINCT bw.order_id) AS order_count,
SUM(ISNULL(bw.shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(bw.promo_fee_8001201, 0)) AS promo_fee_sum,
SUM(ISNULL(bw.commission_fee_sum, 0)) AS commission_fee_sum,
SUM(ISNULL(bw.promo_fee_8001201, 0) + ISNULL(bw.commission_fee_sum, 0)) AS marketing_fee_sum,
CASE
WHEN SUM(ISNULL(bw.shipped_qty, 0)) <= 0 THEN NULL
ELSE (SUM(ISNULL(bw.promo_fee_8001201, 0) + ISNULL(bw.commission_fee_sum, 0)) / SUM(ISNULL(bw.shipped_qty, 0)))
END AS marketing_fee_per_piece,
ISNULL(ra.urgent_risk_sku_count, 0) AS urgent_risk_sku_count,
ISNULL(ra.warning_risk_sku_count, 0) AS warning_risk_sku_count,
ra.min_estimated_stockout_days,
ISNULL(ra.suggest_replenish_qty_10d, 0) AS suggest_replenish_qty_10d,
CASE
WHEN ISNULL(ra.urgent_risk_sku_count, 0) > 0 THEN '补货紧急'
WHEN ISNULL(ra.warning_risk_sku_count, 0) > 0 THEN '补货预警'
ELSE '库存安全/无快照'
END AS product_stock_risk_level,
ISNULL(op.purchaser_name, '未分配') AS purchaser_name,
ISNULL(op.operator_name, '未分配') AS operator_name
INTO #product_week
FROM #base_week AS bw
LEFT JOIN #risk_agg AS ra
ON ra.product_id = bw.product_id
AND ra.shop_name = bw.shop_name
LEFT JOIN #owner_pick AS op
ON op.product_id = bw.product_id
AND op.shop_name = bw.shop_name
GROUP BY
bw.product_id,
bw.shop_channel,
bw.shop_name,
ISNULL(ra.urgent_risk_sku_count, 0),
ISNULL(ra.warning_risk_sku_count, 0),
ra.min_estimated_stockout_days,
ISNULL(ra.suggest_replenish_qty_10d, 0),
CASE
WHEN ISNULL(ra.urgent_risk_sku_count, 0) > 0 THEN '补货紧急'
WHEN ISNULL(ra.warning_risk_sku_count, 0) > 0 THEN '补货预警'
ELSE '库存安全/无快照'
END,
ISNULL(op.purchaser_name, '未分配'),
ISNULL(op.operator_name, '未分配');
-- 1) 周汇总(商品ID维度)
SELECT
@week_start AS week_start,
@week_end AS week_end,
COUNT(*) AS product_shop_count,
COUNT(DISTINCT product_id) AS product_count,
COUNT(DISTINCT shop_name) AS shop_count,
SUM(ISNULL(order_count, 0)) AS order_count_sum,
SUM(ISNULL(shipped_qty_sum, 0)) AS shipped_qty_sum,
SUM(ISNULL(promo_fee_sum, 0)) AS promo_fee_sum,
SUM(ISNULL(commission_fee_sum, 0)) AS commission_fee_sum,
SUM(ISNULL(marketing_fee_sum, 0)) AS marketing_fee_sum,
CASE
WHEN SUM(ISNULL(shipped_qty_sum, 0)) <= 0 THEN NULL
ELSE SUM(ISNULL(marketing_fee_sum, 0)) / SUM(ISNULL(shipped_qty_sum, 0))
END AS marketing_fee_per_piece_avg,
SUM(CASE WHEN product_stock_risk_level = '补货紧急' THEN 1 ELSE 0 END) AS urgent_product_count,
SUM(CASE WHEN product_stock_risk_level = '补货预警' THEN 1 ELSE 0 END) AS warning_product_count
FROM #product_week;
-- 2) 商品ID周报TOP100(费用 + 发货件数 + 缺货风险 + 负责人)
SELECT TOP (100)
product_id,
shop_channel,
shop_name,
sku_count,
order_count,
shipped_qty_sum,
promo_fee_sum,
commission_fee_sum,
marketing_fee_sum,
marketing_fee_per_piece,
product_stock_risk_level,
min_estimated_stockout_days,
suggest_replenish_qty_10d,
urgent_risk_sku_count,
warning_risk_sku_count,
purchaser_name,
operator_name
FROM #product_week
ORDER BY marketing_fee_sum DESC, shipped_qty_sum DESC, product_id ASC;
-- 3) 高风险商品ID清单(运营动作优先)
SELECT TOP (100)
product_id,
shop_channel,
shop_name,
shipped_qty_sum,
marketing_fee_sum,
product_stock_risk_level,
min_estimated_stockout_days,
suggest_replenish_qty_10d,
purchaser_name,
operator_name
FROM #product_week
WHERE product_stock_risk_level IN ('补货紧急', '补货预警')
ORDER BY
CASE product_stock_risk_level WHEN '补货紧急' THEN 1 ELSE 2 END,
min_estimated_stockout_days ASC,
marketing_fee_sum DESC;
-- 4) 负责人缺失清单(数据治理)
SELECT TOP (100)
product_id,
shop_channel,
shop_name,
shipped_qty_sum,
marketing_fee_sum,
purchaser_name,
operator_name
FROM #product_week
WHERE purchaser_name = '未分配'
OR operator_name = '未分配'
ORDER BY marketing_fee_sum DESC, shipped_qty_sum DESC;

BIN
remote_sync/.DS_Store vendored Normal file

Binary file not shown.

448
remote_sync/AGENTS.md Normal file
View File

@ -0,0 +1,448 @@
# AGENTS.md - Your Workspace
This folder is home. Treat it that way.
## Hard Output Rules
## No Pass-Through Policy
- Never throw unresolved problems back to the user with generic blockers like "无法访问"、"请提供平台"、"请先手动" as the first outcome.
- Before asking the user for help, perform at least 2 autonomous recovery attempts (tool retry, alternate page/module, or fallback command).
- If still blocked, report only concrete missing items (for example: OTP/CAPTCHA/2FA) and include the exact attempts already tried.
- For market/sales tasks, do not ask for platform access details; this environment is preconfigured and should be executed directly.
## Sales Non-Compliance Guard
- For sales requests, these replies are forbidden: "没有直接访问", "请提供平台", "公开渠道", "无法连接系统".
- Sales turn validity rule: at least one `exec` tool call must happen before final conclusion text.
- For sales requests, do not send interim progress text; execute tools silently and return final results only.
## Critical Domain Lock
- For any request containing market-analysis intent ("市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), only use domain `hq.yucekj.com` (or `yucekj.com`).
- If the same request also includes company performance intent ("销售", "业绩", "售后", "退款", "费比", "店铺"), override to SQLServer route `company-sales-db-analysis` and do not open yuce.
- In these requests, treat any other domain as blocked and do not use it for navigation or fetching.
- Use browser tool only with valid actions (`open` or `navigate`) and always pass `targetUrl` set to `https://hq.yucekj.com` (or a yucekj subpage).
- Preferred execution path for market-analysis: use `exec` to run `yuce-browser open https://hq.yucekj.com` then `yuce-browser snapshot 60`.
- Never use browser action `search`.
- Never use `web_search` or `web_fetch` for market-analysis requests.
- `browser` and `nodes` tools are disabled; use `exec` with `yuce-browser` for yuce-only navigation.
- Do not produce market-analysis numbers from memory. Always collect evidence from yucekj pages first.
- Before writing any report file, complete at least one successful browser snapshot on `hq.yucekj.com` and base conclusions on that content.
- If login/data collection fails, ask user to assist (OTP/CAPTCHA/manual step) instead of fabricating results.
- If a tool request would hit another domain, abort that step and continue only with the yucekj domain.
## Xiaohongshu MCP Route
- For tasks mentioning "小红书"/"红书"/"xhs"/"笔记"/"种草"/"小红书数据", use local service `xiaohongshu-mcp` first.
- Xiaohongshu tasks override generic market-analysis routing; do not route these tasks to yuce or sales systems.
- Mandatory first actions for Xiaohongshu tasks:
1) `exec`: `xhs-api health`
2) `exec`: `xhs-api login-status`
3) If not logged in: `exec`: `xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`, ask user to scan, then retry login status.
- Data collection path:
- Keyword retrieval: `xhs-api search "<keyword>"`
- Feed detail: extract `id` + `xsecToken` from search result, then `xhs-api detail <id> <xsecToken> true`
- Never ask user for Xiaohongshu platform URL/access basics first; this environment is preconfigured for local xiaohongshu-mcp.
- Do not expose raw source addresses (domain/IP/API endpoint) in final user response.
## Critical Sales Route (High Priority)
- For any request containing sales intent keywords ("业绩", "销售", "店铺", "上周", "旗舰店", "费比", "售后", "退款"), immediately route to SQLServer data source `company-sales-db-analysis`.
- Do not ask generic clarification before attempting data collection.
- These sales/market routes are user-preapproved automation flows; do not ask for extra permission before running `exec` collection steps.
- Do not call `memory_search` or `memory_get` for sales tasks.
- Never call `tts` or `voice_call` for sales tasks unless user explicitly asks for voice output.
- For sales tasks, do at least one `exec` data-collection tool call before any explanatory long text.
- Do not say "没有权限/无法连接系统" for sales tasks; this environment is preconfigured with DB connection + credentials.
- For sales tasks, never set `elevated=true` in `exec` calls.
- For sales tasks, do not call custom scripts under `/root/.openclaw/workspace/scripts/` unless user explicitly asked and file existence was verified first.
- KPI priority for sales/aftersale outputs: `发货件数/发货订单数/售后件数` first, then optional amount metrics.
- Unless user explicitly asks for "金额/费用/收入/利润", avoid using amount as primary ranking dimension.
- Mandatory first actions for sales tasks:
1) `exec`: `company-db health`
2) `exec`: `company-db tables`
3) `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or execute targeted SQL)
- Always read `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` before crafting complex sales/aftersale SQL.
- If a sales request arrives, run tools immediately and output only the final result summary.
- For direct human chat, output final answer only; never output internal analysis, tool JSON, or pseudo code unless explicitly requested.
- Default language is Simplified Chinese.
- Keep answers short and practical; avoid templates and setup scripts unless user asks for them.
- For greetings, reply with one short sentence only.
- If message is exactly an internal relay line (for example `Agent-to-agent announce step`), reply with one short acknowledgment and wait.
## First Run
If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.
## Every Session
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
Don't ask permission. Just do it.
## Memory
You wake up fresh each session. These files are your continuity:
- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
### 🧠 MEMORY.md - Your Long-Term Memory
- **ONLY load in main session** (direct chats with your human)
- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
- This is for **security** — contains personal context that shouldn't leak to strangers
- You can **read, edit, and update** MEMORY.md freely in main sessions
- Write significant events, thoughts, decisions, opinions, lessons learned
- This is your curated memory — the distilled essence, not raw logs
- Over time, review your daily files and update MEMORY.md with what's worth keeping
### 📝 Write It Down - No "Mental Notes"!
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
- When you make a mistake → document it so future-you doesn't repeat it
- **Text > Brain** 📝
## Safety
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:**
- Read files, explore, organize, learn
- Search the web, check calendars
- Work within this workspace
**Ask first:**
- Sending emails, tweets, public posts
- Anything that leaves the machine
- Anything you're uncertain about
Preapproved exception:
- For `hq.yucekj.com` and SQLServer sales data tasks explicitly requested by the human, proceed directly with execution and report results.
## Group Chats
You have access to your human's stuff. That doesn't mean you _share_ their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak.
### 💬 Know When to Speak!
In group chats where you receive every message, be **smart about when to contribute**:
**Respond when:**
- Directly mentioned or asked a question
- You can add genuine value (info, insight, help)
- Something witty/funny fits naturally
- Correcting important misinformation
- Summarizing when asked
**Stay silent (HEARTBEAT_OK) when:**
- It's just casual banter between humans
- Someone already answered the question
- Your response would just be "yeah" or "nice"
- The conversation is flowing fine without you
- Adding a message would interrupt the vibe
**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it.
**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
Participate, don't dominate.
### 😊 React Like a Human!
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
**React when:**
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
- Something made you laugh (😂, 💀)
- You find it interesting or thought-provoking (🤔, 💡)
- You want to acknowledge without interrupting the flow
- It's a simple yes/no or approval situation (✅, 👀)
**Why it matters:**
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
**📝 Platform Formatting:**
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
## 💓 Heartbeats - Be Proactive!
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
Default heartbeat prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
### Heartbeat vs Cron: When to Use Each
**Use heartbeat when:**
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
- You need conversational context from recent messages
- Timing can drift slightly (every ~30 min is fine, not exact)
- You want to reduce API calls by combining periodic checks
**Use cron when:**
- Exact timing matters ("9:00 AM sharp every Monday")
- Task needs isolation from main session history
- You want a different model or thinking level for the task
- One-shot reminders ("remind me in 20 minutes")
- Output should deliver directly to a channel without main session involvement
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
- **Calendar** - Upcoming events in next 24-48h?
- **Mentions** - Twitter/social notifications?
- **Weather** - Relevant if your human might go out?
**Track your checks** in `memory/heartbeat-state.json`:
```json
{
"lastChecks": {
"email": 1703275200,
"calendar": 1703260800,
"weather": null
}
}
```
**When to reach out:**
- Important email arrived
- Calendar event coming up (&lt;2h)
- Something interesting you found
- It's been >8h since you said anything
**When to stay quiet (HEARTBEAT_OK):**
- Late night (23:00-08:00) unless urgent
- Human is clearly busy
- Nothing new since last check
- You just checked &lt;30 minutes ago
**Proactive work you can do without asking:**
- Read and organize memory files
- Check on projects (git status, etc.)
- Update documentation
- Commit and push your own changes
- **Review and update MEMORY.md** (see below)
### 🔄 Memory Maintenance (During Heartbeats)
Periodically (every few days), use a heartbeat to:
1. Read through recent `memory/YYYY-MM-DD.md` files
2. Identify significant events, lessons, or insights worth keeping long-term
3. Update `MEMORY.md` with distilled learnings
4. Remove outdated info from MEMORY.md that's no longer relevant
Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
## Make It Yours
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
## Clean Chat Mode
- In direct human chats, answer the user request only and keep replies concise.
- Do not output internal reasoning or planning text.
- Ignore plain system relay lines like `Agent-to-agent announce step`; do not ask follow-up unless a real task is present.
- If a required argument is missing for a tool action, ask for that one missing field in one short sentence.
## Search Fallback
- When user asks for online search and `web_search` is unavailable (for example missing API key), use `browser` to open a search engine and summarize results instead of returning setup instructions.
- Return concise Chinese results first, then optional source links.
## Market Analysis Hard Route
- If user request mentions market analysis intent ("市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis` in `/root/.openclaw/workspace/websites.yaml`.
- For this route, do not start with `web_search` or generic search engines.
- Canonical market site is `https://hq.yucekj.com` (login entry: `https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F`).
- Start from login entry, then continue in the same site session for evidence collection.
- Use env credentials `SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME` and `SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD` first.
## Company Sales Hard Route
- If user request mentions sales-data intent ("销售数据", "销售报表", "销售看板", "公司销售", "业绩报表", "费比监控", "业绩数据", "售后", "退款"), route to `company-sales-db-analysis` in `/root/.openclaw/workspace/websites.yaml`.
- For this route, do not use generic search first. Start with `exec`: `company-db health`, then run SQL query.
- Allowed data source is SQLServer tables listed in `/root/.openclaw/workspace/websites.yaml` -> `company-sales-db-analysis.allowed_tables`.
- For sales-data requests, never call `memory_search` or `memory_get`; go straight to DB execution.
- Use env credentials `COMPANY_DB_HOST/PORT/NAME/USER/PASSWORD` (loaded from `/root/.openclaw/credentials/company-sql.env`).
- If request contains both market-analysis and sales-data keywords, prioritize company sales DB route when the ask is company performance/aftersale metrics.
## Install Media Hard Route
- If user request mentions install-media intent ("安装图纸", "安装视频", "安装说明书", "图纸地址", "视频地址", "订单号查图纸"), route to `install-media-db-analysis` in `/root/.openclaw/workspace/websites.yaml`.
- Execution must be: order-id -> product_no (SQLServer) -> install-media links (MySQL), command:
- `install-media-lookup --order-id "<订单号>"`
- Keep both fields in output:
- `raw_path` (数据库原始路径)
- `media_url` (浏览器可访问地址)
- URL rule: `D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`.
- If MySQL is unavailable or no mapping hit, return explicit gap and next step; do not fabricate links.
## Company Sales Task Router
- For `company-sales-db-analysis`, use field catalog from `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`.
- Mandatory execution sequence: identify指标 -> map字段/表 -> run SQL -> check null/outlier -> output conclusions.
- For aftersale analysis requests (`售后分析`/`退款原因`/`退货原因`/`客诉原因`), always run:
`company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql`
- Do not output "售后分析" using only `fys_Ticketing_install` work-order metrics.
- If a legacy path references `sqlserver/last_week_after_sale_analysis.sql`, treat it as alias of `after_sale_360_report.sql` and still output reason dimensions.
- Output order for sales/aftersale reports:
1) 发货件数/售后件数/订单数
2) 结构占比与趋势
3) 金额类指标(仅补充,不作主排序,除非用户明确要求)
- Dimension routing defaults:
- fee ratio / margin / refund / expense / platform or shop performance -> `jst_goods_profit_kpi_items_afs` + `jst_order_express_details`
- SKUID/SPU/detail requests -> `jst_sku` + `op_TargetManagement` + `op_operManage`
- logistics/freight questions -> `fys_jstFreightCost` + `fys_jstFreightCostDetail`
- installation/aftersale process -> `fys_Ticketing_install` + `fys_Ticketing_install_item` + `pc_kfChatLog`
- retention decision (退回/补偿/放货) -> `product-retention-decision --depreciation-rate 0.20`
- For retention decision output, must include:
- `depreciation_rate_used` + `depreciation_loss_if_return`
- `option_return_net_profit` / `option_discount_net_profit_{low,high}` / `option_release_net_profit`
- `recommended_option` + `recommended_action`
- Evidence minimum for sales brief:
- at least 1 SQL result set with fields + values
- at least 1 statement of time window and filter conditions
- if values are `--` or empty, report as `未在当前筛选下取到`
## Autonomous Page Planning (Market Analysis)
Priority: This section overrides earlier conflicting market-analysis guidance.
- For market-analysis requests, the agent must autonomously decide which yuce module/page to open based on requested metrics.
- Allowed domain is still strictly limited to `hq.yucekj.com` and `yucekj.com`.
- Execution path must use `exec` with `yuce-browser` only (no `cron`, no `web_search`, no `web_fetch`).
- Page-by-page data fields and button-expansion flow must follow:
- `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`
- Do not use guessed hash routes for modules (forbidden examples: `/#/insight`, `/#/leaf-category-analysis`, `/#/keyword-analysis`, `/#/trend-analysis`, `/#/demand-analysis`, `/#/keyword-layout`, `/#/brand-shop-hot`, `/#/price-segment`, `/#/surge-product`).
- For module access, use either homepage card clicks or known `?report-app=...` report URLs.
- If a page shows `很抱歉,您没有权限访问此页面`, retry once via homepage card click/known report URL before concluding permission is missing.
### Metric -> Page Routing Map
- If request includes market size / growth / trend -> prioritize `市场洞察`.
- If request includes category share / subcategory opportunity -> prioritize `叶子类目分析`.
- If request includes brand/shop/product competition -> prioritize `品牌店铺商品热榜` + `竞店布局分析`.
- If request includes user demand / keyword trend -> prioritize `产品需求分析` + `需求趋势分析` + `关键词布局分析`.
- If request includes pricing strategy / price band -> prioritize `价格段布局` + `品牌布局分析`.
### Autonomous Execution Policy
- First collect evidence pages, then output conclusions.
- Minimum evidence for each report: at least 2 snapshots from relevant modules + key request logs.
- If requested metric has no visible data on current account/plan, explicitly mark as "未在当前账号页面取到" (do not fabricate).
- If CAPTCHA/OTP appears, stop immediately and ask user for manual step.
### Output Format (Mandatory)
- Section 1: "已抓取证据" (page/module names + observed fields)
- Section 2: "分析结论" (only based on Section 1)
- Section 3: "缺失项与下一步" (what still needs manual click/filter or higher permissions)
## Market Analysis Output Guardrails
- For yuce market-analysis tasks, module names must be chosen only from this whitelist:
- 市场洞察
- 叶子类目分析
- 品牌店铺商品热榜
- 飙升品榜单
- 品牌布局分析
- 竞店布局分析
- 价格段布局
- 产品需求分析
- 需求趋势分析
- 关键词布局分析
- Do not invent module ids, dataset names, or pseudo data-source names (e.g. yuce_market_size).
- If not executed yet, output only "拟访问模块(白名单)" + "对应指标"; do not output any numeric conclusions.
- Do not edit files under `memory/` unless user explicitly asks to modify memory files.
## Market Brief Mode (Conclusion + Suggestions)
For market-analysis tasks, final output must be an analysis brief, not a tool plan.
### Mandatory evidence gate
- Before writing any conclusion, execute evidence collection with `exec` + `yuce-browser`.
- Minimum evidence:
- at least 2 snapshots from relevant yuce modules
- at least 1 network/request evidence line from `yuce-browser requests`
- If evidence is insufficient, output only missing items and ask user for one concrete next action.
### Mandatory report structure
Use exactly these sections in Chinese:
1. `已抓取证据`
- List each evidence item with: module/page, metric field, observed value.
2. `核心结论`
- 3-5 concise conclusions, each mapped to evidence.
3. `可执行建议`
- 3-5 actionable suggestions with priority (`P1/P2/P3`).
4. `风险与缺口`
- Explicitly state unavailable metrics and why.
5. `下一步`
- The next 1-3 actions to improve certainty.
### Anti-fabrication constraints
- Do not output market numbers unless they appear in captured evidence.
- Any inference must be labeled as `推断`.
- If a metric is absent, write `未在当前页面取到`.
## Market Brief Script Binding
For market-analysis brief tasks, prefer this deterministic command first:
- `exec`: `market-brief-report <keyword>`
- During execution, enforce SOP steps in `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`,
especially: after any date/filter change, click `查询` before collecting evidence.
Then summarize its output without changing numeric values.
If script output already contains the required sections, reuse them directly.

View File

@ -0,0 +1,121 @@
# AGENTS.md - OpenClaw Workspace Rules
## 1) Core Behavior
- Execute first, explain later. Do not throw generic blockers to the user.
- Before asking user for help, try at least 2 autonomous recoveries.
- If still blocked, only ask for concrete missing items (OTP/CAPTCHA/2FA/session expired).
- Do not fabricate data. Every conclusion must come from tool results.
## 2) Routing Priority (Highest to Lowest)
### A. Xiaohongshu Tasks (override all market routes)
Trigger keywords: 小红书 / 红书 / xhs / 笔记 / 种草 / 舆情
Required sequence:
1. `exec: xhs-api health`
2. `exec: xhs-api login-status`
3. If not logged in: `exec: xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`
4. Then use:
- `xhs-api search "<keyword>"`
- `xhs-api detail <id> <xsecToken> true`
### B. Sales / Aftersale / Refund / Performance Tasks (SQL only)
Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图
Hard rules:
- Must route to `company-sales-db-analysis`.
- Do not use yuce website for sales/aftersale tasks.
- Do not use web_search/web_fetch for these tasks.
- Must run at least one `exec` data query before final response.
- Do not call `tts` unless user explicitly asks voice output.
Mandatory first checks (once per turn):
1. `exec: company-db health`
2. Query by SQL templates in `/root/.openclaw/workspace/sqlserver/`
Template mapping:
- 店铺周报 -> `shop_weekly_report.sql`
- SKU结构 -> `sku_structure_report.sql`
- 产品信息(SKU/成本/材质/预览图) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`
- 商品ID查SKU清单 -> `exec: product-skus-by-id "<商品id>" --limit 1000`
Product info output minimum fields:
- SKU编码, SKU名称, 成本价, 材质, 预览图地址
- 推荐补充:SPU/货号/分类/供应商/尺寸/重量/SKU状态
- 若存在物流数据,追加 一区/二区/三区 运费汇总(近30天)
- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)
- 退款原因/售后 -> `after_sale_360_report.sql`
- 费比异常 -> `fee_ratio_alert.sql`
- 物流时效 -> `logistics_sla_report.sql`
- 运费效率 -> `freight_efficiency_report.sql`
- 客服热点 -> `customer_service_hotspot_report.sql`
- 客服体验/话术分析(可指定时间段) -> `exec: customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`
- 输出需包含:客服能力总分与等级(S/A/B/C/D)
- 安装服务 -> `install_service_quality_report.sql`
- 链接目标覆盖 -> `link_target_coverage_report.sql`
Aftersale special rule:
- Do not analyze only one table.
- Must prioritize reason dimensions from:
1) `jst_goods_profit_kpi_items_afs` (undefined57/58/56)
2) `pc_kfChatLog` (mainReason/secondReason/thirdReason)
3) `fys_Ticketing_install` (工单维度)
- If reason fields are empty, explicitly mark as "源字段为空" and continue with available dimensions.
Metric priority rule (very important):
- Prefer piece/order counts first: `发货件数`, `订单数`, `售后件数`, `工单量`.
- Amount metrics are secondary and optional.
- Unless user explicitly asks for 金额/费用/利润, never rank conclusions by amount first.
Output format for sales/aftersale:
1. 件数与订单核心结论
2. 原因结构(一级/二级/三级)
3. 趋势与异常
4. 金额补充(可选)
5. 可执行建议
6. 若为客服体验/话术任务,必须补充“对话案例 + 推荐回复话术”
7. 若为客服体验/话术任务,必须补充“客服能力评分(按客服名字输出四维度+总分+等级)”
### C. Market Analysis Tasks (non-sales)
Trigger keywords: 市场分析 / 行业分析 / 类目分析 / 竞品分析 / 趋势分析
Hard rules:
- Only allow domain `hq.yucekj.com` / `yucekj.com`.
- Use `exec` with `yuce-browser` only.
- Never use browser `search` action.
- Never use web_search/web_fetch for this route.
- If login/CAPTCHA blocks, ask user for that exact step only.
### D. General Online Search Tasks
Trigger keywords: 在线搜索 / 搜一下 / 查资料 / 最新消息 / 新闻 / 政策更新
Preferred execution:
1. First use `exec: web-search "<query>" --count 8` (stable path; includes Brave+Bing fallback).
2. Optional: if user explicitly asks for Brave-only, use `web_search`.
3. Summarize with source links, and mark date/time context when content is time-sensitive.
## 3) Security and Privacy
- Do not expose raw data source endpoints in user-facing replies.
- Mask domain/IP/api endpoints in final messages when possible.
- Never leak secrets from env files.
## 4) Reply Quality Rules
- Send concise final answer; no long tool transcript dump.
- If task may take time, send one short processing hint first.
- Do not repeat the same result twice in one turn.
- If a request fails, include: what was attempted, what failed, and next actionable fix.
## 5) Workspace Paths
- SQL templates: `/root/.openclaw/workspace/sqlserver/`
- SQL catalog: `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
- Router skill: `/root/.openclaw/skills/web-task-router/SKILL.md`
- Tool guide: `/root/.openclaw/workspace/TOOLS.md`
- Site config: `/root/.openclaw/workspace/websites.yaml`

View File

@ -0,0 +1,123 @@
# 销售分析常用 SQL 模板包
数据源:`JSDataMiddlePlatform`(SQLServer)
执行方式:`company-db run-file <sql文件路径>`
默认分析口径(已按最新要求):
- 主口径优先:`发货件数 / 售后件数 / 订单数`
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
## 模板列表
1. `shop_weekly_report.sql`
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
- 默认时间:上一个自然周
2. `sku_structure_report.sql`
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
- 默认时间:最近30天
3. `refund_reason_report.sql`
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
- 默认时间:最近30天
4. `fee_ratio_alert.sql`
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
- 默认时间:最近14天
5. `logistics_sla_report.sql`
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
- 默认时间:最近30天
6. `freight_efficiency_report.sql`
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
- 默认时间:最近30天
7. `install_service_quality_report.sql`
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
- 默认时间:最近90天
8. `customer_service_hotspot_report.sql`
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
- 默认时间:最近30天
9. `link_target_coverage_report.sql`
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
- 默认时间:在线链接全量
10. `after_sale_360_report.sql`
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
- 默认时间:上一个自然周
11. `product-info`(CLI辅助命令)
- 用途:按 SKU/SPU/关键词查询产品基础信息(成本、材质、预览图地址)
- 命令:`product-info "<sku或关键词>" --days 30 --limit 20`
- 输出:
- 结果集1:产品基础信息(含 `sku_name`、`cost_price`、`board_material`、`preview_pic_url`)
- 结果集2:最近N天出货/售后件数
- 结果集3:最近N天店铺分布(件数)
- 结果集4:最近N天一区/二区/三区运费汇总
- 结果集5:最近N天省份运费TOP
- 结果集6:最近N天售后总览(申请件数/退回件数/退款单数)
- 结果集7:最近N天售后类型/结果矩阵
- 结果集8:最近N天售后原因TOP(含来源)
12. `product-skus-by-id`(CLI辅助命令)
- 用途:按 商品ID 查询该ID下所有 SKU编码 与 SKU名称
- 命令:`product-skus-by-id "<商品id>" --limit 1000`
- 输出:
- 结果集1:匹配汇总(SKU数量/SPU数量)
- 结果集2:SKU编码+SKU名称清单(含分类、成本、预览图)
13. `customer_service_experience_report.sql`
- 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势)
- 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`)
14. `customer-service-analysis`(CLI辅助命令)
- 用途:按指定时间段做客服维度的用户反馈体验/话术分析
- 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
- 输出:
- 结果集1:时间段总览(会话量、空内容率、无对应分类率)
- 结果集2:一级/二级问题结构 TOP
- 结果集3:店铺体验分对比
- 结果集4:话术覆盖率(问候/共情/承诺/收尾)
- 结果集5:风险话术命中 TOP
- 结果集6:每日体验趋势
- 结果集7:风险对话案例 + 建议回复话术
- 结果集8:客服能力评分(响应能力/满意度/退款效率/求助风险控制 + 总分等级)
- 结果集9:按客服名字评分(可识别优秀客服)
### 客服能力评分口径(代理映射)
- 参考维度:旺旺响应能力、旺旺满意度、退款处理体验、平台求助风险控制
- 当前数据库可用字段:`pc_kfChatLog.content/mainReason/secondReason/thirdReason/reasonContent`
- 评分权重(总分100):
- 响应能力 30%
- 满意度 30%
- 退款效率 20%
- 求助风险控制 20%
- 等级:`S>=90`,`A>=80`,`B>=70`,`C>=60`,`D<60`
- 说明:该评分为“规则维度代理口径”,用于内部运营诊断,不等同平台官方最终分数。
## 运行示例
```bash
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/sku_structure_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/refund_reason_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/fee_ratio_alert.sql
company-db run-file /root/.openclaw/workspace/sqlserver/logistics_sla_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/freight_efficiency_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/install_service_quality_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_hotspot_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/link_target_coverage_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_experience_report.sql
customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20
```
## 注意事项
- 所有模板都默认过滤 `配件` 和 `特殊单` 标签。
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
- 如需自定义时间,修改每个模板顶部变量即可。

53
remote_sync/TOOLS.md Normal file
View File

@ -0,0 +1,53 @@
# TOOLS.md - Local Notes
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
## What Goes Here
Things like:
- Camera names and locations
- SSH hosts and aliases
- Preferred voices for TTS
- Speaker/room names
- Device nicknames
- Anything environment-specific
## Examples
```markdown
### Cameras
- living-room → Main area, 180° wide angle
- front-door → Entrance, motion-triggered
### SSH
- home-server → 192.168.1.100, user: admin
### TTS
- Preferred voice: "Nova" (warm, slightly British)
- Default speaker: Kitchen HomePod
```
## Why Separate?
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
---
Add whatever helps you do your job. This is your cheat sheet.
## Web Routing Preset (Auto)
- Site registry file: `/root/.openclaw/workspace/websites.yaml`
- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`.
- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`.
- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping.
- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow.
- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`).
- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback.
- If task does not need login, use `public-search` or direct public site access.
- If OTP/CAPTCHA appears, pause and ask user in chat, then continue.
- Never operate outside `allowed_domains`.

View File

@ -0,0 +1,73 @@
# TOOLS.md - Local Notes
Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup.
## What Goes Here
Things like:
- Camera names and locations
- SSH hosts and aliases
- Preferred voices for TTS
- Speaker/room names
- Device nicknames
- Anything environment-specific
## Examples
```markdown
### Cameras
- living-room → Main area, 180° wide angle
- front-door → Entrance, motion-triggered
### SSH
- home-server → 192.168.1.100, user: admin
### TTS
- Preferred voice: "Nova" (warm, slightly British)
- Default speaker: Kitchen HomePod
```
## Why Separate?
Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure.
---
Add whatever helps you do your job. This is your cheat sheet.
## Web Routing Preset (Auto)
- Site registry file: `/root/.openclaw/workspace/websites.yaml`
- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`.
- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`.
- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping.
- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow.
- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`).
- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback.
- If task does not need login, use `public-search` or direct public site access.
- If OTP/CAPTCHA appears, pause and ask user in chat, then continue.
- Never operate outside `allowed_domains`.
## Product Info Helper
- Command: `product-info "<sku或关键词>" --days 30 --limit 20`
- Returns:
1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)
2) Last N days shipped/aftersale counts
3) Last N days shop distribution by shipped piece count
- Data source: SQLServer `JSDataMiddlePlatform`
- Command: `product-skus-by-id "<商品id>" --limit 1000`
- Returns all SKU codes and SKU names under the given product ID (style_code/product_no/spu/shopproid).
- `product-info` now also returns aftersale insight for matched SKU:
- aftersale summary, type/result matrix, reason TOP (last N days)
## Customer Service Experience Helper
- Command: `customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
- Purpose: Analyze customer feedback experience + service wording quality in a date range.
- Output: overall quality, reason structure, shop comparison score, wording coverage, risk phrase hits, daily trend, dialogue cases, capability score by agent name (S/A/B/C/D).

Binary file not shown.

Binary file not shown.

Binary file not shown.

2319
remote_sync/bot.current.py Executable file

File diff suppressed because it is too large Load Diff

2389
remote_sync/bot.py Executable file

File diff suppressed because it is too large Load Diff

2319
remote_sync/bot.py.minimax-work Executable file

File diff suppressed because it is too large Load Diff

628
remote_sync/bot.remote.py Executable file
View File

@ -0,0 +1,628 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import logging
import os
import re
import subprocess
import time
import urllib.parse
from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage
from dingtalk_stream import Credential, DingTalkStreamClient
MARKET_HINT_KEYWORDS = (
"市场分析",
"市场数据分析",
"行业分析",
"类目分析",
"竞品分析",
"趋势分析",
)
SALES_HINT_KEYWORDS = (
"销售数据",
"销售报表",
"销售看板",
"公司销售",
"业绩报表",
"业绩数据",
"费比监控",
)
SENSITIVE_SALES_KEYWORDS = (
"销售",
"业绩",
"售后",
"退款",
"店铺",
"发货",
"工单",
"费比",
)
EXPLICIT_AMOUNT_KEYWORDS = (
"金额",
"费用",
"收入",
"利润",
"成本",
"客单价",
"gmv",
"销售额",
"退款额",
"毛利",
)
URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE)
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b")
DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE)
IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE)
def bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
class OpenClawDingTalkBot(ChatbotHandler):
def __init__(self, logger: logging.Logger):
super().__init__()
self.logger = logger
self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600"))
self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180"))
self.prefix = os.getenv("BOT_PREFIX", "/oc").strip()
self.require_prefix = bool_env("REQUIRE_PREFIX", False)
self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500"))
self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk")
self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True)
self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True)
self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300"))
self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120"))
self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions")
self.unmask_url_hosts = {
h.strip().lower()
for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",")
if h.strip()
}
self._recent_event_ts = {}
self.processing_hint_text = os.getenv(
"PROCESSING_HINT_TEXT",
"已收到,正在处理你的需求,请稍等1-3分钟。",
).strip()
self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower()
self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip()
self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip()
self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip()
self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip()
self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip()
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
if text.startswith(self.prefix):
return True
if msg.is_in_at_list:
return True
return not self.require_prefix
def _normalize_prompt(self, text: str) -> str:
text = text.strip()
if self.prefix and text.startswith(self.prefix):
text = text[len(self.prefix):].strip()
text = re.sub(r"^@\S+\s*", "", text).strip()
return text
def _session_id(self, msg: ChatbotMessage) -> str:
conversation_id = (msg.conversation_id or "default").replace(" ", "_")
base_session = f"{self.session_prefix}-{conversation_id}"
session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl")
if self.session_max_lines > 0 and os.path.exists(session_file):
try:
with open(session_file, "rb") as fh:
line_count = sum(1 for _ in fh)
if line_count >= self.session_max_lines:
rotate_suffix = time.strftime("%Y%m%d%H%M")
rotated = f"{base_session}-{rotate_suffix}"
self.logger.info(
"session auto-rotated: base=%s lines=%s rotated=%s",
base_session,
line_count,
rotated,
)
return rotated
except Exception:
self.logger.exception("failed to inspect session file: %s", session_file)
return base_session
def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str:
if getattr(msg, "message_id", None):
return f"msg:{msg.message_id}"
data = callback.data if isinstance(callback.data, dict) else {}
for key in ("msgId", "messageId", "eventId", "processQueryKey"):
value = data.get(key)
if value:
return f"data:{key}:{value}"
headers = getattr(callback, "headers", {}) or {}
for key in ("messageId", "eventId", "processQueryKey"):
value = headers.get(key)
if value:
return f"hdr:{key}:{value}"
raw = "|".join(
[
str(getattr(msg, "conversation_id", "") or ""),
str(getattr(msg, "sender_id", "") or ""),
str(getattr(msg, "sender_staff_id", "") or ""),
str(getattr(msg, "create_at", "") or ""),
prompt[:200],
]
)
return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _seen_or_mark_event(self, key: str) -> bool:
now = time.time()
expire_before = now - max(self.dedupe_ttl_seconds, 30)
stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before]
for stale_key in stale_keys:
self._recent_event_ts.pop(stale_key, None)
if key in self._recent_event_ts:
return True
self._recent_event_ts[key] = now
return False
def _is_market_prompt(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt)
if any(k in normalized for k in SALES_HINT_KEYWORDS):
return False
if "电脑桌" in normalized:
return True
if any(k in normalized for k in MARKET_HINT_KEYWORDS):
return True
if "市场" in normalized and "分析" in normalized:
return True
return False
def _is_sales_prompt(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt).lower()
return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS)
def _explicit_amount_requested(self, prompt: str) -> bool:
normalized = re.sub(r"\s+", "", prompt).lower()
return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS)
def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str:
if not self._is_sales_prompt(prompt):
return answer
if self._explicit_amount_requested(prompt):
return answer
filtered_lines = []
removed = 0
for line in answer.splitlines():
if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE):
removed += 1
continue
filtered_lines.append(line)
cleaned = "\n".join(filtered_lines).strip()
if removed > 0:
note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)"
if cleaned:
cleaned = f"{cleaned}\n\n{note}"
else:
cleaned = note
return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。"
def _extract_market_subject(self, prompt: str) -> str:
normalized = prompt.strip()
def clean_subject(raw: str) -> str:
v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'")
v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v)
v = re.sub(r"(的|情况|数据|分析)+$", "", v)
return v.strip()
def pick(raw: str):
v = clean_subject(raw)
if len(v) < 2:
return None
if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}:
return None
return v
if "电脑桌" in normalized:
return "电脑桌"
m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(
r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)",
normalized,
)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(
r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)",
normalized,
)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
if m:
v = pick(m.group(1))
if v:
return v
m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized)
if m:
v = pick(m.group(1))
if v:
return v
return "电脑桌"
def _sanitize_answer(self, answer: str) -> str:
if not self.hide_source_urls:
return answer
keep_map = {}
def _keep_or_mask_url(match):
raw = match.group(0)
try:
host = (urllib.parse.urlparse(raw).hostname or "").lower()
except Exception:
host = ""
if host and host in self.unmask_url_hosts:
token = f"__KEEP_URL_{len(keep_map)}__"
keep_map[token] = raw
return token
return "[内部数据源地址已脱敏]"
out = URL_RE.sub(_keep_or_mask_url, answer)
out = IP_RE.sub("[内部地址已脱敏]", out)
out = DOMAIN_RE.sub("[内部域名已脱敏]", out)
# 保底脱敏(防止某些格式未被正则覆盖)
for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"):
out = out.replace(token, "[内部数据源已脱敏]")
for token, raw in keep_map.items():
out = out.replace(token, raw)
return out
def _run_market_brief(self, subject: str) -> str:
cmd = [self.market_brief_bin, subject]
self.logger.info("market-brief call: subject=%s", subject)
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.market_brief_timeout,
)
output = (proc.stdout or "").strip()
if proc.returncode != 0:
err = (proc.stderr or output or "market brief error").strip()
return f"市场分析执行失败: {err[:500]}"
if not output:
return "市场分析执行完成,但未返回文本结果。"
return output
def _run_openclaw(self, session_id: str, prompt: str) -> str:
cmd = [
self.openclaw_bin,
"agent",
"--session-id",
session_id,
"--message",
prompt,
"--json",
"--timeout",
str(self.openclaw_timeout),
]
self.logger.info("openclaw call: session=%s", session_id)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.openclaw_timeout + 20,
)
except subprocess.TimeoutExpired:
self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout)
return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw error").strip()
return f"OpenClaw 调用失败: {err[:500]}"
try:
data = json.loads(proc.stdout)
payloads = data.get("result", {}).get("payloads", [])
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
answer = "\n".join(t for t in text_items if t).strip()
if not answer:
answer = "任务已执行,但未返回文本结果。"
if self._is_market_prompt(prompt) and (
"无法直接从页面抓取" in answer
or "需要登录后的操作" in answer
or "工具版本不支持点击交互" in answer
):
subject = self._extract_market_subject(prompt)
fallback = self._run_market_brief(subject)
return f"已切换为市场分析直连模式({subject}):\n\n{fallback}"
return answer
except Exception as exc:
self.logger.exception("parse openclaw output failed")
raw = (proc.stdout or "").strip()
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
def _is_image_url(self, raw_url: str) -> bool:
try:
parsed = urllib.parse.urlparse(raw_url)
except Exception:
return False
host = (parsed.hostname or "").lower()
path = (parsed.path or "").lower()
if IMAGE_EXT_RE.search(path):
return True
if host.startswith("img.") or "image" in host:
return True
return False
def _extract_image_urls(self, text: str, limit: int = 5) -> list:
urls = []
seen = set()
for match in URL_RE.finditer(text or ""):
raw = match.group(0).rstrip(".,;!?")
if not self._is_image_url(raw):
continue
if raw in seen:
continue
seen.add(raw)
urls.append(raw)
if len(urls) >= limit:
break
return urls
def _remove_image_urls(self, text: str, urls: list) -> str:
out = text
for u in urls:
out = out.replace(u, "")
out = re.sub(r"\n{3,}", "\n\n", out)
return out.strip()
def _build_action_buttons(self) -> list:
if not self.action_card_btn_url:
return []
return [
{
"text": self.action_card_btn_text or "打开链接",
"url": self.action_card_btn_url,
"iosUrl": self.action_card_btn_url,
"color": "gray",
}
]
def _is_card_reply_ok(self, reply_obj) -> bool:
if reply_obj is None:
return False
card_instance_id = getattr(reply_obj, "card_instance_id", None)
if isinstance(card_instance_id, str):
return bool(card_instance_id.strip())
return bool(card_instance_id)
def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool:
payload = (text or "").strip() or "任务已执行,但未返回文本结果。"
if prefer_card and self.reply_style == "action_card":
image_urls = self._extract_image_urls(payload, limit=5)
if image_urls:
slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)]
markdown = self._remove_image_urls(payload, image_urls)
if not markdown:
markdown = "已整理相关图片如下:"
try:
card = self.reply_carousel_card(
incoming_message=msg,
markdown=markdown,
image_slider=slider,
button_text=self.action_card_btn_text or "查看",
title=self.action_card_title,
logo=self.action_card_logo,
)
if self._is_card_reply_ok(card):
return True
self.logger.warning("carousel card reply returned empty id, fallback to action card/text")
except Exception:
self.logger.exception("carousel card reply failed, fallback to action card")
markdown = payload
if self.action_card_title:
markdown = f"### {self.action_card_title}\n\n{payload}"
try:
card = self.reply_markdown_button(
incoming_message=msg,
markdown=markdown,
button_list=self._build_action_buttons(),
tips=self.action_card_tips,
title=self.action_card_title,
logo=self.action_card_logo,
)
if self._is_card_reply_ok(card):
return True
self.logger.warning("action card reply returned empty id, fallback to text")
except Exception:
self.logger.exception("action card reply failed, fallback to text")
resp = self.reply_text(payload, msg)
if resp is None:
self.logger.error("text reply failed")
return False
return True
async def process(self, callback: CallbackMessage):
try:
msg = ChatbotMessage.from_dict(callback.data)
except Exception:
self.logger.exception("invalid callback payload")
return AckMessage.STATUS_BAD_REQUEST, "bad payload"
text = ""
if msg.text and getattr(msg.text, "content", None):
text = msg.text.content.strip()
else:
text_list = msg.get_text_list() or []
text = text_list[0].strip() if text_list else ""
if not text:
return AckMessage.STATUS_OK, "ignored"
if not self._should_handle(msg, text):
return AckMessage.STATUS_OK, "ignored"
prompt = self._normalize_prompt(text)
if not prompt:
self._reply_message(
msg,
f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩",
prefer_card=False,
)
return AckMessage.STATUS_OK, "ok"
event_key = self._event_key(callback, msg, prompt)
if self._seen_or_mark_event(event_key):
self.logger.info(
"duplicate callback ignored: key=%s conversation=%s sender=%s",
event_key,
getattr(msg, "conversation_id", ""),
getattr(msg, "sender_id", ""),
)
return AckMessage.STATUS_OK, "duplicate_ignored"
if self.send_processing_hint:
hint = self.processing_hint_text or "已收到,正在处理,请稍等。"
try:
self.reply_text(hint, msg)
except Exception:
self.logger.exception("failed to send processing hint")
session_id = self._session_id(msg)
if self._is_market_prompt(prompt):
subject = self._extract_market_subject(prompt)
answer = self._run_market_brief(subject)
else:
answer = self._run_openclaw(session_id, prompt)
answer = self._sanitize_answer(answer)
answer = self._mask_sensitive_amounts(prompt, answer)
if len(answer) > self.reply_max_chars:
answer = answer[: self.reply_max_chars] + "\n...(已截断)"
try:
sent = self._reply_message(msg, answer, prefer_card=True)
if sent:
self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer))
else:
self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer))
except Exception:
self.logger.exception("failed to send final reply: session=%s", session_id)
return AckMessage.STATUS_OK, "ok"
def build_client(logger: logging.Logger) -> DingTalkStreamClient:
client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip()
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip()
if not client_id or not client_secret:
raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置")
credential = Credential(client_id, client_secret)
client = DingTalkStreamClient(credential, logger=logger)
bot = OpenClawDingTalkBot(logger)
client.register_callback_handler(ChatbotMessage.TOPIC, bot)
if bool_env("REGISTER_DELEGATE_TOPIC", False):
client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot)
logger.info("delegate topic enabled")
else:
logger.info("delegate topic disabled (default)")
return client
def self_check(logger: logging.Logger) -> int:
required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"]
missing = [k for k in required if not os.getenv(k)]
if missing:
logger.error("missing env: %s", ", ".join(missing))
return 2
openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw")
proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True)
if proc.returncode != 0:
logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip())
return 3
market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report")
if not os.path.exists(market_brief_bin):
logger.error("market brief bin not found: %s", market_brief_bin)
return 4
logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip())
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge")
parser.add_argument("--check", action="store_true", help="run checks and exit")
args = parser.parse_args()
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("openclaw-dingtalk-bridge")
if args.check:
return self_check(logger)
client = build_client(logger)
logger.info("bridge started, listening DingTalk stream")
client.start_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,193 @@
2026-02-09T20:17:22+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,784 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,784 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,915 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '49b2dabf-05b1-11f1-bebd-26ca3a8714ef'}
2026-02-09T20:23:38+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:23:38,701 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:24:53+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:24:53,306 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:27:26+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:26,274 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T20:27:36+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:36,283 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T20:27:36+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:36,453 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b7654b04-05b2-11f1-b05d-329bedae46d2'}
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 9.584s CPU time.
2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,319 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,319 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,456 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '090e3062-05b3-11f1-b7a2-faf2d53e39a8'}
2026-02-09T20:30:12+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:30:12,030 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:31:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:31:53,570 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 10.324s CPU time.
2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,253 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,253 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,412 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '7fa31af1-05b3-11f1-89fb-2eaa794b294e'}
2026-02-09T20:36:22+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:36:22,895 INFO openclaw-dingtalk-bridge: market-brief call: subject=继续分析价格段布局和需求
2026-02-09T20:39:18+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:39:18,580 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:40:29+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:40:29,129 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:41:20+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:41:20,866 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T20:42:17+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:17,055 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T20:42:17+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:17,055 ERROR openclaw-dingtalk-bridge: error processing message: sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T20:42:27+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:27,065 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T20:42:27+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:27,235 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ca5546e8-05b4-11f1-8bff-5215a8ef9827'}
2026-02-09T21:26:25+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:26:25,497 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T21:28:57+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:28:57,028 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T21:29:18+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:18,153 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T21:29:28+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:28,163 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T21:29:28+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:28,309 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '5bd4cb3b-05bb-11f1-80c0-7e88154b8801'}
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 1min 11.189s CPU time, 424.5M memory peak, 0B memory swap peak.
2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T21:34:41+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:41,954 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T21:34:41+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:41,954 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T21:34:42+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:42,109 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '16df03a3-05bc-11f1-bebd-26ca3a8714ef'}
2026-02-09T21:38:33+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:38:33,276 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T21:40:42+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:40:42,339 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T21:41:51+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:41:51,118 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM=
2026-02-09T21:43:54+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:43:54,848 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T21:46:45+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:46:45,649 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM=
2026-02-09T21:47:28+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:28,052 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T21:47:37+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:37,331 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T21:47:47+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:47,340 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T21:47:47+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:47,475 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'eafc88cb-05bd-11f1-b05d-329bedae46d2'}
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 29.883s CPU time.
2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T21:55:55+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:55,930 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T21:55:55+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:55,931 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T21:55:56+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:56,063 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '0e354371-05bf-11f1-8bc5-6647947ab531'}
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T21:58:46+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:46,878 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T21:58:46+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:46,879 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T21:58:47+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:47,100 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '74253344-05bf-11f1-8bff-5215a8ef9827'}
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,277 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,277 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,415 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'bd0e66b6-05bf-11f1-bee8-0eb3b764c0bb'}
2026-02-09T22:02:29+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:02:29,164 INFO openclaw-dingtalk-bridge: market-brief call: subject=书架
2026-02-09T22:05:13+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:05:13,915 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T22:12:39+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:12:39,286 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T22:16:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:49,128 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T22:16:59+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:59,138 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T22:16:59+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:59,278 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ff24b313-05c1-11f1-80c0-7e88154b8801'}
2026-02-09T22:52:43+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:52:43,125 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T22:54:53+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:54:53,519 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T22:55:20+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:55:20,159 INFO openclaw-dingtalk-bridge: market-brief call: subject=基于以上
2026-02-09T22:56:04+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:04,918 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T22:56:04+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:04,918 ERROR openclaw-dingtalk-bridge: error processing message: sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T22:56:14+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:14,928 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T22:56:15+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:15,069 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '7b4d9192-05c7-11f1-8d8a-de809d1c6511'}
2026-02-09T22:58:08+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:58:08,695 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM=
2026-02-09T23:01:22+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:01:22,116 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T23:05:11+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:05:11,545 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T23:06:21+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:21,414 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T23:06:42+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:42,222 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T23:06:52+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:52,233 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:06:52+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:52,374 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'f72b29ad-05c8-11f1-884b-5a20030ba585'}
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 2min 24.875s CPU time, 430.4M memory peak, 0B memory swap peak.
2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,824 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,824 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,825 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,988 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '2a32a932-05c9-11f1-884b-5a20030ba585'}
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,914 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '3e6bc465-05c9-11f1-8d8a-de809d1c6511'}
2026-02-09T23:12:47+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:12:47,364 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T23:17:27+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:27,896 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgh7C3eoXlIiu8R6WVGDYXqA== conversation=cidZg4TLAufUGG9Axjg1sTY2A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
2026-02-09T23:17:28+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:28,064 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T23:17:37+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:37,266 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgmmZbADzAr1YhyOavIO6amw== conversation=cidZg4TLAufUGG9Axjg1sTY2A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 9.544s CPU time, 414.7M memory peak, 0B memory swap peak.
2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,139 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,139 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,140 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,274 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b45b2986-05ca-11f1-bebd-26ca3a8714ef'}
2026-02-09T23:21:25+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:21:25,894 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T23:24:56+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:24:56,356 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==
2026-02-09T23:26:21+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:26:21,744 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgc8yPTi4RlHrE+NDnT673hw== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
2026-02-09T23:31:51+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:31:51,202 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T23:38:36+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:38:36,958 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A==
2026-02-09T23:38:59+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:38:59,471 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received
2026-02-09T23:39:09+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:39:09,473 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:39:09+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:39:09,609 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '79d969eb-05cd-11f1-8d8a-de809d1c6511'}
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 19.556s CPU time, 433.0M memory peak, 0B memory swap peak.
2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,214 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ba9578cf-05cd-11f1-8bc5-6647947ab531'}
2026-02-09T23:42:42+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:42:42,320 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342
2026-02-09T23:42:42+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:42:42,320 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342
2026-02-09T23:45:00+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:45:00,359 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342 chars=938
2026-02-09T23:45:00+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:45:00,359 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgK5zYDsuFOjCcFfmwOMWvXQ== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 4.965s CPU time, 2.5M memory peak, 0B memory swap peak.
2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,506 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'd24bc157-05ce-11f1-80c0-7e88154b8801'}
2026-02-10T00:41:03+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:41:03,674 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041
2026-02-10T00:41:03+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:41:03,674 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041
2026-02-10T00:42:19+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:42:19,519 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041 chars=1197
2026-02-10T00:42:19+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:42:19,520 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgviGW4x1TkcCrGfqVg4kyHA== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q==
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 5.107s CPU time.
2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,227 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ca672ec1-05d6-11f1-80c0-7e88154b8801'}
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,913 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,913 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,914 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-10T00:52:37+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:37,075 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'bce5bf3b-05d7-11f1-8bc5-6647947ab531'}
2026-02-10T00:53:13+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:13,622 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053
2026-02-10T00:53:13+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:13,622 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053
2026-02-10T00:53:38+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:38,789 ERROR dingtalk_stream.card_replier: CardReplier.create_and_send_card failed, create card instance failed, error=403 Client Error: Forbidden for url: https://api.dingtalk.com/v1.0/card/instances, response.text={"code":"Forbidden.AccessDenied.AccessTokenPermissionDenied","requestid":"25E39354-5AEE-70E0-BEB3-AB85E5BCC784","message":"应用尚未开通所需的权限:[Card.Instance.Write],点击链接申请并开通即可:https://open-dev.dingtalk.com/appscope/apply?content=ding3oaumxwg7sol5stc%23Card.Instance.Write","accessdenieddetail":{"requiredScopes":["Card.Instance.Write"]}}
2026-02-10T00:53:38+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:38,790 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053 chars=847
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge...
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully.
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 5.037s CPU time.
2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge.
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: delegate topic disabled (default)
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open
2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,367 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b6382dab-05d8-11f1-bebd-26ca3a8714ef'}

17
remote_sync/company-db Normal file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
VENV="/root/.openclaw/.venv-db"
TOOL="/root/.openclaw/workspace/sqlserver/company_db_tool.py"
if [ ! -x "$VENV/bin/python" ]; then
echo "missing python runtime: $VENV/bin/python" >&2
exit 1
fi
if [ ! -f "$TOOL" ]; then
echo "missing tool script: $TOOL" >&2
exit 1
fi
exec "$VENV/bin/python" "$TOOL" "$@"

View File

@ -0,0 +1,378 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import json
import os
import pathlib
import sys
from typing import Dict, List, Tuple
import pytds
BUSINESS_TABLES = [
"jst_goods_profit_kpi_items_afs",
"jst_sku",
"fys_shopInfo",
"jst_order_express_details",
"fys_SkuDataLibrary_SpuConfig",
"op_operManage",
"op_TargetManagement",
"fys_Ticketing_install",
"fys_Ticketing_install_item",
"fys_jstFreightCost",
"fys_jstFreightCostDetail",
"pc_kfChatLog",
]
ENV_FILE = "/root/.openclaw/credentials/company-sql.env"
def load_env_file(path: str) -> None:
p = pathlib.Path(path)
if not p.exists():
return
for raw in p.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
def conn_params() -> Dict[str, object]:
load_env_file(ENV_FILE)
return {
"server": os.environ.get("COMPANY_DB_HOST", "218.93.191.16"),
"port": int(os.environ.get("COMPANY_DB_PORT", "1433")),
"database": os.environ.get("COMPANY_DB_NAME", "JSDataMiddlePlatform"),
"user": os.environ.get("COMPANY_DB_USER", "nanting"),
"password": os.environ.get("COMPANY_DB_PASSWORD", ""),
"login_timeout": 15,
"timeout": 300,
}
def get_conn():
params = conn_params()
if not params["password"]:
raise RuntimeError("COMPANY_DB_PASSWORD is empty")
return pytds.connect(**params)
def _fmt(value):
if isinstance(value, dt.datetime):
return value.isoformat(sep=" ")
if isinstance(value, (dt.date, dt.time)):
return value.isoformat()
return value
def print_rows(columns: List[str], rows: List[Tuple[object, ...]], truncated: bool = False) -> None:
print("COLUMNS:", ", ".join(columns))
if not rows:
print("ROWS: 0")
return
if truncated:
print(f"ROWS_SHOWN: {len(rows)} (truncated)")
else:
print(f"ROWS: {len(rows)}")
for row in rows:
obj = {columns[i]: _fmt(row[i]) for i in range(len(columns))}
print(json.dumps(obj, ensure_ascii=False, default=str))
def split_go_batches(sql_text: str) -> List[str]:
batches: List[str] = []
current: List[str] = []
for line in sql_text.splitlines():
if line.strip().upper() == "GO":
block = "\n".join(current).strip()
if block:
batches.append(block)
current = []
else:
current.append(line)
block = "\n".join(current).strip()
if block:
batches.append(block)
return batches
def fetch_with_limit(cursor, row_limit: int) -> Tuple[List[Tuple[object, ...]], bool]:
if row_limit <= 0:
return cursor.fetchall(), False
rows: List[Tuple[object, ...]] = []
truncated = False
while len(rows) <= row_limit:
batch = cursor.fetchmany(256)
if not batch:
break
rows.extend(batch)
if len(rows) > row_limit:
rows = rows[:row_limit]
truncated = True
break
return rows, truncated
def run_sql(sql_text: str, row_limit: int) -> int:
batches = split_go_batches(sql_text)
with get_conn() as conn:
cur = conn.cursor()
set_index = 0
for batch in batches:
cur.execute(batch)
while True:
if cur.description:
set_index += 1
cols = [c[0] for c in cur.description]
rows, truncated = fetch_with_limit(cur, row_limit)
print(f"RESULT_SET {set_index}")
print_rows(cols, rows, truncated=truncated)
if not cur.nextset():
break
cur.close()
return set_index
def cmd_health(_args: argparse.Namespace) -> int:
with get_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT @@VERSION AS version, DB_NAME() AS db_name, GETDATE() AS server_time")
cols = [c[0] for c in cur.description]
row = cur.fetchone()
data = {cols[i]: _fmt(row[i]) for i in range(len(cols))}
print(json.dumps(data, ensure_ascii=False, default=str, indent=2))
cur.close()
return 0
def cmd_tables(_args: argparse.Namespace) -> int:
names = ", ".join([f"'{t}'" for t in BUSINESS_TABLES])
sql = f"""
SELECT c.TABLE_NAME, COUNT(*) AS column_count
FROM INFORMATION_SCHEMA.COLUMNS c
WHERE c.TABLE_SCHEMA='dbo'
AND c.TABLE_NAME IN ({names})
GROUP BY c.TABLE_NAME
ORDER BY c.TABLE_NAME
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
cols = [c[0] for c in cur.description]
print_rows(cols, rows)
cur.close()
return 0
def cmd_columns(args: argparse.Namespace) -> int:
table = args.table
sql = f"""
SELECT
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
NUMERIC_PRECISION,
NUMERIC_SCALE,
IS_NULLABLE,
ORDINAL_POSITION
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA='dbo' AND TABLE_NAME='{table}'
ORDER BY ORDINAL_POSITION
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
cols = [c[0] for c in cur.description]
print_rows(cols, rows)
cur.close()
return 0
def _collect_schema() -> Dict[str, List[Tuple[object, ...]]]:
names = ", ".join([f"'{t}'" for t in BUSINESS_TABLES])
sql = f"""
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
NUMERIC_PRECISION,
NUMERIC_SCALE,
IS_NULLABLE,
ORDINAL_POSITION
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA='dbo'
AND TABLE_NAME IN ({names})
ORDER BY TABLE_NAME, ORDINAL_POSITION
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql)
rows = cur.fetchall()
cur.close()
by_table: Dict[str, List[Tuple[object, ...]]] = {t: [] for t in BUSINESS_TABLES}
for row in rows:
by_table[str(row[0])].append(row)
return by_table
def cmd_catalog(args: argparse.Namespace) -> int:
by_table = _collect_schema()
now = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
out = pathlib.Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
lines: List[str] = []
lines.append("# 公司销售/售后数据库字段与维度目录")
lines.append("")
lines.append(f"> 自动生成时间: {now}")
lines.append("> 数据源: SQLServer / JSDataMiddlePlatform / dbo")
lines.append("> 适用范围: 公司销售数据、售后数据、店铺运营、物流运费、安装工单、客服问题")
lines.append("")
lines.append("## 一、强制路由规则(给AI)")
lines.append("")
lines.append("- 凡是涉及 `销售/业绩/售后/退款/费比/店铺业绩` 的需求,必须走数据库查询,禁止走旧业绩网页。")
lines.append("- 先查字段目录再写 SQL:`/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`")
lines.append("- 优先复用周报模板:`/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql`")
lines.append("- 输出结论必须标注:`时间范围 + 使用表 + 关键字段 + 过滤条件`。")
lines.append("")
lines.append("## 二、可查询业务维度(语义层)")
lines.append("")
dimension_rows = [
("订单维度", "订单号、子订单号、内部订单ID、订单状态、售后状态", "jst_order_express_details, jst_goods_profit_kpi_items_afs"),
("商品维度", "SKU、SPU、货号、商品名称、规格、类目、品牌", "jst_sku, op_TargetManagement, op_operManage, fys_SkuDataLibrary_SpuConfig"),
("店铺维度", "店铺名称、平台、店铺编码、负责人、渠道类型", "fys_shopInfo, jst_order_express_details, jst_goods_profit_kpi_items_afs"),
("销售金额维度", "商品金额、付款金额、实发金额、成本、毛利相关费用", "jst_goods_profit_kpi_items_afs, jst_sku"),
("售后维度", "售后类型、售后原因、退款/退货金额、售后标签", "jst_goods_profit_kpi_items_afs, pc_kfChatLog"),
("物流维度", "快递公司、快递单号、物流状态、运费、重量、收货地区", "jst_order_express_details, fys_jstFreightCost, fys_jstFreightCostDetail"),
("安装工单维度", "工单状态、安装费用、结算金额、服务商", "fys_Ticketing_install, fys_Ticketing_install_item"),
("人员维度", "运营、设计师、采购、美工、店长、客服", "op_TargetManagement, op_operManage, fys_shopInfo, fys_SkuDataLibrary_SpuConfig"),
("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"),
("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"),
]
lines.append("| 维度 | 可查询内容 | 主要来源表 |")
lines.append("|---|---|---|")
for row in dimension_rows:
lines.append(f"| {row[0]} | {row[1]} | {row[2]} |")
lines.append("")
lines.append("## 三、核心关联键(Join Key)")
lines.append("")
lines.append("- `order_id`:`so_id / undefined2 / orderNo / OrderNum / Sourceno`")
lines.append("- `inner_order_id`:`inner_order_id / undefined1 / o_id`")
lines.append("- `sku_id`:`sku_id / undefined10 / barCode`")
lines.append("- `spu`:`spu / other_1 / undefined96`")
lines.append("- `shop_name`:`shop_name / shopName / undefined8 / shopname`")
lines.append("")
lines.append("## 四、全表字段清单(真实表头)")
lines.append("")
total_cols = 0
for table in BUSINESS_TABLES:
rows = by_table.get(table, [])
total_cols += len(rows)
lines.append(f"### {table}({len(rows)} 列)")
lines.append("")
lines.append("| 序号 | 字段名 | 类型 | 长度/精度 | 可空 |")
lines.append("|---:|---|---|---|---|")
for r in rows:
_, col, dtype, clen, prec, scale, nullable, ord_pos = r
if clen is not None:
length = str(clen)
elif prec is not None:
if scale is not None:
length = f"{prec},{scale}"
else:
length = str(prec)
else:
length = "-"
lines.append(f"| {ord_pos} | {col} | {dtype} | {length} | {nullable} |")
lines.append("")
lines.append("## 五、统计")
lines.append("")
lines.append(f"- 业务表数量:{len(BUSINESS_TABLES)}")
lines.append(f"- 字段总数:{total_cols}")
lines.append("- 建议:高频分析优先在订单售后主表 `jst_goods_profit_kpi_items_afs` 先聚合,再按需关联其他表,避免超时。")
out.write_text("\n".join(lines), encoding="utf-8")
print(str(out))
return 0
def cmd_query(args: argparse.Namespace) -> int:
sql_text = None
if args.file:
sql_text = pathlib.Path(args.file).read_text(encoding="utf-8")
elif args.sql:
sql_text = args.sql
elif args.sql_positional:
sql_text = " ".join(args.sql_positional).strip()
if not sql_text:
raise SystemExit("query requires --sql / --file / or inline SQL text")
set_count = run_sql(sql_text, args.limit)
print(f"TOTAL_RESULT_SETS: {set_count}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="company-db", description="Company SQLServer helper")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("health")
sub.add_parser("tables")
p_cols = sub.add_parser("columns")
p_cols.add_argument("table")
p_query = sub.add_parser("query")
p_query.add_argument("--sql", help="SQL text")
p_query.add_argument("--file", help="SQL file path")
p_query.add_argument("--limit", type=int, default=200, help="row limit via SET ROWCOUNT")
p_query.add_argument("sql_positional", nargs="*", help="inline SQL text, e.g. company-db query SELECT 1")
p_run = sub.add_parser("run-file")
p_run.add_argument("file", help="SQL file path")
p_run.add_argument("--limit", type=int, default=0, help="max rows shown per result set (0 means no limit)")
p_cat = sub.add_parser("catalog")
p_cat.add_argument("--out", default="/root/.openclaw/workspace/outputs/company-sales-db-catalog.md")
return p
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.cmd == "health":
return cmd_health(args)
if args.cmd == "tables":
return cmd_tables(args)
if args.cmd == "columns":
return cmd_columns(args)
if args.cmd == "query":
return cmd_query(args)
if args.cmd == "run-file":
args.sql = None
args.file = args.file
return cmd_query(args)
if args.cmd == "catalog":
return cmd_catalog(args)
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,274 @@
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @start_date date = DATEADD(day, -29, CAST(GETDATE() AS date));
DECLARE @end_date date = CAST(GETDATE() AS date);
DECLARE @start_time datetime2 = CAST(@start_date AS datetime2);
DECLARE @end_time datetime2 = DATEADD(day, 1, CAST(@end_date AS datetime2));
DECLARE @shop nvarchar(100) = N''; -- 可填店铺关键词,例如 N'亿家达'
DECLARE @limit int = 20;
IF OBJECT_ID('tempdb..#chat_base') IS NOT NULL
DROP TABLE #chat_base;
SELECT
c.id,
NULLIF(c.orderNo, '') AS order_no,
ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name,
c.createdAt,
c.updatedAt,
c.state,
ISNULL(NULLIF(LTRIM(RTRIM(c.mainReason)), ''), N'未标注') AS main_reason,
ISNULL(NULLIF(LTRIM(RTRIM(c.secondReason)), ''), N'未标注') AS second_reason,
ISNULL(NULLIF(LTRIM(RTRIM(c.thirdReason)), ''), N'未标注') AS third_reason,
CAST(c.content AS nvarchar(max)) AS content_text,
CAST(c.reasonContent AS nvarchar(max)) AS reason_text
INTO #chat_base
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
WHERE c.createdAt >= @start_time
AND c.createdAt < @end_time
AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%');
IF OBJECT_ID('tempdb..#chat_norm') IS NOT NULL
DROP TABLE #chat_norm;
SELECT
b.*,
CASE WHEN b.content_text IS NULL OR LTRIM(RTRIM(b.content_text)) IN (N'', N'[]') THEN 1 ELSE 0 END AS is_empty_content,
CASE WHEN b.main_reason = N'未标注' OR b.second_reason = N'未标注' THEN 1 ELSE 0 END AS is_missing_reason,
CASE WHEN b.main_reason = N'无对应分类' OR b.second_reason = N'无对应分类' THEN 1 ELSE 0 END AS is_no_match_reason,
CASE WHEN b.content_text LIKE N'%欢迎%' OR b.content_text LIKE N'%您好%' OR b.content_text LIKE N'%亲,%' OR b.content_text LIKE N'%亲,%' THEN 1 ELSE 0 END AS has_greeting,
CASE WHEN b.content_text LIKE N'%抱歉%' OR b.content_text LIKE N'%不好意思%' OR b.content_text LIKE N'%理解您%' THEN 1 ELSE 0 END AS has_empathy,
CASE WHEN b.content_text LIKE N'%马上%' OR b.content_text LIKE N'%尽快%' OR b.content_text LIKE N'%给您处理%' OR b.content_text LIKE N'%为您处理%' THEN 1 ELSE 0 END AS has_commitment,
CASE WHEN b.content_text LIKE N'%感谢%' OR b.content_text LIKE N'%祝您%' OR b.content_text LIKE N'%辛苦%' THEN 1 ELSE 0 END AS has_closing,
CASE WHEN b.content_text LIKE N'%不知道%' OR b.content_text LIKE N'%没办法%' OR b.content_text LIKE N'%无法处理%' OR b.content_text LIKE N'%不清楚%' OR b.content_text LIKE N'%等不了%' OR b.content_text LIKE N'%太慢%' THEN 1 ELSE 0 END AS has_risk_phrase
INTO #chat_norm
FROM #chat_base AS b;
-- 1) 时间段客服体验总览
SELECT
@start_date AS start_date,
@end_date AS end_date,
COUNT(*) AS session_count,
COUNT(DISTINCT order_no) AS order_count,
COUNT(DISTINCT shop_name) AS shop_count,
SUM(is_empty_content) AS empty_content_count,
CAST(100.0 * SUM(is_empty_content) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empty_content_rate_pct,
SUM(is_missing_reason) AS missing_reason_count,
CAST(100.0 * SUM(is_missing_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS missing_reason_rate_pct,
SUM(is_no_match_reason) AS no_match_reason_count,
CAST(100.0 * SUM(is_no_match_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS no_match_reason_rate_pct,
SUM(CASE WHEN state = 1 THEN 1 ELSE 0 END) AS state_1_count,
SUM(CASE WHEN state = 2 THEN 1 ELSE 0 END) AS state_2_count
FROM #chat_norm;
-- 2) 用户反馈问题结构 TOP(一级/二级)
SELECT TOP (@limit)
main_reason,
second_reason,
COUNT(*) AS session_count,
COUNT(DISTINCT order_no) AS order_count,
CAST(100.0 * COUNT(*) / NULLIF((SELECT COUNT(*) FROM #chat_norm), 0) AS decimal(6,2)) AS session_share_pct
FROM #chat_norm
GROUP BY main_reason, second_reason
ORDER BY session_count DESC, order_count DESC;
-- 3) 客服体验店铺对比(体验分)
WITH shop_agg AS (
SELECT
shop_name,
COUNT(*) AS session_count,
SUM(is_empty_content) AS empty_content_count,
SUM(is_missing_reason) AS missing_reason_count,
SUM(is_no_match_reason) AS no_match_reason_count
FROM #chat_norm
GROUP BY shop_name
)
SELECT TOP (@limit)
s.shop_name,
s.session_count,
CAST(100.0 * s.empty_content_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS empty_content_rate_pct,
CAST(100.0 * s.missing_reason_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS missing_reason_rate_pct,
CAST(100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS no_match_reason_rate_pct,
CASE
WHEN (
100.0
- (100.0 * s.missing_reason_count / NULLIF(s.session_count, 0)) * 0.4
- (100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0)) * 0.4
- (100.0 * s.empty_content_count / NULLIF(s.session_count, 0)) * 0.2
) < 0 THEN CAST(0 AS decimal(6,2))
ELSE CAST(
100.0
- (100.0 * s.missing_reason_count / NULLIF(s.session_count, 0)) * 0.4
- (100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0)) * 0.4
- (100.0 * s.empty_content_count / NULLIF(s.session_count, 0)) * 0.2
AS decimal(6,2)
)
END AS experience_score
FROM shop_agg AS s
ORDER BY experience_score DESC, s.session_count DESC;
-- 4) 话术覆盖分析
SELECT
COUNT(*) AS session_count,
SUM(has_greeting) AS greeting_hit_count,
CAST(100.0 * SUM(has_greeting) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS greeting_hit_rate_pct,
SUM(has_empathy) AS empathy_hit_count,
CAST(100.0 * SUM(has_empathy) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empathy_hit_rate_pct,
SUM(has_commitment) AS commitment_hit_count,
CAST(100.0 * SUM(has_commitment) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS commitment_hit_rate_pct,
SUM(has_closing) AS closing_hit_count,
CAST(100.0 * SUM(has_closing) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS closing_hit_rate_pct,
SUM(has_risk_phrase) AS risk_phrase_hit_count,
CAST(100.0 * SUM(has_risk_phrase) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS risk_phrase_hit_rate_pct
FROM #chat_norm;
-- 5) 风险话术命中 TOP
WITH phrase_hits AS (
SELECT
n.id,
n.order_no,
n.shop_name,
v.risk_phrase
FROM #chat_norm AS n
CROSS APPLY (VALUES
(CASE WHEN n.content_text LIKE N'%不知道%' THEN N'不知道' END),
(CASE WHEN n.content_text LIKE N'%没办法%' THEN N'没办法' END),
(CASE WHEN n.content_text LIKE N'%无法处理%' THEN N'无法处理' END),
(CASE WHEN n.content_text LIKE N'%不清楚%' THEN N'不清楚' END),
(CASE WHEN n.content_text LIKE N'%太慢%' THEN N'太慢' END),
(CASE WHEN n.content_text LIKE N'%等不了%' THEN N'等不了' END)
) AS v(risk_phrase)
WHERE v.risk_phrase IS NOT NULL
)
SELECT TOP (@limit)
risk_phrase,
COUNT(*) AS hit_count,
COUNT(DISTINCT id) AS session_count,
COUNT(DISTINCT order_no) AS order_count
FROM phrase_hits
GROUP BY risk_phrase
ORDER BY session_count DESC, hit_count DESC;
-- 6) 每日体验趋势
SELECT
CONVERT(date, createdAt) AS stat_date,
COUNT(*) AS session_count,
CAST(100.0 * SUM(is_no_match_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS no_match_reason_rate_pct,
CAST(100.0 * SUM(is_missing_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS missing_reason_rate_pct,
CAST(100.0 * SUM(is_empty_content) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empty_content_rate_pct,
CAST(100.0 * SUM(has_risk_phrase) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS risk_phrase_hit_rate_pct
FROM #chat_norm
GROUP BY CONVERT(date, createdAt)
ORDER BY stat_date ASC;
-- 7) 对话案例(风险话术)+ 推荐回复
WITH case_pool AS (
SELECT
n.id,
n.order_no,
n.shop_name,
n.main_reason,
n.second_reason,
n.content_text,
CASE
WHEN n.content_text LIKE N'%不清楚%' THEN N'不清楚'
WHEN n.content_text LIKE N'%不知道%' THEN N'不知道'
WHEN n.content_text LIKE N'%没办法%' THEN N'没办法'
WHEN n.content_text LIKE N'%太慢%' THEN N'太慢'
WHEN n.content_text LIKE N'%等不了%' THEN N'等不了'
WHEN n.content_text LIKE N'%无法处理%' THEN N'无法处理'
ELSE NULL
END AS risk_tag
FROM #chat_norm AS n
)
SELECT TOP (@limit)
order_no,
shop_name,
main_reason,
second_reason,
risk_tag,
LEFT(REPLACE(REPLACE(ISNULL(content_text, N''), CHAR(13), N' '), CHAR(10), N' '), 220) AS dialogue_sample,
CASE
WHEN risk_tag IN (N'不清楚', N'不知道')
THEN N'建议回复:我先帮您核实一下该问题,预计5分钟内给您明确答复。'
WHEN risk_tag = N'没办法'
THEN N'建议回复:这个情况我来帮您申请处理方案,先给您两个可行选项并同步时效。'
WHEN risk_tag = N'无法处理'
THEN N'建议回复:我已升级给对应专员处理,预计今天内给您处理结果。'
WHEN risk_tag IN (N'太慢', N'等不了')
THEN N'建议回复:抱歉让您久等,我现在优先为您加急处理,并在10分钟内回报进展。'
ELSE N'建议回复:收到您的问题,我先确认订单与处理路径,稍后给您明确方案。'
END AS suggested_reply
FROM case_pool
WHERE risk_tag IS NOT NULL
ORDER BY id DESC;
-- 8) 客服能力评分(参考天猫服务保障维度,按可用字段做代理映射)
;WITH score_base AS (
SELECT
shop_name,
COUNT(*) AS session_count,
CAST(SUM(has_commitment) AS float) / NULLIF(COUNT(*), 0) AS commitment_rate,
CAST(SUM(has_empathy) AS float) / NULLIF(COUNT(*), 0) AS empathy_rate,
CAST(SUM(has_closing) AS float) / NULLIF(COUNT(*), 0) AS closing_rate,
CAST(SUM(has_risk_phrase) AS float) / NULLIF(COUNT(*), 0) AS risk_phrase_rate,
CAST(SUM(CASE WHEN second_reason LIKE N'%催退款%' OR second_reason LIKE N'%催取件%' THEN 1 ELSE 0 END) AS float) / NULLIF(COUNT(*), 0) AS refund_urge_rate,
CAST(SUM(is_no_match_reason) AS float) / NULLIF(COUNT(*), 0) AS no_match_rate,
CAST(SUM(is_missing_reason) AS float) / NULLIF(COUNT(*), 0) AS missing_reason_rate
FROM #chat_norm
GROUP BY shop_name
),
score_calc AS (
SELECT
sb.*,
100.0 * (0.70 * sb.commitment_rate + 0.30 * (1.0 - sb.risk_phrase_rate)) AS resp_ability_score,
100.0 * (0.45 * sb.empathy_rate + 0.35 * sb.closing_rate + 0.20 * (1.0 - sb.risk_phrase_rate)) AS satisfaction_score,
100.0 * (1.0 - sb.refund_urge_rate) AS refund_efficiency_score,
100.0 * (1.0 - (0.80 * sb.no_match_rate + 0.20 * sb.missing_reason_rate)) AS help_risk_control_score
FROM score_base AS sb
)
SELECT TOP (@limit)
shop_name,
session_count,
CAST(resp_ability_score AS decimal(6,2)) AS resp_ability_score,
CAST(satisfaction_score AS decimal(6,2)) AS satisfaction_score,
CAST(refund_efficiency_score AS decimal(6,2)) AS refund_efficiency_score,
CAST(help_risk_control_score AS decimal(6,2)) AS help_risk_control_score,
CAST(
0.30 * resp_ability_score
+ 0.30 * satisfaction_score
+ 0.20 * refund_efficiency_score
+ 0.20 * help_risk_control_score
AS decimal(6,2)
) AS service_capability_score,
CASE
WHEN (
0.30 * resp_ability_score
+ 0.30 * satisfaction_score
+ 0.20 * refund_efficiency_score
+ 0.20 * help_risk_control_score
) >= 90 THEN N'S'
WHEN (
0.30 * resp_ability_score
+ 0.30 * satisfaction_score
+ 0.20 * refund_efficiency_score
+ 0.20 * help_risk_control_score
) >= 80 THEN N'A'
WHEN (
0.30 * resp_ability_score
+ 0.30 * satisfaction_score
+ 0.20 * refund_efficiency_score
+ 0.20 * help_risk_control_score
) >= 70 THEN N'B'
WHEN (
0.30 * resp_ability_score
+ 0.30 * satisfaction_score
+ 0.20 * refund_efficiency_score
+ 0.20 * help_risk_control_score
) >= 60 THEN N'C'
ELSE N'D'
END AS service_grade,
N'映射说明: 响应能力≈旺旺响应能力; 满意度≈旺旺满意度; 退款效率≈退款处理体验; 求助风险控制≈平台求助风险(代理口径)' AS score_mapping_note
FROM score_calc
ORDER BY service_capability_score DESC, session_count DESC;

View File

@ -0,0 +1,133 @@
#!/usr/bin/env node
// 用法:
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_analytics_client.js progress
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_analytics_client.js overview
const crypto = require("crypto");
const BASE_URL = process.env.OPEN_API_BASE_URL || process.env.OPEN_API_BASE || "https://qd.yydd.ai";
const APP_KEY = process.env.OPEN_API_APP_KEY;
const APP_SECRET = process.env.OPEN_API_APP_SECRET;
if (!APP_KEY || !APP_SECRET) {
console.error("缺少环境变量:OPEN_API_APP_KEY / OPEN_API_APP_SECRET");
process.exit(1);
}
function hmacSha256Hex(secret, payload) {
return crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
}
function nowTs() {
return String(Date.now());
}
async function postJson(path, body) {
const resp = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await resp.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (err) {
throw new Error(`HTTP ${resp.status} 非JSON响应: ${text.slice(0, 300)}`);
}
if (!resp.ok || data.code !== 200) {
throw new Error(`HTTP ${resp.status} / code=${data.code} / msg=${data.message}`);
}
return data.data;
}
async function queryTaskProgressOverview({ dateRange = "month", month = "", deptId = "" } = {}) {
const timestamp = nowTs();
const payload = [
"analytics_task_progress_overview",
APP_KEY,
dateRange,
month || "",
String(deptId || ""),
timestamp,
].join("|");
const sign = hmacSha256Hex(APP_SECRET, payload);
return postJson("/api/open/analytics/task-progress/overview", {
appKey: APP_KEY,
timestamp,
sign,
dateRange,
month,
deptId: deptId || undefined,
});
}
async function queryOverview({
dateRange = "month",
month = "",
deptId = "",
userId = "",
includeTaskProgress = false,
} = {}) {
const timestamp = nowTs();
const includeStr = String(Boolean(includeTaskProgress));
const payload = [
"analytics_overview",
APP_KEY,
dateRange,
month || "",
String(deptId || ""),
String(userId || ""),
includeStr,
timestamp,
].join("|");
const sign = hmacSha256Hex(APP_SECRET, payload);
return postJson("/api/open/analytics/overview", {
appKey: APP_KEY,
timestamp,
sign,
dateRange,
month,
deptId: deptId || undefined,
userId: userId || undefined,
includeTaskProgress: Boolean(includeTaskProgress),
});
}
(async () => {
try {
const mode = process.argv[2] || "progress";
if (mode === "progress") {
const data = await queryTaskProgressOverview({
dateRange: process.env.ANALYTICS_DATE_RANGE || "month",
month: process.env.ANALYTICS_MONTH || "",
});
console.log("任务进度总览查询成功:");
console.log(JSON.stringify(data, null, 2));
return;
}
if (mode === "overview") {
const data = await queryOverview({
dateRange: process.env.ANALYTICS_DATE_RANGE || "month",
month: process.env.ANALYTICS_MONTH || "",
includeTaskProgress: String(process.env.ANALYTICS_INCLUDE_TASK_PROGRESS || "false").toLowerCase() === "true",
});
console.log("数据分析总览查询成功:");
console.log(JSON.stringify(data, null, 2));
return;
}
throw new Error(`未知模式: ${mode},可用 progress / overview`);
} catch (err) {
console.error("查询失败:", err.message);
process.exit(1);
}
})();

View File

@ -0,0 +1,257 @@
#!/usr/bin/env node
// file: dalongxia_open_analytics.js
// Node 18+
// 用法示例:
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_open_analytics.js designerPerformance
// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_open_analytics.js dimensionAnalysis
const crypto = require("crypto");
const BASE_URL = process.env.OPEN_API_BASE_URL || process.env.OPEN_API_BASE || "https://qd.yydd.ai";
const APP_KEY = process.env.OPEN_API_APP_KEY;
const APP_SECRET = process.env.OPEN_API_APP_SECRET;
if (!APP_KEY || !APP_SECRET) {
console.error("缺少环境变量 OPEN_API_APP_KEY / OPEN_API_APP_SECRET");
process.exit(1);
}
function nowTs() {
return String(Date.now());
}
function sign(payload) {
return crypto.createHmac("sha256", APP_SECRET).update(payload, "utf8").digest("hex");
}
function payload(action, ...parts) {
return [action, APP_KEY, ...parts].join("|");
}
async function post(path, body) {
const resp = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await resp.text();
let json = {};
try {
json = text ? JSON.parse(text) : {};
} catch (err) {
throw new Error(`HTTP=${resp.status}, 非JSON响应: ${text.slice(0, 300)}`);
}
if (!resp.ok || json.code !== 200) {
throw new Error(`HTTP=${resp.status}, code=${json.code}, msg=${json.message}`);
}
return json.data;
}
async function callOverview({ dateRange = "month", month = "", deptId = "", userId = "", includeTaskProgress = false } = {}) {
const ts = nowTs();
const includeStr = String(Boolean(includeTaskProgress));
const p = payload("analytics_overview", dateRange, month, String(deptId), String(userId), includeStr, ts);
return post("/api/open/analytics/overview", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
dateRange,
month,
deptId: deptId || undefined,
userId: userId || undefined,
includeTaskProgress: !!includeTaskProgress,
});
}
async function callTaskProgressOverview({ dateRange = "month", month = "", deptId = "" } = {}) {
const ts = nowTs();
const p = payload("analytics_task_progress_overview", dateRange, month, String(deptId), ts);
return post("/api/open/analytics/task-progress/overview", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
dateRange,
month,
deptId: deptId || undefined,
});
}
async function callDemandStatistics({ dateRange = "month", month = "", deptId = "", userId = "" } = {}) {
const ts = nowTs();
const p = payload("analytics_demand_statistics", dateRange, month, String(deptId), String(userId), ts);
return post("/api/open/analytics/demand-statistics", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
dateRange,
month,
deptId: deptId || undefined,
userId: userId || undefined,
});
}
async function callDesignerPerformance({ month = "", deptId = "" } = {}) {
const ts = nowTs();
const p = payload("analytics_designer_performance", month, String(deptId), ts);
return post("/api/open/analytics/designer-performance", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
month,
deptId: deptId || undefined,
});
}
async function callProcessEfficiency({ month = "" } = {}) {
const ts = nowTs();
const p = payload("analytics_process_efficiency", month, ts);
return post("/api/open/analytics/process-efficiency", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
month,
});
}
async function callWarnings() {
const ts = nowTs();
const p = payload("analytics_warnings", ts);
return post("/api/open/analytics/warnings", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
});
}
async function callDimensionAnalysis({ dateRange = "month", month = "" } = {}) {
const ts = nowTs();
const p = payload("analytics_dimension_analysis", dateRange, month, ts);
return post("/api/open/analytics/dimension-analysis", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
dateRange,
month,
});
}
async function callDesignerDeductionDetails({ userId, month = "" }) {
const ts = nowTs();
const p = payload("analytics_designer_deduction_details", String(userId), month, ts);
return post("/api/open/analytics/designer-deduction-details", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
userId,
month,
});
}
async function callDesignerScoreDetails({ userId, month = "" }) {
const ts = nowTs();
const p = payload("analytics_designer_score_details", String(userId), month, ts);
return post("/api/open/analytics/designer-score-details", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
userId,
month,
});
}
async function callPrimaryDeptStatistics({ startTime, endTime, deptId = "" }) {
const ts = nowTs();
const p = payload("analytics_primary_dept_statistics", startTime, endTime, String(deptId), ts);
return post("/api/open/analytics/primary-dept/statistics", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
startTime,
endTime,
deptId: deptId || undefined,
});
}
async function callPrimaryDeptRanking({ startTime, endTime }) {
const ts = nowTs();
const p = payload("analytics_primary_dept_ranking", startTime, endTime, ts);
return post("/api/open/analytics/primary-dept/ranking", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
startTime,
endTime,
});
}
async function callPrimaryTaskFlowEfficiency({ startTime, endTime }) {
const ts = nowTs();
const p = payload("analytics_primary_task_flow_efficiency", startTime, endTime, ts);
return post("/api/open/analytics/primary-task/flow-efficiency", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
startTime,
endTime,
});
}
async function callPrimaryTaskBrandDistribution({ startTime, endTime }) {
const ts = nowTs();
const p = payload("analytics_primary_task_brand_distribution", startTime, endTime, ts);
return post("/api/open/analytics/primary-task/brand-distribution", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
startTime,
endTime,
});
}
async function callPrimaryDeptUserRanking({ startTime, endTime }) {
const ts = nowTs();
const p = payload("analytics_primary_dept_user_ranking", startTime, endTime, ts);
return post("/api/open/analytics/primary-dept/user-ranking", {
appKey: APP_KEY,
timestamp: ts,
sign: sign(p),
startTime,
endTime,
});
}
(async () => {
const mode = process.argv[2] || "designerPerformance";
const month = process.argv[3] || "2026-02";
const startTime = process.argv[4] || "2026-02-01T00:00:00";
const endTime = process.argv[5] || "2026-02-27T23:59:59";
const map = {
overview: () => callOverview({ month }),
taskProgressOverview: () => callTaskProgressOverview({ month }),
demandStatistics: () => callDemandStatistics({ month }),
designerPerformance: () => callDesignerPerformance({ month }),
processEfficiency: () => callProcessEfficiency({ month }),
warnings: () => callWarnings(),
dimensionAnalysis: () => callDimensionAnalysis({ month }),
designerDeduction: () => callDesignerDeductionDetails({ userId: 38, month }),
designerScore: () => callDesignerScoreDetails({ userId: 38, month }),
primaryDeptStatistics: () => callPrimaryDeptStatistics({ startTime, endTime }),
primaryDeptRanking: () => callPrimaryDeptRanking({ startTime, endTime }),
primaryTaskFlowEfficiency: () => callPrimaryTaskFlowEfficiency({ startTime, endTime }),
primaryTaskBrandDistribution: () => callPrimaryTaskBrandDistribution({ startTime, endTime }),
primaryDeptUserRanking: () => callPrimaryDeptUserRanking({ startTime, endTime }),
};
if (!map[mode]) {
console.error("未知 mode:", mode);
process.exit(1);
}
const data = await map[mode]();
console.log(JSON.stringify(data, null, 2));
})().catch((err) => {
console.error("查询失败:", err.message);
process.exit(1);
});

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
VENV="/root/.openclaw/.venv-db"
TOOL="/root/.openclaw/workspace/sqlserver/install_media_lookup.py"
if [ ! -x "$VENV/bin/python" ]; then
echo "missing python runtime: $VENV/bin/python" >&2
exit 1
fi
if [ ! -f "$TOOL" ]; then
echo "missing tool script: $TOOL" >&2
exit 1
fi
exec "$VENV/bin/python" "$TOOL" "$@"

View File

@ -0,0 +1,56 @@
---
name: install-media-lookup
description: Query install drawing and install video links by order id. First map order id to product number from SQLServer, then query MySQL install-media database and return browser-accessible URLs.
metadata:
{ "openclaw": { "emoji": "🧩", "os": ["linux"], "requires": { "bins": ["company-db", "install-media-lookup"] } } }
---
# install-media-lookup Skill (OpenClaw)
Use this skill when user asks:
- 根据订单号查询安装图纸
- 根据订单号查询安装视频
- 查询安装说明书图片地址/视频地址
- 订单号 -> 产品货号 -> 安装资源链接
## Command
```bash
install-media-lookup --order-id "<商品订单号>"
```
Optional:
```bash
install-media-lookup --order-id "<商品订单号>" --limit 200
install-media-lookup --order-id "<商品订单号>" --mysql-schema "<schema>" --mysql-table "<table>"
install-media-lookup --product-no "<产品货号>"
install-media-lookup --sku-id "<SKU编码>"
```
## Workflow
1. Resolve `order-id` to `product_no` from SQLServer (`jst_goods_profit_kpi_items_afs` + `jst_sku`).
2. Query MySQL install-media database by `product_no`.
3. Return drawing/video records and convert local path to browser URL:
- `D:\JianSunTuZhiFile\...` -> `https://img.jiansuntuzhi.cn/...`
4. Output three result sets:
- `order_to_product_mapping`
- `mysql_table_match_summary`
- `install_media_links`
## Config
- SQLServer creds (already used by existing `company-db`):
- `/root/.openclaw/credentials/company-sql.env`
- MySQL creds:
- `/root/.openclaw/credentials/install-media-mysql.env`
- Optional fixed MySQL table mapping (preferred in production):
- `/root/.openclaw/workspace/outputs/install-media-mysql-mapping.json`
## Rules
1. If user gives both order id and product number, trust both and merge results.
2. If MySQL auto discovery finds nothing, ask to补充/校准映射文件,不要编造链接。
3. Always return both `raw_path` and converted `media_url`.
4. Never expose DB password in chat output.

View File

@ -0,0 +1,859 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import json
import os
import pathlib
import re
import sys
from typing import Dict, List, Sequence, Tuple
import pytds
import pymysql
SQL_ENV_FILE = "/root/.openclaw/credentials/company-sql.env"
MYSQL_ENV_FILE = "/root/.openclaw/credentials/install-media-mysql.env"
DEFAULT_MAPPING_FILE = "/root/.openclaw/workspace/outputs/install-media-mysql-mapping.json"
def load_env_file(path: str) -> None:
p = pathlib.Path(path)
if not p.exists():
return
for raw in p.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
def sql_conn_params() -> Dict[str, object]:
load_env_file(SQL_ENV_FILE)
return {
"server": os.environ.get("COMPANY_DB_HOST", "218.93.191.16"),
"port": int(os.environ.get("COMPANY_DB_PORT", "1433")),
"database": os.environ.get("COMPANY_DB_NAME", "JSDataMiddlePlatform"),
"user": os.environ.get("COMPANY_DB_USER", "nanting"),
"password": os.environ.get("COMPANY_DB_PASSWORD", ""),
"login_timeout": 15,
"timeout": 120,
}
def mysql_conn_params() -> Dict[str, object]:
load_env_file(MYSQL_ENV_FILE)
return {
"host": os.environ.get("INSTALL_MEDIA_MYSQL_HOST", "218.93.191.20"),
"port": int(os.environ.get("INSTALL_MEDIA_MYSQL_PORT", "3306")),
"user": os.environ.get("INSTALL_MEDIA_MYSQL_USER", "read_only_jiansun"),
"password": os.environ.get("INSTALL_MEDIA_MYSQL_PASSWORD", ""),
"charset": "utf8mb4",
"connect_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_CONNECT_TIMEOUT", "8")),
"read_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_READ_TIMEOUT", "15")),
"write_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_WRITE_TIMEOUT", "15")),
"autocommit": True,
}
def sql_escape(value: str) -> str:
return value.replace("'", "''")
def qi(identifier: str) -> str:
return "`" + identifier.replace("`", "``") + "`"
def fmt_value(value):
if isinstance(value, (dt.datetime, dt.date, dt.time)):
return value.isoformat(sep=" ")
return value
def print_rows(columns: Sequence[str], rows: Sequence[Dict[str, object]]) -> None:
print("COLUMNS:", ", ".join(columns))
if not rows:
print("ROWS: 0")
return
print(f"ROWS: {len(rows)}")
for row in rows:
obj = {c: fmt_value(row.get(c)) for c in columns}
print(json.dumps(obj, ensure_ascii=False, default=str))
def query_order_product_mappings(order_id: str, limit: int) -> List[Dict[str, object]]:
if not order_id:
return []
params = sql_conn_params()
if not params["password"]:
raise RuntimeError("COMPANY_DB_PASSWORD is empty")
oid = sql_escape(order_id.strip())
lim = max(1, min(limit, 2000))
sql = f"""
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @order_id nvarchar(120) = N'{oid}';
WITH order_map AS (
SELECT
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined10, '') AS sku_id,
COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, '')) AS product_no,
COALESCE(NULLIF(s.name, ''), NULLIF(a.undefined36, '')) AS sku_name,
COALESCE(NULLIF(a.undefined8, ''), NULLIF(e.shop_name, '')) AS shop_name,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120),
TRY_CONVERT(datetime2, a.createdAt)
) AS send_time,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(a.undefined2, ''), COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, ''), NULLIF(a.undefined10, ''))
ORDER BY COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120),
TRY_CONVERT(datetime2, a.createdAt)
) DESC,
a.createdAt DESC
) AS rn
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK)
ON s.sku_id = NULLIF(a.undefined10, '')
LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK)
ON NULLIF(e.so_id, '') = NULLIF(a.undefined2, '')
WHERE (
NULLIF(a.undefined2, '') = @order_id
OR NULLIF(a.undefined3, '') = @order_id
OR NULLIF(a.undefined3, '') LIKE @order_id + '%'
)
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'
)
SELECT TOP ({lim})
order_id,
sku_id,
product_no,
sku_name,
shop_name,
send_time
FROM order_map
WHERE rn = 1
AND ISNULL(NULLIF(order_id, ''), '') <> ''
AND ISNULL(NULLIF(product_no, ''), '') <> ''
ORDER BY send_time DESC, order_id ASC;
"""
with pytds.connect(**params) as conn:
cur = conn.cursor()
cur.execute(sql)
cols = [c[0] for c in cur.description]
data = [dict(zip(cols, row)) for row in cur.fetchall()]
cur.close()
return data
def _normalize_text(s: str) -> str:
return (s or "").strip().lower()
PRODUCT_COL_KEYS = [
"productno",
"product_no",
"goodsno",
"goods_no",
"itemno",
"item_no",
"stylecode",
"style_code",
"spu",
"sku",
"barcode",
"procode",
"pro_code",
"货号",
"商品编码",
"产品编码",
]
DRAWING_COL_KEYS = [
"instruction",
"drawing",
"manual",
"install",
"tuzhi",
"图纸",
"安装图",
"说明书",
]
VIDEO_COL_KEYS = [
"video",
"mp4",
"movie",
"教程",
"视频",
"安装视频",
]
PATH_COL_KEYS = [
"path",
"url",
"file",
"img",
"image",
"地址",
"链接",
"文件",
"路径",
]
SYSTEM_SCHEMAS = {"information_schema", "mysql", "performance_schema", "sys"}
def _contains_any(text: str, keys: Sequence[str]) -> bool:
low = _normalize_text(text)
return any(k in low for k in keys)
def discover_table_specs(
conn,
explicit_schema: str,
explicit_table: str,
max_tables: int,
) -> List[Dict[str, object]]:
where = ["TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')"]
params: List[object] = []
if explicit_schema:
where.append("TABLE_SCHEMA = %s")
params.append(explicit_schema)
if explicit_table:
where.append("TABLE_NAME = %s")
params.append(explicit_table)
sql = f"""
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, COALESCE(COLUMN_COMMENT, '')
FROM information_schema.COLUMNS
WHERE {' AND '.join(where)}
ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION
"""
with conn.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
grouped: Dict[Tuple[str, str], List[Tuple[str, str, str]]] = {}
for schema, table, col, dtype, comment in rows:
key = (schema, table)
grouped.setdefault(key, []).append((str(col), str(dtype), str(comment or "")))
specs: List[Dict[str, object]] = []
for (schema, table), cols in grouped.items():
product_cols: List[str] = []
drawing_cols: List[str] = []
video_cols: List[str] = []
generic_media_cols: List[str] = []
for col_name, _dtype, col_comment in cols:
text = f"{col_name} {col_comment}"
has_path = _contains_any(text, PATH_COL_KEYS)
has_drawing = _contains_any(text, DRAWING_COL_KEYS)
has_video = _contains_any(text, VIDEO_COL_KEYS)
has_product = _contains_any(text, PRODUCT_COL_KEYS)
if has_product:
product_cols.append(col_name)
if has_drawing and (has_path or has_drawing):
drawing_cols.append(col_name)
elif has_video and (has_path or has_video):
video_cols.append(col_name)
elif has_path and (has_drawing or has_video):
generic_media_cols.append(col_name)
elif has_path and _contains_any(text, ["install", "instruction", "图纸", "视频"]):
generic_media_cols.append(col_name)
media_cols = sorted(set(drawing_cols + video_cols + generic_media_cols))
if not product_cols or not media_cols:
continue
specs.append(
{
"schema": schema,
"table": table,
"product_cols": sorted(set(product_cols)),
"drawing_cols": sorted(set(drawing_cols)),
"video_cols": sorted(set(video_cols)),
"media_cols": media_cols,
}
)
specs.sort(key=lambda x: (x["schema"], x["table"]))
if max_tables > 0:
specs = specs[:max_tables]
return specs
def load_mapping_specs(mapping_file: str) -> List[Dict[str, object]]:
p = pathlib.Path(mapping_file)
if not p.exists():
return []
try:
obj = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return []
rows = obj.get("tables") if isinstance(obj, dict) else None
if not isinstance(rows, list):
return []
specs: List[Dict[str, object]] = []
for row in rows:
if not isinstance(row, dict):
continue
schema = str(row.get("schema", "")).strip()
table = str(row.get("table", "")).strip()
product_cols = [str(x).strip() for x in row.get("product_cols", []) if str(x).strip()]
drawing_cols = [str(x).strip() for x in row.get("drawing_cols", []) if str(x).strip()]
video_cols = [str(x).strip() for x in row.get("video_cols", []) if str(x).strip()]
media_cols = [str(x).strip() for x in row.get("media_cols", []) if str(x).strip()]
if not schema or not table or not product_cols:
continue
merged_media = sorted(set(media_cols + drawing_cols + video_cols))
if not merged_media:
continue
specs.append(
{
"schema": schema,
"table": table,
"product_cols": sorted(set(product_cols)),
"drawing_cols": sorted(set(drawing_cols)),
"video_cols": sorted(set(video_cols)),
"media_cols": merged_media,
}
)
return specs
def classify_media_type(col_name: str, value: str) -> str:
text = _normalize_text(f"{col_name} {value}")
if _contains_any(text, VIDEO_COL_KEYS) or re.search(r"\.(mp4|avi|mov|mkv|wmv)(\?|$)", text):
return "video"
if _contains_any(text, DRAWING_COL_KEYS) or re.search(r"\.(png|jpg|jpeg|webp|gif|bmp)(\?|$)", text):
return "drawing"
return "unknown"
def normalize_media_url(raw_path: str, local_prefix: str, cdn_prefix: str) -> str:
s = (raw_path or "").strip()
if not s:
return ""
if s.startswith("http://") or s.startswith("https://"):
return s
s_norm = s.replace("\\", "/")
local_norm = (local_prefix or "D:/JianSunTuZhiFile").replace("\\", "/").rstrip("/")
cdn_norm = (cdn_prefix or "https://img.jiansuntuzhi.cn/").rstrip("/") + "/"
if s_norm.lower().startswith(local_norm.lower() + "/"):
rest = s_norm[len(local_norm) + 1 :]
return cdn_norm + rest
if s_norm.lower() == local_norm.lower():
return cdn_norm.rstrip("/")
marker = "jiansuntuzhifile/"
idx = s_norm.lower().find(marker)
if idx >= 0:
rest = s_norm[idx + len(marker) :]
return cdn_norm + rest
if re.match(r"^[a-zA-Z]:/", s_norm):
return s_norm
if s_norm.startswith("/"):
return cdn_norm + s_norm.lstrip("/")
return cdn_norm + s_norm
def _query_table_rows(conn, sql: str, params: Sequence[object]) -> List[Dict[str, object]]:
with conn.cursor() as cur:
cur.execute(sql, params)
cols = [d[0] for d in cur.description]
data = [dict(zip(cols, row)) for row in cur.fetchall()]
return data
def query_jiansun_special_records(
conn,
schema: str,
product_no: str,
row_limit: int,
local_prefix: str,
cdn_prefix: str,
) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]:
"""
Deterministic path for jiansunblueprint:
product_no -> sheet1 -> missiontask -> missiontask_instructions/missiontask_video
"""
table_rows: List[Dict[str, object]] = []
detail_rows: List[Dict[str, object]] = []
if not schema:
return table_rows, detail_rows
with conn.cursor() as cur:
cur.execute(f"SHOW TABLES FROM {qi(schema)}")
table_names = {str(r[0]).lower() for r in cur.fetchall()}
required = {"sheet1", "missiontask", "missiontask_instructions", "missiontask_video"}
if not required.issubset(table_names):
return table_rows, detail_rows
lim = max(1, min(row_limit, 2000))
pno = product_no.strip()
# 1) sheet1: map product_no -> barcode/pictureId/sku model
sheet_sql = (
f"SELECT {qi('sku(货号)')} AS product_no, {qi('工厂条形码')} AS barcode, "
f"{qi('图纸ID')} AS picture_id, {qi('SKU型号')} AS sku_model "
f"FROM {qi(schema)}.{qi('sheet1')} "
f"WHERE {qi('sku(货号)')} = %s OR {qi('sku(货号)')} LIKE %s "
f"LIMIT %s"
)
sheet_rows = _query_table_rows(conn, sheet_sql, (pno, f"{pno}%", lim))
table_rows.append(
{
"product_no": pno,
"source_schema": schema,
"source_table": "sheet1",
"matched_rows": len(sheet_rows),
"matched_media_items": 0,
}
)
barcodes = sorted({str(r.get("barcode", "")).strip() for r in sheet_rows if str(r.get("barcode", "")).strip()})
picture_ids = sorted({str(r.get("picture_id", "")).strip() for r in sheet_rows if str(r.get("picture_id", "")).strip()})
sku_models = sorted({str(r.get("sku_model", "")).strip() for r in sheet_rows if str(r.get("sku_model", "")).strip()})
# 2) missiontask: find task ids
match_parts: List[str] = []
params: List[object] = []
match_parts.append(f"{qi('skucodes')} LIKE %s")
params.append(f"%{pno}%")
for b in barcodes[:60]:
match_parts.append(f"{qi('skucodes')} LIKE %s")
params.append(f"%{b}%")
for pid in picture_ids[:60]:
match_parts.append(f"{qi('pictureId')} = %s")
params.append(pid)
for sm in sku_models[:40]:
match_parts.append(f"{qi('skuModel')} = %s")
params.append(sm)
where_clause = (
f"({qi('isDelete')} = '0' OR {qi('isDelete')} = 0 OR {qi('isDelete')} IS NULL)"
f" AND ({' OR '.join(match_parts)})"
)
mission_sql = (
f"SELECT {qi('id')} AS task_id, {qi('taskId')} AS task_no, {qi('SPU')} AS spu, "
f"{qi('picture')} AS picture_path, {qi('pictureId')} AS picture_id, {qi('skucodes')} AS skucodes, "
f"{qi('withInstructions')} AS with_instructions, {qi('withVideo')} AS with_video, "
f"{qi('createdTime')} AS created_time "
f"FROM {qi(schema)}.{qi('missiontask')} "
f"WHERE {where_clause} "
f"ORDER BY {qi('createdTime')} DESC "
f"LIMIT %s"
)
params.append(max(20, min(lim * 3, 500)))
mission_rows = _query_table_rows(conn, mission_sql, params)
table_rows.append(
{
"product_no": pno,
"source_schema": schema,
"source_table": "missiontask",
"matched_rows": len(mission_rows),
"matched_media_items": 0,
}
)
task_ids = [str(r.get("task_id", "")).strip() for r in mission_rows if str(r.get("task_id", "")).strip()]
if not task_ids:
return table_rows, detail_rows
# 3) instructions
placeholders = ",".join(["%s"] * len(task_ids))
ins_sql = (
f"SELECT {qi('id')} AS id, {qi('hid')} AS task_id, {qi('url')} AS raw_path, {qi('orderNum')} AS order_num "
f"FROM {qi(schema)}.{qi('missiontask_instructions')} "
f"WHERE {qi('hid')} IN ({placeholders}) AND {qi('isDelete')} = 0 "
f"ORDER BY {qi('hid')} ASC, {qi('orderNum')} ASC"
)
ins_rows = _query_table_rows(conn, ins_sql, task_ids)
for r in ins_rows:
raw_path = str(r.get("raw_path", "")).strip()
if not raw_path:
continue
detail_rows.append(
{
"product_no": pno,
"media_type": "drawing",
"media_url": normalize_media_url(raw_path, local_prefix, cdn_prefix),
"raw_path": raw_path,
"source_schema": schema,
"source_table": "missiontask_instructions",
"source_column": "url",
}
)
# 4) video
video_sql = (
f"SELECT {qi('id')} AS id, {qi('hid')} AS task_id, {qi('url')} AS raw_path, "
f"{qi('videoId')} AS video_id, {qi('orderNum')} AS order_num "
f"FROM {qi(schema)}.{qi('missiontask_video')} "
f"WHERE {qi('hid')} IN ({placeholders}) AND {qi('isDelete')} = 0 "
f"ORDER BY {qi('hid')} ASC, {qi('orderNum')} ASC"
)
video_rows = _query_table_rows(conn, video_sql, task_ids)
for r in video_rows:
raw_path = str(r.get("raw_path", "")).strip()
video_id = str(r.get("video_id", "")).strip()
media_url = ""
source_col = "url"
if raw_path:
media_url = normalize_media_url(raw_path, local_prefix, cdn_prefix)
elif video_id:
# youku id format: XNjUyMTkzNjkxMg==
media_url = f"https://v.youku.com/v_show/id_{video_id}.html"
raw_path = video_id
source_col = "videoId"
else:
continue
detail_rows.append(
{
"product_no": pno,
"media_type": "video",
"media_url": media_url,
"raw_path": raw_path,
"source_schema": schema,
"source_table": "missiontask_video",
"source_column": source_col,
}
)
table_rows.append(
{
"product_no": pno,
"source_schema": schema,
"source_table": "missiontask_instructions",
"matched_rows": len(ins_rows),
"matched_media_items": sum(1 for x in detail_rows if x.get("source_table") == "missiontask_instructions"),
}
)
table_rows.append(
{
"product_no": pno,
"source_schema": schema,
"source_table": "missiontask_video",
"matched_rows": len(video_rows),
"matched_media_items": sum(1 for x in detail_rows if x.get("source_table") == "missiontask_video"),
}
)
return table_rows, detail_rows
def query_media_records(
conn,
specs: Sequence[Dict[str, object]],
product_no: str,
row_limit: int,
local_prefix: str,
cdn_prefix: str,
) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]:
detail_rows: List[Dict[str, object]] = []
table_rows: List[Dict[str, object]] = []
for spec in specs:
schema = str(spec["schema"])
table = str(spec["table"])
product_cols = list(spec["product_cols"])
media_cols = list(spec["media_cols"])
drawing_cols = set(spec.get("drawing_cols", []))
video_cols = set(spec.get("video_cols", []))
select_cols = sorted(set(product_cols + media_cols))
where_parts: List[str] = []
params: List[object] = []
for col in product_cols:
qcol = qi(col)
where_parts.append(f"({qcol} = %s OR {qcol} LIKE %s)")
params.extend([product_no, f"{product_no}%"])
if not where_parts:
continue
sql = (
f"SELECT {', '.join(qi(c) for c in select_cols)} "
f"FROM {qi(schema)}.{qi(table)} "
f"WHERE {' OR '.join(where_parts)} "
f"LIMIT %s"
)
params.append(max(1, min(row_limit, 2000)))
with conn.cursor() as cur:
cur.execute(sql, params)
data = cur.fetchall()
if not data:
continue
col_index = {name: i for i, name in enumerate(select_cols)}
matched_media = 0
for row in data:
product_val = ""
for pcol in product_cols:
value = row[col_index[pcol]]
if value is not None and str(value).strip():
product_val = str(value).strip()
break
for mcol in media_cols:
value = row[col_index[mcol]]
if value is None:
continue
raw_path = str(value).strip()
if not raw_path:
continue
media_type = classify_media_type(mcol, raw_path)
if mcol in drawing_cols:
media_type = "drawing"
elif mcol in video_cols:
media_type = "video"
normalized = normalize_media_url(raw_path, local_prefix, cdn_prefix)
detail_rows.append(
{
"product_no": product_val or product_no,
"media_type": media_type,
"media_url": normalized,
"raw_path": raw_path,
"source_schema": schema,
"source_table": table,
"source_column": mcol,
}
)
matched_media += 1
table_rows.append(
{
"product_no": product_no,
"source_schema": schema,
"source_table": table,
"matched_rows": len(data),
"matched_media_items": matched_media,
}
)
return table_rows, detail_rows
def dedupe_media_rows(rows: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
seen = set()
out: List[Dict[str, object]] = []
for row in rows:
key = (
row.get("product_no", ""),
row.get("media_type", ""),
row.get("media_url", ""),
row.get("raw_path", ""),
row.get("source_schema", ""),
row.get("source_table", ""),
row.get("source_column", ""),
)
if key in seen:
continue
seen.add(key)
out.append(dict(row))
return out
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Lookup install drawing/video links by order-id -> product-no -> MySQL media DB"
)
parser.add_argument("--order-id", default="", help="商品订单号")
parser.add_argument("--product-no", default="", help="产品货号(可跳过订单映射直接查)")
parser.add_argument("--sku-id", default="", help="SKU编码(等价于 --product-no,直接在安装库中匹配)")
parser.add_argument("--limit", type=int, default=200, help="每个来源表最多查询行数")
parser.add_argument("--max-tables", type=int, default=120, help="自动发现时最多探测表数")
parser.add_argument("--mysql-schema", default="", help="指定MySQL schema,减少扫描范围")
parser.add_argument("--mysql-table", default="", help="指定MySQL table,减少扫描范围")
parser.add_argument("--mapping-file", default=DEFAULT_MAPPING_FILE, help="固定映射配置JSON")
parser.add_argument("--local-prefix", default=os.environ.get("INSTALL_MEDIA_LOCAL_PREFIX", "D:\\JianSunTuZhiFile"), help="本地路径前缀")
parser.add_argument("--cdn-prefix", default=os.environ.get("INSTALL_MEDIA_CDN_PREFIX", "https://img.jiansuntuzhi.cn/"), help="图纸/视频CDN前缀")
return parser.parse_args()
def main() -> int:
args = parse_args()
order_id = args.order_id.strip()
direct_product = args.product_no.strip() or args.sku_id.strip()
if not order_id and not direct_product:
print("STATUS: failed")
print("ERROR: missing order-id or product-no")
return 2
warnings: List[str] = []
mapping_rows: List[Dict[str, object]] = []
if order_id:
try:
mapping_rows.extend(query_order_product_mappings(order_id, limit=300))
except Exception as exc:
warnings.append(f"SQLSERVER_ORDER_MAPPING_FAILED: {exc}")
if direct_product:
mapping_rows.append(
{
"order_id": order_id or "(direct)",
"sku_id": "",
"product_no": direct_product,
"sku_name": "",
"shop_name": "",
"send_time": None,
}
)
# dedupe mapping by (order_id, product_no, sku_id)
dedup = {}
for row in mapping_rows:
k = (str(row.get("order_id", "")).strip(), str(row.get("product_no", "")).strip(), str(row.get("sku_id", "")).strip())
if not k[1]:
continue
dedup[k] = row
mapping_rows = list(dedup.values())
product_nos = sorted({str(r.get("product_no", "")).strip() for r in mapping_rows if str(r.get("product_no", "")).strip()})
table_summary_rows: List[Dict[str, object]] = []
media_rows: List[Dict[str, object]] = []
if product_nos:
mysql_params = mysql_conn_params()
if not mysql_params["password"]:
warnings.append("MYSQL_PASSWORD_EMPTY: please set INSTALL_MEDIA_MYSQL_PASSWORD")
else:
try:
with pymysql.connect(**mysql_params) as conn:
schema_hint = args.mysql_schema.strip() or "jiansunblueprint"
source_mode = "special_jiansun"
discovered_count = 0
for pno in product_nos:
ts, ms = query_jiansun_special_records(
conn=conn,
schema=schema_hint,
product_no=pno,
row_limit=max(1, args.limit),
local_prefix=args.local_prefix,
cdn_prefix=args.cdn_prefix,
)
table_summary_rows.extend(ts)
media_rows.extend(ms)
if not ms:
specs = load_mapping_specs(args.mapping_file)
if specs:
source_mode = "mapping_file_fallback"
else:
specs = discover_table_specs(
conn,
args.mysql_schema.strip(),
args.mysql_table.strip(),
max(1, args.max_tables),
)
source_mode = "auto_discovery_fallback"
discovered_count = len(specs)
ts2, ms2 = query_media_records(
conn=conn,
specs=specs,
product_no=pno,
row_limit=max(1, args.limit),
local_prefix=args.local_prefix,
cdn_prefix=args.cdn_prefix,
)
table_summary_rows.extend(ts2)
media_rows.extend(ms2)
print(f"DISCOVERY_MODE: {source_mode}")
print(f"DISCOVERED_TABLES: {discovered_count}")
except Exception as exc:
warnings.append(f"MYSQL_LOOKUP_FAILED: {exc}")
else:
warnings.append("NO_PRODUCT_NO_FROM_ORDER: no product_no mapped from order-id")
media_rows = dedupe_media_rows(media_rows)
status = "ok"
if not media_rows and warnings:
status = "partial"
if not media_rows and not product_nos:
status = "failed"
print(f"STATUS: {status}")
print(f"ORDER_ID: {order_id or '(none)'}")
print(f"PRODUCT_NO_COUNT: {len(product_nos)}")
print("RESULT_SET 1")
print("NAME: order_to_product_mapping")
print_rows(
["order_id", "sku_id", "product_no", "sku_name", "shop_name", "send_time"],
mapping_rows,
)
print("RESULT_SET 2")
print("NAME: mysql_table_match_summary")
print_rows(
["product_no", "source_schema", "source_table", "matched_rows", "matched_media_items"],
table_summary_rows,
)
print("RESULT_SET 3")
print("NAME: install_media_links")
print_rows(
["product_no", "media_type", "media_url", "raw_path", "source_schema", "source_table", "source_column"],
media_rows,
)
if warnings:
print("WARNINGS:")
for w in warnings:
print(f"- {w}")
if not media_rows:
print("NEXT_STEP:")
if warnings:
print("- verify MySQL network reachability and fixed table mapping file")
else:
print("- matched sku/product but no install drawing/video rows in current tables")
return 0 if status in {"ok", "partial"} else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,95 @@
USE [JSDataMiddlePlatform];
GO
/*
Unified logical layer (temp-table based, no CREATE VIEW permission required).
Week window = previous natural week:
[start_of_last_week, start_of_this_week)
*/
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
IF OBJECT_ID('tempdb..#filtered') IS NOT NULL
DROP TABLE #filtered;
SELECT
a.id,
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined1, '') AS inner_order_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
NULLIF(a.undefined9, '') AS shop_code,
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined56, '') AS afs_type,
NULLIF(a.undefined57, '') AS afs_reason,
NULLIF(a.undefined58, '') AS afs_result,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS post_ship_apply_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS post_ship_return_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS post_ship_refund_amount,
a.createdAt AS created_at,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name
INTO #filtered
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE a.createdAt >= @week_start
AND a.createdAt < @week_end
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
-- 1) weekly overview
SELECT
MIN(created_at) AS data_min_time,
MAX(created_at) AS data_max_time,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(post_ship_apply_amount, 0)) AS apply_amount,
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
FROM #filtered;
-- 2) platform distribution
SELECT
platform_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
FROM #filtered
GROUP BY platform_name
ORDER BY afs_record_count DESC;
-- 3) shop top 20
SELECT TOP (20)
shop_name,
platform_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
FROM #filtered
GROUP BY shop_name, platform_name
ORDER BY afs_record_count DESC;
-- 4) reason top 20
SELECT TOP (20)
ISNULL(NULLIF(afs_reason, ''), '未标注') AS afs_reason,
COUNT(*) AS afs_record_count,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount,
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount
FROM #filtered
GROUP BY ISNULL(NULLIF(afs_reason, ''), '未标注')
ORDER BY afs_record_count DESC;
-- 5) type/result matrix
SELECT
ISNULL(NULLIF(afs_type, ''), '未标注') AS afs_type,
ISNULL(NULLIF(afs_result, ''), '未标注') AS afs_result,
COUNT(*) AS afs_record_count
FROM #filtered
GROUP BY
ISNULL(NULLIF(afs_type, ''), '未标注'),
ISNULL(NULLIF(afs_result, ''), '未标注')
ORDER BY afs_record_count DESC;

414
remote_sync/market-brief-report Executable file
View File

@ -0,0 +1,414 @@
#!/usr/bin/env bash
set -euo pipefail
keyword=${1:-电脑桌}
report_scope=${2:-淘宝口径}
workdir=/tmp/market-brief
mkdir -p "$workdir"
profile=${BROWSER_PROFILE:-openclaw}
capture_snapshot() {
local page="$1"
local url="$2"
local wait_text="${3:-}"
local limit="${4:-260}"
yuce-browser open "$url" >/dev/null 2>&1 || true
openclaw browser wait --browser-profile "$profile" --load domcontentloaded --timeout-ms 15000 >/dev/null 2>&1 || true
openclaw browser wait --browser-profile "$profile" --time 2000 >/dev/null 2>&1 || true
if [ -n "$wait_text" ]; then
openclaw browser wait --browser-profile "$profile" --text "$wait_text" --timeout-ms 12000 >/dev/null 2>&1 || true
fi
openclaw browser snapshot --browser-profile "$profile" --labels --limit "$limit" > "$workdir/${page}.snapshot.txt" || true
}
# Multi-page evidence collection
declare -A PAGE_URLS
PAGE_URLS[leaf_category]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_TAPE_ANALYSIS/M_TB_TAPE_ANALYSIS_0/report-view/CZqcPFPRsZ'
PAGE_URLS[ranking_list]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_RANKING_LIST/M_TB_RANKING_LIST_0/report-view/hEq4a98B7B'
PAGE_URLS[industry_distribution]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_INSIGHT/M_TB_INDUSTRY_DISTRIBUTION/report-view/sfQrjDMn5l'
PAGE_URLS[surging_rank]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_SURGING_RANK/M_TB_SURGING_RANK_0/report-view/CD6nSlf6pC'
LOGIN_URL='https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F'
HOME_URL='https://hq.yucekj.com/#/home?tab=TAOBAO_MNO'
yuce-browser open "$LOGIN_URL" >/dev/null 2>&1 || true
yuce-browser open "$HOME_URL" >/dev/null 2>&1 || true
openclaw browser wait --browser-profile "$profile" --time 1500 >/dev/null 2>&1 || true
capture_snapshot leaf_category "${PAGE_URLS[leaf_category]}" "销售金额" 360
capture_snapshot ranking_list "${PAGE_URLS[ranking_list]}" "榜单" 260
capture_snapshot industry_distribution "${PAGE_URLS[industry_distribution]}" "行业" 220
capture_snapshot surging_rank "${PAGE_URLS[surging_rank]}" "飙升" 280
yuce-browser requests --filter yucekj.com > "$workdir/requests.txt" || true
python3 - "$workdir" "$keyword" "$report_scope" <<'PY'
import json
import re
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
workdir = Path(sys.argv[1])
keyword = (sys.argv[2] or "").strip()
scope = (sys.argv[3] or "").strip()
pages = {
"leaf_category": workdir / "leaf_category.snapshot.txt",
"ranking_list": workdir / "ranking_list.snapshot.txt",
"industry_distribution": workdir / "industry_distribution.snapshot.txt",
"surging_rank": workdir / "surging_rank.snapshot.txt",
}
page_text = {k: (p.read_text(encoding="utf-8", errors="ignore") if p.exists() else "") for k, p in pages.items()}
request_text = (workdir / "requests.txt").read_text(encoding="utf-8", errors="ignore") if (workdir / "requests.txt").exists() else ""
ROW_RE = re.compile(r'row "([^\"]+)"')
PCT_RE = re.compile(r'^-?\d+(?:\.\d+)?%$')
def norm(s: str) -> str:
return re.sub(r"[\s/()()【】\[\]·\-_,,。::;;]", "", (s or "")).lower()
def amount_to_num(s: str):
if not s:
return None
t = s.replace(",", "").strip()
mul = 1.0
if t.endswith("亿"):
mul = 1e8
t = t[:-1]
elif t.endswith("万"):
mul = 1e4
t = t[:-1]
try:
return float(t) * mul
except Exception:
return None
def pct_to_num(s: str):
if not s:
return None
t = s.strip().replace("%", "")
try:
return float(t)
except Exception:
return None
def keyword_candidates(kw: str):
out = []
if kw:
out.append(kw)
cleaned = re.sub(r"(类目|市场|市场数据|市场分析|行业分析|类目分析|竞品分析|趋势分析|关键词|热词|飙升词)$", "", kw or "").strip()
if cleaned and cleaned not in out:
out.append(cleaned)
alias_map = {
"书架": ["书架", "架类书架", "置物架", "收纳架"],
"电脑桌": ["电脑桌", "书桌", "学习桌"],
"茶桌": ["茶桌", "茶几", "家用茶台"],
"床垫": ["床垫", "席梦思", "乳胶床垫"],
}
for k, vals in alias_map.items():
if k in (kw or ""):
for v in vals:
if v not in out:
out.append(v)
return [x for x in out if len(x) >= 2]
def parse_leaf_rows(text: str):
rows = []
for row in ROW_RE.findall(text):
if "查看 详情" not in row:
continue
m = re.match(r'^(.*?) 查看 详情 ([^ ]+) (-?\d+(?:\.\d+)?)%$', row)
if not m:
continue
prefix = m.group(1).strip()
amount_raw = m.group(2).strip()
yoy_raw = f"{m.group(3)}%"
toks = prefix.split()
if len(toks) < 4:
continue
one, two, three = toks[0], toks[1], toks[2]
leaf = " ".join(toks[3:])
rows.append(
{
"row": row,
"一级类目": one,
"二级类目": two,
"三级类目": three,
"叶子类目": leaf,
"销售金额": amount_raw,
"销售金额同比": yoy_raw,
"销售金额_num": amount_to_num(amount_raw),
"销售金额同比_num": pct_to_num(yoy_raw),
}
)
rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True)
return rows
def parse_ranking_rows(text: str):
rows = []
for row in ROW_RE.findall(text):
if not row.endswith(" 查看"):
continue
m = re.match(r'^(.*?) (\d{6,20}) (\S+) (\S+) 查看$', row)
if not m:
continue
rows.append(
{
"商品名称": m.group(1).strip(),
"商品ID": m.group(2).strip(),
"店铺": m.group(3).strip(),
"品牌": m.group(4).strip(),
"row": row,
}
)
return rows
def parse_surging_rows(text: str):
rows = []
for row in ROW_RE.findall(text):
# 商品名 店铺 连续增长月份 近6月平均单价 销售额
m = re.match(r'^(.*?) (\S+) (\d+) ([\d,]+(?:\.\d+)?) ([\d,]+(?:\.\d+)?(?:万|亿)?)$', row)
if not m:
continue
rows.append(
{
"商品": m.group(1).strip(),
"店铺": m.group(2).strip(),
"连续增长月份": int(m.group(3)),
"近6月平均单价": m.group(4).strip(),
"销售金额": m.group(5).strip(),
"销售金额_num": amount_to_num(m.group(5).strip()),
"row": row,
}
)
rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True)
return rows
def parse_industry(text: str):
m = re.search(r'一级类目 销售额 销售额同比\s+([^\s]+)\s+([\d,]+(?:\.\d+)?(?:万|亿)?)\s+(-?\d+(?:\.\d+)?)%', text)
if not m:
return None
return {
"一级类目": m.group(1),
"销售额": m.group(2),
"销售额_num": amount_to_num(m.group(2)),
"销售额同比": f"{m.group(3)}%",
"销售额同比_num": float(m.group(3)),
}
leaf_rows = parse_leaf_rows(page_text.get("leaf_category", ""))
ranking_rows = parse_ranking_rows(page_text.get("ranking_list", ""))
surging_rows = parse_surging_rows(page_text.get("surging_rank", ""))
industry = parse_industry(page_text.get("industry_distribution", ""))
cand_norm = [norm(x) for x in keyword_candidates(keyword)]
def row_hit(text: str):
n = norm(text)
return any(c in n for c in cand_norm)
kw_leaf_candidates = [r for r in leaf_rows if row_hit(f"{r['三级类目']} {r['叶子类目']} {r['row']}")]
kw_leaf = kw_leaf_candidates[0] if kw_leaf_candidates else None
kw_rank = None
if kw_leaf and kw_leaf in leaf_rows:
kw_rank = leaf_rows.index(kw_leaf) + 1
same_secondary = []
if kw_leaf:
same_secondary = [r for r in leaf_rows if r["二级类目"] == kw_leaf["二级类目"]]
secondary_top3 = same_secondary[:3]
secondary_growth_top3 = sorted(same_secondary, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or -999), reverse=True)[:3]
top10_amt = sum([r["销售金额_num"] or 0 for r in leaf_rows[:10]]) if leaf_rows else 0
kw_share_of_total = None
if kw_leaf and industry and industry.get("销售额_num"):
if industry["销售额_num"] > 0 and kw_leaf.get("销售金额_num") is not None:
kw_share_of_total = kw_leaf["销售金额_num"] / industry["销售额_num"] * 100
ranking_top20 = ranking_rows[:20]
ranking_shop_counter = Counter([r["店铺"] for r in ranking_top20])
ranking_brand_counter = Counter([r["品牌"] for r in ranking_top20])
ranking_kw_hits = [r for r in ranking_top20 if row_hit(f"{r['商品名称']} {r['店铺']} {r['品牌']}")]
surging_top10 = surging_rows[:10]
surging_kw_hits = [r for r in surging_top10 if row_hit(f"{r['商品']} {r['店铺']}")]
api_lines = [line.strip() for line in request_text.splitlines() if "/api/" in line]
api_lines = api_lines[-12:]
coverage = {
"leaf_category": bool(page_text.get("leaf_category", "")),
"ranking_list": bool(page_text.get("ranking_list", "")),
"industry_distribution": bool(page_text.get("industry_distribution", "")),
"surging_rank": bool(page_text.get("surging_rank", "")),
}
metrics = {
"topic": keyword,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"scope": scope,
"coverage": coverage,
"industry": industry,
"leaf": {
"rows_count": len(leaf_rows),
"keyword_row": kw_leaf,
"keyword_rank": kw_rank,
"keyword_share_of_total_pct": kw_share_of_total,
"top10_amount_sum": top10_amt,
"secondary_top3": secondary_top3,
"secondary_growth_top3": secondary_growth_top3,
"decline_top3": sorted(leaf_rows, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or 999))[:3],
},
"ranking": {
"rows_count": len(ranking_rows),
"top20_unique_shop_count": len(ranking_shop_counter),
"top5_shops": ranking_shop_counter.most_common(5),
"top5_brands": ranking_brand_counter.most_common(5),
"keyword_hits": ranking_kw_hits[:5],
},
"surging": {
"rows_count": len(surging_rows),
"top10": surging_top10,
"keyword_hits": surging_kw_hits[:5],
},
"evidence": {
"pages": [
{"name": "叶子类目分析", "source": "leaf_category.snapshot.txt", "captured": coverage["leaf_category"]},
{"name": "品牌店铺商品热榜", "source": "ranking_list.snapshot.txt", "captured": coverage["ranking_list"]},
{"name": "行业分布", "source": "industry_distribution.snapshot.txt", "captured": coverage["industry_distribution"]},
{"name": "飙升品榜单", "source": "surging_rank.snapshot.txt", "captured": coverage["surging_rank"]},
],
"api_lines": api_lines,
},
}
(workdir / "metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8")
def fmt_pct(v):
if v is None:
return "未在当前页面取到"
return f"{v:.2f}%"
def fmt_ratio(v):
if v is None:
return "未在当前页面取到"
return f"{v:.2f}%"
print(f"# {keyword}市场分析简报(增强版)")
print()
print("## 已抓取证据")
print(f"- 时间: {metrics['generated_at']}")
print(f"- 范围: {metrics['scope']}")
for p in metrics["evidence"]["pages"]:
print(f"- 页面: {p['name']} ({'已抓取' if p['captured'] else '未抓取'})")
if industry:
print(f"- 行业总览: 一级类目={industry['一级类目']},销售额={industry['销售额']},同比={industry['销售额同比']}")
else:
print("- 行业总览: 未在当前页面取到")
if kw_leaf:
print(f"- 关键词命中: {kw_leaf['row']}")
print(f"- 关键词排名: 第{kw_rank}/{len(leaf_rows)}(按销售金额)")
else:
print("- 关键词命中: 未在当前页面取到")
print(f"- 请求证据(API): {len(api_lines)}条(展示最近6条)")
for line in api_lines[-6:]:
print(f" - {line}")
print()
print("## 深度分析")
print("### 1) 规模与趋势")
if kw_leaf:
print(f"- {keyword}销售金额={kw_leaf['销售金额']},同比={kw_leaf['销售金额同比']}。")
print(f"- {keyword}占一级类目销售额占比={fmt_ratio(kw_share_of_total)}。")
if industry and kw_leaf.get("销售金额同比_num") is not None and industry.get("销售额同比_num") is not None:
gap = kw_leaf["销售金额同比_num"] - industry["销售额同比_num"]
direction = "跑赢" if gap >= 0 else "跑输"
print(f"- 相对行业同比差值={gap:.2f}pct,{direction}行业大盘。")
else:
print("- 相对行业同比差值: 未在当前页面取到")
else:
print("- 关键词行未命中,暂不输出规模趋势定量结论。")
print("### 2) 类目结构")
if kw_leaf and same_secondary:
print(f"- {keyword}位于二级类目「{kw_leaf['二级类目']}」,同二级类目共抓取{len(same_secondary)}个叶子类目。")
if secondary_top3:
top_desc = ";".join([f"{r['叶子类目']}({r['销售金额']}, {r['销售金额同比']})" for r in secondary_top3])
print(f"- 同二级类目销售TOP3: {top_desc}")
if secondary_growth_top3:
growth_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in secondary_growth_top3])
print(f"- 同二级类目增速TOP3: {growth_desc}")
else:
print("- 未命中关键词二级类目,结构分析不完整。")
print("### 3) 竞争格局")
if ranking_rows:
print(f"- 热榜样本: 抓取{len(ranking_rows)}行,TOP20覆盖店铺数={metrics['ranking']['top20_unique_shop_count']}。")
if metrics["ranking"]["top5_shops"]:
shop_desc = ";".join([f"{k}({v})" for k, v in metrics["ranking"]["top5_shops"][:3]])
print(f"- 高频店铺(样本): {shop_desc}")
if metrics["ranking"]["keyword_hits"]:
hit_desc = ";".join([f"{r['商品名称']}[{r['店铺']}]" for r in metrics["ranking"]["keyword_hits"][:3]])
print(f"- 关键词相关商品(热榜命中): {hit_desc}")
else:
print("- 关键词相关商品(热榜命中): 未在当前页面取到")
else:
print("- 热榜数据未抓取到有效行。")
print("### 4) 增长机会与风险")
if surging_rows:
print(f"- 飙升榜样本: 抓取{len(surging_rows)}行,TOP10中关键词命中={len(surging_kw_hits)}。")
if surging_top10:
top_surge = ";".join([f"{r['商品']}({r['销售金额']}, 连增{r['连续增长月份']}月)" for r in surging_top10[:3]])
print(f"- 当前飙升代表样本: {top_surge}")
else:
print("- 飙升榜数据未抓取到有效行。")
if metrics["leaf"]["decline_top3"]:
decline_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in metrics["leaf"]["decline_top3"][:3]])
print(f"- 同页下滑风险叶子类目TOP3: {decline_desc}")
print()
print("## 可执行建议")
if kw_leaf:
print(f"- P1: 围绕「{kw_leaf['二级类目']}->{kw_leaf['叶子类目']}」拆分价格带与主卖点,先对齐同比下滑原因。")
else:
print(f"- P1: 先在叶子类目页精确命中「{keyword}」后再做定量策略。")
print("- P1: 以热榜TOP店铺与品牌为对标,复刻高频素材结构(标题词/卖点图/价格锚点)。")
print("- P2: 用飙升榜样本补充新品池,优先测试连续增长月份>=5的商品方向。")
print("- P2: 对同比显著下滑类目做止损策略(预算下调+创意替换+词包重组)。")
print("- P3: 补抓价格段布局、需求趋势、关键词布局三页后再做投放预算分配。")
print()
print("## 风险与缺口")
if not kw_leaf:
print("- 关键词主行未命中,当前报告存在样本偏差风险。")
if kw_leaf and kw_leaf.get("销售金额同比") == "未在当前页面取到":
print("- 缺失关键词同比字段,无法做趋势判断。")
print("- 当前脚本未稳定抓到销售数量/转化率字段,件数口径需在详情/字段面板补抓。")
print("- 若页面筛选条件被改动且未点查询,指标可能失真(已要求SOP强制点查询)。")
print()
print("## 下一步")
print("- 补抓: 价格段布局 + 需求趋势分析 + 关键词布局分析(同关键词、同时间窗)。")
print("- 追加: 近3个月滚动对比,验证当前同比结论是否持续。")
print("- 输出: 可选生成“策略执行单”(词包/价格带/竞店清单)。")
print()
print(f"(结构化指标文件: {workdir / 'metrics.json'})")
PY

View File

@ -0,0 +1,12 @@
{
"tables": [
{
"schema": "your_schema",
"table": "your_table",
"product_cols": ["product_no"],
"drawing_cols": ["instruction_img_path"],
"video_cols": ["install_video_path"],
"media_cols": []
}
]
}

View File

@ -0,0 +1,3 @@
{
"tables": []
}

View File

@ -0,0 +1,90 @@
# 市场数据页面采集 SOP(Yuce)
适用范围:`hq.yucekj.com` / `yucekj.com` 的市场分析任务(行业/类目/竞品/趋势)。
## 1. 总体硬规则
1. 只允许访问 `hq.yucekj.com` 与 `yucekj.com`。
2. 先采集证据再出结论,禁止凭记忆输出数字。
3. 每次修改筛选条件(尤其时间周期)后,必须点击一次`查询`再抓取。
4. 结果至少包含:
- 2 个页面快照证据
- 1 条请求证据(`yuce-browser requests --filter yucekj.com`)
5. 若出现验证码/二次验证,立即暂停并要求人工处理。
## 2. 标准执行流程(所有页面通用)
1. 打开站点并确认登录态。
2. 进入目标模块页面(优先首页卡片点击,次选已知 report URL)。
3. 设置筛选:时间周期、类目词(如“书架”)、平台/店铺等。
4. 点击`查询`,等待表格/图表刷新。
5. 抓取基础页面快照。
6. 对目标行执行展开动作(`查看`/`详情`/`更多数据`等),抓取展开页快照。
7. 导出请求证据并与快照一起入档。
8. 按“证据 -> 结论 -> 建议 -> 缺口”输出简报。
## 3. 页面级采集清单(模块 + 按钮展开)
> 字段以页面真实表头为准;以下是最低必采维度。
| 模块 | 主要采集维度(最低) | 常见按钮/展开动作 | 备注 |
|---|---|---|---|
| 市场洞察 | 市场规模、同比/环比、趋势区间、核心类目 | 查询、切换周期 | 先取总览,再下钻类目 |
| 叶子类目分析 | 一级/二级/三级类目、销售额、增速、占比 | `查看`、`详情` | 书架等关键词优先在该页命中 |
| 品牌店铺商品热榜 | 品牌/店铺/商品排名、销量/销售额、价格带 | `查看`、`详情` | 热榜可做竞品对标 |
| 飙升品榜单 | 飙升商品、增速、店铺、价格段 | `查看`、`详情`、`选择字段` | 常需先勾选字段再确定 |
| 品牌布局分析 | 品牌覆盖、价格分布、类目布局 | 查询、明细展开 | 对比头部/腰部品牌 |
| 竞店布局分析 | 竞店结构、渠道布局、商品结构 | 查看明细、店铺下钻 | 结合热榜看竞店策略 |
| 价格段布局 | 价格带占比、增速、区间分布 | 区间筛选、查询 | 用于定价建议 |
| 产品需求分析 | 需求词、需求强度、关联类目 | 查看明细、词下钻 | 与关键词布局联动 |
| 需求趋势分析 | 时间序列趋势、峰谷变化 | 切周期、查询 | 必须标注时间范围 |
| 关键词布局分析 | 关键词覆盖、词排名、流量/需求信号 | 查看明细、关键词下钻 | 支撑投放与标题优化 |
## 4. 已验证的页面入口(可直达)
- 叶子类目分析:
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_TAPE_ANALYSIS&report-view=CZqcPFPRsZ`
- 品牌店铺商品热榜:
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_RANKING_LIST&report-view=hEq4a98B7B`
- 市场洞察(行业分布页):
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_INSIGHT&tab=M_TB_INDUSTRY_DISTRIBUTION&report-view=sfQrjDMn5l`
- 飙升品榜单:
`https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_SURGING_RANK&report-view=CD6nSlf6pC`
## 5. 按钮展开采集逻辑(强制)
1. **行命中规则**:先精确匹配,再做标准化匹配(去空格、全半角统一、符号清洗),最后做包含匹配。
2. **`查看/详情`**:
- 点击后等待页面稳定(建议 2-5 秒)
- 立刻抓取展开页快照
- 记录是否发生 URL 跳转(含 `jumpingFilter`)
3. **`选择字段/更多数据`**:
- 打开字段面板
- 勾选任务所需字段
- 点击`确定`
- 再次`查询`并抓快照
4. **分页/滚动**:首屏无目标行时,向下滚动或翻页继续命中,直到达到最大页数或命中成功。
5. **失败回退**:
- 先回首页重进模块一次
- 再改用 report URL 直达一次
- 仍失败则输出“当前账号页面未取到”并给出缺口项
## 6. 证据落盘规范
- 快照:`/tmp/market-sop/<module>.<stage>.txt`
- 点击日志:`/tmp/market-sop/<module>.click.<action>.txt`
- 请求日志:`/tmp/market-sop/<module>.requests.txt`
- 每次任务至少保留:
- 1 份基础页快照
- 1 份展开页快照
- 1 份请求日志
## 7. 输出模板(给市场分析技能)
1. 已抓取证据(模块/字段/值)
2. 核心结论(逐条引用证据)
3. 可执行建议(P1/P2/P3)
4. 风险与缺口(明确“未在当前页面取到”)
5. 下一步(1-3 条)
> 备注:市场分析结果不做来源脱敏;保留原始指标表达,禁止改写数值。

View File

@ -0,0 +1,514 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
def _sql_escape(value: str) -> str:
return value.replace("'", "''")
def build_sql(
product_no: str,
province: str,
city: str,
district: str,
days: int,
warehouse_transfer_fee: float,
depreciation_rate: float,
limit: int,
) -> str:
pno = _sql_escape(product_no.strip())
prov = _sql_escape(province.strip())
city_v = _sql_escape((city or "").strip())
dist_v = _sql_escape((district or "").strip())
wh_fee = f"{warehouse_transfer_fee:.2f}"
dep_rate = f"{depreciation_rate:.4f}"
return f"""
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @product_no nvarchar(120) = N'{pno}';
DECLARE @province nvarchar(50) = N'{prov}';
DECLARE @city nvarchar(50) = N'{city_v}';
DECLARE @district nvarchar(50) = N'{dist_v}';
DECLARE @days int = {days};
DECLARE @limit int = {limit};
DECLARE @warehouse_transfer_fee decimal(18,2) = {wh_fee};
DECLARE @depreciation_rate decimal(10,4) = {dep_rate};
DECLARE @start_time datetime2 = DATEADD(day, -@days, GETDATE());
DECLARE @end_time datetime2 = GETDATE();
DECLARE @province_norm nvarchar(50) =
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@province, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '');
DECLARE @city_norm nvarchar(50) =
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@city, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '');
DECLARE @district_norm nvarchar(50) =
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@district, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '');
IF OBJECT_ID('tempdb..#sku_pool') IS NOT NULL
DROP TABLE #sku_pool;
SELECT DISTINCT
s.sku_id,
s.name AS sku_name,
s.other_7 AS product_no,
s.i_id AS style_code,
s.other_1 AS spu
INTO #sku_pool
FROM dbo.jst_sku AS s WITH (NOLOCK)
WHERE ISNULL(NULLIF(s.sku_id, ''), '') <> ''
AND (
NULLIF(s.other_7, '') = @product_no
OR NULLIF(s.i_id, '') = @product_no
OR NULLIF(s.other_1, '') = @product_no
OR NULLIF(s.sku_id, '') = @product_no
OR NULLIF(s.other_7, '') LIKE @product_no + '%'
);
INSERT INTO #sku_pool (sku_id, sku_name, product_no, style_code, spu)
SELECT DISTINCT
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined36, '') AS sku_name,
NULLIF(a.undefined96, '') AS product_no,
NULL,
NULL
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
LEFT JOIN #sku_pool AS p
ON p.sku_id = NULLIF(a.undefined10, '')
WHERE p.sku_id IS NULL
AND ISNULL(NULLIF(a.undefined10, ''), '') <> ''
AND (
NULLIF(a.undefined96, '') = @product_no
OR NULLIF(a.undefined96, '') LIKE @product_no + '%'
);
IF OBJECT_ID('tempdb..#order_base') IS NOT NULL
DROP TABLE #order_base;
SELECT
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined10, '') AS sku_id,
COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, ''), @product_no) AS product_no,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
ISNULL(NULLIF(a.undefined73, ''), ISNULL(NULLIF(e.logistic_company_name, ''), '未标注')) AS express_company,
COALESCE(NULLIF(a.undefined66, ''), NULLIF(s.other_3, ''), '未标注') AS ship_warehouse_name,
ISNULL(NULLIF(a.undefined74, ''), '') AS province_raw,
ISNULL(NULLIF(a.undefined75, ''), '') AS city_raw,
ISNULL(NULLIF(e.receiver_address, ''), '') AS receiver_address,
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(a.undefined74, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS province_norm,
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(a.undefined75, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS city_norm,
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(e.receiver_address, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS receiver_address_norm,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined21, '')),
CAST(0 AS decimal(18, 2))
) AS sale_amount,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined22, '')),
CAST(0 AS decimal(18, 2))
) AS cost_amount,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined40, '')),
CAST(0 AS decimal(18, 2))
)
+ COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined49, '')),
CAST(0 AS decimal(18, 2))
) AS return_qty,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined90, '')),
CAST(0 AS decimal(18, 2))
) AS return_freight_fee,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined79, '')),
CAST(0 AS decimal(18, 2))
) AS warehouse_transfer_fee_obs
INTO #order_base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
INNER JOIN #sku_pool AS p
ON p.sku_id = NULLIF(a.undefined10, '')
LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK)
ON s.sku_id = NULLIF(a.undefined10, '')
LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK)
ON NULLIF(e.so_id, '') = NULLIF(a.undefined2, '')
WHERE ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'
AND a.createdAt >= DATEADD(day, -(@days + 7), GETDATE());
IF OBJECT_ID('tempdb..#region_line') IS NOT NULL
DROP TABLE #region_line;
SELECT
ob.*
INTO #region_line
FROM #order_base AS ob
WHERE ob.ship_dt >= @start_time
AND ob.ship_dt < @end_time
AND (
(@province_norm = '' AND ISNULL(NULLIF(ob.province_norm, ''), '') <> '')
OR ob.province_norm LIKE '%' + @province_norm + '%'
OR @province_norm LIKE '%' + ob.province_norm + '%'
OR ob.receiver_address_norm LIKE '%' + @province_norm + '%'
)
AND (
@city_norm = ''
OR ob.city_norm LIKE '%' + @city_norm + '%'
OR @city_norm LIKE '%' + ob.city_norm + '%'
OR ob.receiver_address_norm LIKE '%' + @city_norm + '%'
)
AND (
@district_norm = ''
OR ob.receiver_address LIKE '%' + @district + '%'
OR ob.receiver_address_norm LIKE '%' + @district_norm + '%'
);
IF OBJECT_ID('tempdb..#order_agg') IS NOT NULL
DROP TABLE #order_agg;
SELECT
rl.order_id,
MAX(rl.shop_name) AS shop_name,
MAX(rl.product_no) AS product_no,
MAX(rl.ship_dt) AS ship_dt,
SUM(ISNULL(rl.sale_amount, 0)) AS sale_amount_order,
SUM(ISNULL(rl.cost_amount, 0)) AS cost_amount_order,
SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_order,
SUM(ISNULL(rl.return_qty, 0)) AS return_qty_order,
SUM(ISNULL(rl.return_freight_fee, 0)) AS return_freight_fee_order,
SUM(ISNULL(rl.warehouse_transfer_fee_obs, 0)) AS warehouse_transfer_fee_order
INTO #order_agg
FROM #region_line AS rl
GROUP BY rl.order_id;
IF OBJECT_ID('tempdb..#freight_order') IS NOT NULL
DROP TABLE #freight_order;
SELECT
NULLIF(f.so_id, '') AS order_id,
SUM(COALESCE(TRY_CONVERT(decimal(18, 2), f.freightCost), 0)) AS outbound_freight_order
INTO #freight_order
FROM dbo.fys_jstFreightCostDetail AS f WITH (NOLOCK)
INNER JOIN #region_line AS rl
ON rl.order_id = NULLIF(f.so_id, '')
AND rl.sku_id = NULLIF(f.sku_id, '')
WHERE f.send_date >= @start_time
AND f.send_date < @end_time
GROUP BY NULLIF(f.so_id, '');
IF OBJECT_ID('tempdb..#order_fact') IS NOT NULL
DROP TABLE #order_fact;
SELECT
oa.order_id,
oa.shop_name,
oa.product_no,
oa.ship_dt,
oa.sale_amount_order,
oa.cost_amount_order,
oa.shipped_qty_order,
oa.return_qty_order,
oa.return_freight_fee_order,
oa.warehouse_transfer_fee_order,
COALESCE(fo.outbound_freight_order, 0) AS outbound_freight_order
INTO #order_fact
FROM #order_agg AS oa
LEFT JOIN #freight_order AS fo
ON fo.order_id = oa.order_id;
IF OBJECT_ID('tempdb..#kpi') IS NOT NULL
DROP TABLE #kpi;
SELECT
COUNT(*) AS sample_order_count,
SUM(ISNULL(ofa.shipped_qty_order, 0)) AS shipped_qty_sum,
SUM(ISNULL(ofa.return_qty_order, 0)) AS return_qty_sum,
SUM(ISNULL(ofa.sale_amount_order, 0)) AS sale_amount_sum,
SUM(ISNULL(ofa.cost_amount_order, 0)) AS cost_amount_sum,
AVG(NULLIF(ISNULL(ofa.outbound_freight_order, 0), 0)) AS avg_outbound_freight_nonzero,
AVG(ISNULL(ofa.outbound_freight_order, 0)) AS avg_outbound_freight_per_order,
AVG(NULLIF(ISNULL(ofa.return_freight_fee_order, 0), 0)) AS avg_return_freight_observed,
AVG(NULLIF(ISNULL(ofa.warehouse_transfer_fee_order, 0), 0)) AS avg_warehouse_transfer_observed,
MIN(ofa.ship_dt) AS first_ship_time,
MAX(ofa.ship_dt) AS last_ship_time
INTO #kpi
FROM #order_fact AS ofa;
-- 1) 样本概览
SELECT
@product_no AS input_product_no,
@province AS input_province,
@city AS input_city,
@district AS input_district,
@days AS lookback_days,
(SELECT COUNT(*) FROM #sku_pool) AS matched_sku_count,
k.sample_order_count,
k.shipped_qty_sum,
k.return_qty_sum,
CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN NULL ELSE CAST(k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) AS decimal(10,4)) END AS return_rate,
k.first_ship_time,
k.last_ship_time
FROM #kpi AS k;
-- 2) 利润估算因子(按订单均值)
SELECT
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.sale_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_sale_amount_per_order,
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.cost_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_cost_amount_per_order,
CAST(ISNULL(k.avg_outbound_freight_per_order, 0) AS decimal(18,2)) AS est_outbound_freight_per_order_15d,
CAST(
COALESCE(
k.avg_return_freight_observed,
ISNULL(k.avg_outbound_freight_per_order, 0)
* CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
0
) AS decimal(18,2)
) AS est_return_freight_per_order_15d,
CAST(COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) AS decimal(18,2)) AS warehouse_transfer_fee_used,
CAST(
CASE WHEN k.sample_order_count = 0 THEN NULL ELSE
(k.sale_amount_sum / NULLIF(k.sample_order_count, 0))
- (k.cost_amount_sum / NULLIF(k.sample_order_count, 0))
- ISNULL(k.avg_outbound_freight_per_order, 0)
- COALESCE(
k.avg_return_freight_observed,
ISNULL(k.avg_outbound_freight_per_order, 0)
* CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
0
)
- COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0)
END AS decimal(18,2)
) AS est_profit_per_order,
CAST(
CASE WHEN k.sample_order_count = 0 THEN NULL
WHEN (k.sale_amount_sum / NULLIF(k.sample_order_count, 0)) = 0 THEN NULL
ELSE (
(
(k.sale_amount_sum / NULLIF(k.sample_order_count, 0))
- (k.cost_amount_sum / NULLIF(k.sample_order_count, 0))
- ISNULL(k.avg_outbound_freight_per_order, 0)
- COALESCE(
k.avg_return_freight_observed,
ISNULL(k.avg_outbound_freight_per_order, 0)
* CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
0
)
- COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0)
)
/ NULLIF((k.sale_amount_sum / NULLIF(k.sample_order_count, 0)), 0)
)
END AS decimal(10,4)
) AS est_profit_margin
FROM #kpi AS k;
-- 3) 处置建议(退货/补偿/放货,含退回折旧损失)
WITH decision_base AS (
SELECT
k.sample_order_count,
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.sale_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_sale,
CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.cost_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_cost,
CAST(ISNULL(k.avg_outbound_freight_per_order, 0) AS decimal(18,2)) AS avg_outbound,
CAST(COALESCE(
k.avg_return_freight_observed,
ISNULL(k.avg_outbound_freight_per_order, 0) * CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END,
0
) AS decimal(18,2)) AS est_return_freight,
CAST(COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) AS decimal(18,2)) AS warehouse_transfer
FROM #kpi AS k
),
calc AS (
SELECT
db.*,
CAST(ISNULL(db.avg_cost, 0) * @depreciation_rate AS decimal(18,2)) AS depreciation_loss_if_return,
CAST((ISNULL(db.avg_outbound, 0) + ISNULL(db.est_return_freight, 0) + ISNULL(db.warehouse_transfer, 0) + (ISNULL(db.avg_cost, 0) * @depreciation_rate)) AS decimal(18,2)) AS expected_loss_if_return,
CAST((ISNULL(db.avg_sale, 0) - ISNULL(db.avg_cost, 0) - ISNULL(db.avg_outbound, 0)) AS decimal(18,2)) AS keep_order_net_before_comp,
CAST((0 - ISNULL(db.avg_outbound, 0) - ISNULL(db.est_return_freight, 0) - ISNULL(db.warehouse_transfer, 0) - (ISNULL(db.avg_cost, 0) * @depreciation_rate)) AS decimal(18,2)) AS option_return_net_profit,
CAST((0 - ISNULL(db.avg_cost, 0) - ISNULL(db.avg_outbound, 0)) AS decimal(18,2)) AS option_release_net_profit
FROM decision_base AS db
),
range_calc AS (
SELECT
c.*,
CAST(
CASE
WHEN c.sample_order_count = 0 THEN 0
WHEN c.keep_order_net_before_comp <= 0 THEN 0
ELSE
CASE
WHEN (c.keep_order_net_before_comp * 0.70) < (c.expected_loss_if_return * 0.90) THEN (c.keep_order_net_before_comp * 0.70)
ELSE (c.expected_loss_if_return * 0.90)
END
END
AS decimal(18,2)
) AS comp_high_raw
FROM calc AS c
)
SELECT
rc.sample_order_count,
rc.keep_order_net_before_comp AS est_profit_per_order,
CAST(@depreciation_rate AS decimal(10,4)) AS depreciation_rate_used,
rc.depreciation_loss_if_return,
rc.expected_loss_if_return,
CAST(
CASE
WHEN rc.sample_order_count = 0 THEN 0
WHEN rc.comp_high_raw <= 0 THEN 0
ELSE rc.comp_high_raw * 0.50
END AS decimal(18,2)
) AS compensation_low,
CAST(
CASE
WHEN rc.sample_order_count = 0 THEN 0
WHEN rc.comp_high_raw <= 0 THEN 0
ELSE rc.comp_high_raw
END AS decimal(18,2)
) AS compensation_high,
rc.option_return_net_profit,
rc.option_release_net_profit,
CAST(
rc.keep_order_net_before_comp
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw * 0.50 END)
AS decimal(18,2)
) AS option_discount_net_profit_low,
CAST(
rc.keep_order_net_before_comp
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
AS decimal(18,2)
) AS option_discount_net_profit_high,
CASE
WHEN rc.sample_order_count = 0 THEN N'样本不足'
WHEN (
rc.keep_order_net_before_comp
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
) >= rc.option_return_net_profit
AND (
rc.keep_order_net_before_comp
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
) >= rc.option_release_net_profit
THEN N'优惠留客'
WHEN rc.option_return_net_profit >= rc.option_release_net_profit THEN N'退回'
ELSE N'放货'
END AS recommended_option,
CASE
WHEN rc.sample_order_count = 0 THEN N'样本不足,暂不做退补决策(建议放宽省市区或延长到30天)。'
WHEN (
rc.keep_order_net_before_comp
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
) >= rc.option_return_net_profit
AND (
rc.keep_order_net_before_comp
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END)
) >= rc.option_release_net_profit
THEN N'建议优先优惠留客:在建议补偿区间内,净收益优于退回和放货。'
WHEN rc.option_return_net_profit >= rc.option_release_net_profit
THEN N'建议优先退回:按当前折旧率和运费估算,退回净损失低于放货。'
ELSE N'建议优先放货:退回综合损失(含20%折旧)高于放货损失。'
END AS recommended_action,
CASE
WHEN rc.sample_order_count >= 30 THEN 'high'
WHEN rc.sample_order_count >= 10 THEN 'medium'
WHEN rc.sample_order_count > 0 THEN 'low'
ELSE 'none'
END AS confidence_level,
CAST(
CASE
WHEN rc.sample_order_count = 0 THEN NULL
ELSE rc.expected_loss_if_return
- (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw * 0.75 END)
END AS decimal(18,2)
) AS est_saving_vs_return
FROM range_calc AS rc;
-- 4) 快递/仓库策略(近15天样本)
SELECT TOP (@limit)
rl.express_company,
COUNT(DISTINCT rl.order_id) AS order_count,
SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_sum,
CAST(
SUM(ISNULL(rl.shipped_qty, 0))
/ NULLIF((SELECT SUM(ISNULL(shipped_qty, 0)) FROM #region_line), 0)
AS decimal(10,4)
) AS shipped_qty_ratio
FROM #region_line AS rl
GROUP BY rl.express_company
ORDER BY shipped_qty_sum DESC, order_count DESC;
SELECT TOP (@limit)
rl.ship_warehouse_name,
COUNT(DISTINCT rl.order_id) AS order_count,
SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_sum,
CAST(
SUM(ISNULL(rl.shipped_qty, 0))
/ NULLIF((SELECT SUM(ISNULL(shipped_qty, 0)) FROM #region_line), 0)
AS decimal(10,4)
) AS shipped_qty_ratio
FROM #region_line AS rl
GROUP BY rl.ship_warehouse_name
ORDER BY shipped_qty_sum DESC, order_count DESC;
"""
def main() -> int:
parser = argparse.ArgumentParser(
description="Estimate profit-based aftersale decision (退回/优惠/放货) by product_no + region, including return depreciation loss"
)
parser.add_argument("--product-no", required=True, help="商品货号(product_no/style_code/spu/SKU)")
parser.add_argument("--province", required=True, help="省份")
parser.add_argument("--city", default="", help="城市(可选)")
parser.add_argument("--district", default="", help="区县(可选)")
parser.add_argument("--days", type=int, default=15, help="lookback window, default 15")
parser.add_argument("--warehouse-transfer-fee", type=float, default=0.0, help="仓储转运费兜底值(当样本无观测值时使用)")
parser.add_argument("--depreciation-rate", type=float, default=0.20, help="退回折旧损失率,默认0.20(20%%)")
parser.add_argument("--limit", type=int, default=10, help="max rows for strategy result sets")
args = parser.parse_args()
product_no = args.product_no.strip()
province = args.province.strip()
if not product_no:
print("--product-no is required", file=sys.stderr)
return 2
if not province:
print("--province is required", file=sys.stderr)
return 2
days = max(7, min(args.days, 90))
limit = max(1, min(args.limit, 100))
depreciation_rate = max(0.0, min(args.depreciation_rate, 1.0))
sql = build_sql(
product_no=product_no,
province=province,
city=args.city,
district=args.district,
days=days,
warehouse_transfer_fee=args.warehouse_transfer_fee,
depreciation_rate=depreciation_rate,
limit=limit,
)
cmd = ["company-db", "query", "--sql", sql]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "company-db failed").strip()
print(err, file=sys.stderr)
return proc.returncode
print(proc.stdout.rstrip())
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,63 @@
---
name: product-retention-decision
description: Evaluate profit-based aftersale handling by product number and region, include return depreciation loss (default 20%), and recommend among return vs discount-compensation vs release-goods.
metadata:
{ "openclaw": { "emoji": "💰", "os": ["linux"], "requires": { "bins": ["company-db", "product-retention-decision"] } } }
---
# product-retention-decision Skill (OpenClaw)
Use this skill when user asks:
- 货号 + 省市区,判断退货还是补偿更划算
- 计算该订单利润后给出补偿区间
- 希望尽量留客、降低售后成本
- 要求把退回折旧损失(如20%)纳入决策
- 要求比较“退回/优惠/放货”三种方案
## Required inputs
- `product_no` (货号/商品ID/SPU/SKU)
- `province`
- Optional: `city`, `district`
## Command
```bash
product-retention-decision --product-no "<货号>" --province "<省>" [--city "<市>"] [--district "<区县>"] --days 15 --warehouse-transfer-fee 0 --depreciation-rate 0.20 --limit 10
```
## Cost model (15 days)
估算按订单均值:
- 销售价格(avg_sale_amount_per_order)
- 产品成本(avg_cost_amount_per_order)
- 发货运费(est_outbound_freight_per_order_15d)
- 退货运费(est_return_freight_per_order_15d)
- 仓储转运费(warehouse_transfer_fee_used)
- 退回折旧损失(depreciation_loss_if_return = 产品成本 * depreciation_rate)
公式:
`est_profit_per_order = 销售 - 成本 - 发货运费 - 退货运费 - 仓储转运费`
三方案净收益比较:
- `option_return_net_profit = -(发货运费 + 退货运费 + 仓储转运费 + 退回折旧损失)`
- `option_discount_net_profit_{low/high} = (销售 - 成本 - 发货运费) - 补偿金额`
- `option_release_net_profit = -(产品成本 + 发货运费)` (放货:仅退款不退货)
## Output result sets
1. 样本概览(订单数、发货件数、退货率)
2. 利润估算因子(5项成本+利润)
3. 处置建议(退回/优惠/放货三方案净收益、补偿区间、推荐方案、置信度)
4. 快递策略TOP(近15天)
5. 仓库策略TOP(近15天)
## Execution rules
1. Always run `exec: company-db health` once per turn before this command.
2. Piece/order counts first; amount as model inputs.
3. If no sample, return clear fallback (放宽地区条件或延长周期) and do not fake compensation.
4. This route must stay in SQL only.

View File

@ -0,0 +1,19 @@
import pymysql
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
cur=conn.cursor()
cases = [
("select `sku(货号)`,`工厂条形码`,`图纸ID`,`SKU型号` from sheet1 where `sku(货号)` like %s limit 20", ("%DSG003919%",), "sheet1 like DSG003919"),
("select `sku(货号)`,`工厂条形码`,`图纸ID`,`SKU型号` from sheet1 where `sku(货号)` like %s limit 20", ("%DSG0039%",), "sheet1 like DSG0039"),
("select id,taskId,SPU,picture,pictureId,skucodes,withInstructions,withVideo,createdTime from missiontask where skucodes like %s limit 20", ("%DSG003919%",), "missiontask skucodes like DSG003919"),
("select id,taskId,SPU,picture,pictureId,skucodes,withInstructions,withVideo,createdTime from missiontask where skucodes like %s limit 20", ("%DSG0039%",), "missiontask skucodes like DSG0039"),
("select id,hid,url,orderNum from missiontask_instructions where hid in (select id from missiontask where skucodes like %s limit 20) limit 40", ("%DSG0039%",), "instructions via missiontask"),
("select id,hid,url,orderNum from missiontask_video where hid in (select id from missiontask where skucodes like %s limit 20) limit 40", ("%DSG0039%",), "video via missiontask"),
]
for sql, params, title in cases:
print(f"\n=== {title} ===")
cur.execute(sql, params)
rows = cur.fetchall()
print('rows', len(rows))
for r in rows[:10]:
print(r)
cur.close(); conn.close()

View File

@ -0,0 +1,14 @@
import pymysql
hid='26de2902-8c28-4ba9-97d4-167609043fcc'
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
cur=conn.cursor()
cur.execute('select id,hid,url,orderNum from missiontask_instructions where hid=%s order by orderNum asc limit 20',(hid,))
print('instructions',len(cur.fetchall()))
cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 20',(hid,))
rows=cur.fetchall()
print('video',len(rows))
for r in rows[:5]:
print(r)
cur.execute('select id,picture,pictureId,skucodes,withInstructions,withVideo from missiontask where id=%s',(hid,))
print('task',cur.fetchone())
cur.close();conn.close()

View File

@ -0,0 +1,15 @@
import pymysql
hid='26de2902-8c28-4ba9-97d4-167609043fcc'
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
cur=conn.cursor()
cur.execute('select id,hid,url,orderNum from missiontask_instructions where hid=%s order by orderNum asc limit 10',(hid,))
rows=cur.fetchall()
print('instructions',len(rows))
for r in rows:
print(r)
cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 10',(hid,))
rows=cur.fetchall()
print('video',len(rows))
for r in rows:
print(r)
cur.close();conn.close()

View File

@ -0,0 +1,13 @@
import pymysql
conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True)
cur=conn.cursor()
cur.execute("select id,picture,pictureId,skucodes,withInstructions,withVideo from missiontask where withVideo='1' and isDelete='0' order by createdTime desc limit 5")
rows=cur.fetchall()
print('tasks',len(rows))
for r in rows:
print('task',r[0],'picid',r[2],'withVideo',r[5])
cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 5',(r[0],))
v=cur.fetchall(); print(' video_rows',len(v));
for x in v:
print(' ',x)
cur.close(); conn.close()

View File

@ -0,0 +1,131 @@
---
name: web-task-router
description: Route website tasks by intent using a preset site registry, then decide whether to access public pages or login with saved credentials. Use when user asks to fetch/process data from websites, with or without login, especially market analysis tasks.
metadata:
{ "openclaw": { "emoji": "🌐", "os": ["linux", "darwin"] } }
---
# web-task-router Skill (OpenClaw)
Use this skill to choose the right website automatically and run login/public web tasks with human-in-the-loop verification.
## Registry file
- Read: `/root/.openclaw/workspace/websites.yaml`
- Use `routing_rules.hard_route_rules` first, then `sites[].purposes` + `sites[].keywords`.
## Decision flow
1. Parse user intent (what data/action is needed).
2. Check `routing_rules.hard_route_rules` first.
3. If user text matches any `when_keywords`, force `force_site_id`.
4. If no hard rule matched, choose one best site from `purposes/keywords`.
5. If no reliable match, ask one short clarification question.
6. If matched site has `login_required: false`, open target directly and proceed.
7. If `login_required: true`, resolve credentials, login, then execute task.
## Hard rule for market analysis
- If user request mentions market-analysis intent (for example: "市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis`.
- In this case, never use `web_search` or `web_fetch`.
- `browser` tool is disabled for market analysis.
- Preferred execution path: use `exec` with `yuce-browser open https://hq.yucekj.com` and `yuce-browser snapshot 60`.
- Page-level extraction and button-expansion SOP:
- `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`
- Do not output market-analysis metrics unless collected from yucekj pages in this run.
- If collection fails, ask the user for OTP/CAPTCHA/manual assist and pause.
## Hard rule for company sales / aftersale analysis
- If user request mentions company sales intent (for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比"), always route to `company-sales-db-analysis`.
- This route must use SQLServer queries only. Do not open the old web dashboard for these tasks.
- Preferred execution path:
1) `exec`: `company-db health`
2) `exec`: `company-db tables`
3) `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or targeted SQL query)
- Before writing analysis, first read field catalog:
- `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
- If a metric cannot be mapped to known tables/fields, return missing field list and ask one precise follow-up.
## Hard rule for install drawing/video lookup
- If user request mentions install media intent (for example: "安装图纸", "安装视频", "安装说明书", "图纸地址", "视频地址", "订单号查图纸"), always route to `install-media-db-analysis`.
- Preferred execution path:
1) `exec`: `install-media-lookup --order-id "<订单号>"`
2) if user gives product number directly: `exec`: `install-media-lookup --product-no "<货号>"`
- Keep two fields in final answer:
- `raw_path`
- `media_url` (browser URL after path conversion)
- URL conversion must follow:
- `D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`
- If no data or MySQL connection fails, return explicit failure reason and the next actionable check. Do not fabricate links.
## Credential resolution order
1. If `credentials.source = env`, read `credentials.username_env` and `credentials.password_env` from environment variables.
2. If env is missing and `fallback_source = 1password`, use `username_ref/password_ref`.
3. If still unresolved, ask user for manual input.
## Login/verification policy
- Do not print raw password in chat.
- If OTP/CAPTCHA appears, pause and ask user for input.
- After user replies with code (or says challenge is done), resume from paused step.
## Safety rules
- Operate only inside `allowed_domains`.
- If redirected outside allowed domains, stop and ask user.
- Keep actions non-destructive unless user explicitly requests changes.
- Summarize final result briefly with data outcome and status.
## Autonomous module selection (yuce market-analysis)
Use this deterministic map before clicking pages:
- market size / growth / trend -> 市场洞察
- category share / fine-grained opportunities -> 叶子类目分析
- brand/shop/product competition -> 品牌店铺商品热榜, 竞店布局分析
- consumer demand / keyword changes -> 产品需求分析, 需求趋势分析, 关键词布局分析
- price strategy / price band -> 价格段布局, 品牌布局分析
Execution requirements:
1. Build a mini plan: requested metrics -> target modules (inside yuce only).
2. Open/login yuce, navigate by module names, capture snapshots as evidence.
3. If any metric lacks visible data, return "not available in current view" instead of guessing.
4. Never use `cron` for market-analysis tasks.
5. Never output final numbers before evidence capture.
### Strict module whitelist
Only use these exact module names in planning or reporting:
市场洞察 / 叶子类目分析 / 品牌店铺商品热榜 / 飙升品榜单 / 品牌布局分析 / 竞店布局分析 / 价格段布局 / 产品需求分析 / 需求趋势分析 / 关键词布局分析
If model generates non-whitelist names, discard and regenerate with whitelist names only.
## Analysis brief workflow (mandatory for market tasks)
When user asks for market analysis report/brief:
1. Route to yuce modules by requested metrics.
2. Collect evidence first:
- `yuce-browser open https://hq.yucekj.com`
- `yuce-browser snapshot <limit>` on at least 2 relevant views
- `yuce-browser requests --filter yucekj.com`
- If any filter/date changed, click `查询` first (mandatory in SOP)
3. Build brief with fixed sections:
- 已抓取证据
- 核心结论
- 可执行建议
- 风险与缺口
- 下一步
4. Mark inferred statements as `推断`, never present as hard fact.
5. If evidence is incomplete, stop at gap list and ask user one precise follow-up.
### Deterministic brief command
For requests like "分析简报/结论+建议", run:
- `market-brief-report <keyword>`
Use command output as primary evidence text. Do not rewrite numeric values.

View File

@ -0,0 +1,119 @@
---
name: web-task-router
description: Route website tasks by intent using a preset site registry, then decide whether to access public pages or login with saved credentials. Use when user asks to fetch/process data from websites, with or without login, especially market analysis tasks.
metadata:
{ "openclaw": { "emoji": "🌐", "os": ["linux", "darwin"] } }
---
# web-task-router Skill (OpenClaw)
Use this skill to choose the right website automatically and run login/public web tasks with human-in-the-loop verification.
## Registry file
- Read: `/root/.openclaw/workspace/websites.yaml`
- Use `routing_rules.hard_route_rules` first, then `sites[].purposes` + `sites[].keywords`.
## Decision flow
1. Parse user intent (what data/action is needed).
2. Check `routing_rules.hard_route_rules` first.
3. If user text matches any `when_keywords`, force `force_site_id`.
4. If no hard rule matched, choose one best site from `purposes/keywords`.
5. If no reliable match, ask one short clarification question.
6. If matched site has `login_required: false`, open target directly and proceed.
7. If `login_required: true`, resolve credentials, login, then execute task.
## Hard rule for market analysis
- If user request mentions market-analysis intent (for example: "市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis`.
- In this case, never use `web_search` or `web_fetch`.
- `browser` tool is disabled for market analysis.
- Preferred execution path: use `exec` with `yuce-browser open https://hq.yucekj.com` and `yuce-browser snapshot 60`.
- Do not output market-analysis metrics unless collected from yucekj pages in this run.
- If collection fails, ask the user for OTP/CAPTCHA/manual assist and pause.
## Hard rule for company sales / aftersale analysis
- If user request mentions company sales intent (for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图"), always route to `company-sales-db-analysis`.
- This route must use SQLServer queries only. Do not open the old web dashboard for these tasks.
- Preferred execution path:
1) `exec`: `company-db health`
2) Product info query (`SKU/成本/材质/预览图`): `exec`: `product-info "<sku或关键词>" --days 30 --limit 20`
3) Product-ID SKU list query: `exec`: `product-skus-by-id "<商品id>" --limit 1000`
4) Sales/aftersale analysis: `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or targeted SQL query)
5) Customer-service experience/writing analysis: `exec`: `customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`
- For customer-service analysis outputs, include at least 2 dialogue snippets and recommended reply wording.
- For customer-service analysis outputs, include capability scoring by agent name (4 dimensions + overall grade).
- Before writing analysis, first read field catalog:
- `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`
- If a metric cannot be mapped to known tables/fields, return missing field list and ask one precise follow-up.
## Credential resolution order
1. If `credentials.source = env`, read `credentials.username_env` and `credentials.password_env` from environment variables.
2. If env is missing and `fallback_source = 1password`, use `username_ref/password_ref`.
3. If still unresolved, ask user for manual input.
## Login/verification policy
- Do not print raw password in chat.
- If OTP/CAPTCHA appears, pause and ask user for input.
- After user replies with code (or says challenge is done), resume from paused step.
## Safety rules
- Operate only inside `allowed_domains`.
- If redirected outside allowed domains, stop and ask user.
- Keep actions non-destructive unless user explicitly requests changes.
- Summarize final result briefly with data outcome and status.
## Autonomous module selection (yuce market-analysis)
Use this deterministic map before clicking pages:
- market size / growth / trend -> 市场洞察
- category share / fine-grained opportunities -> 叶子类目分析
- brand/shop/product competition -> 品牌店铺商品热榜, 竞店布局分析
- consumer demand / keyword changes -> 产品需求分析, 需求趋势分析, 关键词布局分析
- price strategy / price band -> 价格段布局, 品牌布局分析
Execution requirements:
1. Build a mini plan: requested metrics -> target modules (inside yuce only).
2. Open/login yuce, navigate by module names, capture snapshots as evidence.
3. If any metric lacks visible data, return "not available in current view" instead of guessing.
4. Never use `cron` for market-analysis tasks.
5. Never output final numbers before evidence capture.
### Strict module whitelist
Only use these exact module names in planning or reporting:
市场洞察 / 叶子类目分析 / 品牌店铺商品热榜 / 飙升品榜单 / 品牌布局分析 / 竞店布局分析 / 价格段布局 / 产品需求分析 / 需求趋势分析 / 关键词布局分析
If model generates non-whitelist names, discard and regenerate with whitelist names only.
## Analysis brief workflow (mandatory for market tasks)
When user asks for market analysis report/brief:
1. Route to yuce modules by requested metrics.
2. Collect evidence first:
- `yuce-browser open https://hq.yucekj.com`
- `yuce-browser snapshot <limit>` on at least 2 relevant views
- `yuce-browser requests --filter yucekj.com`
3. Build brief with fixed sections:
- 已抓取证据
- 核心结论
- 可执行建议
- 风险与缺口
- 下一步
4. Mark inferred statements as `推断`, never present as hard fact.
5. If evidence is incomplete, stop at gap list and ask user one precise follow-up.
### Deterministic brief command
For requests like "分析简报/结论+建议", run:
- `market-brief-report <keyword>`
Use command output as primary evidence text. Do not rewrite numeric values.

View File

@ -0,0 +1,187 @@
version: 1
default_policy:
credential_store: env
otp_policy: ask_in_chat
captcha_policy: pause_and_ask
domain_guard: true
ask_before_destructive: true
routing_rules:
prefer_exact_keyword_match: true
fallback_site_id: public-search
ask_when_ambiguous: true
hard_route_rules:
- when_keywords:
- 小红书
- 红书
- xhs
- 笔记
- 种草
- 小红书数据
force_site_id: xiaohongshu-mcp
note: 小红书相关任务强制走本地 xiaohongshu-mcp 服务。
- when_keywords:
- 市场分析
- 行业分析
- 类目分析
- 竞品分析
- 趋势分析
force_site_id: ecommerce-market-analysis
note: 只要提到市场分析相关任务,强制走电商市场分析站点。
- when_keywords:
- 销售数据
- 销售报表
- 销售看板
- 公司销售
- 售后数据
- 售后分析
- 退款分析
- 退货分析
- 业绩报表
- 费比监控
- 业绩数据
- 业绩
- 上周业绩
- 产品信息
- 商品ID
- SKU编码
- 产品材质
- 成本价
- 预览图
- 店铺业绩
- 旗舰店业绩
- 客服体验
- 用户反馈
- 话术分析
- 上周
force_site_id: company-sales-db-analysis
note: 销售/售后类任务强制走 SQLServer 数据库查询分析,不再走业绩网页。
sites:
- id: xiaohongshu-mcp
name: 小红书数据服务(本地MCP)
purposes:
- 小红书数据查询
- 小红书笔记搜索
- 小红书互动分析
keywords:
- 小红书
- 红书
- xhs
- 笔记
- 种草
login_required: true
entry_url: http://127.0.0.1:18060
login_url: http://127.0.0.1:18060/api/v1/login/qrcode
allowed_domains:
- 127.0.0.1
- localhost
notes: 通过 xhs-api 本地命令调用 xiaohongshu-mcp,先检查登录状态再执行查询。
- id: public-search
name: 公开搜索(免登录)
purposes:
- 通用搜索
- 公开资料查询
- 无需登录任务
keywords:
- 搜索
- 查一下
- 公开信息
- 网页查询
- 新闻
- 资料
login_required: false
entry_url: https://www.bing.com
allowed_domains:
- bing.com
- cn.bing.com
- id: ecommerce-market-analysis
name: 电商市场分析数据查询
purposes:
- 电商市场分析
- 行业数据查询
- 竞品分析
- 销量趋势
keywords:
- 市场分析
- 电商分析
- 行业数据
- 竞品
- 趋势
- yuce
- 预测科技
login_required: true
entry_url: https://hq.yucekj.com
login_url: https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F
allowed_domains:
- hq.yucekj.com
- yucekj.com
credentials:
source: env
username_env: SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME
password_env: SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD
fallback_source: 1password
username_ref: op://Business/ecommerce-market-analysis/username
password_ref: op://Business/ecommerce-market-analysis/password
notes: 登录后按用户任务提取报表/图表/指标。
- id: company-sales-db-analysis
name: 公司销售/售后数据库分析
purposes:
- 公司销售数据查询
- 售后与退款分析
- 店铺与平台对比
- 费用与费比分析
- 物流与安装协同分析
keywords:
- 销售数据
- 销售报表
- 售后数据
- 售后分析
- 退款分析
- 费比监控
- 业绩数据
- 业绩
- 店铺业绩
- 旗舰店业绩
- 客服体验
- 用户反馈
- 话术分析
- 产品信息
- 商品ID
- SKU编码
- 产品材质
- 成本价
- 预览图
login_required: false
connector: sqlserver
data_source:
host_env: COMPANY_DB_HOST
port_env: COMPANY_DB_PORT
database_env: COMPANY_DB_NAME
user_env: COMPANY_DB_USER
password_env: COMPANY_DB_PASSWORD
allowed_tables:
- jst_goods_profit_kpi_items_afs
- jst_sku
- fys_shopInfo
- jst_order_express_details
- fys_SkuDataLibrary_SpuConfig
- op_operManage
- op_TargetManagement
- fys_Ticketing_install
- fys_Ticketing_install_item
- fys_jstFreightCost
- fys_jstFreightCostDetail
- pc_kfChatLog
query_principles:
- 销售/售后任务优先使用 SQL 查询,不再进入业绩网页。
- 先读字段目录再写查询:/root/.openclaw/workspace/outputs/company-sales-db-catalog.md
- 优先复用模板 SQL:/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql
- 产品信息查询(SKU编码/成本/材质/预览图)优先命令:`product-info "<sku或关键词>" --days 30 --limit 20`
- 商品ID下SKU清单查询优先命令:`product-skus-by-id "<商品id>" --limit 1000`
- 客服体验/话术分析优先命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20`
- 客服体验/话术分析输出需包含:问题结构、话术覆盖、风险对话案例、建议回复、按客服名字的能力评分与等级。
- 售后分析必须包含原因维度(一级/二级/来源),优先模板:/root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
- 旧模板 /root/.openclaw/workspace/sqlserver/last_week_after_sale_analysis.sql 仅作为兼容入口,实际应输出售后360口径。
- 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。
- 非用户明确要求时,禁止按金额排序输出榜单,优先按件数排序。
- 输出需注明:时间范围、表名、关键字段、是否排除配件/特殊单。
notes: 统一数据源为 SQLServer。涉及公司销售、售后、退款、费比等任务,强制走数据库分析。

210
remote_sync/websites.yaml Normal file
View File

@ -0,0 +1,210 @@
version: 1
default_policy:
credential_store: env
otp_policy: ask_in_chat
captcha_policy: pause_and_ask
domain_guard: true
ask_before_destructive: true
routing_rules:
prefer_exact_keyword_match: true
fallback_site_id: public-search
ask_when_ambiguous: true
hard_route_rules:
- when_keywords:
- 小红书
- 红书
- xhs
- 笔记
- 种草
- 小红书数据
force_site_id: xiaohongshu-mcp
note: 小红书相关任务强制走本地 xiaohongshu-mcp 服务。
- when_keywords:
- 市场分析
- 行业分析
- 类目分析
- 竞品分析
- 趋势分析
force_site_id: ecommerce-market-analysis
note: 只要提到市场分析相关任务,强制走电商市场分析站点。
- when_keywords:
- 安装图纸
- 安装视频
- 安装说明书
- 图纸地址
- 视频地址
- 安装教程
- 订单号查图纸
- 订单号查视频
force_site_id: install-media-db-analysis
note: 安装图纸/安装视频相关任务,强制走订单映射+安装资源数据库查询。
- when_keywords:
- 销售数据
- 销售报表
- 销售看板
- 公司销售
- 售后数据
- 售后分析
- 退款分析
- 退货分析
- 业绩报表
- 费比监控
- 业绩数据
- 业绩
- 上周业绩
- 店铺业绩
- 旗舰店业绩
- 上周
force_site_id: company-sales-db-analysis
note: 销售/售后类任务强制走 SQLServer 数据库查询分析,不再走业绩网页。
sites:
- id: xiaohongshu-mcp
name: 小红书数据服务(本地MCP)
purposes:
- 小红书数据查询
- 小红书笔记搜索
- 小红书互动分析
keywords:
- 小红书
- 红书
- xhs
- 笔记
- 种草
login_required: true
entry_url: http://127.0.0.1:18060
login_url: http://127.0.0.1:18060/api/v1/login/qrcode
allowed_domains:
- 127.0.0.1
- localhost
notes: 通过 xhs-api 本地命令调用 xiaohongshu-mcp,先检查登录状态再执行查询。
- id: public-search
name: 公开搜索(免登录)
purposes:
- 通用搜索
- 公开资料查询
- 无需登录任务
keywords:
- 搜索
- 查一下
- 公开信息
- 网页查询
- 新闻
- 资料
login_required: false
entry_url: https://www.bing.com
allowed_domains:
- bing.com
- cn.bing.com
- id: ecommerce-market-analysis
name: 电商市场分析数据查询
purposes:
- 电商市场分析
- 行业数据查询
- 竞品分析
- 销量趋势
keywords:
- 市场分析
- 电商分析
- 行业数据
- 竞品
- 趋势
- yuce
- 预测科技
login_required: true
entry_url: https://hq.yucekj.com
login_url: https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F
allowed_domains:
- hq.yucekj.com
- yucekj.com
credentials:
source: env
username_env: SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME
password_env: SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD
fallback_source: 1password
username_ref: op://Business/ecommerce-market-analysis/username
password_ref: op://Business/ecommerce-market-analysis/password
notes: 登录后按用户任务提取报表/图表/指标。
- id: install-media-db-analysis
name: 安装图纸与安装视频数据库查询
purposes:
- 订单号查询安装图纸
- 订单号查询安装视频
- 货号查询安装说明书资源
keywords:
- 安装图纸
- 安装视频
- 安装说明书
- 图纸地址
- 视频地址
- 安装教程
login_required: false
connector: sqlserver+mysql
data_source:
sqlserver:
host_env: COMPANY_DB_HOST
port_env: COMPANY_DB_PORT
database_env: COMPANY_DB_NAME
user_env: COMPANY_DB_USER
password_env: COMPANY_DB_PASSWORD
mysql:
host_env: INSTALL_MEDIA_MYSQL_HOST
port_env: INSTALL_MEDIA_MYSQL_PORT
user_env: INSTALL_MEDIA_MYSQL_USER
password_env: INSTALL_MEDIA_MYSQL_PASSWORD
query_principles:
- 先按订单号映射产品货号,再按货号查询安装图纸/安装视频。
- 优先命令:`install-media-lookup --order-id "<订单号>"`。
- 返回时保留 raw_path,同时返回浏览器可访问的 media_url。
- 路径转换规则:`D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`。
- 若未命中资源,返回空结果与下一步,不编造链接。
notes: 专用于安装图纸/视频查询,禁止路由到市场分析或销售报表页面。
- id: company-sales-db-analysis
name: 公司销售/售后数据库分析
purposes:
- 公司销售数据查询
- 售后与退款分析
- 店铺与平台对比
- 费用与费比分析
- 物流与安装协同分析
keywords:
- 销售数据
- 销售报表
- 售后数据
- 售后分析
- 退款分析
- 费比监控
- 业绩数据
- 业绩
- 店铺业绩
- 旗舰店业绩
login_required: false
connector: sqlserver
data_source:
host_env: COMPANY_DB_HOST
port_env: COMPANY_DB_PORT
database_env: COMPANY_DB_NAME
user_env: COMPANY_DB_USER
password_env: COMPANY_DB_PASSWORD
allowed_tables:
- jst_goods_profit_kpi_items_afs
- jst_sku
- fys_shopInfo
- jst_order_express_details
- fys_SkuDataLibrary_SpuConfig
- op_operManage
- op_TargetManagement
- fys_Ticketing_install
- fys_Ticketing_install_item
- fys_jstFreightCost
- fys_jstFreightCostDetail
- pc_kfChatLog
query_principles:
- 销售/售后任务优先使用 SQL 查询,不再进入业绩网页。
- 先读字段目录再写查询:/root/.openclaw/workspace/outputs/company-sales-db-catalog.md
- 优先复用模板 SQL:/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql
- 售后分析必须包含原因维度(一级/二级/来源),优先模板:/root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
- 旧模板 /root/.openclaw/workspace/sqlserver/last_week_after_sale_analysis.sql 仅作为兼容入口,实际应输出售后360口径。
- 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。
- 非用户明确要求时,禁止按金额排序输出榜单,优先按件数排序。
- 输出需注明:时间范围、表名、关键字段、是否排除配件/特殊单。
notes: 统一数据源为 SQLServer。涉及公司销售、售后、退款、费比等任务,强制走数据库分析。

View File

@ -0,0 +1,53 @@
from pathlib import Path
DEFAULT_LINK = "https://acnuoqgha8fk.feishu.cn/wiki/RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI"
DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g"
DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5"
agents = Path('/root/.openclaw/workspace/AGENTS.md')
text = agents.read_text(encoding='utf-8')
start = text.find('## 5) Feishu Delivery Rules\n')
end = text.find('\n## 6) Workspace Paths\n', start)
if start == -1 or end == -1:
raise SystemExit('Feishu section not found in AGENTS.md')
new_section = f'''## 5) Feishu Delivery Rules
Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接
Default target (pinned):
- default_link: {DEFAULT_LINK}
- default_app_token: {DEFAULT_APP_TOKEN}
- default_table_id: {DEFAULT_TABLE_ID}
Required behavior:
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload.
- If user does not provide a link in current turn, ALWAYS use the pinned default target above.
- Do NOT ask user for Feishu link again unless user explicitly asks to switch to another table.
- In final reply, always include:
1) push status (success/failed)
2) target sheet link (actual link used)
3) written record_id (or updated count)
- If write partially fails, return succeeded records and failed reason list separately.
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
'''
text = text[:start] + new_section + text[end+1:]
agents.write_text(text, encoding='utf-8')
print('updated', agents)
user = Path('/root/.openclaw/workspace/USER.md')
ut = user.read_text(encoding='utf-8')
line = f'- 飞书推送默认固定到:{DEFAULT_LINK}'
line2 = '- 除非我明确要求切换,不要再向我索要飞书表格链接。'
if line not in ut:
if not ut.endswith('\n'):
ut += '\n'
ut += line + '\n'
if line2 not in ut:
ut += line2 + '\n'
user.write_text(ut, encoding='utf-8')
print('updated', user)

222
sku_stock_risk_report.sql Normal file
View File

@ -0,0 +1,222 @@
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @sku nvarchar(100) = N'CJ019317N1097V01';
DECLARE @lookback_days int = 30;
DECLARE @lead_days int = 10;
DECLARE @has_exact bit = 0;
DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE());
DECLARE @end_time datetime2 = GETDATE();
IF EXISTS (SELECT 1 FROM dbo.jst_sku WITH (NOLOCK) WHERE sku_id = @sku)
SET @has_exact = 1;
IF OBJECT_ID('tempdb..#matched_sku') IS NOT NULL
DROP TABLE #matched_sku;
SELECT TOP (50)
s.sku_id,
s.name AS sku_name,
s.i_id AS style_code,
s.other_1 AS spu,
s.other_7 AS product_no,
s.cost_price,
s.other_10 AS board_material,
s.pic AS preview_pic_url,
s.modified
INTO #matched_sku
FROM dbo.jst_sku AS s WITH (NOLOCK)
WHERE
(@has_exact = 1 AND s.sku_id = @sku)
OR (
@has_exact = 0
AND (
s.sku_id = @sku
OR s.i_id = @sku
OR s.other_1 = @sku
OR s.other_7 = @sku
OR s.sku_id LIKE '%' + @sku + '%'
)
)
ORDER BY CASE WHEN s.sku_id = @sku THEN 0 ELSE 1 END, s.modified DESC;
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
DROP TABLE #inv_latest;
WITH inv_rank AS (
SELECT
NULLIF(i.sku_id, '') AS sku_id,
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
i.wms_co_id,
i.name AS inventory_note,
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(i.sku_id, '')
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
) AS rn
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #matched_sku)
)
SELECT
sku_id,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
virtual_qty,
defective_qty,
purchase_qty,
return_qty,
in_qty,
min_qty,
max_qty,
wms_co_id,
inventory_note,
inventory_snapshot_time,
CASE
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
THEN CAST(0 AS decimal(18, 2))
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
END AS available_qty
INTO #inv_latest
FROM inv_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
DROP TABLE #ship_base;
SELECT
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined2, '') AS order_id,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty
INTO #ship_base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku)
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#ship_daily') IS NOT NULL
DROP TABLE #ship_daily;
SELECT
sb.sku_id,
CAST(sb.ship_dt AS date) AS ship_date,
COUNT(DISTINCT sb.order_id) AS order_count,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum
INTO #ship_daily
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id, CAST(sb.ship_dt AS date);
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
DROP TABLE #ship_stats;
SELECT
sd.sku_id,
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -7, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_7d,
SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -30, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_30d,
MAX(sd.ship_date) AS last_ship_date
INTO #ship_stats
FROM #ship_daily AS sd
GROUP BY sd.sku_id;
-- 1) SKU基础信息 + 库存快照
SELECT
ms.sku_id,
ms.sku_name,
ms.style_code,
ms.spu,
ms.product_no,
ms.cost_price,
ms.board_material,
ms.preview_pic_url,
inv.stock_qty,
inv.order_lock_qty,
inv.pick_lock_qty,
inv.lock_qty,
inv.available_qty,
inv.inventory_snapshot_time,
inv.wms_co_id
FROM #matched_sku AS ms
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = ms.sku_id
ORDER BY ms.modified DESC;
-- 2) 近N天每日总发货件数
SELECT
sd.sku_id,
ms.sku_name,
sd.ship_date,
sd.order_count,
sd.shipped_qty_sum
FROM #ship_daily AS sd
LEFT JOIN #matched_sku AS ms
ON ms.sku_id = sd.sku_id
ORDER BY sd.ship_date DESC;
-- 3) 缺货预估与补货建议(补货安全线=10天)
SELECT
ms.sku_id,
ms.sku_name,
inv.available_qty,
ss.last_ship_date,
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
END AS decimal(18, 2)
) AS estimated_stockout_days,
@lead_days AS replenishment_lead_days,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
END AS decimal(18, 2)
) AS suggest_replenish_qty_to_10d,
CASE
WHEN inv.sku_id IS NULL THEN '无库存快照'
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= (@lead_days + 5) THEN '补货预警(10-15天)'
ELSE '库存安全'
END AS stock_risk_level
FROM #matched_sku AS ms
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = ms.sku_id
LEFT JOIN #ship_stats AS ss
ON ss.sku_id = ms.sku_id
ORDER BY
CASE
WHEN inv.sku_id IS NULL THEN 9
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 8
WHEN inv.available_qty <= 0 THEN 1
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN 2
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= (@lead_days + 5) THEN 3
ELSE 4
END,
estimated_stockout_days ASC;

View File

@ -0,0 +1,71 @@
# 销售分析常用 SQL 模板包
数据源:`JSDataMiddlePlatform`(SQLServer)
执行方式:`company-db run-file <sql文件路径>`
默认分析口径(已按最新要求):
- 主口径优先:`发货件数 / 售后件数 / 订单数`
- 金额口径:仅补充展示;除非明确要求,不作为主排序维度
## 模板列表
1. `shop_weekly_report.sql`
- 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺)
- 默认时间:上一个自然周
2. `sku_structure_report.sql`
- 用途:SKU结构分析(类目结构、SKU Top、价格带结构)
- 默认时间:最近30天
3. `refund_reason_report.sql`
- 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵)
- 默认时间:最近30天
4. `fee_ratio_alert.sql`
- 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势)
- 默认时间:最近14天
5. `logistics_sla_report.sql`
- 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警)
- 默认时间:最近30天
6. `freight_efficiency_report.sql`
- 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本)
- 默认时间:最近30天
7. `install_service_quality_report.sql`
- 用途:安装服务质量(工单状态、服务商表现、待审核区域)
- 默认时间:最近90天
8. `customer_service_hotspot_report.sql`
- 用途:客服问题热点(问题Top、店铺问题热点、日趋势)
- 默认时间:最近30天
9. `link_target_coverage_report.sql`
- 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布)
- 默认时间:在线链接全量
10. `after_sale_360_report.sql`
- 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动)
- 默认时间:上一个自然周
## 运行示例
```bash
company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/sku_structure_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/refund_reason_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/fee_ratio_alert.sql
company-db run-file /root/.openclaw/workspace/sqlserver/logistics_sla_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/freight_efficiency_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/install_service_quality_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_hotspot_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/link_target_coverage_report.sql
company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql
```
## 注意事项
- 所有模板都默认过滤 `配件` 和 `特殊单` 标签。
- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。
- 如需自定义时间,修改每个模板顶部变量即可。

View File

@ -0,0 +1,193 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 售后360分析(退款 + 原因 + 工单 + 客诉)
Window: 上一个自然周 [week_start, week_end)
*/
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
DROP TABLE #chat_latest;
WITH chat_rank AS (
SELECT
NULLIF(c.orderNo, '') AS order_id,
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
c.createdAt,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(c.orderNo, '')
ORDER BY c.createdAt DESC, c.id DESC
) AS rn
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
WHERE c.createdAt >= @week_start
AND c.createdAt < @week_end
AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''
)
SELECT
order_id,
main_reason,
second_reason,
third_reason
INTO #chat_latest
FROM chat_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#afs') IS NOT NULL
DROP TABLE #afs;
SELECT
NULLIF(a.undefined2, '') AS order_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name,
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason,
ISNULL(cl.main_reason, '') AS chat_main_reason,
ISNULL(cl.second_reason, '') AS chat_second_reason,
ISNULL(cl.third_reason, '') AS chat_third_reason,
CASE
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '')
WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason
ELSE '未标注'
END AS afs_reason_l1,
CASE
WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason
ELSE '未标注'
END AS afs_reason_l2,
CASE
WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason
ELSE '未标注'
END AS afs_reason_l3,
CASE
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason'
WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason'
ELSE 'unknown'
END AS reason_source,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount
INTO #afs
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
LEFT JOIN #chat_latest AS cl
ON cl.order_id = NULLIF(a.undefined2, '')
WHERE a.createdAt >= @week_start
AND a.createdAt < @week_end
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#install') IS NOT NULL
DROP TABLE #install;
SELECT
NULLIF(i.OrderNum, '') AS order_id,
i.SubmitTime,
TRY_CONVERT(int, i.StatusId) AS status_id,
TRY_CONVERT(int, i.installComplete) AS install_complete,
TRY_CONVERT(decimal(18, 2), i.Num) AS install_num,
TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount,
TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost,
TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount,
TRY_CONVERT(int, i.kfsh) AS kf_audit,
TRY_CONVERT(int, i.cwsh) AS cw_audit,
ISNULL(NULLIF(i.Province, ''), 'unknown') AS province,
ISNULL(NULLIF(i.City, ''), 'unknown') AS city
INTO #install
FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK)
WHERE i.SubmitTime >= @week_start
AND i.SubmitTime < @week_end
AND ISNULL(NULLIF(i.OrderNum, ''), '') <> ''
AND (i.[Delete] = 0 OR i.[Delete] IS NULL);
-- 1) 售后总览(退款口径)
SELECT
@week_start AS week_start,
@week_end AS week_end,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(order_qty, 0)) AS order_qty_sum,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
SUM(ISNULL(return_amount, 0)) AS return_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate
FROM #afs;
-- 2) 售后原因分析(一级原因 + 来源)
SELECT TOP (30)
afs_reason_l1 AS afs_reason,
reason_source,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS reason_refund_rate
FROM #afs
GROUP BY afs_reason_l1, reason_source
ORDER BY afs_record_count DESC;
-- 3) 售后二级原因 TOP20
SELECT TOP (20)
afs_reason_l1,
afs_reason_l2,
COUNT(*) AS afs_record_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(refund_amount, 0)) AS refund_amount
FROM #afs
GROUP BY afs_reason_l1, afs_reason_l2
ORDER BY afs_record_count DESC;
-- 4) 售后类型/结果矩阵
SELECT
afs_type,
afs_result,
COUNT(*) AS afs_record_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(refund_amount, 0)) AS refund_amount
FROM #afs
GROUP BY afs_type, afs_result
ORDER BY afs_record_count DESC;
-- 5) 安装工单概览(同周期)
SELECT
COUNT(*) AS install_ticket_count,
COUNT(DISTINCT order_id) AS install_order_count,
SUM(ISNULL(install_num, 0)) AS install_piece_count,
SUM(ISNULL(payment_amount, 0)) AS install_payment_amount,
SUM(ISNULL(install_cost, 0)) AS install_cost,
SUM(ISNULL(settle_amount, 0)) AS settle_amount,
SUM(CASE WHEN ISNULL(install_complete, 0) = 1 THEN 1 ELSE 0 END) AS install_completed_count,
SUM(CASE WHEN ISNULL(kf_audit, 0) <> 1 OR ISNULL(cw_audit, 0) <> 1 THEN 1 ELSE 0 END) AS pending_audit_count
FROM #install;
-- 6) 售后退款与安装工单联动(按订单)
SELECT
COUNT(*) AS overlap_order_count,
SUM(ISNULL(a.refund_amount, 0)) AS overlap_refund_amount,
SUM(ISNULL(i.install_cost, 0)) AS overlap_install_cost,
SUM(CASE WHEN ISNULL(i.install_complete, 0) = 1 THEN 1 ELSE 0 END) AS overlap_install_completed_count
FROM (
SELECT DISTINCT order_id, refund_amount
FROM #afs
WHERE ISNULL(order_id, '') <> ''
) a
INNER JOIN #install i
ON i.order_id = a.order_id;

View File

@ -0,0 +1,79 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 客服问题热点分析
Window: 最近30天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
c.id,
c.orderNo AS order_id,
ISNULL(NULLIF(c.shopName, ''), 'unknown') AS shop_name,
ISNULL(NULLIF(e.platform_name, ''), 'unknown') AS platform_name,
ISNULL(NULLIF(c.mainReason, ''), '未标注') AS main_reason,
ISNULL(NULLIF(c.secondReason, ''), '未标注') AS second_reason,
ISNULL(NULLIF(c.thirdReason, ''), '未标注') AS third_reason,
c.createdAt
INTO #base
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK)
ON e.so_id = c.orderNo
WHERE c.createdAt >= @date_from
AND c.createdAt < @date_to
AND ISNULL(NULLIF(c.orderNo, ''), '') <> '';
-- 1) 总览
SELECT
@date_from AS date_from,
@date_to AS date_to,
COUNT(*) AS chat_count,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT shop_name) AS shop_count
FROM #base;
-- 2) 一级问题 TOP30
SELECT TOP (30)
platform_name,
main_reason,
COUNT(*) AS chat_count,
COUNT(DISTINCT order_id) AS order_count
FROM #base
GROUP BY platform_name, main_reason
ORDER BY chat_count DESC;
-- 3) 一级+二级问题组合 TOP30
SELECT TOP (30)
main_reason,
second_reason,
COUNT(*) AS chat_count,
COUNT(DISTINCT order_id) AS order_count
FROM #base
GROUP BY main_reason, second_reason
ORDER BY chat_count DESC;
-- 4) 店铺热点问题
SELECT TOP (50)
shop_name,
platform_name,
main_reason,
COUNT(*) AS chat_count,
COUNT(DISTINCT order_id) AS order_count
FROM #base
GROUP BY shop_name, platform_name, main_reason
ORDER BY chat_count DESC;
-- 5) 每日趋势
SELECT
CAST(createdAt AS date) AS biz_date,
COUNT(*) AS chat_count,
COUNT(DISTINCT order_id) AS order_count
FROM #base
GROUP BY CAST(createdAt AS date)
ORDER BY biz_date DESC;

View File

@ -0,0 +1,179 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 费比异常预警
Window: 最近14天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -14, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
CAST(a.createdAt AS date) AS biz_date,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name,
NULLIF(a.undefined2, '') AS order_id,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined82, '')) AS fee_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined85, '')) AS fee_taoke,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined86, '')) AS fee_freight_insurance,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined97, '')) AS fee_promotion,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined102, '')) AS fee_software_service,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined108, '')) AS fee_taobao_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined109, '')) AS fee_jd_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined110, '')) AS fee_pdd_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined111, '')) AS fee_taogongchang_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined112, '')) AS fee_alibaba_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined113, '')) AS fee_douyin_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined114, '')) AS fee_maoning_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined115, '')) AS fee_vip_mp_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined116, '')) AS fee_vip_commission,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined117, '')) AS fee_dewu_commission,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
INTO #base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE a.createdAt >= @date_from
AND a.createdAt < @date_to
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#agg') IS NOT NULL
DROP TABLE #agg;
SELECT
shop_name,
platform_name,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
SUM(
ABS(ISNULL(fee_commission, 0))
+ ABS(ISNULL(fee_taoke, 0))
+ ABS(ISNULL(fee_freight_insurance, 0))
+ ABS(ISNULL(fee_promotion, 0))
+ ABS(ISNULL(fee_software_service, 0))
+ ABS(ISNULL(fee_taobao_commission, 0))
+ ABS(ISNULL(fee_jd_commission, 0))
+ ABS(ISNULL(fee_pdd_commission, 0))
+ ABS(ISNULL(fee_taogongchang_commission, 0))
+ ABS(ISNULL(fee_alibaba_commission, 0))
+ ABS(ISNULL(fee_douyin_commission, 0))
+ ABS(ISNULL(fee_maoning_commission, 0))
+ ABS(ISNULL(fee_vip_mp_commission, 0))
+ ABS(ISNULL(fee_vip_commission, 0))
+ ABS(ISNULL(fee_dewu_commission, 0))
) AS fee_amount_abs
INTO #agg
FROM #base
GROUP BY shop_name, platform_name;
-- 1) 全局预警总览
SELECT
@date_from AS date_from,
@date_to AS date_to,
COUNT(*) AS shop_count,
SUM(order_count) AS order_count,
SUM(payment_amount) AS payment_amount,
SUM(refund_amount) AS refund_amount,
SUM(fee_amount_abs) AS fee_amount_abs,
CASE
WHEN SUM(payment_amount) = 0 THEN NULL
ELSE SUM(refund_amount) / SUM(payment_amount)
END AS refund_rate,
CASE
WHEN SUM(payment_amount) = 0 THEN NULL
ELSE SUM(fee_amount_abs) / SUM(payment_amount)
END AS fee_abs_rate
FROM #agg;
-- 2) 店铺级异常清单
SELECT
shop_name,
platform_name,
order_count,
payment_amount,
refund_amount,
fee_amount_abs,
CASE
WHEN payment_amount = 0 THEN NULL
ELSE refund_amount / payment_amount
END AS refund_rate,
CASE
WHEN payment_amount = 0 THEN NULL
ELSE fee_amount_abs / payment_amount
END AS fee_abs_rate,
CASE
WHEN payment_amount = 0 THEN 'P3'
WHEN (fee_amount_abs / payment_amount) >= 0.35 OR (refund_amount / payment_amount) >= 0.25 THEN 'P1'
WHEN (fee_amount_abs / payment_amount) >= 0.25 OR (refund_amount / payment_amount) >= 0.18 THEN 'P2'
ELSE 'P3'
END AS alert_level
FROM #agg
WHERE order_count >= 30
ORDER BY
CASE
WHEN payment_amount = 0 THEN 0
ELSE fee_amount_abs / payment_amount
END DESC,
refund_amount DESC;
-- 3) 日趋势(全局)
SELECT
biz_date,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
SUM(
ABS(ISNULL(fee_commission, 0))
+ ABS(ISNULL(fee_taoke, 0))
+ ABS(ISNULL(fee_freight_insurance, 0))
+ ABS(ISNULL(fee_promotion, 0))
+ ABS(ISNULL(fee_software_service, 0))
+ ABS(ISNULL(fee_taobao_commission, 0))
+ ABS(ISNULL(fee_jd_commission, 0))
+ ABS(ISNULL(fee_pdd_commission, 0))
+ ABS(ISNULL(fee_taogongchang_commission, 0))
+ ABS(ISNULL(fee_alibaba_commission, 0))
+ ABS(ISNULL(fee_douyin_commission, 0))
+ ABS(ISNULL(fee_maoning_commission, 0))
+ ABS(ISNULL(fee_vip_mp_commission, 0))
+ ABS(ISNULL(fee_vip_commission, 0))
+ ABS(ISNULL(fee_dewu_commission, 0))
) AS fee_amount_abs,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(
ABS(ISNULL(fee_commission, 0))
+ ABS(ISNULL(fee_taoke, 0))
+ ABS(ISNULL(fee_freight_insurance, 0))
+ ABS(ISNULL(fee_promotion, 0))
+ ABS(ISNULL(fee_software_service, 0))
+ ABS(ISNULL(fee_taobao_commission, 0))
+ ABS(ISNULL(fee_jd_commission, 0))
+ ABS(ISNULL(fee_pdd_commission, 0))
+ ABS(ISNULL(fee_taogongchang_commission, 0))
+ ABS(ISNULL(fee_alibaba_commission, 0))
+ ABS(ISNULL(fee_douyin_commission, 0))
+ ABS(ISNULL(fee_maoning_commission, 0))
+ ABS(ISNULL(fee_vip_mp_commission, 0))
+ ABS(ISNULL(fee_vip_commission, 0))
+ ABS(ISNULL(fee_dewu_commission, 0))
) / SUM(ISNULL(payment_amount, 0))
END AS fee_abs_rate
FROM #base
GROUP BY biz_date
ORDER BY biz_date DESC;

View File

@ -0,0 +1,103 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 运费效率分析
Window: 最近30天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
fc.so_id AS order_id,
ISNULL(NULLIF(fc.shop_name, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(fc.shop_name, '')) > 1
THEN LEFT(fc.shop_name, CHARINDEX('-', fc.shop_name) - 1)
ELSE 'unknown'
END AS platform_name,
ISNULL(NULLIF(fc.receiver_state, ''), 'unknown') AS receiver_state,
ISNULL(NULLIF(fc.expressName, ''), 'unknown') AS express_name,
TRY_CONVERT(decimal(18, 2), fc.weight) AS weight,
TRY_CONVERT(decimal(18, 2), fc.freightCost) AS freight_cost,
fc.send_date,
fc.type AS freight_type
INTO #base
FROM dbo.fys_jstFreightCost AS fc WITH (NOLOCK)
WHERE fc.send_date >= @date_from
AND fc.send_date < @date_to
AND ISNULL(NULLIF(fc.so_id, ''), '') <> '';
-- 1) 总览
SELECT
@date_from AS date_from,
@date_to AS date_to,
COUNT(*) AS shipment_count,
SUM(ISNULL(weight, 0)) AS total_weight,
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
CASE
WHEN COUNT(*) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*)
END AS avg_cost_per_order,
CASE
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
END AS avg_cost_per_kg
FROM #base;
-- 2) 平台运费效率
SELECT
platform_name,
COUNT(*) AS shipment_count,
SUM(ISNULL(weight, 0)) AS total_weight,
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
CASE
WHEN COUNT(*) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*)
END AS avg_cost_per_order,
CASE
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
END AS avg_cost_per_kg
FROM #base
GROUP BY platform_name
ORDER BY shipment_count DESC;
-- 3) 店铺运费异常(发货单>=100)
SELECT TOP (50)
shop_name,
platform_name,
COUNT(*) AS shipment_count,
SUM(ISNULL(weight, 0)) AS total_weight,
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
CASE
WHEN COUNT(*) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*)
END AS avg_cost_per_order,
CASE
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
END AS avg_cost_per_kg
FROM #base
GROUP BY shop_name, platform_name
HAVING COUNT(*) >= 100
ORDER BY avg_cost_per_kg DESC, shipment_count DESC;
-- 4) 省份与快递公司组合成本
SELECT TOP (50)
receiver_state,
express_name,
COUNT(*) AS shipment_count,
SUM(ISNULL(weight, 0)) AS total_weight,
SUM(ISNULL(freight_cost, 0)) AS total_freight_cost,
CASE
WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0))
END AS avg_cost_per_kg
FROM #base
GROUP BY receiver_state, express_name
ORDER BY total_freight_cost DESC;

View File

@ -0,0 +1,106 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 安装服务质量分析
Window: 最近90天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -90, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#ticket') IS NOT NULL
DROP TABLE #ticket;
SELECT
i.Id,
i.SubmitTime,
i.OrderNum,
ISNULL(NULLIF(i.ShopCode, ''), 'unknown') AS shop_code,
ISNULL(NULLIF(i.Province, ''), 'unknown') AS province,
ISNULL(NULLIF(i.City, ''), 'unknown') AS city,
ISNULL(NULLIF(i.Operation, ''), 'unknown') AS operation_user,
ISNULL(NULLIF(i.sourceFrom, ''), 'unknown') AS source_from,
TRY_CONVERT(int, i.StatusId) AS status_id,
TRY_CONVERT(int, i.installComplete) AS install_complete,
TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount,
TRY_CONVERT(decimal(18, 2), i.FinalPayMent) AS final_payment_amount,
TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost,
TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount,
TRY_CONVERT(decimal(18, 2), i.acceptAmount) AS accept_amount,
TRY_CONVERT(int, i.kfsh) AS kf_audit,
TRY_CONVERT(int, i.cwsh) AS cw_audit,
i.SourceGuid
INTO #ticket
FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK)
WHERE i.SubmitTime >= @date_from
AND i.SubmitTime < @date_to
AND (i.[Delete] = 0 OR i.[Delete] IS NULL)
AND ISNULL(NULLIF(i.OrderNum, ''), '') <> '';
IF OBJECT_ID('tempdb..#item_agg') IS NOT NULL
DROP TABLE #item_agg;
SELECT
ii.guid AS source_guid,
COUNT(*) AS item_count,
SUM(ISNULL(ii.installcount, 0)) AS install_count,
SUM(ISNULL(ii.Payfee, 0)) AS pay_fee_sum,
SUM(ISNULL(ii.applyItemAmount, 0)) AS apply_item_amount_sum,
SUM(ISNULL(ii.settleItemAmount, 0)) AS settle_item_amount_sum,
SUM(ISNULL(ii.acceptItemAmount, 0)) AS accept_item_amount_sum,
MAX(ISNULL(NULLIF(ii.scmname, ''), 'unknown')) AS sample_scm_name
INTO #item_agg
FROM dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK)
GROUP BY ii.guid;
-- 1) 总览
SELECT
@date_from AS date_from,
@date_to AS date_to,
COUNT(*) AS ticket_count,
COUNT(DISTINCT ordernum) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(final_payment_amount, 0)) AS final_payment_amount,
SUM(ISNULL(install_cost, 0)) AS install_cost,
SUM(ISNULL(settle_amount, 0)) AS settle_amount,
SUM(ISNULL(accept_amount, 0)) AS accept_amount
FROM #ticket;
-- 2) 工单状态分布
SELECT
status_id,
install_complete,
COUNT(*) AS ticket_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(install_cost, 0)) AS install_cost
FROM #ticket
GROUP BY status_id, install_complete
ORDER BY ticket_count DESC;
-- 3) 服务商表现(取明细表scm)
SELECT TOP (30)
ISNULL(NULLIF(ii.scmname, ''), 'unknown') AS scm_name,
COUNT(*) AS item_count,
SUM(ISNULL(ii.installcount, 0)) AS install_count,
SUM(ISNULL(ii.Payfee, 0)) AS pay_fee_sum,
SUM(ISNULL(ii.settleItemAmount, 0)) AS settle_item_amount_sum,
SUM(ISNULL(ii.acceptItemAmount, 0)) AS accept_item_amount_sum
FROM dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK)
WHERE ii.createTime >= @date_from
AND ii.createTime < @date_to
GROUP BY ISNULL(NULLIF(ii.scmname, ''), 'unknown')
ORDER BY item_count DESC;
-- 4) 区域问题清单(客服/财务未审核)
SELECT TOP (50)
t.province,
t.city,
COUNT(*) AS pending_ticket_count,
SUM(ISNULL(t.payment_amount, 0)) AS payment_amount,
SUM(ISNULL(t.install_cost, 0)) AS install_cost
FROM #ticket AS t
WHERE ISNULL(t.kf_audit, 0) <> 1
OR ISNULL(t.cw_audit, 0) <> 1
GROUP BY t.province, t.city
ORDER BY pending_ticket_count DESC, payment_amount DESC;

View File

@ -0,0 +1,103 @@
USE [JSDataMiddlePlatform];
GO
/*
Unified logical layer (temp-table based, no CREATE VIEW permission required).
Week window = previous natural week:
[start_of_last_week, start_of_this_week)
*/
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
IF OBJECT_ID('tempdb..#filtered') IS NOT NULL
DROP TABLE #filtered;
SELECT
a.id,
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined1, '') AS inner_order_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
NULLIF(a.undefined9, '') AS shop_code,
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined56, '') AS afs_type,
NULLIF(a.undefined57, '') AS afs_reason,
NULLIF(a.undefined58, '') AS afs_result,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS post_ship_apply_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS post_ship_return_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS post_ship_refund_amount,
a.createdAt AS created_at,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name
INTO #filtered
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE a.createdAt >= @week_start
AND a.createdAt < @week_end
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
-- 1) weekly overview
SELECT
MIN(created_at) AS data_min_time,
MAX(created_at) AS data_max_time,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(order_qty, 0)) AS order_qty_sum,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(post_ship_apply_amount, 0)) AS apply_amount,
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
FROM #filtered;
-- 2) platform distribution
SELECT
platform_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
FROM #filtered
GROUP BY platform_name
ORDER BY afs_record_count DESC;
-- 3) shop top 20
SELECT TOP (20)
shop_name,
platform_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount
FROM #filtered
GROUP BY shop_name, platform_name
ORDER BY afs_record_count DESC;
-- 4) reason top 20
SELECT TOP (20)
ISNULL(NULLIF(afs_reason, ''), '未标注') AS afs_reason,
COUNT(*) AS afs_record_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount,
SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount
FROM #filtered
GROUP BY ISNULL(NULLIF(afs_reason, ''), '未标注')
ORDER BY afs_record_count DESC;
-- 5) type/result matrix
SELECT
ISNULL(NULLIF(afs_type, ''), '未标注') AS afs_type,
ISNULL(NULLIF(afs_result, ''), '未标注') AS afs_result,
COUNT(*) AS afs_record_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum
FROM #filtered
GROUP BY
ISNULL(NULLIF(afs_type, ''), '未标注'),
ISNULL(NULLIF(afs_result, ''), '未标注')
ORDER BY afs_record_count DESC;

View File

@ -0,0 +1,193 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 售后360分析(退款 + 原因 + 工单 + 客诉)
Window: 上一个自然周 [week_start, week_end)
*/
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
DROP TABLE #chat_latest;
WITH chat_rank AS (
SELECT
NULLIF(c.orderNo, '') AS order_id,
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
c.createdAt,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(c.orderNo, '')
ORDER BY c.createdAt DESC, c.id DESC
) AS rn
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
WHERE c.createdAt >= @week_start
AND c.createdAt < @week_end
AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''
)
SELECT
order_id,
main_reason,
second_reason,
third_reason
INTO #chat_latest
FROM chat_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#afs') IS NOT NULL
DROP TABLE #afs;
SELECT
NULLIF(a.undefined2, '') AS order_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name,
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason,
ISNULL(cl.main_reason, '') AS chat_main_reason,
ISNULL(cl.second_reason, '') AS chat_second_reason,
ISNULL(cl.third_reason, '') AS chat_third_reason,
CASE
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '')
WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason
ELSE '未标注'
END AS afs_reason_l1,
CASE
WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason
ELSE '未标注'
END AS afs_reason_l2,
CASE
WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason
ELSE '未标注'
END AS afs_reason_l3,
CASE
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason'
WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason'
ELSE 'unknown'
END AS reason_source,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount
INTO #afs
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
LEFT JOIN #chat_latest AS cl
ON cl.order_id = NULLIF(a.undefined2, '')
WHERE a.createdAt >= @week_start
AND a.createdAt < @week_end
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#install') IS NOT NULL
DROP TABLE #install;
SELECT
NULLIF(i.OrderNum, '') AS order_id,
i.SubmitTime,
TRY_CONVERT(int, i.StatusId) AS status_id,
TRY_CONVERT(int, i.installComplete) AS install_complete,
TRY_CONVERT(decimal(18, 2), i.Num) AS install_num,
TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount,
TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost,
TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount,
TRY_CONVERT(int, i.kfsh) AS kf_audit,
TRY_CONVERT(int, i.cwsh) AS cw_audit,
ISNULL(NULLIF(i.Province, ''), 'unknown') AS province,
ISNULL(NULLIF(i.City, ''), 'unknown') AS city
INTO #install
FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK)
WHERE i.SubmitTime >= @week_start
AND i.SubmitTime < @week_end
AND ISNULL(NULLIF(i.OrderNum, ''), '') <> ''
AND (i.[Delete] = 0 OR i.[Delete] IS NULL);
-- 1) 售后总览(退款口径)
SELECT
@week_start AS week_start,
@week_end AS week_end,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS afs_order_count,
SUM(ISNULL(order_qty, 0)) AS order_qty_sum,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
SUM(ISNULL(return_amount, 0)) AS return_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate
FROM #afs;
-- 2) 售后原因分析(一级原因 + 来源)
SELECT TOP (30)
afs_reason_l1 AS afs_reason,
reason_source,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS reason_refund_rate
FROM #afs
GROUP BY afs_reason_l1, reason_source
ORDER BY afs_record_count DESC;
-- 3) 售后二级原因 TOP20
SELECT TOP (20)
afs_reason_l1,
afs_reason_l2,
COUNT(*) AS afs_record_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(refund_amount, 0)) AS refund_amount
FROM #afs
GROUP BY afs_reason_l1, afs_reason_l2
ORDER BY afs_record_count DESC;
-- 4) 售后类型/结果矩阵
SELECT
afs_type,
afs_result,
COUNT(*) AS afs_record_count,
SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum,
SUM(ISNULL(refund_amount, 0)) AS refund_amount
FROM #afs
GROUP BY afs_type, afs_result
ORDER BY afs_record_count DESC;
-- 5) 安装工单概览(同周期)
SELECT
COUNT(*) AS install_ticket_count,
COUNT(DISTINCT order_id) AS install_order_count,
SUM(ISNULL(install_num, 0)) AS install_piece_count,
SUM(ISNULL(payment_amount, 0)) AS install_payment_amount,
SUM(ISNULL(install_cost, 0)) AS install_cost,
SUM(ISNULL(settle_amount, 0)) AS settle_amount,
SUM(CASE WHEN ISNULL(install_complete, 0) = 1 THEN 1 ELSE 0 END) AS install_completed_count,
SUM(CASE WHEN ISNULL(kf_audit, 0) <> 1 OR ISNULL(cw_audit, 0) <> 1 THEN 1 ELSE 0 END) AS pending_audit_count
FROM #install;
-- 6) 售后退款与安装工单联动(按订单)
SELECT
COUNT(*) AS overlap_order_count,
SUM(ISNULL(a.refund_amount, 0)) AS overlap_refund_amount,
SUM(ISNULL(i.install_cost, 0)) AS overlap_install_cost,
SUM(CASE WHEN ISNULL(i.install_complete, 0) = 1 THEN 1 ELSE 0 END) AS overlap_install_completed_count
FROM (
SELECT DISTINCT order_id, refund_amount
FROM #afs
WHERE ISNULL(order_id, '') <> ''
) a
INNER JOIN #install i
ON i.order_id = a.order_id;

View File

@ -0,0 +1,77 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 链接目标覆盖分析(运营视角)
Window: 在售链接(linkStatus = 当前在线)
*/
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
t.Id,
ISNULL(NULLIF(t.shopname, ''), 'unknown') AS shop_name,
ISNULL(NULLIF(t.category, ''), 'unknown') AS category,
ISNULL(NULLIF(t.shopGoodsCategory, ''), 'unknown') AS shop_goods_category,
ISNULL(NULLIF(t.spu, ''), 'unknown') AS spu,
ISNULL(NULLIF(t.linkType, ''), 'unknown') AS link_type,
ISNULL(NULLIF(t.linkStatus, ''), 'unknown') AS link_status,
TRY_CONVERT(int, t.onSaleDays) AS on_sale_days,
TRY_CONVERT(decimal(18, 2), t.salesMoneyTarget) AS sales_money_target,
TRY_CONVERT(int, t.salesAmountTarget) AS sales_amount_target,
TRY_CONVERT(decimal(18, 2), t.salesMoneyTargetAvg) AS sales_money_target_avg,
TRY_CONVERT(int, t.salesAmountTargetAvg) AS sales_amount_target_avg,
ISNULL(NULLIF(t.oper, ''), 'unknown') AS oper,
ISNULL(NULLIF(t.cater, ''), 'unknown') AS cater,
ISNULL(NULLIF(t.ider, ''), 'unknown') AS designer,
ISNULL(NULLIF(t.dept, ''), 'unknown') AS dept,
ISNULL(NULLIF(t.topsgn, ''), 'unknown') AS topsgn,
ISNULL(NULLIF(t.bind, ''), 'unknown') AS price_band_label,
TRY_CONVERT(decimal(18, 2), t.priceBand) AS sale_floor_price
INTO #base
FROM dbo.op_TargetManagement AS t WITH (NOLOCK)
WHERE ISNULL(NULLIF(t.linkStatus, ''), 'unknown') = '当前在线';
-- 1) 在线链接总览
SELECT
COUNT(*) AS online_link_count,
COUNT(DISTINCT shop_name) AS shop_count,
COUNT(DISTINCT spu) AS spu_count,
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target,
SUM(ISNULL(sales_amount_target, 0)) AS sales_amount_target
FROM #base;
-- 2) 店铺覆盖度
SELECT TOP (50)
shop_name,
COUNT(*) AS online_link_count,
COUNT(DISTINCT spu) AS spu_count,
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target,
AVG(ISNULL(on_sale_days, 0)) AS avg_on_sale_days
FROM #base
GROUP BY shop_name
ORDER BY online_link_count DESC;
-- 3) 运营人负载
SELECT TOP (50)
dept,
oper,
COUNT(*) AS online_link_count,
COUNT(DISTINCT shop_name) AS shop_count,
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target
FROM #base
GROUP BY dept, oper
ORDER BY online_link_count DESC;
-- 4) 类目与价格带分布
SELECT
category,
shop_goods_category,
price_band_label,
COUNT(*) AS online_link_count,
AVG(ISNULL(sale_floor_price, 0)) AS avg_sale_floor_price,
SUM(ISNULL(sales_money_target, 0)) AS sales_money_target
FROM #base
GROUP BY category, shop_goods_category, price_band_label
ORDER BY online_link_count DESC;

View File

@ -0,0 +1,95 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 物流时效/SLA分析
Window: 最近30天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
e.so_id AS order_id,
ISNULL(NULLIF(e.shop_name, ''), 'unknown') AS shop_name,
ISNULL(NULLIF(e.platform_name, ''), 'unknown') AS platform_name,
ISNULL(NULLIF(e.logistic_company_name, ''), 'unknown') AS logistic_company_name,
ISNULL(NULLIF(e.logistic_status_name, ''), 'unknown') AS logistic_status_name,
e.pay_time,
e.send_time,
e.fetch_time,
e.sign_time,
TRY_CONVERT(decimal(18, 4), e.weight) AS weight,
TRY_CONVERT(decimal(18, 4), e.f_weight) AS f_weight,
CASE
WHEN e.pay_time IS NOT NULL AND e.send_time IS NOT NULL
THEN DATEDIFF(HOUR, e.pay_time, e.send_time)
ELSE NULL
END AS pay_to_send_h,
CASE
WHEN e.send_time IS NOT NULL AND e.sign_time IS NOT NULL
THEN DATEDIFF(HOUR, e.send_time, e.sign_time)
ELSE NULL
END AS send_to_sign_h,
CASE
WHEN e.pay_time IS NOT NULL AND e.sign_time IS NOT NULL
THEN DATEDIFF(HOUR, e.pay_time, e.sign_time)
ELSE NULL
END AS pay_to_sign_h
INTO #base
FROM dbo.jst_order_express_details AS e WITH (NOLOCK)
WHERE e.createdAt >= @date_from
AND e.createdAt < @date_to
AND ISNULL(NULLIF(e.so_id, ''), '') <> ''
AND ISNULL(NULLIF(e.order_labels, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(e.order_labels, ''), '') NOT LIKE '%特殊单%';
-- 1) 全局时效总览
SELECT
@date_from AS date_from,
@date_to AS date_to,
COUNT(*) AS order_count,
SUM(CASE WHEN sign_time IS NOT NULL THEN 1 ELSE 0 END) AS signed_order_count,
AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h,
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
AVG(CASE WHEN pay_to_sign_h >= 0 THEN CAST(pay_to_sign_h AS decimal(18, 2)) END) AS avg_pay_to_sign_h
FROM #base;
-- 2) 平台时效
SELECT
platform_name,
COUNT(*) AS order_count,
AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h,
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
SUM(CASE WHEN pay_to_send_h > 48 THEN 1 ELSE 0 END) AS delayed_send_over_48h,
SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h
FROM #base
GROUP BY platform_name
ORDER BY order_count DESC;
-- 3) 店铺时效预警(订单数>=100)
SELECT TOP (50)
shop_name,
platform_name,
COUNT(*) AS order_count,
AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h,
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
SUM(CASE WHEN pay_to_send_h > 48 THEN 1 ELSE 0 END) AS delayed_send_over_48h,
SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h
FROM #base
GROUP BY shop_name, platform_name
HAVING COUNT(*) >= 100
ORDER BY delayed_send_over_48h DESC, avg_send_to_sign_h DESC;
-- 4) 快递公司表现
SELECT TOP (30)
logistic_company_name,
COUNT(*) AS order_count,
AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h,
SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h
FROM #base
GROUP BY logistic_company_name
ORDER BY order_count DESC;

View File

@ -0,0 +1,161 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 退款原因分析
Window: 最近30天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL
DROP TABLE #chat_latest;
WITH chat_rank AS (
SELECT
NULLIF(c.orderNo, '') AS order_id,
ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason,
ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason,
ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(c.orderNo, '')
ORDER BY c.createdAt DESC, c.id DESC
) AS rn
FROM dbo.pc_kfChatLog AS c WITH (NOLOCK)
WHERE c.createdAt >= @date_from
AND c.createdAt < @date_to
AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''
)
SELECT
order_id,
main_reason,
second_reason,
third_reason
INTO #chat_latest
FROM chat_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
NULLIF(a.undefined2, '') AS order_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name,
ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type,
ISNULL(NULLIF(a.undefined57, ''), '') AS native_afs_reason,
ISNULL(cl.main_reason, '') AS chat_main_reason,
ISNULL(cl.second_reason, '') AS chat_second_reason,
ISNULL(cl.third_reason, '') AS chat_third_reason,
CASE
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '')
WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason
ELSE '未标注'
END AS afs_reason_l1,
CASE
WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason
ELSE '未标注'
END AS afs_reason_l2,
CASE
WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason
ELSE '未标注'
END AS afs_reason_l3,
CASE
WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason'
WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason'
ELSE 'unknown'
END AS reason_source,
ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
INTO #base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
LEFT JOIN #chat_latest AS cl
ON cl.order_id = NULLIF(a.undefined2, '')
WHERE a.createdAt >= @date_from
AND a.createdAt < @date_to
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
-- 1) 全局退款原因 TOP30
SELECT TOP (30)
afs_reason_l1 AS afs_reason,
reason_source,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
SUM(ISNULL(return_amount, 0)) AS return_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS reason_refund_rate
FROM #base
GROUP BY afs_reason_l1, reason_source
ORDER BY afs_record_count DESC;
-- 2) 平台内原因 TOP10
WITH reason_rank AS (
SELECT
platform_name,
afs_reason_l1 AS afs_reason,
COUNT(*) AS afs_record_count,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
ROW_NUMBER() OVER (
PARTITION BY platform_name
ORDER BY COUNT(*) DESC
) AS rn
FROM #base
GROUP BY platform_name, afs_reason_l1
)
SELECT
platform_name,
afs_reason,
afs_record_count,
refund_amount
FROM reason_rank
WHERE rn <= 10
ORDER BY platform_name, afs_record_count DESC;
-- 3) 二级原因 TOP20(可用于更细分原因)
SELECT TOP (20)
afs_reason_l1,
afs_reason_l2,
COUNT(*) AS afs_record_count,
SUM(ISNULL(refund_amount, 0)) AS refund_amount
FROM #base
GROUP BY afs_reason_l1, afs_reason_l2
ORDER BY afs_record_count DESC;
-- 4) 店铺退款风险清单(订单数>=50)
SELECT
shop_name,
platform_name,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate
FROM #base
GROUP BY shop_name, platform_name
HAVING COUNT(DISTINCT order_id) >= 50
ORDER BY refund_rate DESC, payment_amount DESC;
-- 5) 售后类型/结果矩阵
SELECT
afs_type,
afs_result,
COUNT(*) AS afs_record_count,
SUM(ISNULL(refund_amount, 0)) AS refund_amount
FROM #base
GROUP BY afs_type, afs_result
ORDER BY afs_record_count DESC;

View File

@ -0,0 +1,111 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: 店铺周报
Window: 上一个自然周 [week_start, week_end)
*/
DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2);
DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2);
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
NULLIF(a.undefined2, '') AS order_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1
THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1)
ELSE 'unknown'
END AS platform_name,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined21, '')) AS goods_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined22, '')) AS goods_cost,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')) AS actual_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')) AS actual_cost,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels,
a.createdAt AS created_at
INTO #base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE a.createdAt >= @week_start
AND a.createdAt < @week_end
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
-- 1) 周总览
SELECT
@week_start AS week_start,
@week_end AS week_end,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(qty, 0)) AS qty_sum,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(actual_amount, 0)) AS actual_amount,
SUM(ISNULL(actual_cost, 0)) AS actual_cost,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
SUM(ISNULL(return_amount, 0)) AS return_amount
FROM #base;
-- 2) 平台周报
SELECT
platform_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate,
SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0)) AS gross_profit
FROM #base
GROUP BY platform_name
ORDER BY payment_amount DESC;
-- 3) 店铺 TOP30
SELECT TOP (30)
shop_name,
platform_name,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate,
SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0)) AS gross_profit,
CASE
WHEN SUM(ISNULL(actual_amount, 0)) = 0 THEN NULL
ELSE (SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0))) / SUM(ISNULL(actual_amount, 0))
END AS gross_margin_rate
FROM #base
GROUP BY shop_name, platform_name
ORDER BY payment_amount DESC;
-- 4) 高退款风险店铺(门槛:订单数>=50)
SELECT
shop_name,
platform_name,
COUNT(DISTINCT order_id) AS order_count,
SUM(ISNULL(payment_amount, 0)) AS payment_amount,
SUM(ISNULL(refund_amount, 0)) AS refund_amount,
CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END AS refund_rate
FROM #base
GROUP BY shop_name, platform_name
HAVING COUNT(DISTINCT order_id) >= 50
AND CASE
WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN 0
ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0))
END >= 0.15
ORDER BY refund_rate DESC, payment_amount DESC;

View File

@ -0,0 +1,124 @@
USE [JSDataMiddlePlatform];
GO
/*
Template: SKU结构分析
Window: 最近30天 [date_from, date_to)
*/
DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2);
DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2);
IF OBJECT_ID('tempdb..#base') IS NOT NULL
DROP TABLE #base;
SELECT
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined10, '') AS sku_id,
COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS qty,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')) AS actual_amount,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')) AS actual_cost,
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount,
ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels
INTO #base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE a.createdAt >= @date_from
AND a.createdAt < @date_to
AND ISNULL(NULLIF(a.undefined2, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined10, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#sku_agg') IS NOT NULL
DROP TABLE #sku_agg;
SELECT
b.sku_id,
ISNULL(NULLIF(s.name, ''), 'unknown') AS sku_name,
ISNULL(NULLIF(s.other_1, ''), 'unknown') AS spu,
ISNULL(NULLIF(s.category, ''), 'unknown') AS category,
s.sale_price,
COUNT(*) AS afs_record_count,
COUNT(DISTINCT b.order_id) AS order_count,
SUM(ISNULL(b.qty, 0)) AS qty_sum,
SUM(ISNULL(b.payment_amount, 0)) AS payment_amount,
SUM(ISNULL(b.actual_amount, 0)) AS actual_amount,
SUM(ISNULL(b.actual_cost, 0)) AS actual_cost,
SUM(ISNULL(b.refund_amount, 0)) AS refund_amount
INTO #sku_agg
FROM #base AS b
LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK)
ON s.sku_id = b.sku_id
GROUP BY
b.sku_id,
s.name,
s.other_1,
s.category,
s.sale_price;
-- 1) 类目结构(按销售额)
SELECT TOP (20)
category,
COUNT(*) AS sku_count,
SUM(payment_amount) AS payment_amount,
CASE
WHEN SUM(SUM(payment_amount)) OVER () = 0 THEN NULL
ELSE SUM(payment_amount) / SUM(SUM(payment_amount)) OVER ()
END AS payment_share
FROM #sku_agg
GROUP BY category
ORDER BY payment_amount DESC;
-- 2) SKU TOP50(按销售额)
SELECT TOP (50)
sku_id,
sku_name,
spu,
category,
sale_price,
order_count,
qty_sum,
payment_amount,
refund_amount,
CASE
WHEN payment_amount = 0 THEN NULL
ELSE refund_amount / payment_amount
END AS refund_rate,
actual_amount - actual_cost AS gross_profit,
CASE
WHEN actual_amount = 0 THEN NULL
ELSE (actual_amount - actual_cost) / actual_amount
END AS gross_margin_rate
FROM #sku_agg
ORDER BY payment_amount DESC;
-- 3) 价格带结构
WITH price_band AS (
SELECT
CASE
WHEN sale_price IS NULL THEN '未知价格'
WHEN sale_price < 100 THEN '0-99'
WHEN sale_price < 200 THEN '100-199'
WHEN sale_price < 300 THEN '200-299'
WHEN sale_price < 500 THEN '300-499'
ELSE '500+'
END AS price_band,
payment_amount,
order_count,
refund_amount
FROM #sku_agg
)
SELECT
price_band,
SUM(order_count) AS order_count,
SUM(payment_amount) AS payment_amount,
SUM(refund_amount) AS refund_amount,
CASE
WHEN SUM(payment_amount) = 0 THEN NULL
ELSE SUM(refund_amount) / SUM(payment_amount)
END AS refund_rate
FROM price_band
GROUP BY price_band
ORDER BY payment_amount DESC;

488
stock-risk-alerts Executable file
View File

@ -0,0 +1,488 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
def build_sql(days: int, lead_days: int, warn_days: int, limit: int, include_safe: bool) -> str:
include_safe_bit = 1 if include_safe else 0
return f"""
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @lookback_days int = {days};
DECLARE @lead_days int = {lead_days};
DECLARE @warn_days int = {warn_days};
DECLARE @limit int = {limit};
DECLARE @include_safe bit = {include_safe_bit};
DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE());
DECLARE @end_time datetime2 = GETDATE();
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
DROP TABLE #ship_base;
SELECT
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined12, '') AS product_id,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道')
END AS shop_channel,
ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty
INTO #ship_base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE ISNULL(NULLIF(a.undefined10, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
DROP TABLE #ship_stats;
SELECT
sb.sku_id,
COUNT(DISTINCT sb.order_id) AS order_count_30d,
COUNT(DISTINCT CASE WHEN ISNULL(sb.product_id, '') <> '' THEN sb.product_id END) AS product_id_count_30d,
COUNT(DISTINCT sb.shop_name) AS shop_count_30d,
SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -7, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_7d,
SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -30, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_30d,
MAX(sb.ship_dt) AS last_ship_time
INTO #ship_stats
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id;
IF OBJECT_ID('tempdb..#sku_ref') IS NOT NULL
DROP TABLE #sku_ref;
SELECT
s.sku_id,
s.name AS sku_name,
s.other_1 AS spu,
s.i_id AS style_code,
s.category,
s.item_type,
s.brand,
s.supplier_name,
s.other_3 AS default_warehouse,
s.other_5 AS sku_status,
s.cost_price,
s.other_10 AS board_material,
s.modified
INTO #sku_ref
FROM dbo.jst_sku AS s WITH (NOLOCK)
WHERE NULLIF(s.sku_id, '') IN (SELECT sku_id FROM #ship_stats);
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
DROP TABLE #inv_latest;
WITH inv_rank AS (
SELECT
NULLIF(i.sku_id, '') AS sku_id,
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
i.wms_co_id,
i.name AS inventory_note,
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(i.sku_id, '')
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
) AS rn
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #ship_stats)
)
SELECT
sku_id,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
virtual_qty,
defective_qty,
purchase_qty,
return_qty,
in_qty,
min_qty,
max_qty,
wms_co_id,
inventory_note,
inventory_snapshot_time,
CASE
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
THEN CAST(0 AS decimal(18, 2))
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
END AS available_qty
INTO #inv_latest
FROM inv_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL
DROP TABLE #sku_owner;
SELECT
sr.sku_id,
COALESCE(
NULLIF(pc_exact.purchaser, ''),
NULLIF(pc_style.purchaser, ''),
NULLIF(pc_base.purchaser, ''),
'未分配'
) AS purchaser_name,
COALESCE(
NULLIF(po_exact.oper, ''),
NULLIF(po_style.oper, ''),
NULLIF(po_base.oper, ''),
NULLIF(pc_exact.oper, ''),
NULLIF(pc_style.oper, ''),
NULLIF(pc_base.oper, ''),
'未分配'
) AS spu_oper_name
INTO #sku_owner
FROM #sku_ref AS sr
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = NULLIF(sr.spu, '')
ORDER BY c.Id DESC
) AS pc_exact
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = NULLIF(sr.style_code, '')
ORDER BY c.Id DESC
) AS pc_style
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = CASE
WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0
THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1)
ELSE NULL
END
ORDER BY c.Id DESC
) AS pc_base
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = NULLIF(sr.spu, '')
ORDER BY o.Id DESC
) AS po_exact
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = NULLIF(sr.style_code, '')
ORDER BY o.Id DESC
) AS po_style
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = CASE
WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0
THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1)
ELSE NULL
END
ORDER BY o.Id DESC
) AS po_base;
IF OBJECT_ID('tempdb..#operator_top') IS NOT NULL
DROP TABLE #operator_top;
WITH oper_rank AS (
SELECT
sb.sku_id,
NULLIF(t.oper, '') AS oper_name,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, NULLIF(t.oper, '') ASC
) AS rn
FROM #ship_base AS sb
INNER JOIN dbo.op_TargetManagement AS t WITH (NOLOCK)
ON NULLIF(t.shopproid, '') = NULLIF(sb.product_id, '')
AND ISNULL(NULLIF(t.shopname, ''), '') = ISNULL(NULLIF(sb.shop_name, ''), '')
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
AND ISNULL(NULLIF(t.oper, ''), '') <> ''
GROUP BY sb.sku_id, NULLIF(t.oper, '')
)
SELECT
sku_id,
oper_name AS operator_name
INTO #operator_top
FROM oper_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#channel_top') IS NOT NULL
DROP TABLE #channel_top;
WITH channel_rank AS (
SELECT
sb.sku_id,
sb.shop_channel,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_channel ASC
) AS rn
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id, sb.shop_channel
)
SELECT
sku_id,
shop_channel AS top_shop_channel,
shipped_qty_sum_30d AS top_channel_shipped_qty_30d
INTO #channel_top
FROM channel_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#shop_top') IS NOT NULL
DROP TABLE #shop_top;
WITH shop_rank AS (
SELECT
sb.sku_id,
sb.shop_name,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_name ASC
) AS rn
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id, sb.shop_name
)
SELECT
sku_id,
shop_name AS top_shop_name,
shipped_qty_sum_30d AS top_shop_shipped_qty_30d
INTO #shop_top
FROM shop_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#warehouse_top') IS NOT NULL
DROP TABLE #warehouse_top;
WITH wh_rank AS (
SELECT
sb.sku_id,
sb.ship_warehouse_name,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.ship_warehouse_name ASC
) AS rn
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
AND ISNULL(NULLIF(sb.ship_warehouse_name, ''), '') <> ''
GROUP BY sb.sku_id, sb.ship_warehouse_name
)
SELECT
sku_id,
ship_warehouse_name AS top_warehouse_name
INTO #warehouse_top
FROM wh_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL
DROP TABLE #risk_eval;
SELECT
sr.sku_id,
sr.sku_name,
sr.spu,
sr.category,
sr.item_type,
sr.brand,
sr.cost_price,
sr.board_material,
sr.sku_status,
COALESCE(NULLIF(sr.supplier_name, ''), '未标注') AS factory_name,
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name,
COALESCE(NULLIF(ot.operator_name, ''), NULLIF(ow.spu_oper_name, ''), '未分配') AS operator_name,
COALESCE(NULLIF(wt.top_warehouse_name, ''), NULLIF(sr.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name,
ct.top_shop_channel,
stp.top_shop_name,
ISNULL(ss.product_id_count_30d, 0) AS product_id_count_30d,
ISNULL(ss.shop_count_30d, 0) AS shop_count_30d,
ISNULL(ss.order_count_30d, 0) AS order_count_30d,
ISNULL(ss.shipped_qty_7d, 0) AS shipped_qty_7d,
ISNULL(ss.shipped_qty_30d, 0) AS shipped_qty_30d,
ss.last_ship_time,
inv.stock_qty,
inv.order_lock_qty,
inv.pick_lock_qty,
inv.lock_qty,
inv.available_qty,
inv.virtual_qty,
inv.defective_qty,
inv.purchase_qty,
inv.return_qty,
inv.in_qty,
inv.min_qty,
inv.max_qty,
inv.inventory_snapshot_time,
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
END AS decimal(18, 2)
) AS estimated_stockout_days,
@lead_days AS replenishment_lead_days,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
END AS decimal(18, 2)
) AS suggest_replenish_qty_to_lead_days,
CASE
WHEN inv.sku_id IS NULL THEN '无库存快照'
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)'
ELSE '库存安全'
END AS stock_risk_level,
CASE
WHEN inv.sku_id IS NULL THEN 1
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty <= 0 THEN 1
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN 1
ELSE 0
END AS has_stock_risk
INTO #risk_eval
FROM #ship_stats AS ss
LEFT JOIN #sku_ref AS sr
ON sr.sku_id = ss.sku_id
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = ss.sku_id
LEFT JOIN #sku_owner AS ow
ON ow.sku_id = ss.sku_id
LEFT JOIN #operator_top AS ot
ON ot.sku_id = ss.sku_id
LEFT JOIN #channel_top AS ct
ON ct.sku_id = ss.sku_id
LEFT JOIN #shop_top AS stp
ON stp.sku_id = ss.sku_id
LEFT JOIN #warehouse_top AS wt
ON wt.sku_id = ss.sku_id;
-- 1) 汇总:近30天在售SKU + 缺货风险SKU数量
SELECT
COUNT(*) AS sold_sku_count_30d,
SUM(CASE WHEN has_stock_risk = 1 THEN 1 ELSE 0 END) AS stock_risk_sku_count,
SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN 1 ELSE 0 END) AS urgent_sku_count,
SUM(CASE WHEN stock_risk_level LIKE '补货预警%' THEN 1 ELSE 0 END) AS warning_sku_count,
@lookback_days AS lookback_days,
@lead_days AS replenishment_lead_days,
@warn_days AS warning_days
FROM #risk_eval;
-- 2) 明细:全量SKU风险清单(默认只显示风险项)
SELECT TOP (@limit)
sku_id,
sku_name,
spu,
category,
item_type,
brand,
sku_status,
cost_price,
board_material,
factory_name,
purchaser_name,
operator_name,
warehouse_name,
top_shop_channel,
top_shop_name,
product_id_count_30d,
shop_count_30d,
order_count_30d,
shipped_qty_7d,
shipped_qty_30d,
avg_daily_shipped_7d,
avg_daily_shipped_30d,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
available_qty,
estimated_stockout_days,
replenishment_lead_days,
suggest_replenish_qty_to_lead_days,
stock_risk_level,
inventory_snapshot_time,
last_ship_time
FROM #risk_eval
WHERE @include_safe = 1
OR has_stock_risk = 1
ORDER BY
CASE
WHEN stock_risk_level LIKE '缺货%' THEN 1
WHEN stock_risk_level LIKE '补货紧急%' THEN 2
WHEN stock_risk_level LIKE '补货预警%' THEN 3
WHEN stock_risk_level = '无库存快照' THEN 4
ELSE 9
END,
estimated_stockout_days ASC,
shipped_qty_30d DESC,
sku_id ASC;
"""
def main() -> int:
parser = argparse.ArgumentParser(description="All-SKU stock risk alert report")
parser.add_argument("--days", type=int, default=30, help="lookback window days, default 30")
parser.add_argument("--lead-days", type=int, default=10, help="replenishment lead time days")
parser.add_argument("--warn-days", type=int, default=15, help="warning line days (must >= lead-days)")
parser.add_argument("--limit", type=int, default=500, help="max rows for detail result set")
parser.add_argument("--include-safe", action="store_true", help="include safe SKUs in detail output")
args = parser.parse_args()
days = max(7, min(args.days, 180))
lead_days = max(1, min(args.lead_days, 60))
warn_days = max(lead_days, min(args.warn_days, 90))
limit = max(1, min(args.limit, 5000))
sql = build_sql(days, lead_days, warn_days, limit, args.include_safe)
cmd = ["company-db", "query", "--sql", sql]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "company-db failed").strip()
print(err, file=sys.stderr)
return proc.returncode
print(proc.stdout.rstrip())
return 0
if __name__ == "__main__":
raise SystemExit(main())

306
stock-risk-to-feishu Executable file
View File

@ -0,0 +1,306 @@
#!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Dict, List, Tuple
DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g"
DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5"
DEFAULT_TABLE_LINK = (
"https://acnuoqgha8fk.feishu.cn/wiki/"
"RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI"
)
OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
REQUIRED_FIELDS = [
"记录类型",
"批次ID",
"报表时间",
"统计周期天数",
"补货提前期天数",
"预警阈值天数",
"在售SKU数",
"风险SKU数",
"紧急SKU数",
"预警SKU数",
"SKU编码",
"SKU名称",
"SPU",
"风险等级",
"预计缺货天数",
"建议补货件数",
"可用库存",
"近7天发货件数",
"近30天发货件数",
"近30天日均发货",
"渠道",
"店铺",
"采购",
"运营",
"工厂",
"仓库",
]
def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict:
data = None
req_headers = dict(headers)
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json; charset=utf-8"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8")
if not raw:
return {}
return json.loads(raw)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e
except Exception as e:
raise RuntimeError(f"request failed {method} {url}: {e}") from e
def _load_feishu_app() -> Tuple[str, str]:
app_id = os.getenv("FEISHU_APP_ID", "").strip()
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
if app_id and app_secret:
return app_id, app_secret
cfg_path = Path(OPENCLAW_CONFIG)
if not cfg_path.exists():
raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)")
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
ch = (cfg.get("channels") or {}).get("feishu") or {}
app_id = (ch.get("appId") or "").strip()
app_secret = (ch.get("appSecret") or "").strip()
if not app_id or not app_secret:
raise RuntimeError("feishu appId/appSecret is empty in openclaw.json")
return app_id, app_secret
def _tenant_token(app_id: str, app_secret: str) -> str:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
res = _http_json(
"POST",
url,
headers={},
payload={"app_id": app_id, "app_secret": app_secret},
)
if res.get("code") != 0:
raise RuntimeError(f"feishu auth failed: {res}")
token = res.get("tenant_access_token")
if not token:
raise RuntimeError("feishu auth succeeded but tenant_access_token missing")
return token
def _parse_company_rows(output: str) -> Dict[int, Dict]:
result_sets: Dict[int, Dict] = {}
current_idx = None
current_cols: List[str] = []
current_rows: List[Dict] = []
def flush():
nonlocal current_idx, current_cols, current_rows
if current_idx is not None:
result_sets[current_idx] = {"columns": current_cols, "rows": current_rows}
current_idx = None
current_cols = []
current_rows = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("RESULT_SET "):
flush()
current_idx = int(line.split()[1])
continue
if line.startswith("COLUMNS:"):
cols = line.split(":", 1)[1].strip()
current_cols = [c.strip() for c in cols.split(",")]
continue
if line.startswith("{") and line.endswith("}"):
try:
current_rows.append(json.loads(line))
except Exception:
pass
continue
flush()
return result_sets
def _run_stock_risk(days: int, lead_days: int, warn_days: int, limit: int) -> Tuple[Dict, List[Dict]]:
cmd = [
"stock-risk-alerts",
"--days",
str(days),
"--lead-days",
str(lead_days),
"--warn-days",
str(warn_days),
"--limit",
str(limit),
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "stock-risk-alerts failed").strip()
raise RuntimeError(err)
parsed = _parse_company_rows(proc.stdout or "")
summary_rows = parsed.get(1, {}).get("rows", [])
detail_rows = parsed.get(2, {}).get("rows", [])
if not summary_rows:
raise RuntimeError("stock-risk-alerts returned no summary rows")
return summary_rows[0], detail_rows
def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]:
url = (
f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
"?page_size=500"
)
res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"})
if res.get("code") != 0:
raise RuntimeError(f"list fields failed: {res}")
items = (((res.get("data") or {}).get("items")) or [])
out: Dict[str, str] = {}
for it in items:
name = it.get("field_name")
fid = it.get("field_id")
if name and fid:
out[name] = fid
return out
def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
payload = {"field_name": field_name, "type": 1}
res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload)
if res.get("code") != 0:
raise RuntimeError(f"create field failed ({field_name}): {res}")
def _ensure_fields(token: str, app_token: str, table_id: str) -> None:
fields = _list_fields(token, app_token, table_id)
for name in REQUIRED_FIELDS:
if name not in fields:
_create_field(token, app_token, table_id, name)
# wait a bit for schema consistency
time.sleep(0.5)
def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> str:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
payload = {"fields": fields}
res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload)
if res.get("code") != 0:
raise RuntimeError(f"create record failed: {res}")
rec = (res.get("data") or {}).get("record") or {}
return rec.get("record_id") or rec.get("id") or ""
def _fmt(v) -> str:
if v is None:
return ""
if isinstance(v, float):
return f"{v:.2f}".rstrip("0").rstrip(".")
return str(v)
def main() -> int:
parser = argparse.ArgumentParser(description="Push stock risk alerts to Feishu as structured rows")
parser.add_argument("--days", type=int, default=30)
parser.add_argument("--lead-days", type=int, default=10)
parser.add_argument("--warn-days", type=int, default=15)
parser.add_argument("--top", type=int, default=50, help="top N detail rows to push")
parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN)
parser.add_argument("--table-id", default=DEFAULT_TABLE_ID)
parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK)
args = parser.parse_args()
days = max(7, min(args.days, 180))
lead_days = max(1, min(args.lead_days, 60))
warn_days = max(lead_days, min(args.warn_days, 90))
top = max(1, min(args.top, 500))
summary, details = _run_stock_risk(days, lead_days, warn_days, limit=max(top, 50))
app_id, app_secret = _load_feishu_app()
token = _tenant_token(app_id, app_secret)
_ensure_fields(token, args.app_token, args.table_id)
batch_id = time.strftime("%Y%m%d%H%M%S")
now_text = time.strftime("%Y-%m-%d %H:%M:%S")
summary_record = {
"记录类型": "汇总",
"批次ID": batch_id,
"报表时间": now_text,
"统计周期天数": _fmt(summary.get("lookback_days", days)),
"补货提前期天数": _fmt(summary.get("replenishment_lead_days", lead_days)),
"预警阈值天数": _fmt(summary.get("warning_days", warn_days)),
"在售SKU数": _fmt(summary.get("sold_sku_count_30d")),
"风险SKU数": _fmt(summary.get("stock_risk_sku_count")),
"紧急SKU数": _fmt(summary.get("urgent_sku_count")),
"预警SKU数": _fmt(summary.get("warning_sku_count")),
}
summary_record_id = _create_record(token, args.app_token, args.table_id, summary_record)
detail_record_ids: List[str] = []
for row in details[:top]:
fields = {
"记录类型": "明细",
"批次ID": batch_id,
"报表时间": now_text,
"统计周期天数": _fmt(days),
"补货提前期天数": _fmt(lead_days),
"预警阈值天数": _fmt(warn_days),
"SKU编码": _fmt(row.get("sku_id")),
"SKU名称": _fmt(row.get("sku_name")),
"SPU": _fmt(row.get("spu")),
"风险等级": _fmt(row.get("stock_risk_level")),
"预计缺货天数": _fmt(row.get("estimated_stockout_days")),
"建议补货件数": _fmt(row.get("suggest_replenish_qty_to_lead_days")),
"可用库存": _fmt(row.get("available_qty")),
"近7天发货件数": _fmt(row.get("shipped_qty_7d")),
"近30天发货件数": _fmt(row.get("shipped_qty_30d")),
"近30天日均发货": _fmt(row.get("avg_daily_shipped_30d")),
"渠道": _fmt(row.get("top_shop_channel")),
"店铺": _fmt(row.get("top_shop_name")),
"采购": _fmt(row.get("purchaser_name")),
"运营": _fmt(row.get("operator_name")),
"工厂": _fmt(row.get("factory_name")),
"仓库": _fmt(row.get("warehouse_name")),
}
rec_id = _create_record(token, args.app_token, args.table_id, fields)
detail_record_ids.append(rec_id)
print("PUSH_STATUS: success")
print(f"TABLE_LINK: {args.table_link}")
print(f"BATCH_ID: {batch_id}")
print(f"SUMMARY_RECORD_ID: {summary_record_id}")
print(f"DETAIL_RECORD_COUNT: {len(detail_record_ids)}")
if detail_record_ids:
print(f"FIRST_DETAIL_RECORD_ID: {detail_record_ids[0]}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as e:
print(f"PUSH_STATUS: failed\nERROR: {e}", file=sys.stderr)
raise

View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
APP_TOKEN="SZ4ebCKU3a9OdfspetxcSeg1n4g"
TABLE_ID="tblzuMy85WiRAUX5"
TS="$(date +%F_%H:%M:%S)"
MSG1="调用 feishu_bitable_list_records,app_token=${APP_TOKEN},table_id=${TABLE_ID},page_size=3。返回记录数量和前3条文本字段。"
MSG2="调用 feishu_bitable_create_record,app_token=${APP_TOKEN},table_id=${TABLE_ID},fields={\"文本\":\"OpenClaw联调测试-${TS}\"}。返回新建record_id和写入内容。"
MSG3="再次调用 feishu_bitable_list_records,app_token=${APP_TOKEN},table_id=${TABLE_ID},page_size=5。返回最近5条的文本字段。"
/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-1 --message "$MSG1" --json --timeout 120 > /tmp/feishu_test_step1.out 2>&1 || true
/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-2 --message "$MSG2" --json --timeout 120 > /tmp/feishu_test_step2.out 2>&1 || true
/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-3 --message "$MSG3" --json --timeout 120 > /tmp/feishu_test_step3.out 2>&1 || true
python3 - <<'PY'
import json
from pathlib import Path
def parse(path):
raw = Path(path).read_text(encoding="utf-8", errors="ignore").strip()
dec = json.JSONDecoder()
data = None
try:
data, _ = dec.raw_decode(raw)
except Exception:
idx = raw.find('{')
while idx != -1:
try:
data, _ = dec.raw_decode(raw[idx:])
break
except Exception:
idx = raw.find('{', idx + 1)
if not isinstance(data, dict):
return {"ok": False, "raw": raw[:600]}
payloads = data.get("result", {}).get("payloads", [])
text = "\n".join(p.get("text", "") for p in payloads if isinstance(p, dict) and p.get("text"))
return {"ok": True, "text": text[:2000], "status": data.get("status"), "summary": data.get("summary")}
for i in (1, 2, 3):
r = parse(f"/tmp/feishu_test_step{i}.out")
print(f"===== STEP {i} =====")
if not r.get("ok"):
print("PARSE_FAIL", r.get("raw"))
else:
print("status=", r.get("status"), "summary=", r.get("summary"))
print(r.get("text") or "<empty>")
PY

25
test_feishu_node.py Normal file
View File

@ -0,0 +1,25 @@
import requests
app_id = 'cli_a9000d2e8678dbde'
app_secret = 'E4sZQAEk4kZC1jYXE5JUodr8rtXnnB7W'
node = 'RBSxwq8woiQDCUkRZfkcg7x8nMb'
r = requests.post(
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
json={'app_id': app_id, 'app_secret': app_secret},
timeout=15,
)
print('token_status=', r.status_code)
print('token_body=', r.text)
res = r.json()
tok = res.get('tenant_access_token')
headers = {'Authorization': f'Bearer {tok}'}
r2 = requests.get(
'https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node',
params={'token': node},
headers=headers,
timeout=15,
)
print('node_status=', r2.status_code)
print('node_body=', r2.text)

View File

@ -0,0 +1,28 @@
from pathlib import Path
p=Path('/root/.openclaw/workspace/AGENTS.md')
text=p.read_text(encoding='utf-8')
old='''Required behavior:
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
- In final reply, always include:
1) push status (success/failed)
2) target sheet link (the user-provided wiki/base URL)
3) written record_id (or updated count)
- If write partially fails, return succeeded records and failed reason list separately.
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
'''
new='''Required behavior:
- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record).
- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload.
- If target sheet link is omitted this turn, reuse the most recent Feishu wiki/base link from this conversation; if no link exists, ask one short question for the link.
- In final reply, always include:
1) push status (success/failed)
2) target sheet link (the user-provided wiki/base URL)
3) written record_id (or updated count)
- If write partially fails, return succeeded records and failed reason list separately.
- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line.
'''
if old not in text:
raise SystemExit('target block not found')
text=text.replace(old,new,1)
p.write_text(text,encoding='utf-8')
print('updated',p)

View File

@ -0,0 +1,26 @@
from pathlib import Path
p = Path('/root/.openclaw/workspace/AGENTS.md')
text = p.read_text(encoding='utf-8')
text = text.replace(
'Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图',
'Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 / 库存 / 缺货 / 补货 / 安全库存'
)
text = text.replace(
'- 产品信息(SKU/成本/材质/预览图) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`',
'- 产品信息/库存预警(SKU/成本/材质/预览图/库存/发货件数/缺货天数) -> `exec: product-info "<sku或关键词>" --days 30 --limit 20`'
)
text = text.replace(
'- SKU编码, SKU名称, 成本价, 材质, 预览图地址',
'- SKU编码, SKU名称, 成本价, 材质, 预览图地址, 当前库存, 日均发货件数, 预计缺货天数, 补货建议'
)
extra = '''- 库存预警(强制):
- 必须输出 `estimated_stockout_days` 与 `replenishment_lead_days(=10)`
- 当 `estimated_stockout_days <= 10` 时,结论必须标记 `补货紧急`
'''
if '库存预警(强制)' not in text:
insert_after = '- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)\n'
if insert_after in text:
text = text.replace(insert_after, insert_after + extra)
p.write_text(text, encoding='utf-8')
print('updated', p)

View File

@ -0,0 +1,53 @@
from pathlib import Path
p=Path('/root/.openclaw/workspace/sqlserver/company_db_tool.py')
text=p.read_text(encoding='utf-8')
old='''BUSINESS_TABLES = [
"jst_goods_profit_kpi_items_afs",
"jst_sku",
"fys_shopInfo",
"jst_order_express_details",
"fys_SkuDataLibrary_SpuConfig",
"op_operManage",
"op_TargetManagement",
"fys_Ticketing_install",
"fys_Ticketing_install_item",
"fys_jstFreightCost",
"fys_jstFreightCostDetail",
"pc_kfChatLog",
]
'''
new='''BUSINESS_TABLES = [
"jst_goods_profit_kpi_items_afs",
"jst_sku",
"fys_shopInfo",
"jst_order_express_details",
"fys_SkuDataLibrary_SpuConfig",
"op_operManage",
"op_TargetManagement",
"fys_Ticketing_install",
"fys_Ticketing_install_item",
"fys_jstFreightCost",
"fys_jstFreightCostDetail",
"pc_kfChatLog",
"jst_inventoryInfo",
"pm_InventoryMonitor",
"pm_stockMonitor",
"wp_stock",
"erp_wms_stockbiz",
]
'''
if old not in text:
raise SystemExit('target block not found')
text=text.replace(old,new,1)
# also update dimension row if exists
needle=''' ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"),
("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"),
'''
insert=''' ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"),
("库存维度", "可用库存、锁定库存、在途库存、近7/30天发货均值、缺货预警", "jst_inventoryInfo, pm_InventoryMonitor, pm_stockMonitor, wp_stock, erp_wms_stockbiz"),
("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"),
'''
if needle in text:
text=text.replace(needle,insert,1)
p.write_text(text,encoding='utf-8')
print('updated',p)

View File

@ -0,0 +1,18 @@
from pathlib import Path
p = Path('/root/.openclaw/skills/web-task-router/SKILL.md')
text = p.read_text(encoding='utf-8')
text = text.replace(
'for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图"',
'for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图", "库存", "缺货", "补货"'
)
text = text.replace(
' 2) Product info query (`SKU/成本/材质/预览图`): `exec`: `product-info "<sku或关键词>" --days 30 --limit 20`',
' 2) Product info / stock-risk query (`SKU/成本/材质/预览图/库存/发货件数/缺货天数`): `exec`: `product-info "<sku或关键词>" --days 30 --limit 20`'
)
extra = '- For stock-risk tasks, always include: current stock, daily shipped pieces, estimated stockout days, and whether it is within the 10-day replenishment lead line.\n'
if 'For stock-risk tasks' not in text:
anchor = ' 5) Customer-service experience/writing analysis: `exec`: `customer-service-analysis --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--shop "店铺关键词"] --limit 20`\n'
if anchor in text:
text = text.replace(anchor, anchor + extra)
p.write_text(text, encoding='utf-8')
print('updated', p)

View File

@ -0,0 +1,25 @@
from pathlib import Path
p = Path('/root/.openclaw/workspace/sqlserver/README_SQL_TEMPLATES.md')
text = p.read_text(encoding='utf-8')
text = text.replace(
'- 用途:按 SKU/SPU/关键词查询产品基础信息(成本、材质、预览图地址)',
'- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存与缺货预警(成本、材质、预览图、库存、发货件数、预计缺货天数)'
)
if '- 结果集9:最近N天每日总发货件数(按SKU)' not in text:
text = text.replace(
' - 结果集8:最近N天售后原因TOP(含来源)\n',
' - 结果集8:最近N天售后原因TOP(含来源)\n'
' - 结果集9:最近N天每日总发货件数(按SKU)\n'
' - 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)\n'
)
if '15. `sku_stock_risk_report.sql`' not in text:
insert = '''
15. `sku_stock_risk_report.sql`
- 用途:按 SKU 编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天)
- 默认时间:最近30天(模板顶部可改 `@lookback_days`)
- 参数:修改模板顶部 `@sku` 即可
'''
text = text.replace('14. `customer-service-analysis`(CLI辅助命令)\n', '14. `customer-service-analysis`(CLI辅助命令)\n' + insert)
p.write_text(text, encoding='utf-8')
print('updated', p)

View File

@ -0,0 +1,11 @@
from pathlib import Path
p = Path('/root/.openclaw/workspace/TOOLS.md')
text = p.read_text(encoding='utf-8')
text = text.replace(
'- Returns:\n 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)\n 2) Last N days shipped/aftersale counts\n 3) Last N days shop distribution by shipped piece count\n',
'- Returns:\n 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)\n 2) Last N days shipped/aftersale counts\n 3) Last N days shop distribution by shipped piece count\n 4) Last N days daily shipped pieces by SKU\n 5) Stock risk estimation (`available_qty`, `avg_daily_shipped_30d`, `estimated_stockout_days`, `suggest_replenish_qty_to_10d`)\n'
)
if '- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.' not in text:
text += '\n- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.\n'
p.write_text(text, encoding='utf-8')
print('updated', p)

27
update_websites_stock.py Normal file
View File

@ -0,0 +1,27 @@
from pathlib import Path
p=Path('/root/.openclaw/workspace/websites.yaml')
text=p.read_text(encoding='utf-8')
for kw in [' - 库存\n',' - 缺货\n',' - 补货\n',' - 安全库存\n']:
if kw not in text:
text=text.replace(' - 上周\n force_site_id: company-sales-db-analysis\n', ' - 上周\n'+kw+' force_site_id: company-sales-db-analysis\n')
for kw in [' - 库存\n',' - 缺货\n',' - 补货\n',' - 安全库存\n']:
if kw not in text:
text=text.replace(' - 预览图\n login_required: false\n', ' - 预览图\n'+kw+' login_required: false\n')
add_tables = [' - jst_inventoryInfo\n',' - pm_InventoryMonitor\n',' - pm_stockMonitor\n',' - wp_stock\n']
for t in add_tables:
if t not in text:
text=text.replace(' - pc_kfChatLog\n', ' - pc_kfChatLog\n'+t)
line=' - 产品信息查询(SKU编码/成本/材质/预览图)优先命令:`product-info "<sku或关键词>" --days 30 --limit 20`\n'
newline=' - 产品信息/库存查询(SKU编码/成本/材质/预览图/库存/缺货天数)优先命令:`product-info "<sku或关键词>" --days 30 --limit 20`\n'
if line in text:
text=text.replace(line,newline)
stock_rule=' - 库存预警口径:输出当前库存、近30天日均发货件数、预计缺货天数;若预计缺货天数<=10天,必须标记补货紧急。\n'
if stock_rule not in text:
text=text.replace(' - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。\n', ' - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。\n'+stock_rule)
p.write_text(text,encoding='utf-8')
print('updated',p)

BIN
xhs-login-qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
xhs-main.zip Normal file

Binary file not shown.

BIN
xiaohongshu-mcp-main/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,212 @@
{
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitType": "docs",
"commitConvention": "angular",
"contributors": [
{
"login": "xpzouying",
"name": "zy",
"avatar_url": "https://avatars.githubusercontent.com/u/3946563?v=4",
"profile": "https://haha.ai",
"contributions": [
"code",
"ideas",
"doc",
"design",
"maintenance",
"infra",
"review"
]
},
{
"login": "esperyong",
"name": "clearwater",
"avatar_url": "https://avatars.githubusercontent.com/u/1271815?v=4",
"profile": "http://www.hwbuluo.com",
"contributions": [
"code"
]
},
{
"login": "laryzhong",
"name": "Zhongpeng",
"avatar_url": "https://avatars.githubusercontent.com/u/47939471?v=4",
"profile": "https://github.com/laryzhong",
"contributions": [
"code"
]
},
{
"login": "DTDucas",
"name": "Duong Tran",
"avatar_url": "https://avatars.githubusercontent.com/u/105262836?v=4",
"profile": "https://github.com/DTDucas",
"contributions": [
"code"
]
},
{
"login": "Angiin",
"name": "Angiin",
"avatar_url": "https://avatars.githubusercontent.com/u/17389304?v=4",
"profile": "https://github.com/Angiin",
"contributions": [
"code"
]
},
{
"login": "muhenan",
"name": "Henan Mu",
"avatar_url": "https://avatars.githubusercontent.com/u/43441941?v=4",
"profile": "https://github.com/muhenan",
"contributions": [
"code"
]
},
{
"login": "chengazhen",
"name": "Journey",
"avatar_url": "https://avatars.githubusercontent.com/u/52627267?v=4",
"profile": "https://github.com/chengazhen",
"contributions": [
"code"
]
},
{
"login": "eveyuyi",
"name": "Eve Yu",
"avatar_url": "https://avatars.githubusercontent.com/u/69026872?v=4",
"profile": "https://github.com/eveyuyi",
"contributions": [
"code"
]
},
{
"login": "CooperGuo",
"name": "CooperGuo",
"avatar_url": "https://avatars.githubusercontent.com/u/183056602?v=4",
"profile": "https://github.com/CooperGuo",
"contributions": [
"code"
]
},
{
"login": "BiboyQG",
"name": "Banghao Chi",
"avatar_url": "https://avatars.githubusercontent.com/u/125724218?v=4",
"profile": "https://biboyqg.github.io/",
"contributions": [
"code"
]
},
{
"login": "varz1",
"name": "varz1",
"avatar_url": "https://avatars.githubusercontent.com/u/60377372?v=4",
"profile": "https://github.com/varz1",
"contributions": [
"code"
]
},
{
"login": "Meloyg",
"name": "Melo Y Guan",
"avatar_url": "https://avatars.githubusercontent.com/u/62586556?v=4",
"profile": "https://google.meloguan.site",
"contributions": [
"code"
]
},
{
"login": "lmxdawn",
"name": "lmxdawn",
"avatar_url": "https://avatars.githubusercontent.com/u/21293193?v=4",
"profile": "https://github.com/lmxdawn",
"contributions": [
"code"
]
},
{
"login": "haikow",
"name": "haikow",
"avatar_url": "https://avatars.githubusercontent.com/u/22428382?v=4",
"profile": "https://github.com/haikow",
"contributions": [
"code"
]
},
{
"login": "a67793581",
"name": "Carlo",
"avatar_url": "https://avatars.githubusercontent.com/u/18513362?v=4",
"profile": "https://carlo-blog.aiju.fun/",
"contributions": [
"code"
]
},
{
"login": "hrz394943230",
"name": "hrz",
"avatar_url": "https://avatars.githubusercontent.com/u/28583005?v=4",
"profile": "https://github.com/hrz394943230",
"contributions": [
"code"
]
},
{
"login": "ctrlz526",
"name": "Ctrlz",
"avatar_url": "https://avatars.githubusercontent.com/u/143257420?v=4",
"profile": "https://github.com/ctrlz526",
"contributions": [
"code"
]
},
{
"login": "flippancy",
"name": "flippancy",
"avatar_url": "https://avatars.githubusercontent.com/u/6467703?v=4",
"profile": "https://github.com/flippancy",
"contributions": [
"code"
]
},
{
"login": "Infinityay",
"name": "Yuhang Lu",
"avatar_url": "https://avatars.githubusercontent.com/u/103165980?v=4",
"profile": "https://github.com/Infinityay",
"contributions": [
"code"
]
},
{
"login": "triepod-ai",
"name": "Bryan Thompson",
"avatar_url": "https://avatars.githubusercontent.com/u/199543909?v=4",
"profile": "https://triepod.ai",
"contributions": [
"code"
]
},
{
"login": "tanxxjun321",
"name": "tan jun",
"avatar_url": "https://avatars.githubusercontent.com/u/7806992?v=4",
"profile": "http://www.megvii.com",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"skipCi": true,
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "xiaohongshu-mcp",
"projectOwner": "xpzouying"
}

View File

@ -0,0 +1,8 @@
{
"mcpServers": {
"xiaohongshu-mcp": {
"url": "http://localhost:18060/mcp",
"description": "小红书内容发布服务 - MCP Streamable HTTP"
}
}
}

View File

@ -0,0 +1,16 @@
.git
.idea
.vscode
.claude
.cursor
.github
**/*.log
bin
dist
vendor
Dockerfile
docker-compose.yml
docker
.DS_Store
cookies.json

View File

@ -0,0 +1 @@
* @xpzouying

View File

@ -0,0 +1,2 @@
custom:
- "https://github.com/xpzouying/xiaohongshu-mcp#赞赏支持"

View File

@ -0,0 +1,55 @@
name: Aliyun Docker Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
required: true
default: 'v1.0.0'
permissions:
contents: read
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Build and push Docker image (AMD64)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: |
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Docker image (ARM64)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.arm64
push: true
platforms: linux/arm64
tags: |
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64
crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest-arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -0,0 +1,54 @@
name: Docker Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
required: true
default: 'v1.0.0'
permissions:
contents: read
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: xpzouying
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image (AMD64)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: |
xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}
xpzouying/xiaohongshu-mcp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Docker image (ARM64)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.arm64
push: true
platforms: linux/arm64
tags: |
xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64
xpzouying/xiaohongshu-mcp:latest-arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -0,0 +1,158 @@
name: Build and Release
on:
push:
branches: [ main ]
paths-ignore:
- 'README.md'
- 'README_EN.md'
- 'CLAUDE.md'
- '.all-contributorsrc'
- '.gitignore'
- '.dockerignore'
- 'Dockerfile'
- '.claude/**'
- '.cursor/**'
- '.github/**'
- '.vscode/**'
- 'assets/**'
- 'configs/**'
- 'cookies/**'
- 'docker/**'
- 'deploy/**'
- 'docs/**'
- 'donate/**'
- 'examples/**'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
# 只在推送到 main 时运行,或手动触发
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
- name: Generate release name
id: version
run: |
TIMESTAMP=$(date +%Y.%m.%d.%H%M)
COMMIT_SHA=$(git rev-parse --short HEAD)
VERSION="v${TIMESTAMP}-${COMMIT_SHA}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Generated version: ${VERSION}"
- name: Build for multiple platforms
run: |
# 主程序构建
# 禁用 CGO 以生成静态链接的二进制文件,避免 GLIBC 依赖问题
# macOS ARM64 (Apple Silicon)
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-mcp-darwin-arm64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-login-darwin-arm64 ./cmd/login
# macOS Intel
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-mcp-darwin-amd64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-login-darwin-amd64 ./cmd/login
# Windows x64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o xiaohongshu-mcp-windows-amd64.exe .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o xiaohongshu-login-windows-amd64.exe ./cmd/login
# Linux x64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o xiaohongshu-mcp-linux-amd64 .
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o xiaohongshu-login-linux-amd64 ./cmd/login
- name: Package binaries
run: |
# 创建压缩包
# macOS ARM64
tar czf xiaohongshu-mcp-darwin-arm64.tar.gz xiaohongshu-mcp-darwin-arm64 xiaohongshu-login-darwin-arm64
# macOS Intel
tar czf xiaohongshu-mcp-darwin-amd64.tar.gz xiaohongshu-mcp-darwin-amd64 xiaohongshu-login-darwin-amd64
# Windows x64
zip xiaohongshu-mcp-windows-amd64.zip xiaohongshu-mcp-windows-amd64.exe xiaohongshu-login-windows-amd64.exe
# Linux x64
tar czf xiaohongshu-mcp-linux-amd64.tar.gz xiaohongshu-mcp-linux-amd64 xiaohongshu-login-linux-amd64
- name: Clean up old releases
run: |
# 获取所有自动构建的 releases (v开头的时间戳格式)
RELEASES=$(gh release list --limit 100 | grep -E '^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}-' | awk '{print $3}' | tail -n +11)
# 删除超过 10 个的旧 releases 和对应的 tags
for release in $RELEASES; do
echo "Deleting old release: $release"
gh release delete "$release" --yes --cleanup-tag
done
env:
GH_TOKEN: ${{ github.token }}
continue-on-error: true
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
draft: false
prerelease: false
body: |
## 🔧 自动构建版本
**注意:这是自动构建的预发布版本,用于测试。正式版本请等待手动发布。**
### 📦 下载说明
选择适合您系统的压缩包下载:
- **macOS Apple Silicon (M1/M2/M3)**: `xiaohongshu-mcp-darwin-arm64.tar.gz`
- **macOS Intel**: `xiaohongshu-mcp-darwin-amd64.tar.gz`
- **Windows x64**: `xiaohongshu-mcp-windows-amd64.zip`
- **Linux x64**: `xiaohongshu-mcp-linux-amd64.tar.gz`
每个压缩包包含:
- `xiaohongshu-mcp-*`: MCP 服务主程序
- `xiaohongshu-login-*`: 登录工具
### 🔧 使用方法
```bash
# 1. 解压文件(macOS/Linux)
tar xzf xiaohongshu-mcp-darwin-arm64.tar.gz
# 或 Windows
# 解压 xiaohongshu-mcp-windows-amd64.zip
# 2. 运行登录工具
./xiaohongshu-login-darwin-arm64
# 3. 启动 MCP 服务
./xiaohongshu-mcp-darwin-arm64
```
### 🐳 Docker 镜像
Docker 镜像需要手动触发构建,请到 Actions 页面运行 "Docker Release" workflow。
### 📊 构建信息
- **Commit**: ${{ github.sha }}
- **Branch**: main
- **Build Time**: ${{ steps.version.outputs.version }}
files: |
xiaohongshu-mcp-darwin-arm64.tar.gz
xiaohongshu-mcp-darwin-amd64.tar.gz
xiaohongshu-mcp-windows-amd64.zip
xiaohongshu-mcp-linux-amd64.tar.gz

View File

@ -0,0 +1,153 @@
name: Tag and Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
required: true
type: string
release_notes:
description: 'Release notes (optional)'
required: false
type: string
default: ''
permissions:
contents: write
jobs:
tag-and-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate version format
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?$ ]]; then
echo "Error: Version must follow semantic versioning (e.g., v1.0.0, v1.0.0-beta.1)"
exit 1
fi
# Check if tag already exists
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Error: Tag $VERSION already exists"
exit 1
fi
echo "Version $VERSION is valid"
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
- name: Build for multiple platforms
run: |
# 主程序构建
# macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-mcp-darwin-arm64 .
GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-login-darwin-arm64 ./cmd/login
# macOS Intel
GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-mcp-darwin-amd64 .
GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-login-darwin-amd64 ./cmd/login
# Windows x64
GOOS=windows GOARCH=amd64 go build -o xiaohongshu-mcp-windows-amd64.exe .
GOOS=windows GOARCH=amd64 go build -o xiaohongshu-login-windows-amd64.exe ./cmd/login
# Linux x64
GOOS=linux GOARCH=amd64 go build -o xiaohongshu-mcp-linux-amd64 .
GOOS=linux GOARCH=amd64 go build -o xiaohongshu-login-linux-amd64 ./cmd/login
- name: Generate changelog
id: changelog
run: |
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found, including all commits"
COMMITS=$(git log --oneline --format="- %s (%h)" | head -20)
else
echo "Generating changelog from $PREV_TAG to HEAD"
COMMITS=$(git log $PREV_TAG..HEAD --oneline --format="- %s (%h)")
fi
# Save to output
{
echo "commits<<EOF"
echo "$COMMITS"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Create tag and release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.event.inputs.version }}
name: Release ${{ github.event.inputs.version }}
draft: false
prerelease: ${{ contains(github.event.inputs.version, '-') }}
body: |
## 🚀 新版本发布: ${{ github.event.inputs.version }}
${{ github.event.inputs.release_notes }}
### 📋 更新内容
${{ steps.changelog.outputs.commits }}
---
### 📦 下载说明
**主程序(MCP 服务):**
- **macOS Apple Silicon**: `xiaohongshu-mcp-darwin-arm64`
- **macOS Intel**: `xiaohongshu-mcp-darwin-amd64`
- **Windows x64**: `xiaohongshu-mcp-windows-amd64.exe`
- **Linux x64**: `xiaohongshu-mcp-linux-amd64`
**登录工具:**
- **macOS Apple Silicon**: `xiaohongshu-login-darwin-arm64`
- **macOS Intel**: `xiaohongshu-login-darwin-amd64`
- **Windows x64**: `xiaohongshu-login-windows-amd64.exe`
- **Linux x64**: `xiaohongshu-login-linux-amd64`
### 🔧 使用方法
```bash
# 1. 首先运行登录工具获取 cookie
./xiaohongshu-login-darwin-arm64
# 2. 然后启动 MCP 服务
./xiaohongshu-mcp-darwin-arm64
# 或指定参数
./xiaohongshu-mcp-darwin-arm64 -headless=false
```
### ⚠️ 注意事项
- 首次运行时会自动下载无头浏览器(约 150MB),请确保网络连接正常
- 后续运行无需重复下载浏览器
- 登录工具生成的 cookie 保存在 `~/.xiaohongshu/cookies.json`
### 📊 构建信息
- **Commit**: ${{ github.sha }}
- **Go Version**: 1.24
- **Build Time**: ${{ github.event.repository.updated_at }}
files: |
xiaohongshu-mcp-darwin-arm64
xiaohongshu-mcp-darwin-amd64
xiaohongshu-mcp-windows-amd64.exe
xiaohongshu-mcp-linux-amd64
xiaohongshu-login-darwin-arm64
xiaohongshu-login-darwin-amd64
xiaohongshu-login-windows-amd64.exe
xiaohongshu-login-linux-amd64

44
xiaohongshu-mcp-main/.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
.idea
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
.claude/
# Build artifacts
xiaohongshu-mcp
# Test scripts
test_*.sh
# Cookies files (contain sensitive login information)
cookies.json

9
xiaohongshu-mcp-main/.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"servers": {
"xiaohongshu-mcp": {
"url": "http://localhost:18060/mcp",
"type": "http"
}
},
"inputs": []
}

View File

@ -0,0 +1,7 @@
- 要求每次修改完后,需要帮我格式化 Go 源码文件.
- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除.
- 所有的feature变更,都需要使用分支进行开发.
- 在我未同意之前, 你不能推送到远程.
- 我需要: 1.本地 review; 2.远程 PR review.
- 不要过度设计, 保持代码的简洁和易读.
- 使用中文注释,一定要简洁明了.专业名词可以用英文.

View File

@ -0,0 +1,141 @@
# 赞赏与公益捐赠公开账本
本项目的所有赞赏,将全部用于公益捐赠。
> 本页按月公开记录:收到的赞赏(默认匿名或使用对方指定昵称)、对应捐出、以及捐赠凭证截图(已脱敏)。
> 如需更正/撤回署名,请开 Issue 或通过邮箱联系。
## 摘要
- 累计收到赞赏:¥ 969.95
- 累计捐赠:¥ 1300
- 最近更新时间:2026-02-05
---
## 维护说明
- **隐私**:默认匿名展示;仅在赞助者明确授权时展示昵称。请在截图中打码/涂抹交易号、手机号、邮箱、二维码关键元素等敏感信息。
- **更正机制**:如有遗漏或需要修改,请开 Issue;所有更动保留在 Git 历史中。
---
## 月度明细
### 2026-01
**本月小结**
- 收到赞赏合计:¥ 99.98
- 捐出合计:¥ 200.00
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2026-01-02 | K91431 | 49.99 | 赞赏码 |
| 2026-01-05 | Yancy | 49.99 | 赞赏码 |
<table>
<tr>
<td><img height="400" alt="donation-2026-01" src="https://github.com/user-attachments/assets/ab6eae5e-9e8a-496d-94aa-d3f0c8a1cfc1" /></td>
</tr>
</table>
### 2025-12
**本月小结**
- 收到赞赏合计:¥ 440.09
- 捐出合计:¥ 500.00
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2025-12-03 | 源 | 39.90 | 微信红包 |
| 2025-12-07 | 来自于微信群的小爷 | 29.99 | 赞赏码 |
| 2025-12-08 | 来自于微信群的小爷 | 29.99 | 赞赏码 |
| 2025-12-08 | 无名大侠 | 9.99 | 赞赏码 |
| 2025-12-10 | 许掌柜 | 100.00 | 微信红包 |
| 2025-12-11 | matheasyer | 10.00 | 支付宝 |
| 2025-12-18 | 来自于微信群的小爷 | 29.99 | 微信红包 |
| 2025-12-19 | 查狸尼克 | 20.26 | 赞赏码 |
| 2025-12-19 | 无名大侠 | 49.99 | 赞赏码 |
| 2025-12-21 | 陈懂 | 50.00 | 赞赏码,for 泡芙小姐 |
| 2025-12-22 | G仔 | 49.99 | 赞赏码 |
| 2025-12-25 | 未来可期 | 19.99 | 赞赏码 |
<table>
<tr>
<td><img height="400" alt="PixPin_2026-01-01_20-45-48" src="https://github.com/user-attachments/assets/b0d9721e-c250-4df3-a993-fda5c4d62e3f" /></td>
<td><img height="400" alt="PixPin_2026-01-01_20-46-03" src="https://github.com/user-attachments/assets/bc2c8076-1ad4-41b6-b8f7-d2cdc1b5fa87" /></td>
</tr>
</table>
### 2025-11
**本月小结**
- 收到赞赏合计:¥ 249.96
- 捐出合计:¥ 400.00。守望相助,驰援香江:为香港大浦火灾同胞筹集善款!
<table>
<tr>
<td><img height="400" alt="PixPin_2025-10-26_21-34-08" src="https://github.com/user-attachments/assets/2dc52a01-d14a-4eec-961f-406f1ef91889" /></td>
</tr>
</table>
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2025-11-05 | 勇敢的心 | 9.99 | 赞赏码 |
| 2025-11-10 | Sijin Yang | 99.99 | 赞赏码 |
| 2025-11-17 | cym | 29.99 | 赞赏码 |
| 2025-11-26 | 一虎君 | 10.00 | 赞赏码 |
| 2025-11-26 | Sijin Yang | 99.99 | 赞赏码 |
### 2025-10
**本月小结**
- 收到赞赏合计:¥ 109.93
- 捐出合计:¥ 200.00。 9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。
<table>
<tr>
<td><img height="400" alt="PixPin_2025-10-26_21-34-08" src="https://github.com/user-attachments/assets/8329275c-a328-410e-8744-9bc267661c31" /></td>
</tr>
</table>
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2025-10-11 | Sijin Yang | 29.99 | 赞赏码 |
| 2025-10-13 | Sijin Yang | 29.99 | 赞赏码 |
| 2025-10-16 | RESOLUTION | 9.99 | 赞赏码 |
| 2025-10-17 | Sijin Yang | 9.99 | 赞赏码 |
| 2025-10-19 | 无名大侠 | 9.99 | 赞赏码 |
| 2025-10-22 | Sijin Yang | 9.99 | 赞赏码 |
| 2025-10-22 | 无名大侠 | 9.99 | 赞赏码 |
### 2025-09
**本月小结**
- 收到赞赏合计:¥ 69.99
- 捐出合计:9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。
**收到的赞赏**
| 日期 | 昵称 | 金额 | 备注 |
|------------|-----:|-----:|------|
| 2025-09-23 | 米爸 | 50.00 | 微信红包 |
| 2025-09-27 | 麦子 | 19.99 | 赞赏码 |
---
## 变更记录
- 2025-10-26:初始化赞赏记录。汇总 2025 年 9 月、10 月份的赞赏,捐赠给「春蕾计划她们想上学」。

View File

@ -0,0 +1,86 @@
# ---- build stage ----
FROM golang:1.24 AS builder
WORKDIR /src
# 配置 Go 模块代理为国内源
ENV GOPROXY=https://goproxy.cn,direct
ENV GOSUMDB=sum.golang.google.cn
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app .
# ---- run stage ----
FROM ubuntu:22.04
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 1. 先安装必要工具,然后配置阿里云镜像源
RUN apt-get update && apt-get install -y ca-certificates wget gnupg && \
sed -i 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list && \
sed -i 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list
# 2. 添加 Google Chrome APT 源并安装 Chrome(更稳定的无头浏览器)
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list
# 3. 安装 Google Chrome + 依赖(无头模式运行 rod)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
lsb-release \
wget \
xdg-utils \
google-chrome-stable \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/app .
# 4. 创建共享目录并设置权限
RUN mkdir -p /app/images && \
chmod 777 /app/images
# 5. 设置默认 Chrome 路径(rod 会用)
ENV ROD_BROWSER_BIN=/usr/bin/google-chrome
EXPOSE 18060
CMD ["./app"]

Some files were not shown because too many files have changed in this diff Show More