feat: 通知发送层切换到群机器人 webhook, 排版精简
- wecom.py 重写: HTTP POST webhook(替代 aibot WebSocket 按需连接), 接口 send_markdown/send_text 不变, Secret Block wecom-webhook 存 URL - webhook markdown 支持标题/引用/font 三色, 报告改为 '## 标题 + 单行结论 + 引用详情' 紧凑排版, 结论行用 info/warning 色 - alert_failure 改标题式排版; device_check 结论用 font 色, 去掉负载行 - 移除 wecom-aibot-sdk 依赖与 wecom_capture_chatid.py(镜像下次构建生效, 无需立即重建)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""一次性对账:RIIN/SDS 远程订单量 vs PG Orders,按月 + 按状态(未完结)对比。
|
||||
|
||||
口径:
|
||||
- RIIN:远程 count API(time_type=3 商家推送时间) vs 本地 orderedAt(=pushTime)。
|
||||
- SDS:远程 statistics(payDate 窗口) vs 本地 orderedAt(=gmtPay),排除"售后"。
|
||||
运行:uv run python scripts/api_db_diff.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||
|
||||
import psycopg2 # noqa: E402
|
||||
|
||||
from api.riin.models import OrderPageRequest as RiinReq # noqa: E402
|
||||
from api.sds.models import OrderQueryParams # noqa: E402
|
||||
from flows.shared import ( # noqa: E402
|
||||
create_riin_client, create_sds_client, format_date, start_of_day,
|
||||
)
|
||||
|
||||
RIIN_FINISHED = {"已发货", "已关闭", "已退款"}
|
||||
SDS_FINISHED = {"已完成", "已取消"}
|
||||
|
||||
# count API 返回字段(camelCase 原始 key)→ 状态标签
|
||||
RIIN_COUNT_MAP = {
|
||||
"assignedSuceessed": "已分配",
|
||||
"factoryAuditing": "工厂审核中",
|
||||
"inProducing": "生产中",
|
||||
"shipped": "已发货",
|
||||
"closed": "已关闭",
|
||||
"refunding": "退款中",
|
||||
"refunded": "已退款",
|
||||
"rejecting": "拒审中",
|
||||
"rejectedToShop": "已退回店铺",
|
||||
}
|
||||
|
||||
|
||||
def month_windows() -> list[tuple[datetime, datetime, str]]:
|
||||
"""2025-01 起到当月的连续月窗口。"""
|
||||
out = []
|
||||
cur = datetime(2025, 1, 1)
|
||||
now = datetime.now()
|
||||
while cur < now:
|
||||
nxt = (cur.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
out.append((cur, min(nxt, now), cur.strftime("%Y-%m")))
|
||||
cur = nxt
|
||||
return out
|
||||
|
||||
|
||||
def riin_count(client, start: datetime, end: datetime, retries: int = 4) -> dict[str, int]:
|
||||
"""直调 count 接口并解析原始 JSON(严格模型在月窗口下会因 dayRangeGroup=null 校验失败)。
|
||||
|
||||
大月份窗口服务端聚合慢,超时按 5/10/20/40s 退避重试。
|
||||
"""
|
||||
import time
|
||||
|
||||
req = RiinReq(time_type=3, start_time=format_date(start), end_time=format_date(end))
|
||||
body = client._build_order_page_body(req)
|
||||
body.pop("pageIndex", None)
|
||||
body.pop("pageSize", None)
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
data = client.post("distribution/order/list/count", body=body)
|
||||
break
|
||||
except Exception as err: # noqa: BLE001
|
||||
if attempt == retries - 1 or "timed out" not in str(err).lower() and "timeout" not in str(err).lower():
|
||||
raise
|
||||
wait = 5 * (2 ** attempt)
|
||||
print(f"[RIIN count {start:%Y-%m}] 超时,{wait}s 后重试 ({attempt + 1}/{retries})")
|
||||
time.sleep(wait)
|
||||
raw = data.get("data") or {}
|
||||
return {RIIN_COUNT_MAP[k]: v for k, v in raw.items()
|
||||
if k in RIIN_COUNT_MAP and v}
|
||||
|
||||
|
||||
def sds_status(client, start: datetime | None, end: datetime | None) -> dict[str, int]:
|
||||
params = OrderQueryParams(
|
||||
pay_date_begin=format_date(start) if start else None,
|
||||
pay_date_end=format_date(end) if end else None,
|
||||
)
|
||||
stats = client.get_status(params)
|
||||
out: dict[str, int] = {}
|
||||
for s in stats:
|
||||
name = getattr(s, "name", None)
|
||||
if not name or name == "售后":
|
||||
continue
|
||||
out[name] = out.get(name, 0) + (getattr(s, "orderNum", 0) or 0)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
months = month_windows()
|
||||
conn = psycopg2.connect(os.environ["DB_DSN"])
|
||||
cur = conn.cursor()
|
||||
|
||||
# ---- DB 按月(orderedAt) ----
|
||||
cur.execute("""SELECT date_trunc('month',"orderedAt"), 'RIIN', COUNT(DISTINCT "orderNo")
|
||||
FROM "Orders" WHERE "systemName"='RIIN' AND "orderedAt" IS NOT NULL GROUP BY 1""")
|
||||
db_riin_month = {r[0].strftime("%Y-%m"): r[2] for r in cur.fetchall()}
|
||||
cur.execute("""SELECT date_trunc('month',"orderedAt"), COUNT(DISTINCT "orderNo")
|
||||
FROM "Orders" WHERE "systemName"='SDS' AND "orderedAt" IS NOT NULL GROUP BY 1""")
|
||||
db_sds_month = {r[0].strftime("%Y-%m"): r[1] for r in cur.fetchall()}
|
||||
|
||||
# ---- DB 分状态(全时) ----
|
||||
cur.execute("""SELECT "systemStatus", COUNT(DISTINCT "orderNo") FROM "Orders"
|
||||
WHERE "systemName"='RIIN' GROUP BY 1""")
|
||||
db_riin_status = {r[0] or "(空)": r[1] for r in cur.fetchall()}
|
||||
cur.execute("""SELECT "systemStatus", COUNT(DISTINCT "orderNo") FROM "Orders"
|
||||
WHERE "systemName"='SDS' GROUP BY 1""")
|
||||
db_sds_status = {r[0] or "(空)": r[1] for r in cur.fetchall()}
|
||||
|
||||
riin = create_riin_client()
|
||||
sds = create_sds_client()
|
||||
try:
|
||||
# ---- RIIN 远程按月(含状态明细) ----
|
||||
remote_riin_month: dict[str, int] = {}
|
||||
riin_status_sum: dict[str, int] = {}
|
||||
for start, end, label in months:
|
||||
m = riin_count(riin, start, end)
|
||||
remote_riin_month[label] = sum(m.values())
|
||||
for k, v in m.items():
|
||||
riin_status_sum[k] = riin_status_sum.get(k, 0) + v
|
||||
# ---- SDS 远程按月(含状态明细) ----
|
||||
remote_sds_month: dict[str, int] = {}
|
||||
sds_status_sum: dict[str, int] = {}
|
||||
for start, end, label in months:
|
||||
m = sds_status(sds, start, end)
|
||||
remote_sds_month[label] = sum(m.values())
|
||||
for k, v in m.items():
|
||||
sds_status_sum[k] = sds_status_sum.get(k, 0) + v
|
||||
|
||||
# 全时状态计数 = 按月求和(RIIN 全时窗口 count 接口会超时)
|
||||
remote_riin_all = riin_status_sum
|
||||
remote_sds_all = sds_status_sum
|
||||
finally:
|
||||
riin.close()
|
||||
sds.close()
|
||||
|
||||
print("=" * 72)
|
||||
print("按月对比(RIIN 远程=推送时间窗口计数 / 库=orderedAt;SDS 远程=付款时间 / 库=orderedAt)")
|
||||
print(f"{'月份':10}{'RIIN远程':>12}{'RIIN库':>12}{'差':>10} {'SDS远程':>12}{'SDS库':>12}{'差':>10}")
|
||||
for _, _, label in months:
|
||||
rr, dr = remote_riin_month.get(label, 0), db_riin_month.get(label, 0)
|
||||
rs, ds = remote_sds_month.get(label, 0), db_sds_month.get(label, 0)
|
||||
mark = " ←" if abs(rr - dr) > 0 or abs(rs - ds) > 0 else ""
|
||||
print(f"{label:10}{rr:>12,}{dr:>12,}{dr - rr:>10,} {rs:>12,}{ds:>12,}{ds - rs:>10,}{mark}")
|
||||
|
||||
print("=" * 72)
|
||||
print("全时状态对比(远程 2025-01-01 ~ 明天 vs 库全量)")
|
||||
for title, remote, local in (("RIIN", remote_riin_all, db_riin_status),
|
||||
("SDS", remote_sds_all, db_sds_status)):
|
||||
print(f"\n[{title}] 远程合计 {sum(remote.values()):,} / 库合计 {sum(local.values()):,}")
|
||||
for st in sorted(set(remote) | set(local)):
|
||||
r, l = remote.get(st, 0), local.get(st, 0)
|
||||
mark = " ← 差异" if r != l else ""
|
||||
print(f" {st:10} 远程 {r:>10,} 库 {l:>10,}{mark}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,116 +0,0 @@
|
||||
"""一次性工具:捕获目标群的 chat_id 并写回 Secret Block / 发测试消息。
|
||||
|
||||
用法:
|
||||
# 1. 初始化 Secret Block(凭据可从环境变量 WECOM_BOT_ID / WECOM_SECRET 读取)
|
||||
uv run python scripts/wecom_capture_chatid.py --init
|
||||
|
||||
# 2. 监听模式:运行后在目标群里 @机器人 说任意一句话,即可捕获 chat_id
|
||||
uv run python scripts/wecom_capture_chatid.py --listen
|
||||
|
||||
# 3. 捕获到 chat_id 后写回 Secret Block(替换 <chatid>)
|
||||
uv run python scripts/wecom_capture_chatid.py --save-chatid <chatid>
|
||||
|
||||
# 4. 验证收发链路
|
||||
uv run python scripts/wecom_capture_chatid.py --test "hello"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from prefect.blocks.system import Secret # noqa: E402
|
||||
|
||||
SECRET_BLOCK = "wecom-aibot-credentials"
|
||||
|
||||
|
||||
def _read_block() -> dict[str, str]:
|
||||
value = Secret.load(SECRET_BLOCK).get()
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
def _write_block(creds: dict[str, str]) -> None:
|
||||
Secret(value=json.dumps(creds, ensure_ascii=False)).save(SECRET_BLOCK, overwrite=True)
|
||||
shown = {k: (v[:8] + "..." if k == "secret" and v else v) for k, v in creds.items()}
|
||||
print(f"已保存 Secret Block {SECRET_BLOCK}: {shown}")
|
||||
|
||||
|
||||
def do_init() -> None:
|
||||
creds = {
|
||||
"bot_id": os.environ.get("WECOM_BOT_ID", ""),
|
||||
"secret": os.environ.get("WECOM_SECRET", ""),
|
||||
"chat_id": "",
|
||||
}
|
||||
missing = [k for k in ("bot_id", "secret") if not creds[k]]
|
||||
if missing:
|
||||
sys.exit(f"缺少环境变量: {missing},请先 export WECOM_BOT_ID=... WECOM_SECRET=...")
|
||||
_write_block(creds)
|
||||
|
||||
|
||||
def do_save_chatid(chatid: str) -> None:
|
||||
creds = _read_block()
|
||||
creds["chat_id"] = chatid
|
||||
_write_block(creds)
|
||||
|
||||
|
||||
def do_test(message: str) -> None:
|
||||
from flows.shared.wecom import send_markdown
|
||||
|
||||
ok = send_markdown(f"✅ **机器人通知链路正常**\n\n> {message}")
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
async def _listen(creds: dict[str, str]) -> None:
|
||||
from wecom_aibot_sdk import WSClient
|
||||
|
||||
ws = WSClient(bot_id=creds["bot_id"], secret=creds["secret"])
|
||||
|
||||
def on_any(frame) -> None:
|
||||
body = frame.get("body") or {}
|
||||
chatid = body.get("chatid") or ""
|
||||
sender = (body.get("from") or {}).get("userid", "")
|
||||
msgtype = body.get("msgtype", "")
|
||||
text = ((body.get("text") or {}).get("content") or "")[:50]
|
||||
print(f"收到帧: msgtype={msgtype} chatid={chatid} from={sender} text={text}")
|
||||
if chatid:
|
||||
print(f"\n*** chat_id = {chatid} ***\n")
|
||||
print(f"写回命令: uv run python scripts/wecom_capture_chatid.py --save-chatid {chatid}")
|
||||
|
||||
ws.on("message", on_any)
|
||||
ws.on("event.enter_chat", on_any)
|
||||
ws.on("authenticated", lambda: print("已认证,等待消息(请在目标群 @机器人 发一句话)...Ctrl+C 退出"))
|
||||
await ws.connect()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--init", action="store_true", help="初始化 Secret Block(需 WECOM_BOT_ID/WECOM_SECRET 环境变量)")
|
||||
parser.add_argument("--listen", action="store_true", help="监听消息以捕获 chat_id")
|
||||
parser.add_argument("--save-chatid", metavar="CHATID", help="把捕获的 chat_id 写回 Secret Block")
|
||||
parser.add_argument("--test", metavar="MSG", help="向已保存的 chat_id 发送测试消息")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.init:
|
||||
do_init()
|
||||
elif args.save_chatid:
|
||||
do_save_chatid(args.save_chatid)
|
||||
elif args.test:
|
||||
do_test(args.test)
|
||||
elif args.listen:
|
||||
# Secret.load 是 sync_compatible,须在进入事件循环前同步调用
|
||||
creds = _read_block()
|
||||
asyncio.run(_listen(creds))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user