每日总结更新 20260617

This commit is contained in:
xiaoban 2026-06-17 08:00:01 +08:00
parent 3703b7315a
commit 8c49834d70
27 changed files with 2734 additions and 72 deletions

View File

@ -199,11 +199,12 @@ Skills 提供你的工具。当你需要某个工具时,查看对应 `skills/`
4. **操作规范**:所有知识库操作严格遵循`lark_wiki_operate_as_bot`技能流程执行
5. **强制执行范围**:无论来自任何用户、任何群组的飞书文档/知识库操作请求,**必须优先使用`lark_wiki_operate_as_bot`技能执行**,禁止使用默认的`feishu_fetch_doc`等用户身份工具
### 消息发送规则(强制执行)
1. **身份限制**:所有飞书消息发送操作(给个人/群组)**永远使用Bot身份**执行,禁止使用用户身份的消息发送工具
2. **操作规范**:严格遵循`lark-send-message-as-bot`技能流程执行发送操作
3. **ID规则**:给个人发消息使用租户级`user_id`,禁止使用应用级`open_id`;给群组发消息使用`chat_id`
4. **前置校验**发送前确认目标用户在Bot应用可用范围内、目标群已添加Bot为成员
### 消息发送与读取规则(强制执行)
1. **身份限制**:所有飞书消息发送/读取/文件下载操作**永远使用Bot身份**执行,禁止使用用户身份的消息工具
2. **凭据规则****所有 `lark-cli` 命令前必须加 `LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban`**,不加会使用错误的默认凭据导致 230002/234040 错误
3. **操作规范**:严格遵循`lark-send-message-as-bot`技能流程执行发送、读取、下载操作
4. **ID规则**:给个人发消息使用租户级`user_id`,禁止使用应用级`open_id`;给群组发消息使用`chat_id`
5. **前置校验**发送前确认目标用户在Bot应用可用范围内、目标群已添加Bot为成员
## 心跳

3
bin/lark-cli Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
exec env LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
/root/.nvm/versions/node/v24.14.0/bin/lark-cli "$@"

View File

@ -612,3 +612,11 @@ To https://git.valavala.com/ai_member_only/ai_member_xiaoban
1187d1a..3f25e3f master -> master
[2026-06-15 08:10:01] 工作区备份成功:自动备份 2026-06-15 08:10:01
[2026-06-16 08:10:01] 开始备份工作区...
[master 3703b73] 自动备份 2026-06-16 08:10:01
2 files changed, 1 insertion(+), 3 deletions(-)
delete mode 100644 tmp_daily_summary.md
remote: . Processing 1 references
remote: Processed 1 references in total
To https://git.valavala.com/ai_member_only/ai_member_xiaoban
3f25e3f..3703b73 master -> master
[2026-06-16 08:11:15] 工作区备份成功:自动备份 2026-06-16 08:10:01

View File

@ -82,3 +82,10 @@
{"type":"memory.recall.recorded","timestamp":"2026-06-12T07:16:32.880Z","query":"S1 U16 用户数据 查询","resultCount":5,"results":[{"path":"memory/2026-05-28.md","startLine":504,"endLine":536,"score":1},{"path":"memory/2026-05-28.md","startLine":337,"endLine":366,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1},{"path":"memory/2026-05-24.md","startLine":46,"endLine":71,"score":1},{"path":"memory/2026-05-24.md","startLine":66,"endLine":92,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-12T07:16:32.881Z","query":"用户学习数据导出 脚本","resultCount":4,"results":[{"path":"memory/2026-05-24.md","startLine":46,"endLine":71,"score":1},{"path":"memory/2026-05-24.md","startLine":66,"endLine":92,"score":1},{"path":"memory/2026-05-28.md","startLine":596,"endLine":624,"score":1},{"path":"memory/2026-05-28.md","startLine":572,"endLine":602,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-15T04:54:12.059Z","query":"达人解码局 ou_361f1540608c192aa0c8e0d4f916698e","resultCount":4,"results":[{"path":"memory/2026-06-02.md","startLine":1,"endLine":32,"score":1},{"path":"memory/2026-06-02.md","startLine":30,"endLine":47,"score":1},{"path":"memory/2026-05-28.md","startLine":551,"endLine":580,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T06:19:06.189Z","query":"渠道归属分类 Z列 销售转化场景","resultCount":4,"results":[{"path":"memory/2026-05-24.md","startLine":122,"endLine":132,"score":1},{"path":"memory/2026-05-28.md","startLine":551,"endLine":580,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1},{"path":"memory/2026-05-28.md","startLine":572,"endLine":602,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T06:22:11.809Z","query":"销售转化渠道分类规则 渠道归属 端内 销转 达人 直购","resultCount":4,"results":[{"path":"memory/2026-05-28.md","startLine":572,"endLine":602,"score":1},{"path":"memory/2026-05-28.md","startLine":551,"endLine":580,"score":1},{"path":"memory/2026-05-24.md","startLine":122,"endLine":132,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T06:22:32.406Z","query":"渠道归属 classify_channel 端内 销转 达人 直购 分类规则 王虹茗","resultCount":5,"results":[{"path":"memory/2026-05-24.md","startLine":122,"endLine":132,"score":1},{"path":"memory/2026-05-28.md","startLine":551,"endLine":580,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1},{"path":"memory/2026-05-24.md","startLine":66,"endLine":92,"score":1},{"path":"memory/2026-06-02.md","startLine":1,"endLine":32,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T09:36:31.306Z","query":"sales_leads_full_refresh 订单汇总 merge 细水入海 三表","resultCount":6,"results":[{"path":"memory/2026-06-16.md","startLine":1,"endLine":19,"score":1},{"path":"memory/2026-05-28.md","startLine":31,"endLine":60,"score":1},{"path":"memory/2026-05-28.md","startLine":389,"endLine":418,"score":1},{"path":"memory/2026-05-28.md","startLine":199,"endLine":227,"score":1},{"path":"memory/2026-05-28.md","startLine":551,"endLine":580,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T11:17:42.687Z","query":"王璐辰 user data export S1 U16","resultCount":4,"results":[{"path":"memory/2026-05-28.md","startLine":504,"endLine":536,"score":1},{"path":"memory/2026-05-28.md","startLine":173,"endLine":205,"score":1},{"path":"memory/2026-05-28.md","startLine":31,"endLine":60,"score":1},{"path":"memory/2026-05-28.md","startLine":389,"endLine":418,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T11:18:05.577Z","query":"S1 U16 课程 unit 用户数据","resultCount":6,"results":[{"path":"memory/2026-05-28.md","startLine":504,"endLine":536,"score":1},{"path":"memory/2026-05-28.md","startLine":337,"endLine":366,"score":1},{"path":"memory/2026-05-28.md","startLine":530,"endLine":557,"score":1},{"path":"memory/2026-05-24.md","startLine":122,"endLine":132,"score":1},{"path":"memory/2026-05-24.md","startLine":66,"endLine":92,"score":1},{"path":"memory/2026-05-24.md","startLine":46,"endLine":71,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-06-16T11:21:05.649Z","query":"童瑶 user data S1 U16 学习数据","resultCount":4,"results":[{"path":"memory/2026-05-28.md","startLine":504,"endLine":536,"score":1},{"path":"memory/2026-05-28.md","startLine":173,"endLine":205,"score":1},{"path":"memory/2026-05-28.md","startLine":31,"endLine":60,"score":1},{"path":"memory/2026-05-28.md","startLine":389,"endLine":418,"score":1}]}

View File

@ -1,6 +1,6 @@
{
"version": 1,
"updatedAt": "2026-06-15T04:54:12.059Z",
"updatedAt": "2026-06-16T11:21:05.649Z",
"entries": {
"memory:memory/2026-05-24.md:1:30": {
"key": "memory:memory/2026-05-24.md:1:30",
@ -174,13 +174,13 @@
"endLine": 71,
"source": "memory",
"snippet": "### 增强: 报告开头加入角色基本信息 (2026-05-24) [刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: - 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 **数据源(新增)** - MySQL Online `vala_user` 库 - `vala_app_character` 表id, account_id, nickname, gender(0=女/1=男), birthday(varchar \"YYYY-MM-DD\") - `vala_app_account` 表id, tel(已脱敏如 186****1625) - 手机号已脱敏直接取后4位年龄从 birthday 计算 **修改文件** - `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()` 和 `fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息 - 已验证 2895 正常运行输出 - 已同步 SkillHub + Git ### Unit 显示修复: 季度名称 → 全局单元编号 (2026-05-24) [刘庆逊提出] HTML 报告中 Unit 列显示错误——显示的是季度名称(如\"小镇时光\"\"钢铁之心\"而非单元数字0-48。 **根因分析** - `vala_game_chapter`MySQL无 `unit_",
"recallCount": 23,
"recallCount": 24,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 23,
"totalScore": 24,
"maxScore": 1,
"firstRecalledAt": "2026-05-25T05:47:41.388Z",
"lastRecalledAt": "2026-06-12T07:16:32.881Z",
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
"queryHashes": [
"9aff8ec9594a",
"566b5958861e",
@ -204,7 +204,8 @@
"b2c23ece6608",
"206acd60d69d",
"363241a84e3c",
"6d1afbed352e"
"6d1afbed352e",
"a87866c0fa75"
],
"recallDays": [
"2026-05-25",
@ -215,7 +216,8 @@
"2026-06-04",
"2026-06-09",
"2026-06-10",
"2026-06-12"
"2026-06-12",
"2026-06-16"
],
"conceptTags": [
"studytime-analysis",
@ -399,13 +401,13 @@
"endLine": 92,
"source": "memory",
"snippet": "**根因分析** - `vala_game_chapter`MySQL无 `unit_index` 字段 - `big_map_chapter`PostgreSQL有 `unit_index` 字段,但仅包含 A1 数据,且与 `vala_game_chapter` 无直接关联键 - 两者 ID 空间不重叠big_map: ~1720-2070game_chapter: ~55-399UUID 也不匹配 **映射方案** - 每个 season_package 内,`lesson_type=1` 的章节按 `id` 排序,每 5 个连续章节组成一个单元 - Season 0序章/L1-U0所有章节属于 Unit 0 - Season 1-4每个 season 有 12 个单元60 个 lesson 章节) - 全局 unit_index = base_offset(season_of_quarter) + unit_within_season - base_offset: 0→0, 1→1, 2→13, 3→25, 4→37 **关键 Bug**:初版按 `season_of_quarter` 分组时 A1 和 A2 混在一起,因为相同季度值合并了。修复:改为按 `(level, season_of_quarter)` 分组。 **验证结果** - A1: Unit 0-4849 个单元),与 big_map_chapter 的 unit_index 范围一致 - A2: Unit 0-4950 个单元,比 A1 多 1 个) *",
"recallCount": 10,
"recallCount": 12,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 10,
"totalScore": 12,
"maxScore": 1,
"firstRecalledAt": "2026-05-28T09:07:57.953Z",
"lastRecalledAt": "2026-06-12T07:16:32.881Z",
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
"queryHashes": [
"c6c7ff4ed75d",
"c59410788b42",
@ -416,7 +418,9 @@
"6e3a2daa0a9f",
"5d71b876843a",
"363241a84e3c",
"6d1afbed352e"
"6d1afbed352e",
"02e468d377f4",
"a87866c0fa75"
],
"recallDays": [
"2026-05-28",
@ -424,7 +428,8 @@
"2026-05-30",
"2026-06-04",
"2026-06-09",
"2026-06-12"
"2026-06-12",
"2026-06-16"
],
"conceptTags": [
"vala-game-chapter",
@ -642,13 +647,13 @@
"endLine": 536,
"source": "memory",
"snippet": "| 2b | 客户主表 → 订单 | ✅ | 公式就绪 | | 3 | 日报 C1HVN2 | ✅ | 四段刷新 | | 3a/3c | 口径审计 | ✅ | 推群全过 | | 4a | 结算月汇总 | ✅ | 公式就绪 | **结论:当前表内所有数据与重拉完全一致,零差异,无需刷写。** ### 18:36 小溪行课触发实跑 - 已推 1920 条到小溪查询表(新 Sheet - 已 @小溪 到小红书数据需求群,请处理 ~5 条待查询记录 - 表格:`RFIJsXT8FhGHhctY4RwczcOfnac` - 等待小溪回填后跑 `pull_xiaoxi_results.py` 回收结果 ### 18:50 PG 实时行课数据全量覆盖 [陈逸鸫确认] 不用等小溪回填那 5 条,直接用 PG `user_course_detail` 表实时数据覆盖: - 448 人有新进展(相比小溪历史快照) - pull → 3PRySY 口径对齐 12/12 ✅ - lesson_cache → C1HVN2 16 格 ✅ - 变化有限PG=正式课,小溪=体验课,口径不同) - sync_base (4b) → 多维表格 8 张 ✅ 21.2s ### 18:54 漏斗看板发布待解决 - funnel HTML 已构建scripts/build_funnel_dashboard.py ✅) - 妙搭发布 `apps +html-publish` 需要 `--as user`bot 模式不支持 - 系统 lark-cli `/usr/local/lib/node_mod",
"recallCount": 11,
"recallCount": 14,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 11,
"totalScore": 14,
"maxScore": 1,
"firstRecalledAt": "2026-05-28T20:51:03.908Z",
"lastRecalledAt": "2026-06-12T07:16:32.880Z",
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
"queryHashes": [
"f22544a8757c",
"2af907cea93d",
@ -660,7 +665,10 @@
"c5fe8ced03de",
"a70555b22269",
"78873c102522",
"363241a84e3c"
"363241a84e3c",
"b65a5ab98ae7",
"a87866c0fa75",
"5bcdd0ab32df"
],
"recallDays": [
"2026-05-29",
@ -668,7 +676,8 @@
"2026-06-05",
"2026-06-08",
"2026-06-10",
"2026-06-12"
"2026-06-12",
"2026-06-16"
],
"conceptTags": [
"3a/3c",
@ -688,13 +697,13 @@
"endLine": 366,
"source": "memory",
"snippet": "- 448 人有新进展(相比小溪历史快照) - pull → 3PRySY 口径对齐 12/12 ✅ - lesson_cache → C1HVN2 16 格 ✅ - 变化有限PG=正式课,小溪=体验课,口径不同) - sync_base (4b) → 多维表格 8 张 ✅ 21.2s ### 18:54 漏斗看板发布待解决 - funnel HTML 已构建scripts/build_funnel_dashboard.py ✅) - 妙搭发布 `apps +html-publish` 需要 `--as user`bot 模式不支持 - 系统 lark-cli `/usr/local/lib/node_modules/@anthropic/lark-cli` 可能支持 - 待确认:服务器是否已 `lark-cli auth login --as user` ### 19:00 陈逸鸫派 Image2 生图任务 **任务:** L1-S1-U1《秘密基地》5 课投放用小地图底图 - 模型gpt-image-2 · 3:4 · 2K · 不要文字 - 风格:太阳朋克 + L1 场景 - 5 张 PNGU1-L1U1-L5 - FUNCLOUD_API_KEY 在小研 workspace `.env` 中可用(`fc_eea138933b02b4797ce0779ffb637d8b8a6368db7b435dfdab7b4be1cd254d98` - Brief 文档 `/docx/KsVadUTmooO7yYxHaGmc1R0Bn5b` + 投放手册 `/do",
"recallCount": 12,
"recallCount": 13,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 12,
"totalScore": 13,
"maxScore": 1,
"firstRecalledAt": "2026-05-28T20:51:03.908Z",
"lastRecalledAt": "2026-06-12T07:16:32.880Z",
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
"queryHashes": [
"f22544a8757c",
"2af907cea93d",
@ -707,7 +716,8 @@
"c5fe8ced03de",
"a70555b22269",
"a90ba76a41cf",
"363241a84e3c"
"363241a84e3c",
"a87866c0fa75"
],
"recallDays": [
"2026-05-29",
@ -716,7 +726,8 @@
"2026-06-03",
"2026-06-08",
"2026-06-09",
"2026-06-12"
"2026-06-12",
"2026-06-16"
],
"conceptTags": [
"gpt",
@ -847,19 +858,14 @@
"endLine": 557,
"source": "memory",
"snippet": "- 妙搭发布 `apps +html-publish` 需要 `--as user`bot 模式不支持 - 系统 lark-cli `/usr/local/lib/node_modules/@anthropic/lark-cli` 可能支持 - 待确认:服务器是否已 `lark-cli auth login --as user` ### 19:00 陈逸鸫派 Image2 生图任务 **任务:** L1-S1-U1《秘密基地》5 课投放用小地图底图 - 模型gpt-image-2 · 3:4 · 2K · 不要文字 - 风格:太阳朋克 + L1 场景 - 5 张 PNGU1-L1U1-L5 - FUNCLOUD_API_KEY 在小研 workspace `.env` 中可用(`fc_eea138933b02b4797ce0779ffb637d8b8a6368db7b435dfdab7b4be1cd254d98` - Brief 文档 `/docx/KsVadUTmooO7yYxHaGmc1R0Bn5b` + 投放手册 `/docx/QhYQdz7PvoN7Eaxmhu0c0Q5UnHe` — 均为个人文档AGENTS.md 规则禁止读取 - 素材库入口https://llm-dev.valavala.com/web_tools/material_prod --- ### 19:30 同事数据查询流程演练 [陈逸鸫测试] **场景:模拟王虹茗请求小龙 4/21-5/20 订单详情,验证三级查询流程** **小龙订单查询结果数据源3wcle8 销售订",
"recallCount": 35,
"recallCount": 40,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 35,
"totalScore": 40,
"maxScore": 1,
"firstRecalledAt": "2026-05-28T20:51:03.908Z",
"lastRecalledAt": "2026-06-15T04:54:12.059Z",
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
"queryHashes": [
"f7ae50ae228d",
"3737f6af1445",
"cf12fd62a5e5",
"833509d09ccb",
"5b675d96f1da",
"b8b71654e7aa",
"f0e8e3da16e8",
"771aaeed7600",
@ -886,7 +892,12 @@
"cfa4c0443735",
"d248105a4ef8",
"363241a84e3c",
"4c8704b4fc00"
"4c8704b4fc00",
"ff112f97114e",
"d7be4ba41be4",
"02e468d377f4",
"f6f7815a1493",
"a87866c0fa75"
],
"recallDays": [
"2026-05-29",
@ -900,7 +911,8 @@
"2026-06-10",
"2026-06-11",
"2026-06-12",
"2026-06-15"
"2026-06-15",
"2026-06-16"
],
"conceptTags": [
"gpt",
@ -1028,13 +1040,13 @@
"endLine": 132,
"source": "memory",
"snippet": "- HTML 模板使用 f-string 时CSS 中的 `{` 必须双写 `{{`,且不能在 f-string 内做字符串拼接(会打断 f-string - 年龄从 birthdayvarchar \"YYYY-MM-DD\")计算,非 DATE 类型 - 手机号在 MySQL 已脱敏(如 186****1625直接取后4位 ### 已测试角色 - **32009 (zyl)**26 条完课651 条中互动(Perfect 82.6%、Good 2.9%)26 节巩固全部满分。课程 A2Apple App Store 购买,渠道 newmedia-daren-xhs-宣儿麻麻。设备小课屏E3。学习时间 2026-04-24 ~ 2026-05-24。 ### 同步 - 已推送到 SkillHub`studycourse-analysis.xiaoban` - 已 commit 8648b7b 到 Git 远程仓库 - 已通知 Cris李若松",
"recallCount": 9,
"recallCount": 13,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 9,
"totalScore": 13,
"maxScore": 1,
"firstRecalledAt": "2026-05-28T20:57:42.055Z",
"lastRecalledAt": "2026-06-09T03:15:16.782Z",
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
"queryHashes": [
"f139ebdae100",
"f7ae50ae228d",
@ -1044,13 +1056,18 @@
"f0e8e3da16e8",
"fbe762c60bc6",
"1382c5611b17",
"a90ba76a41cf"
"a90ba76a41cf",
"ff112f97114e",
"d7be4ba41be4",
"02e468d377f4",
"a87866c0fa75"
],
"recallDays": [
"2026-05-29",
"2026-06-02",
"2026-06-03",
"2026-06-09"
"2026-06-09",
"2026-06-16"
],
"conceptTags": [
"f-string",
@ -1184,25 +1201,27 @@
"endLine": 227,
"source": "memory",
"snippet": "| 4a | 结算月汇总 | <1s | ✅ 真写成功(零警告) | | 3a/3c/3d/4c | 审计 | — | ⏭ dry-run 跳过 | **修复的 wrapper 兼容性问题:** - API 响应格式:`data.spreadsheet` → `data.sheets.sheets`lark-cli 兼容) - 参数名:`--values` 与 `--data` 等价处理 - camelCase→snake_case`sheetId` → `sheet_id``grid_properties` 包装 - `+info` 端点v3 metadata → v2 metainfo含 sheets 列表) - `update-dimension``PUT /dimension_range`1-indexed ### 16:48 聚光凭证部署 **来源:陈逸鸫** — 从 Mac 发来 `.env.juguang.9180` 和 `.env.juguang.9181`,走方案 A复制文件不在服务器重 OAuth - 9180云智 adv=9746532refresh 验证通过 ✅ - 9181谦禾 adv=9013261,9598861 / YTL adv=7242040,9891870,10157917,10562529refresh 验证通过 ✅ - OAuth 回调地址:`https://odourless-demetra-cany.ngrok-free.dev/callback.html`ngrok 隧道) - `jugu",
"recallCount": 6,
"recallCount": 7,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 6,
"totalScore": 7,
"maxScore": 1,
"firstRecalledAt": "2026-05-29T02:53:52.924Z",
"lastRecalledAt": "2026-06-09T09:31:49.829Z",
"lastRecalledAt": "2026-06-16T09:36:31.306Z",
"queryHashes": [
"d9f1601110da",
"117aaedb584d",
"c0b581bfb144",
"d24884bfecf1",
"50b1cd5a5d08",
"c3aab736911e"
"c3aab736911e",
"f6f7815a1493"
],
"recallDays": [
"2026-05-29",
"2026-06-04",
"2026-06-09"
"2026-06-09",
"2026-06-16"
],
"conceptTags": [
"3a/3c/3d/4c",
@ -1222,13 +1241,13 @@
"endLine": 580,
"source": "memory",
"snippet": "- 18 单 | GMV ¥44,375 | 退款 ¥0 - 渠道:销转 11 / 达人 4 / 端内 3 - 客单价¥599×2 / ¥1,999×9 / ¥3,598×7 - 订单日期分布在 11 天04/2305/15 **看板发布全流程梳理:** - 服务器 build HTML + Base sync → DM 陈逸鸫 → Mac 妙搭 `html-publish` + `access-scope-set` - 三个看板 App ID漏斗 `app_4k886pmc9x6yt` · 指挥舱 `app_4k79smc6fa1kf` · 销售 `app_4k7qkz9wrga74` - 当前 cron 不自动发布看板,仅 build HTML **王虹茗身份确认:** wanghongming@makee.comuser_id 未获取 - 尝试 `lark-cli contact +search-user wanghongming@makee.com` → 失败bot API 缺少 `search:user` scope - 替代方案:让她发消息给大麦(系统自动获取 user_id或陈逸鸫截图资料页 **同事查询三级场景定稿:** 1. 常规只读 → 直接查append 输出表 2. 权限外用户 → 先通知业务负责人,再决定是否返回 3. 写操作 → 回复「这会影响生产数据,已转 @陈逸鸫 确认」 ### 20:20 行课转化分析 [陈逸鸫需求] **需求:行课记录新增当日进线→当天行课 + 7天首课率 + 销售排名 + 日报展示** **",
"recallCount": 25,
"recallCount": 29,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 25,
"totalScore": 29,
"maxScore": 1,
"firstRecalledAt": "2026-05-29T06:11:40.432Z",
"lastRecalledAt": "2026-06-15T04:54:12.059Z",
"lastRecalledAt": "2026-06-16T09:36:31.306Z",
"queryHashes": [
"82be33d1f911",
"2aa08c6652fb",
@ -1254,7 +1273,11 @@
"206acd60d69d",
"cfa4c0443735",
"d248105a4ef8",
"4c8704b4fc00"
"4c8704b4fc00",
"ff112f97114e",
"d7be4ba41be4",
"02e468d377f4",
"f6f7815a1493"
],
"recallDays": [
"2026-05-29",
@ -1266,7 +1289,8 @@
"2026-06-09",
"2026-06-10",
"2026-06-11",
"2026-06-15"
"2026-06-15",
"2026-06-16"
],
"conceptTags": [
"04/23",
@ -1325,20 +1349,23 @@
"endLine": 602,
"source": "memory",
"snippet": "**需求:行课记录新增当日进线→当天行课 + 7天首课率 + 销售排名 + 日报展示** **数据摸底结果:** | 指标 | 5月数据 | 定义 | |------|---------|------| | 当日进线→当天行课 | 3.0% (4/135) | 进线当天有 chapter settlement | | 7天线索→首课 | 28.6% (10/35) | 过去7天进线中 小溪阶段≥5 | **按销售拆解:** | 销售 | 当日行课率 | 7天首课率 | |------|-----------|----------| | Bob | 7.4% (最高) | 28.6% | | Tom | 3.7% | 28.6% | | 吴迪 | 0% | 28.6% | | 小龙 | 0% | 14.3% (最低) | **改动方案6处待陈逸鸫确认后执行** 1. **2aNzzy 每日线索分配** — 加 2 列(首课日期 V、当日行课 W 2. **3PRySY 行课情况** — 加 4 列当日行课数、当日行课率、7天线索数、7天首课率 3. **C1HVN2 日报** — 新增「五、行课转化」段 4. **Base 多维表格** — 3 张表加字段tbl9HgnKU2qgj4gE / tblB27fjK7mDnxjY / tblxjCeGOPNoLHl6 5. **funnel-daily 看板 HTML** — 新增「当日转化」+「7天首课」指标 6. **build_pipeline 脚本** — 新增 `scripts/compute_l",
"recallCount": 2,
"recallCount": 4,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 2,
"totalScore": 4,
"maxScore": 1,
"firstRecalledAt": "2026-05-29T06:12:52.521Z",
"lastRecalledAt": "2026-06-12T07:16:32.881Z",
"lastRecalledAt": "2026-06-16T06:22:11.809Z",
"queryHashes": [
"2aa08c6652fb",
"6d1afbed352e"
"6d1afbed352e",
"ff112f97114e",
"d7be4ba41be4"
],
"recallDays": [
"2026-05-29",
"2026-06-12"
"2026-06-12",
"2026-06-16"
],
"conceptTags": [
"3.0",
@ -1805,13 +1832,13 @@
"endLine": 32,
"source": "memory",
"snippet": "# 2026-06-02 工作日志 ## 达播达人筛选 [苏雅] 苏雅在群里发多位达人让大麦分析是否适合带瓦拉英语。共分析 17 位达人。 ### 瓦拉英语客单价 - 1999 元 / 3598 元 [苏雅确认] ### 筛选标准 - 产出权重优先场均GMV、品牌场均 - 品类匹配(教育品类占比) - 人群匹配学龄段家长6-12岁最佳 - 客单价匹配瓦拉1999/3598 - 无MCN优先 --- ## 最终分级汇总17人 ### 🥇 S级 — 优先建联推进2人 | 达人 | 平台 | 粉丝 | 场均GMV | 品牌场均 | 教育品类 | 客单价 | 核心优势 | |------|------|------|------|------|------|------|------| | lilymum闹妈 | 小红书 | 34.3万 | 100-200万 | 2.5-5万 | 2.4% | 500+ | 带货断层领先选品意向含教育无MCN纯佣 | | 小朱妈妈Nancy | 小红书 | — | 10-25万 | 1-2.5万 | 66.87% | 500+ | 教育垂类+均价1515+选品极克制(11商家25商品),南大硕士 | ### 🥈 A级 — 同步触达测试4人 | 达人 | 平台 | 粉丝 | 场均GMV | 品牌场均 | 教育品类 | 客单价 | 核心优势 | |------|------|------|------|------|------|------|------| | CallMe王阿姨 | 小红书 | 24",
"recallCount": 12,
"recallCount": 13,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 12,
"totalScore": 13,
"maxScore": 1,
"firstRecalledAt": "2026-06-03T06:24:53.892Z",
"lastRecalledAt": "2026-06-15T04:54:12.059Z",
"lastRecalledAt": "2026-06-16T06:22:32.406Z",
"queryHashes": [
"faadf692331b",
"c2163a583b15",
@ -1824,7 +1851,8 @@
"ffe9b822f4a2",
"235546f84d27",
"d248105a4ef8",
"4c8704b4fc00"
"4c8704b4fc00",
"02e468d377f4"
],
"recallDays": [
"2026-06-03",
@ -1832,7 +1860,8 @@
"2026-06-05",
"2026-06-09",
"2026-06-11",
"2026-06-15"
"2026-06-15",
"2026-06-16"
],
"conceptTags": [
"6-12岁最佳",
@ -2055,19 +2084,23 @@
"endLine": 60,
"source": "memory",
"snippet": "| 2b | 客户主表→订单明细 | 5.1s | ✅ dry-run真跑数据量大预计几分钟 | | 3 | 日报 C1HVN2 | 38.3s | ✅ 真写成功 | | 4a | 结算月汇总 | <1s | ✅ 真写成功(零警告) | | 3a/3c/3d/4c | 审计 | — | ⏭ dry-run 跳过 | **修复的 wrapper 兼容性问题:** - API 响应格式:`data.spreadsheet` → `data.sheets.sheets`lark-cli 兼容) - 参数名:`--values` 与 `--data` 等价处理 - camelCase→snake_case`sheetId` → `sheet_id``grid_properties` 包装 - `+info` 端点v3 metadata → v2 metainfo含 sheets 列表) - `update-dimension``PUT /dimension_range`1-indexed ### 16:48 聚光凭证部署 **来源:陈逸鸫** — 从 Mac 发来 `.env.juguang.9180` 和 `.env.juguang.9181`,走方案 A复制文件不在服务器重 OAuth - 9180云智 adv=9746532refresh 验证通过 ✅ - 9181谦禾 adv=9013261,9598861 / YTL adv=7242040,9891870,10157917,10562529refresh 验证通过 ✅ - OAu",
"recallCount": 2,
"recallCount": 5,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 2,
"totalScore": 5,
"maxScore": 1,
"firstRecalledAt": "2026-06-09T09:31:36.077Z",
"lastRecalledAt": "2026-06-09T09:31:49.829Z",
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
"queryHashes": [
"50b1cd5a5d08",
"c3aab736911e"
"c3aab736911e",
"f6f7815a1493",
"b65a5ab98ae7",
"5bcdd0ab32df"
],
"recallDays": [
"2026-06-09"
"2026-06-09",
"2026-06-16"
],
"conceptTags": [
"5.1s",
@ -2087,19 +2120,23 @@
"endLine": 418,
"source": "memory",
"snippet": "| 2b | 客户主表→订单明细 | 5.1s | ✅ dry-run真跑数据量大预计几分钟 | | 3 | 日报 C1HVN2 | 38.3s | ✅ 真写成功 | | 4a | 结算月汇总 | <1s | ✅ 真写成功(零警告) | | 3a/3c/3d/4c | 审计 | — | ⏭ dry-run 跳过 | **修复的 wrapper 兼容性问题:** - API 响应格式:`data.spreadsheet` → `data.sheets.sheets`lark-cli 兼容) - 参数名:`--values` 与 `--data` 等价处理 - camelCase→snake_case`sheetId` → `sheet_id``grid_properties` 包装 - `+info` 端点v3 metadata → v2 metainfo含 sheets 列表) - `update-dimension``PUT /dimension_range`1-indexed ### 16:48 聚光凭证部署 **来源:陈逸鸫** — 从 Mac 发来 `.env.juguang.9180` 和 `.env.juguang.9181`,走方案 A复制文件不在服务器重 OAuth - 9180云智 adv=9746532refresh 验证通过 ✅ - 9181谦禾 adv=9013261,9598861 / YTL adv=7242040,9891870,10157917,10562529refresh 验证通过 ✅ - OAu",
"recallCount": 2,
"recallCount": 5,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 2,
"totalScore": 5,
"maxScore": 1,
"firstRecalledAt": "2026-06-09T09:31:36.077Z",
"lastRecalledAt": "2026-06-09T09:31:49.829Z",
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
"queryHashes": [
"50b1cd5a5d08",
"c3aab736911e"
"c3aab736911e",
"f6f7815a1493",
"b65a5ab98ae7",
"5bcdd0ab32df"
],
"recallDays": [
"2026-06-09"
"2026-06-09",
"2026-06-16"
],
"conceptTags": [
"5.1s",
@ -2142,6 +2179,69 @@
"分析",
"增加"
]
},
"memory:memory/2026-06-16.md:1:19": {
"key": "memory:memory/2026-06-16.md:1:19",
"path": "memory/2026-06-16.md",
"startLine": 1,
"endLine": 19,
"source": "memory",
"snippet": "# 2026-06-16 工作日志 ## 陈逸鸫 - 创建「细水入海」数据刷新 skill **来源:** 陈逸鸫(`ou_f981d4811369c954b3597908ca93a01c` **需求:** 将小溪的 full_refresh 数据刷新流程固化为大麦的 skill命名为「细水入海」与 Cursor 协作 skill 同名)。 **完成内容:** - 创建 `skills/full-data-refresh/SKILL.md` - 梳理了四个脚本的完整逻辑: - `bot_sales_step2_refresh.py` — S2 刷新(销售三表 D/H/I/J + K-U + X/Y/Z - `sales_leads_full_refresh.py` — 全量刷新S2 + 订单汇总) - `refresh_order_summary.py` — 仅订单汇总镜像刷新 - `full_refresh_sales.py` — 旧版全量刷新(含过程数据) - 文档包含:表格配置、列结构、数据规则、三种执行模式、常见问题 **触发词:** 细水入海、全量刷新、跑全量、full refresh、刷新销售数据",
"recallCount": 1,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 1,
"maxScore": 1,
"firstRecalledAt": "2026-06-16T09:36:31.306Z",
"lastRecalledAt": "2026-06-16T09:36:31.306Z",
"queryHashes": [
"f6f7815a1493"
],
"recallDays": [
"2026-06-16"
],
"conceptTags": [
"full-refresh",
"bot-sales-step2-refresh.py",
"d/h/i/j",
"k-u",
"x/y/z",
"sales-leads-full-refresh.py",
"refresh-order-summary.py",
"full-refresh-sales.py"
]
},
"memory:memory/2026-05-28.md:173:205": {
"key": "memory:memory/2026-05-28.md:173:205",
"path": "memory/2026-05-28.md",
"startLine": 173,
"endLine": 205,
"source": "memory",
"snippet": "- 核心职能:增长数据分析、营销效果评估、转化漏斗分析、商业化洞察 - Emoji📚 → 🌾 - 已更新文件IDENTITY.md、AGENTS.md@规则同步变更、MEMORY.md - SOUL.md 无需改动(行为方法论为通用框架) ### 16:20 pipeline 非聚光部分验证完成 **lark-cli wrapper v0.3 支持的 action** | 类别 | action | 状态 | |------|--------|------| | sheets | +read, +write, +append, +info, +meta | ✅ | | sheets | +create-sheet, +delete-sheet | ✅ | | sheets | +batch-set-style (stub) | ✅ | | sheets | +merge-cells, +unmerge-cells | ✅ | | sheets | +update-dimension | ✅ | | bitable | +app, +tables, +records, +create, +update | ✅ | | auth | status | ✅ | | im | +messages-send (stub) | ✅ | **pipeline 试跑结果(--dry-run** | 步骤 | 说明 | 耗时 | 状态 | |------|------|------|------| | 1a/1a2 | 微伴 xlsx | — | ⏭ 跳过(无 xlsx",
"recallCount": 2,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 2,
"maxScore": 1,
"firstRecalledAt": "2026-06-16T11:17:42.687Z",
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
"queryHashes": [
"b65a5ab98ae7",
"5bcdd0ab32df"
],
"recallDays": [
"2026-06-16"
],
"conceptTags": [
"identity.md",
"agents.md",
"memory.md",
"soul.md",
"lark-cli",
"v0.3",
"create-sheet",
"delete-sheet"
]
}
}
}

48
memory/2026-06-16.md Normal file
View File

@ -0,0 +1,48 @@
# 2026-06-16 工作日志
## 陈逸鸫 - 细水入海 full_refresh v2 定稿
**来源:** 陈逸鸫(`ou_f981d4811369c954b3597908ca93a01c`
**核心变更6/16 定稿):**
### 1. 订单汇总列结构变更
- 从 A-X(24列) → A-W(23列):去掉原 W「有效成单」列订单号从 X 左移到 W
- V=渠道归属W=订单号
### 2. 线索只绑有效单
- `pick_valid_order()`: GSV>0 · 非全额退 · K≥C取最新一笔
- Y=1 时 K/L/X/N/O/P/Z 全写有效主单真实值
- 已退单不写旧 X/L
- 无有效单 → Y=0K/L/X 留空
### 3. 汇总 gate 全量覆盖(非 DB 扩行)
- 唯一真源 = 三表 Y=1 gate 的 unique X
- Step 4 + Step 5 同一 run共用 `pick_valid_order()` + `db_info`
- 汇总 W = 三表 Xgate 同源,不是 merge 再查 DB
- clear → gate 全量覆盖,不保留旧 W
- 同 X 多进线 → 只保留行号最小的 1 行
### 4. 分工定稿
- **Cursor**: 微伴/旧表同步 · 三键去重 · V/W 公式 · 验单 · 撞库消解
- **大麦**: full_refresh · 手机/UID/行课回填 · 订单汇总 merge · 完成后群回「full_refresh 完成」
- **小溪**: 不再参与 Bot 刷新
### 5. 验收标准
- gate X = 汇总 W当前 406=406
- 绑单审计 E1E9 全部 0
- 孤儿 X = 0
### 6. 脚本修改清单
- `bot_sales_step2_refresh.py`: DB 层改为逐单存储 + `pick_valid_order()` + Y≠1 不写 X
- `sales_leads_full_refresh.py`: 同上 + 汇总改为 gate 全量重建
- `refresh_order_summary.py`: A-W(23列) + 渠道分类改用 L 列
- 新增 `audit_lead_primary_order_bind.py`: 线索绑单审计脚本
### 7. 环境修复
- `secrets.env` 需要软链接: `ln -sf /root/.openclaw/workspace/secrets.env /root/.openclaw/workspace-xiaoban/secrets.env`
### 8. Skill 文档已更新
- `skills/full-data-refresh/SKILL.md` → v2 定稿,含 6 条核心架构规则
- 协作契约: `xhs-ark-dashboard/docs/bot-full-refresh-v2.md`
- 大麦侧主文档: `xhs-ark-dashboard/docs/damai-full-refresh-skill.md`

View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
线索绑单审计 交叉验证线索只绑有效单规则
Cursor 同口径输出 fingerprint + E1E9 计数
"""
import json, os, re, hashlib, requests
from datetime import datetime
from collections import defaultdict
CRED_DIR = "/root/.openclaw/credentials/xiaoxi"
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
SALES_SHEETS = [
("qJF4I", "小龙"),
("f975f0", "吴迪"),
("qJF4J", "成都"),
]
def s(val, default=""):
if val is None: return default
return str(val).strip()
def get_token():
with open(os.path.join(CRED_DIR, "config.json")) as f:
cfg = json.load(f)
resp = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": cfg["apps"][0]["appId"], "app_secret": cfg["apps"][0]["appSecret"]},
timeout=15
)
return resp.json()["tenant_access_token"]
def get_sheet_data(token, sheet_id, range_str):
resp = requests.get(
f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}?majorDimension=ROWS&range={sheet_id}!{range_str}",
headers={"Authorization": f"Bearer {token}"}, timeout=30
)
if resp.json().get("code") == 0:
return resp.json()["data"]["valueRange"]["values"]
return []
def parse_date(d):
"""Parse 'M月D日 HH:MM:SS' or 'M月D日' → datetime"""
if not d: return None
m = re.match(r'(\d+)月(\d+)日', d)
if not m: return None
month, day = int(m.group(1)), int(m.group(2))
# Extract time if present
hour, minute, second = 0, 0, 0
time_match = re.search(r'(\d+):(\d+):(\d+)', d)
if time_match:
hour, minute, second = int(time_match.group(1)), int(time_match.group(2)), int(time_match.group(3))
return datetime(2026, month, day, hour, minute, second)
def main():
token = get_token()
all_rows = []
for sheet_id, name in SALES_SHEETS:
data = get_sheet_data(token, sheet_id, f"A1:Z3000")
if not data: continue
rows = data[2:] if len(data) > 2 else []
for i, row in enumerate(rows):
row_idx = i + 3
all_rows.append({
"sheet": name,
"row": row_idx,
"A": s(row[0]) if len(row) > 0 else "", # 销售
"B": s(row[1]) if len(row) > 1 else "", # 昵称
"C": s(row[2]) if len(row) > 2 else "", # 进线日期
"D": s(row[3]) if len(row) > 3 else "", # 体验节数
"E": s(row[4]) if len(row) > 4 else "", # 手机号
"H": s(row[7]) if len(row) > 7 else "", # UID
"K": s(row[10]) if len(row) > 10 else "", # 下单日期
"L": s(row[11]) if len(row) > 11 else "", # 成交渠道
"N": s(row[13]) if len(row) > 13 else "", # GMV
"O": s(row[14]) if len(row) > 14 else "", # 退款
"P": s(row[15]) if len(row) > 15 else "", # GSV
"X": s(row[23]) if len(row) > 23 else "", # 订单号
"Y": s(row[24]) if len(row) > 24 else "", # 有效订单
"Z": s(row[25]) if len(row) > 25 else "", # 渠道
})
# Filter: rows with any data
active_rows = [r for r in all_rows if any(r[k] for k in ["A","B","C","E","H","K","X","Y"])]
print(f"扫描线索行: {len(active_rows)}")
# ── E1: Y=1 but X empty ──
e1 = [r for r in active_rows if r["Y"] == "1" and not r["X"]]
print(f"E1 Y=1无X: {len(e1)}")
# ── E2: Y=1 but K empty ──
e2 = [r for r in active_rows if r["Y"] == "1" and not r["K"]]
print(f"E2 Y=1无K: {len(e2)}")
# ── E3: Y=1 but L empty ──
e3 = [r for r in active_rows if r["Y"] == "1" and not r["L"]]
print(f"E3 Y=1无L: {len(e3)}")
# ── E4: Y=1 but N empty ──
e4 = [r for r in active_rows if r["Y"] == "1" and not r["N"]]
print(f"E4 Y=1无N: {len(e4)}")
# ── E5: Y=1 but K<C (下单早于进线) ──
e5 = []
for r in active_rows:
if r["Y"] != "1": continue
c_dt = parse_date(r["C"])
k_dt = parse_date(r["K"])
if c_dt and k_dt and k_dt < c_dt:
e5.append(r)
print(f"E5 Y=1但K<C: {len(e5)}")
# ── E6: Y=0 but has K/L/N (有订单数据但未标Y=1) ──
e6 = [r for r in active_rows if r["Y"] != "1" and (r["K"] or r["L"] or r["N"])]
print(f"E6 Y=0有K/L/N: {len(e6)}")
# ── E7: Y=0 but has X and K≥C (有效订单但未标Y=1) ──
e7 = []
for r in active_rows:
if r["Y"] == "1": continue
if not r["X"]: continue
c_dt = parse_date(r["C"])
k_dt = parse_date(r["K"])
if c_dt and k_dt and k_dt >= c_dt:
e7.append(r)
print(f"E7 Y=0有X且K≥C: {len(e7)}")
# ── E8: Y=0 has X but K<C ──
e8 = []
for r in active_rows:
if r["Y"] == "1": continue
if not r["X"]: continue
c_dt = parse_date(r["C"])
k_dt = parse_date(r["K"])
if c_dt and k_dt and k_dt < c_dt:
e8.append(r)
print(f"E8 Y=0有X但K<C: {len(e8)}")
# ── E9: Y=0 has X but no K (DB占X, 无下单日期) ──
e9 = [r for r in active_rows if r["Y"] != "1" and r["X"] and not r["K"]]
print(f"E9 Y=0仅DB占X: {len(e9)}")
# ── 同手机多X ──
phone_x = defaultdict(set)
for r in active_rows:
if r["E"] and r["X"] and len(r["E"]) == 11 and r["E"].isdigit():
phone_x[r["E"]].add(r["X"])
multi_x_phones = {p: xs for p, xs in phone_x.items() if len(xs) > 1}
print(f"同手机多X手机数: {len(multi_x_phones)}")
# ── 问题行合计 ──
problem_rows = set()
for lst in [e1, e2, e3, e4, e5, e6, e7, e8, e9]:
for r in lst:
problem_rows.add((r["sheet"], r["row"]))
print(f"问题行合计: {len(problem_rows)}")
# ── Fingerprint ──
# Build canonical string from problem codes + counts
fp_parts = []
for code, lst in [("E1", e1), ("E2", e2), ("E3", e3), ("E4", e4), ("E5", e5),
("E6", e6), ("E7", e7), ("E8", e8), ("E9", e9)]:
fp_parts.append(f"{code}:{len(lst)}")
fp_parts.append(f"multiX:{len(multi_x_phones)}")
fp_parts.append(f"total:{len(problem_rows)}")
fp_str = "|".join(fp_parts)
fingerprint = hashlib.md5(fp_str.encode()).hexdigest()[:16]
print(f"\n指纹: {fingerprint}")
# ── 详细输出 ──
if e5:
print(f"\nE5 明细 (Y=1但K<C):")
for r in e5[:10]:
print(f" {r['sheet']} Row {r['row']}: {r['B']} C={r['C']} K={r['K']} X={r['X']}")
if e8:
print(f"\nE8 明细 (Y=0有X但K<C):")
for r in e8[:10]:
print(f" {r['sheet']} Row {r['row']}: {r['B']} C={r['C']} K={r['K']} X={r['X']}")
if e9:
print(f"\nE9 明细 (Y=0仅DB占X):")
for r in e9[:10]:
print(f" {r['sheet']} Row {r['row']}: {r['B']} C={r['C']} X={r['X']}")
if multi_x_phones:
print(f"\n同手机多X 明细:")
for phone, xs in list(multi_x_phones.items())[:5]:
print(f" {phone}: {xs}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,657 @@
#!/usr/bin/env python3
"""
Bot 销转看板 Step2 刷新 XXTEA 精确匹配版 (v3)
E列11位明文手机号 XXTEA加密 bi_vala_app_account.tel_encrypt精确匹配 H列UID
S2 规则:
EH: phone_encrypt.py XXTEA 精确匹配, 查不到留空
HD/I/J: 只补空, 不覆盖已有值
Y=1: 仅当 K(下单日) >= C(线索日期)
全额退清: 所有订单都退费 N/O/P 全部清空
N/O/P 0留空, O整元
G列不动, 订单汇总不动
覆盖列: D/H/I/J + K-U + X/Y/Z
"""
import json, re, time, sys, os, requests, psycopg2
from datetime import datetime
from collections import defaultdict
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
WORKSPACE = os.path.dirname(SCRIPTS_DIR)
CRED_DIR = "/root/.openclaw/credentials/xiaoxi"
# XXTEA 加密
sys.path.insert(0, SCRIPTS_DIR)
from phone_encrypt import encrypt_phone
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
SALES_SHEETS = [
("qJF4I", "小龙", "A1:Z1200"),
("f975f0", "吴迪", "A1:Z700"),
("qJF4J", "成都", "A1:Z2500"),
]
CS_MAP = {"吴迪": "吴迪", "小龙": "小龙", "Tom": "Tom", "Bob": "Bob"}
GOODS_NAMES = {
57: "瓦拉英语level1·单季", 60: "瓦拉英语level1", 63: "瓦拉英语level1·单季",
31: "瓦拉英语年包", 32: "瓦拉英语单季度包", 33: "瓦拉英语level2", 54: "瓦拉英语季度包",
61: "瓦拉英语level1+2",
}
CHANNEL_MAP = {
"Apple App Store": "苹果", "科大讯飞学习机": "讯飞", "学而思学习机": "学而思",
"华为应用市场": "华为", "小米应用市场": "小米", "应用宝应用市场": "应用宝",
"希沃学习机": "希沃", "荣耀应用市场": "荣耀", "小度学习机": "小度",
"oppo应用市场": "OPPO", "vivo应用市场": "VIVO", "京东方学习机": "京东方",
"步步高学习机": "步步高", "作业帮学习机": "作业帮", "魅族应用市场": "魅族",
"官网": "官网",
}
# Z列渠道归属分类规则 [王虹茗确认 2026-06-15]
def classify_channel(key_from):
"""将 key_from 归类为: 端内 / 销转 / 达人 / 直购"""
if not key_from:
return "直购"
kf = key_from.strip()
if kf in ("app-active-h5-0-0", "app-sales-bj-qhm-0", "app-sales-bj-wd-0"):
return "端内"
if kf.startswith("sales-adp-"):
return "销转"
if kf.startswith("newmedia-daren-") or kf == "newmedia-dianpu-wwxx-0-0":
return "达人"
# 其余: dianpu(不含wwxx) + partner/stream/miniprogram/jingxuan/空/shuadan等 → 直购
return "直购"
def parse_clue_date(date_str):
"""解析进线日期: '6月14日 19:09:12''6月14日' → datetime.date"""
if not date_str:
return None
date_str = date_str.strip()
# 格式: "6月14日 19:09:12" 或 "6月14日"
m = re.match(r'(\d+)月(\d+)日', date_str)
if not m:
return None
month, day = int(m.group(1)), int(m.group(2))
return datetime(2026, month, day).date()
LOG_FILE = "/var/log/xiaoxi_step2_refresh.log"
def log(msg):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
def get_secret(key):
with open(os.path.join(WORKSPACE, "secrets.env")) as f:
for line in f:
if line.startswith(f"{key}="):
return line.strip().split("=", 1)[1].strip("'\"")
def get_fs_token():
with open(os.path.join(CRED_DIR, "config.json")) as f:
cfg = json.load(f)
resp = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": cfg["apps"][0]["appId"], "app_secret": cfg["apps"][0]["appSecret"]},
timeout=15
)
return resp.json()["tenant_access_token"]
def read_sheet(token, sheet_id, range_str=None):
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}"
if range_str:
url += f"!{range_str}"
resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(f"读取失败 {sheet_id}: {data}")
return data["data"]["valueRange"]["values"]
def put_values(token, sheet_id, range_str, values):
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values"
body = {"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}
resp = requests.put(url, headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}, json=body, timeout=30)
r = resp.json()
if r.get("code") != 0:
log(f"{range_str}: {r.get('code')} {r.get('msg')}")
return False
return True
def batch_in(cur, sql_tpl, params, chunk=500):
results = []
for i in range(0, len(params), chunk):
batch = params[i:i+chunk]
ph = ",".join(["%s"] * len(batch))
cur.execute(sql_tpl % ph, batch)
results.extend(cur.fetchall())
return results
# ── Step 1: 解析销售三表 ──
def parse_sales_sheets(token):
"""返回 {sheet_id: [(row_idx, sales_name, nickname, date_str, phone, existing_uid, g_val, existing_d, existing_i, existing_j), ...]}"""
all_data = {}
for sid, sname, rng in SALES_SHEETS:
rows = read_sheet(token, sid, rng)
entries = []
for idx, row in enumerate(rows[2:], start=3):
if not row:
continue
sr = str(row[0]).strip() if len(row) > 0 and row[0] else ""
sales = None
for k, v in CS_MAP.items():
if k in sr:
sales = v
break
if not sales:
continue
nickname = str(row[1]).strip() if len(row) > 1 and row[1] else ""
date_str = str(row[2]).strip() if len(row) > 2 and row[2] else ""
phone = ""
if len(row) > 4 and row[4]:
try:
phone = str(int(float(row[4])))
except:
pass
uid = ""
if len(row) > 7 and row[7]:
try:
uid = str(int(float(row[7])))
except:
pass
g_val = str(row[6]).strip() if len(row) > 6 and row[6] else ""
# 读取已有 D/I/J 值 (用于只补空判断)
d_val = str(row[3]).strip() if len(row) > 3 and row[3] else ""
i_val = str(row[8]).strip() if len(row) > 8 and row[8] else ""
j_val = str(row[9]).strip() if len(row) > 9 and row[9] else ""
entries.append((idx, sales, nickname, date_str, phone, uid, g_val, d_val, i_val, j_val))
all_data[sid] = entries
log(f" {sname}: {len(entries)} rows, {sum(1 for e in entries if e[5] and e[5].isdigit() and int(e[5])>0)} with uid")
return all_data
# ── Step 2: XXTEA 加密 → PG tel_encrypt 精确匹配 ──
def phone_to_uid_xxtea(all_entries):
"""E列11位明文手机号 → XXTEA加密 → bi_vala_app_account.tel_encrypt精确匹配 → UID"""
# 收集所有 11 位手机号
phone_rows = []
for sid, entries in all_entries.items():
for idx, sales, nick, date_str, phone, uid, g_val, d_val, i_val, j_val in entries:
if re.match(r'^\d{11}$', phone):
phone_rows.append((sid, idx, phone))
if not phone_rows:
return {}
log(f" XXTEA 加密匹配: {len(phone_rows)} 个手机号")
# 加密所有手机号
phone_enc_map = {} # {encrypted: phone}
for _, _, phone in phone_rows:
try:
enc = encrypt_phone(phone)
phone_enc_map[enc] = phone
except Exception as e:
log(f" 加密失败 {phone}: {e}")
log(f" 加密完成, 唯一密文: {len(phone_enc_map)}")
# PG 精确查询
conn = psycopg2.connect(
host="bj-postgres-16pob4sg.sql.tencentcdb.com", port=28591,
user="ai_member", password=get_secret("PG_ONLINE_PASSWORD"),
dbname="vala_bi", connect_timeout=30
)
cur = conn.cursor()
enc_list = list(phone_enc_map.keys())
phone_to_uid = {}
for i in range(0, len(enc_list), 500):
chunk = enc_list[i:i+500]
ph = ",".join(["%s"] * len(chunk))
cur.execute(
f"SELECT id, tel_encrypt FROM bi_vala_app_account WHERE tel_encrypt IN ({ph}) AND status=1 AND deleted_at IS NULL",
chunk
)
for uid, tel_enc in cur.fetchall():
plain = phone_enc_map.get(tel_enc)
if plain:
phone_to_uid[plain] = str(uid)
time.sleep(0.05)
cur.close()
conn.close()
log(f" 精确匹配到 {len(phone_to_uid)} 个 UID (via XXTEA)")
return phone_to_uid
# ── Step 3: PostgreSQL 批量查询 ──
def query_all_pg(all_entries, phone_map):
"""查询所有需要的数据"""
uid_set = set()
for sid, entries in all_entries.items():
for idx, sales, nick, date_str, phone, uid, g_val, d_val, i_val, j_val in entries:
if re.match(r'^\d{11}$', phone) and phone in phone_map:
uid_set.add(int(phone_map[phone]))
if uid and uid.isdigit() and int(uid) > 0:
uid_set.add(int(uid))
uid_list = list(uid_set)
log(f" 有效 user_id: {len(uid_list)}")
conn = psycopg2.connect(
host="bj-postgres-16pob4sg.sql.tencentcdb.com", port=28591,
user="ai_member", password=get_secret("PG_ONLINE_PASSWORD"),
dbname="vala_bi", connect_timeout=30
)
cur = conn.cursor()
info = {uid: {
"reg_date": "", "download_channel": "", "trial_count": 0,
"has_order": False, "orders": [],
"activation": "", "lesson_progress": "", "lesson_time": "", "lesson_minutes": 0,
"max_lesson": 0,
} for uid in uid_set}
# 3a. 注册信息
log(" 查询注册信息...")
reg_info = batch_in(cur,
"SELECT id, created_at, download_channel FROM bi_vala_app_account WHERE id IN (%s) AND status=1 AND deleted_at IS NULL",
uid_list
)
for aid, created_at, dc in reg_info:
if aid in info:
info[aid]["reg_date"] = created_at.strftime("%Y-%m-%d") if created_at else ""
raw_ch = dc or ""
info[aid]["download_channel"] = CHANNEL_MAP.get(raw_ch, raw_ch)
# 3b. 体验节数
log(" 查询体验节数...")
trial_info = batch_in(cur,
"SELECT account_id, COUNT(*) FROM bi_user_course_detail WHERE account_id IN (%s) AND expire_time IS NULL AND deleted_at IS NULL GROUP BY account_id",
uid_list
)
for aid, cnt in trial_info:
if aid in info:
info[aid]["trial_count"] = cnt
# 3c. 订单信息
log(" 查询订单信息...")
orders = batch_in(cur,
"SELECT account_id, trade_no, pay_success_date, key_from, goods_id, pay_amount_int, order_status FROM bi_vala_order WHERE account_id IN (%s) AND pay_success_date IS NOT NULL AND order_status IN (3,4) ORDER BY pay_success_date DESC",
uid_list
)
user_orders = defaultdict(list)
for o in orders:
user_orders[o[0]].append(o)
trade_nos = [o[1] for o in orders if o[1]]
refund_map = {}
if trade_nos:
refunds = batch_in(cur,
"SELECT trade_no, refund_amount_int FROM bi_refund_order WHERE trade_no IN (%s) AND status=3",
trade_nos
)
for tn, amt in refunds:
refund_map[tn] = amt
for aid, olist in user_orders.items():
if aid not in info:
continue
info[aid]["has_order"] = True
# 存储逐单数据(按 pay_success_date DESC供后续选取有效单
orders_data = []
for o in olist:
trade_no = o[1] or ""
pay_dt = o[2]
key_from = o[3] or ""
goods_id = o[4]
pay_amount = o[5] / 100.0
refund_amount = refund_map.get(trade_no, 0) / 100.0
gsv = pay_amount - refund_amount
orders_data.append({
"trade_no": trade_no,
"pay_dt": pay_dt,
"key_from": key_from,
"goods_id": goods_id,
"product": GOODS_NAMES.get(goods_id, f"商品{goods_id}"),
"pay_amount": pay_amount,
"refund_amount": refund_amount,
"gsv": gsv,
})
info[aid]["orders"] = orders_data
# 3d. 激活课程
log(" 查询激活课程...")
try:
activations = batch_in(cur,
"SELECT t.account_id, t.season_package_level FROM bi_vala_seasonal_ticket t WHERE t.account_id IN (%s) AND t.status=1 AND t.deleted_at IS NULL AND t.season_package_level IN ('A1','A2')",
uid_list
)
for aid, lvl in activations:
if aid in info:
info[aid]["activation"] = lvl
except Exception as e:
log(f" 激活查询异常: {e}")
# 3e. 角色信息
log(" 查询角色信息...")
char_info = batch_in(cur,
"SELECT account_id, id FROM bi_vala_app_character WHERE account_id IN (%s) AND deleted_at IS NULL",
uid_list
)
account_chars = defaultdict(list)
char_to_account = {}
for aid, cid in char_info:
account_chars[aid].append(cid)
char_to_account[cid] = aid
char_ids = list(char_to_account.keys())
log(f" 角色数: {len(char_ids)}")
# 3f. 课程映射
cur.execute("SELECT id, course_level, course_season, course_unit, course_lesson FROM bi_level_unit_lesson")
chapter_map = {}
for ch_id, cl, cs, cu, cl2 in cur.fetchall():
chapter_map[ch_id] = (cl or "", cs or "", cu or "", cl2 or "")
# 3g. 课时完成记录
log(" 查询课时完成记录...")
char_plays = defaultdict(lambda: {"latest_time": None, "latest_chapter": None, "max_lesson_idx": 0, "total_ms": 0})
for tbl_idx in range(8):
table = f"bi_user_chapter_play_record_{tbl_idx}"
try:
cur.execute(
f"SELECT user_id, chapter_id, created_at FROM {table} WHERE play_status=1 AND deleted_at IS NULL AND user_id = ANY(%s)",
(char_ids,)
)
for uid, ch_id, created_at in cur.fetchall():
ch_data = chapter_map.get(ch_id)
if not ch_data:
continue
rec = char_plays[uid]
if rec["latest_time"] is None or created_at > rec["latest_time"]:
rec["latest_time"] = created_at
rec["latest_chapter"] = (ch_id, ch_data)
cl, cs, cu, cl2 = ch_data
try:
u_num = int(cu[1:]) if cu and len(cu) >= 2 else 0
l_num = int(cl2[1:]) if cl2 and len(cl2) >= 2 else 0
lesson_idx = u_num * 5 + l_num
if lesson_idx > rec["max_lesson_idx"]:
rec["max_lesson_idx"] = lesson_idx
except:
pass
except Exception as e:
log(f" 警告 {table}: {e}")
# 3h. 学习总耗时
log(" 查询学习耗时...")
for tbl_idx in range(8):
table = f"bi_user_component_play_record_{tbl_idx}"
try:
cur.execute(
f"SELECT user_id, SUM(COALESCE(interval_time,0)) FROM {table} WHERE user_id = ANY(%s) AND deleted_at IS NULL GROUP BY user_id",
(char_ids,)
)
for uid, total_ms in cur.fetchall():
if uid in char_plays:
char_plays[uid]["total_ms"] += (total_ms or 0)
except Exception as e:
log(f" 警告 {table}: {e}")
cur.close()
conn.close()
# 汇总到 account 级别
for aid in uid_set:
chars = account_chars.get(aid, [])
best_time = None
best_ch = None
max_lesson = 0
total_ms = 0
for cid in chars:
play = char_plays.get(cid)
if not play:
continue
if play["latest_chapter"]:
if best_time is None or play["latest_time"] > best_time:
best_time = play["latest_time"]
best_ch = play["latest_chapter"]
if play["max_lesson_idx"] > max_lesson:
max_lesson = play["max_lesson_idx"]
total_ms += play["total_ms"]
info[aid]["max_lesson"] = max_lesson
info[aid]["lesson_minutes"] = round(total_ms / 60000, 1)
if info[aid]["lesson_minutes"] == int(info[aid]["lesson_minutes"]):
info[aid]["lesson_minutes"] = int(info[aid]["lesson_minutes"])
if best_ch:
ch_id, (cl, cs, cu, cl2) = best_ch
info[aid]["lesson_progress"] = f"{cl}-{cs}-{cu}-{cl2}"
info[aid]["lesson_time"] = best_time.strftime("%Y-%m-%d") if best_time else ""
log(f" 数据库查询完成")
return info
# ── Step 4: 写入销售三表 ──
def pick_valid_order(orders, clue_date):
"""
从订单列表中选取有效主单
规则: GSV>0 · 非全额退 · KC · 有时序
返回: (order_dict, is_valid) (None, False)
"""
if not orders:
return None, False
clue_dt = parse_clue_date(clue_date) if clue_date else None
valid = []
for o in orders:
gsv = o["gsv"]
pay_amount = o["pay_amount"]
refund_amount = o["refund_amount"]
is_full_refund = (pay_amount > 0 and pay_amount == refund_amount)
if gsv <= 0 or is_full_refund:
continue
pay_dt = o["pay_dt"]
if pay_dt and clue_dt and pay_dt.date() < clue_dt:
continue
valid.append(o)
if not valid:
return None, False
# 取最新一笔有效单
valid.sort(key=lambda o: o["pay_dt"] or datetime.min, reverse=True)
return valid[0], True
def write_sales_sheets(token, all_entries, phone_map, db_info):
"""全覆盖写入销售表的自动列: D/H/I/J + K-U + X/Y/Z"""
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for sid, sname, _ in SALES_SHEETS:
entries = all_entries[sid]
log(f" 写入 {sname} ({sid})...")
groups = []
cur_grp = []
for idx, sales, nick, date_str, phone, uid, g_val, d_val, i_val, j_val in entries:
item = {"row": idx, "phone": phone, "uid": uid, "g_val": g_val,
"date": date_str, "d_val": d_val, "i_val": i_val, "j_val": j_val}
if not cur_grp or idx == cur_grp[-1]["row"] + 1:
cur_grp.append(item)
else:
groups.append(cur_grp)
cur_grp = [item]
if cur_grp:
groups.append(cur_grp)
for g in groups:
sr, er = g[0]["row"], g[-1]["row"]
d_vals, h_vals, i_vals, j_vals = [], [], [], []
k_vals, l_vals, m_vals, n_vals = [], [], [], []
o_vals, p_vals, q_vals, r_vals = [], [], [], []
s_vals, t_vals, u_vals = [], [], []
x_vals, y_vals, z_vals = [], [], []
for item in g:
phone = item["phone"]
existing_uid = item["uid"]
existing_d = item.get("d_val", "")
existing_i = item.get("i_val", "")
existing_j = item.get("j_val", "")
clue_date = item.get("date", "")
# 确定 UID: XXTEA 精确匹配优先
aid = 0
uid_str = ""
if re.match(r'^\d{11}$', phone) and phone in phone_map:
uid_str = phone_map[phone]
aid = int(uid_str)
elif existing_uid and existing_uid.isdigit() and int(existing_uid) > 0:
uid_str = existing_uid
aid = int(existing_uid)
# H: UID — XXTEA匹配到就写否则留空
if re.match(r'^\d{11}$', phone) and phone in phone_map:
h_vals.append([phone_map[phone]])
elif re.match(r'^\d{11}$', phone):
h_vals.append([""])
elif existing_uid and existing_uid.isdigit():
h_vals.append([existing_uid])
else:
h_vals.append([""])
if aid > 0 and aid in db_info:
di = db_info[aid]
# D: 体验节数 — 只补空
if existing_d:
d_vals.append([existing_d])
else:
tc = di["trial_count"]
d_vals.append([tc if tc > 0 else ""])
# I: 注册日 — 只补空
if existing_i:
i_vals.append([existing_i])
else:
i_vals.append([di["reg_date"]])
# J: 下载渠道 — 只补空
if existing_j:
j_vals.append([existing_j])
else:
j_vals.append([di["download_channel"]])
# 选取有效主单
orders = di.get("orders", [])
valid_order, is_valid = pick_valid_order(orders, clue_date)
if is_valid and valid_order:
# Y=1: K/L/X/N/O/P/Z 全写该有效单真实值
pay_dt = valid_order["pay_dt"]
order_date = f"{pay_dt.month}{pay_dt.day}{pay_dt.strftime('%H:%M:%S')}" if pay_dt else ""
k_vals.append([order_date])
l_vals.append([valid_order["key_from"]])
m_vals.append([valid_order["product"]])
n_vals.append([int(valid_order["pay_amount"]) if valid_order["pay_amount"] > 0 else ""])
o_vals.append([int(valid_order["refund_amount"]) if valid_order["refund_amount"] > 0 else ""])
p_vals.append([int(valid_order["gsv"]) if valid_order["gsv"] > 0 else ""])
x_vals.append([valid_order["trade_no"]])
y_vals.append([1])
z_vals.append([classify_channel(valid_order["key_from"])])
elif di["has_order"]:
# 有订单但无有效单 → Y=0, K/L/X 留空, N/O/P 不写
k_vals.append([""])
l_vals.append([""])
m_vals.append([""])
n_vals.append([""])
o_vals.append([""])
p_vals.append([""])
x_vals.append([""])
y_vals.append([""])
z_vals.append([""])
else:
# 无订单
k_vals.append([""])
l_vals.append([""])
m_vals.append([""])
n_vals.append([""])
o_vals.append([""])
p_vals.append([""])
x_vals.append([""])
y_vals.append([""])
z_vals.append([""])
# Q: 激活课程
act = di["activation"]
if act:
q_vals.append([f"{act}体验课" if act in ("A1", "A2") else act])
else:
q_vals.append([""])
# R: 行课进度, S: 最近行课时间
lp = di["lesson_progress"]
r_vals.append([lp if lp else ""])
s_vals.append([di["lesson_time"]])
# T: 学习时长
lm = di["lesson_minutes"]
t_vals.append([lm if lm > 0 else ""])
else:
for arr in [d_vals, i_vals, j_vals, k_vals, l_vals, m_vals, n_vals,
o_vals, p_vals, q_vals, r_vals, s_vals, t_vals,
x_vals, y_vals, z_vals]:
arr.append([""])
# U: 更新时间
u_vals.append([now_str])
cols = [
("D", d_vals), ("H", h_vals), ("I", i_vals), ("J", j_vals),
("K", k_vals), ("L", l_vals), ("M", m_vals), ("N", n_vals),
("O", o_vals), ("P", p_vals), ("Q", q_vals), ("R", r_vals),
("S", s_vals), ("T", t_vals), ("U", u_vals),
("X", x_vals), ("Y", y_vals), ("Z", z_vals),
]
for col_letter, vals in cols:
put_values(token, sid, f"{col_letter}{sr}:{col_letter}{er}", vals)
time.sleep(0.1)
log(f" {sname}: {len(entries)} rows done")
# ── Main ──
def main():
log("=" * 50)
log("Bot 销转看板 Step2 刷新 (XXTEA精确匹配版) 启动")
try:
token = get_fs_token()
log("Step 1: 解析销售三表")
all_entries = parse_sales_sheets(token)
log("Step 2: XXTEA 加密 → PG tel_encrypt 精确匹配")
phone_map = phone_to_uid_xxtea(all_entries)
log("Step 3: PostgreSQL 批量查询")
db_info = query_all_pg(all_entries, phone_map)
log("Step 4: 写入销售三表")
write_sales_sheets(token, all_entries, phone_map, db_info)
log("✅ Step2 刷新完成 (XXTEA)")
return 0
except Exception as e:
log(f"❌ ERROR: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
飞书表格安全写入工具 自动遵守 5000 / API 上限
飞书 Open API 单次写入上限为 5000 ×
超过上限的请求会静默失败API 不报错但数据不完整
导致旧数据残留新数据被部分覆盖末尾行丢失等问题
本模块封装了安全的分批写入和清空逻辑所有操作自动计算
批大小确保 4400 / 12% 安全余量
用法:
from feishu_sheet_utils import FeishuSheetWriter
writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token)
writer.clear(sheet_id, start_row=3, end_row=500, cols=26)
writer.write(sheet_id, start_row=3, rows=data, cols=26)
"""
import time
import requests
# 飞书 API 单次写入格数上限
FEISHU_CELL_LIMIT = 5000
# 安全余量系数0.88,即实际使用 ≤ 4400 格/批)
SAFETY_FACTOR = 0.88
# 单批最大格数
SAFE_CELLS_PER_BATCH = int(FEISHU_CELL_LIMIT * SAFETY_FACTOR) # 4400
def max_rows_per_batch(cols):
"""根据列数计算单批最大行数(确保 ≤ 4400 格)。"""
return max(1, SAFE_CELLS_PER_BATCH // cols)
class FeishuSheetWriter:
"""飞书表格安全写入器,自动分批遵守 5000 格上限。"""
def __init__(self, spreadsheet_token, tenant_token):
self.spreadsheet_token = spreadsheet_token
self.token = tenant_token
self.base_url = "https://open.feishu.cn/open-apis/sheets/v2"
def _put(self, sheet_id, range_str, values, retries=3):
"""单次写入,含重试。"""
url = f"{self.base_url}/spreadsheets/{self.spreadsheet_token}/values"
body = {"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}
for attempt in range(retries):
resp = requests.put(url, headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}, json=body, timeout=30)
result = resp.json()
if result.get("code") == 0:
return True
print(f" Retry {attempt+1} for {range_str}: {result.get('msg','')}")
time.sleep(1)
print(f" FAILED {range_str}")
return False
def _col_letter(self, idx):
"""0-based column index → Excel column letter(s). 0→A, 25→Z, 26→AA."""
result = ""
n = idx
while n >= 0:
result = chr(ord('A') + n % 26) + result
n = n // 26 - 1
return result
def _range_str(self, start_row, end_row, cols):
"""生成范围字符串,如 A3:Z52。"""
end_col = self._col_letter(cols - 1)
return f"A{start_row}:{end_col}{end_row}"
def clear(self, sheet_id, start_row, end_row, cols):
"""
安全清空指定区域写入空字符串
自动分批每批 4400
"""
if end_row < start_row:
return
batch_rows = max_rows_per_batch(cols)
total = end_row - start_row + 1
print(f" Clearing {sheet_id} rows {start_row}-{end_row} "
f"({total} rows × {cols} cols, batch={batch_rows} rows)")
for batch_start in range(start_row, end_row + 1, batch_rows):
batch_end = min(batch_start + batch_rows - 1, end_row)
n_rows = batch_end - batch_start + 1
empty = [[""] * cols for _ in range(n_rows)]
rng = self._range_str(batch_start, batch_end, cols)
ok = self._put(sheet_id, rng, empty)
if not ok:
print(f" Clear batch {rng} failed, continuing...")
time.sleep(0.15)
def write(self, sheet_id, start_row, rows, cols):
"""
安全写入数据行
自动分批每批 4400
rows: list of list每行长度应为 cols
"""
if not rows:
return
batch_rows = max_rows_per_batch(cols)
total = len(rows)
print(f" Writing {sheet_id} {total} rows × {cols} cols "
f"(batch={batch_rows} rows, {batch_rows * cols} cells/batch)")
for batch_start in range(0, total, batch_rows):
batch = rows[batch_start:batch_start + batch_rows]
sr = start_row + batch_start
er = sr + len(batch) - 1
rng = self._range_str(sr, er, cols)
ok = self._put(sheet_id, rng, batch)
if not ok:
print(f" Write batch {rng} failed!")
time.sleep(0.3)
def clear_excess(self, sheet_id, total_written, old_count, cols):
"""清除超出新数据范围的旧行残留。"""
if old_count <= total_written:
return
clear_start = start_row_base = 3 # 假设数据从第3行开始
actual_start = clear_start + total_written
actual_end = clear_start + old_count - 1
if actual_start > actual_end:
return
print(f" Clearing excess rows {actual_start}-{actual_end}")
self.clear(sheet_id, actual_start, actual_end, cols)
def safe_clear_range(token, spreadsheet_token, sheet_id, start_row, end_row, cols):
"""便捷函数:安全清空指定区域。"""
writer = FeishuSheetWriter(spreadsheet_token, token)
writer.clear(sheet_id, start_row, end_row, cols)
def safe_write_rows(token, spreadsheet_token, sheet_id, start_row, rows, cols):
"""便捷函数:安全写入数据行。"""
writer = FeishuSheetWriter(spreadsheet_token, token)
writer.write(sheet_id, start_row, rows, cols)

66
scripts/phone_encrypt.py Normal file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
手机号加密工具 Go Encrypt 函数完全一致
Go 原始逻辑:
func Encrypt(data string) string {
key := "K1pNOZ5O5+ZqTPSHA2kzPdoNOMOGcv6g"
encryptData := xxtea.Encrypt([]byte(data), []byte(key))
n := base64.StdEncoding.EncodeToString(encryptData)
n = strings.ReplaceAll(n, "+", "-")
n = strings.ReplaceAll(n, "/", "_")
n = strings.ReplaceAll(n, "=", ".")
return n
}
匹配方式: 加密明文手机号 bi_vala_app_account.tel_encrypt 比对 获取 account_id
MD5 加密: tel_encrypt 解密为明文 MD5 用于跨系统关联
"""
import xxtea
import base64
import hashlib
KEY = "K1pNOZ5O5+ZqTPSHA2kzPdoNOMOGcv6g"
def encrypt_phone(phone: str) -> str:
"""加密明文手机号,返回与数据库 tel_encrypt 字段一致的密文"""
encrypted = xxtea.encrypt(phone.encode(), KEY.encode())
result = base64.b64encode(encrypted).decode()
result = result.replace("+", "-").replace("/", "_").replace("=", ".")
return result
def encrypt_phones(phones: list[str]) -> dict[str, str]:
"""批量加密手机号,返回 {密文: 明文手机号} 映射"""
return {encrypt_phone(p): p for p in phones}
def decrypt_phone(encrypted: str) -> str:
"""解密 tel_encrypt 还原明文手机号(仅用于验证)"""
restored = encrypted.replace("-", "+").replace("_", "/").replace(".", "=")
decrypted = xxtea.decrypt(base64.b64decode(restored), KEY.encode())
return decrypted.decode()
def phone_md5(phone: str) -> str:
"""明文手机号 → MD532位小写十六进制"""
return hashlib.md5(phone.encode()).hexdigest()
def tel_encrypt_to_md5(tel_encrypt: str) -> str:
"""tel_encrypt 密文 → 解密 → MD5一步到位"""
phone = decrypt_phone(tel_encrypt)
return phone_md5(phone)
if __name__ == "__main__":
# 自测
test_phones = ["13800138000", "15912345678", "18888888888"]
for p in test_phones:
enc = encrypt_phone(p)
dec = decrypt_phone(enc)
md5 = phone_md5(p)
status = "" if dec == p else ""
print(f"{p}{enc}{dec} → MD5:{md5} {status}")

View File

@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
订单汇总 AX 全量镜像刷新
触发Step2Cursor Step1 完成后 @小溪
归属小溪 (xiaoxi)
进表条件K= · O>0 · 非全额退(P空或P<O) · LC
全额退 整行不进订单表销售行清 K/O/P/Q
镜像 AV 原样 + W 渠道归属 + X=1
分工约定见 docs/bot-step2-schedule-and-orders.md
"""
import json, time, re, sys, requests, psycopg2
from datetime import datetime
from feishu_sheet_utils import FeishuSheetWriter
# ── 配置 ──
APP_ID = "cli_a929ae22e0b8dcc8"
APP_SECRET = "OtFjMy7p3qE3VvLbMdcWidwgHOnGD4FJ"
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
SALES_SHEETS = {"f975f0": "吴迪", "qJF4I": "小龙", "qJF4J": "成都"}
SUMMARY_SHEET = "2smjwA"
def _get_pg_password():
import os
secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "secrets.env")
with open(secrets_path) as f:
for line in f:
if line.startswith("PG_ONLINE_PASSWORD="):
return line.strip().split("=", 1)[1].strip('"').strip("'")
raise RuntimeError("PG_ONLINE_PASSWORD not found in secrets.env")
PG_CONFIG = {
"host": "bj-postgres-16pob4sg.sql.tencentcdb.com", "port": 28591,
"user": "ai_member", "password": _get_pg_password(), "database": "vala_bi",
}
GOODS_MAP = {
57: "瓦拉英语level1·单季", 60: "瓦拉英语level1", 63: "瓦拉英语level1·单季",
31: "瓦拉英语年包", 32: "瓦拉英语单季度包", 33: "瓦拉英语level2", 54: "瓦拉英语季度包",
61: "瓦拉英语level1+2",
}
# 达人昵称关键词
DAREN_NICKNAMES = ["晚柠", "学霸", "念妈", "神奇瓜妈", "三人行", "老王"]
def get_token():
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)
return r.json()["tenant_access_token"]
def read_sheet(token, sheet_id, range_str):
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}!{range_str}?valueRenderOption=ToString"
r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)
data = r.json()
if data.get("code") != 0:
print(f"Error reading {sheet_id}: {data}")
return []
return data["data"]["valueRange"]["values"]
def put_values(token, sheet_id, range_str, values, retries=3):
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values"
body = {"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}
for attempt in range(retries):
r = requests.put(url, headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}, json=body, timeout=30)
result = r.json()
if result.get("code") == 0:
return True
print(f" Retry {attempt+1} for {range_str}: {result.get('msg','')}")
time.sleep(1)
print(f" FAILED {range_str}")
return False
def parse_date(s):
"""Parse date string to (year, month, day) tuple."""
s = str(s).strip()
if not s:
return None
# YYYY-MM-DD
m = re.match(r'(\d{4})-(\d{1,2})-(\d{1,2})', s)
if m:
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
# M月D日
m = re.match(r'(\d{1,2})月(\d{1,2})日', s)
if m:
return (2026, int(m.group(1)), int(m.group(2)))
# YYYY/M/D
m = re.match(r'(\d{4})/(\d{1,2})/(\d{1,2})', s)
if m:
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
return None
def date_le(a, b):
"""Return True if date a <= date b."""
if a is None or b is None:
return False
return a <= b
# W列渠道归属分类规则 [王虹茗确认 2026-06-15]
def classify_w_channel(m_channel, sales_name=""):
"""
W 渠道归属基于 M 成交渠道 + 销售昵称
端内: 精确匹配 3 个渠道
销转: sales-adp-*
达人: newmedia-daren-* / newmedia-dianpu-wwxx-0-0 / 昵称含达人关键词
直购: 其余全部
"""
m = str(m_channel).strip() if m_channel else ""
# 达人(昵称关键词优先,保持原有逻辑)
if any(nick in str(sales_name) for nick in DAREN_NICKNAMES):
return "达人"
if not m:
return "直购"
if m in ("app-active-h5-0-0", "app-sales-bj-qhm-0", "app-sales-bj-wd-0"):
return "端内"
if m.startswith("sales-adp-"):
return "销转"
if m.startswith("newmedia-daren-") or m == "newmedia-dianpu-wwxx-0-0":
return "达人"
# 其余: dianpu(不含wwxx) + partner/stream/miniprogram/jingxuan/空/shuadan等 → 直购
return "直购"
def phone_match(sheet_phone, db_tel):
"""Match sheet phone number against DB tel (masked like 138****4503)."""
if not sheet_phone or not db_tel:
return False
sheet_phone = str(sheet_phone).strip()
db_tel = str(db_tel).strip()
if sheet_phone == db_tel:
return True
if "****" in db_tel:
parts = db_tel.split("****")
if len(parts) == 2:
prefix, suffix = parts
if sheet_phone.startswith(prefix) and sheet_phone.endswith(suffix):
return True
return False
def main():
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] 订单汇总全量刷新 启动")
token = get_token()
# ── Step 1: 读取销售三表 ──
all_rows = []
for sid, name in SALES_SHEETS.items():
print(f"Reading {name}...")
vals = read_sheet(token, sid, "A3:V10000")
filtered = []
for i, row in enumerate(vals):
while len(row) < 22:
row.append("")
b = str(row[1]).strip() if row[1] else ""
e = str(row[4]).strip() if row[4] else ""
h = str(row[7]).strip() if row[7] else ""
if b or e or h:
filtered.append({
"sid": sid, "name": name, "row": i + 3,
"raw": row[:22], # A-V
})
print(f" {len(filtered)} non-empty rows")
all_rows.extend(filtered)
print(f"Total rows: {len(all_rows)}")
# ── Step 2: 筛选进订单汇总的行 ──
# 条件K=是 · O>0 · 非全额退(P空或P<O) · L≥C
order_rows = []
for r in all_rows:
raw = r["raw"]
k = str(raw[10]).strip() if len(raw) > 10 and raw[10] else ""
if k != "":
continue
# O > 0
try:
o_val = float(raw[14]) if len(raw) > 14 and raw[14] not in (None, "") else 0
except (ValueError, TypeError):
o_val = 0
if o_val <= 0:
continue
# 非全额退: P空或P<O
try:
p_val = float(raw[15]) if len(raw) > 15 and raw[15] not in (None, "") else 0
except (ValueError, TypeError):
p_val = 0
if p_val > 0 and p_val >= o_val:
# 全额退 → 不进订单表
continue
# L ≥ C (C为空时跳过此检查如直购用户无进线日期)
c_str = str(raw[2]).strip() if len(raw) > 2 and raw[2] else ""
l_str = str(raw[11]).strip() if len(raw) > 11 and raw[11] else ""
c_date = parse_date(c_str)
l_date = parse_date(l_str)
if c_date is not None and not date_le(c_date, l_date):
continue
# 通过所有条件
order_rows.append(r)
print(f"Order rows after filter: {len(order_rows)}")
# ── Step 2.5: 去重(同一人可能在三表中出现多次)──
# 按 (A销售归属, B微信昵称, O下单金额, P退款金额, L下单日期) 去重
seen = set()
deduped = []
for r in order_rows:
raw = r["raw"]
a = str(raw[0]).strip() if raw[0] else ""
b = str(raw[1]).strip() if len(raw) > 1 and raw[1] else ""
o = str(raw[14]).strip() if len(raw) > 14 and raw[14] else ""
p = str(raw[15]).strip() if len(raw) > 15 and raw[15] else ""
l = str(raw[11]).strip() if len(raw) > 11 and raw[11] else ""
key = (a, b, o, p, l)
if key not in seen:
seen.add(key)
deduped.append(r)
dup_count = len(order_rows) - len(deduped)
if dup_count > 0:
print(f" Removed {dup_count} duplicate rows")
order_rows = deduped
# ── Step 3: 按 L 下单日降序 ──
order_rows.sort(key=lambda r: str(r["raw"][11]) if len(r["raw"]) > 11 and r["raw"][11] else "", reverse=True)
# ── Step 4: 构建 AW 行23列──
# 新契约: A-U镜像 + V=渠道归属 + W=订单号
summary_rows = []
for r in order_rows:
raw = r["raw"]
# AU 原样镜像21列
new_row = list(raw[:21])
# V: 渠道归属(基于 L 成交渠道)
l_channel = str(raw[11]).strip() if len(raw) > 11 and raw[11] else ""
sales_name = str(raw[0]).strip() if len(raw) > 0 and raw[0] else ""
v = classify_w_channel(l_channel, sales_name)
new_row.append(v)
# W: 订单号(原 X 列)
order_no = str(raw[23]).strip() if len(raw) > 23 and raw[23] else ""
new_row.append(order_no)
summary_rows.append(new_row)
print(f"Summary rows: {len(summary_rows)}")
# ── Step 5: 写入订单汇总(使用安全写入工具,自动遵守 5000 格上限)──
print("Writing to 订单汇总...")
writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token)
# 先清空旧数据区23 列,自动计算批大小 ≤ 4400 格/批)
writer.clear(SUMMARY_SHEET, start_row=3, end_row=2000, cols=23)
time.sleep(0.5)
# 写入新数据23 列 A-W自动分批
total = len(summary_rows)
writer.write(SUMMARY_SHEET, start_row=3, rows=summary_rows, cols=23)
# ── Step 6: 清除多余旧行 ──
existing = read_sheet(token, SUMMARY_SHEET, "A3:A4000")
old_count = len([r for r in existing if r and any(c for c in r if c)])
if old_count > total:
writer.clear(SUMMARY_SHEET, start_row=3 + total, end_row=3 + old_count - 1, cols=23)
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] ✅ 订单汇总刷新完成")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,843 @@
#!/usr/bin/env python3
"""
销售线索全量刷新脚本 XXTEA 精确匹配版
功能:
1. 读取小龙吴迪成都三个 sheet E 列手机号
2. XXTEA 加密 bi_vala_app_account.tel_encrypt 精确匹配 获取 account_id
3. 查询 PostgreSQL 获取用户订单/学习数据
4. 填写 D/H/I/J/K~U/X/Y/Z 自动列U 列为操作更新时间
5. 将三个 sheet Y=1有效订单的用户汇总到订单汇总sheet
规则沿用 S2 规则:
EH: XXTEA 精确匹配, 查不到留空
HD/I/J: 只补空, 不覆盖已有值
Y=1: 仅当 K(下单日) >= C(线索日期)
全额退清: 所有订单都退费 N/O/P 全部清空
N/O/P 0 留空, O 整元
G 列不动
用法:
python3 scripts/sales_leads_full_refresh.py
"""
import json, re, time, sys, os, requests, psycopg2
from datetime import datetime
from collections import defaultdict
from feishu_sheet_utils import FeishuSheetWriter
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
WORKSPACE = os.path.dirname(SCRIPTS_DIR)
CRED_DIR = "/root/.openclaw/credentials/xiaoxi"
sys.path.insert(0, SCRIPTS_DIR)
from phone_encrypt import encrypt_phone
SPREADSHEET_TOKEN = "NoZqsFi47hIOHEt9j8WcfRtbnug"
SALES_SHEETS = [
("qJF4I", "小龙", "A3:Z2607"),
("f975f0", "吴迪", "A3:Z8149"),
("qJF4J", "成都", "A3:Z2500"),
]
SUMMARY_SHEET_ID = "2smjwA"
CS_MAP = {"吴迪": "吴迪", "小龙": "小龙", "Tom": "Tom", "Bob": "Bob"}
GOODS_NAMES = {
57: "瓦拉英语level1·单季", 60: "瓦拉英语level1", 63: "瓦拉英语level1·单季",
31: "瓦拉英语年包", 32: "瓦拉英语单季度包", 33: "瓦拉英语level2", 54: "瓦拉英语季度包",
61: "瓦拉英语level1+2",
}
CHANNEL_MAP = {
"Apple App Store": "苹果", "科大讯飞学习机": "讯飞", "学而思学习机": "学而思",
"华为应用市场": "华为", "小米应用市场": "小米", "应用宝应用市场": "应用宝",
"希沃学习机": "希沃", "荣耀应用市场": "荣耀", "小度学习机": "小度",
"oppo应用市场": "OPPO", "vivo应用市场": "VIVO", "京东方学习机": "京东方",
"步步高学习机": "步步高", "作业帮学习机": "作业帮", "魅族应用市场": "魅族",
"官网": "官网",
}
# Z列渠道归属分类规则 [王虹茗确认 2026-06-15]
def classify_channel(key_from):
"""将 key_from 归类为: 端内 / 销转 / 达人 / 直购"""
if not key_from:
return "直购"
kf = key_from.strip()
if kf in ("app-active-h5-0-0", "app-sales-bj-qhm-0", "app-sales-bj-wd-0"):
return "端内"
if kf.startswith("sales-adp-"):
return "销转"
if kf.startswith("newmedia-daren-") or kf == "newmedia-dianpu-wwxx-0-0":
return "达人"
# 其余: dianpu(不含wwxx) + partner/stream/miniprogram/jingxuan/空/shuadan等 → 直购
return "直购"
LOG_FILE = "/var/log/xiaoxi_full_refresh.log"
def log(msg):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
def get_secret(key):
with open(os.path.join(WORKSPACE, "secrets.env")) as f:
for line in f:
if line.startswith(f"{key}="):
return line.strip().split("=", 1)[1].strip("'\"")
def get_fs_token():
with open(os.path.join(CRED_DIR, "config.json")) as f:
cfg = json.load(f)
resp = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": cfg["apps"][0]["appId"], "app_secret": cfg["apps"][0]["appSecret"]},
timeout=15
)
return resp.json()["tenant_access_token"]
def read_sheet(token, sheet_id, range_str=None):
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}"
if range_str:
url += f"!{range_str}"
resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)
data = resp.json()
if data.get("code") != 0:
raise RuntimeError(f"读取失败 {sheet_id}: {data}")
return data["data"]["valueRange"]["values"]
def put_values(token, sheet_id, range_str, values):
url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values"
body = {"valueRange": {"range": f"{sheet_id}!{range_str}", "values": values}}
resp = requests.put(url, headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}, json=body, timeout=30)
r = resp.json()
if r.get("code") != 0:
log(f"{range_str}: code={r.get('code')} msg={r.get('msg')}")
return False
return True
def batch_in(cur, sql_tpl, params, chunk=500):
results = []
for i in range(0, len(params), chunk):
batch = params[i:i + chunk]
ph = ",".join(["%s"] * len(batch))
cur.execute(sql_tpl % ph, batch)
results.extend(cur.fetchall())
return results
def safe_cell(row, idx):
"""安全获取单元格值,数字转整数字符串"""
if len(row) > idx and row[idx] is not None:
try:
if isinstance(row[idx], (int, float)):
if row[idx] == int(row[idx]):
return str(int(row[idx]))
return str(row[idx]).strip()
except (ValueError, TypeError):
return str(row[idx]).strip()
return ""
def parse_date_str(s):
"""'6月7日'/'6月7日 10:23:48''2026-06-07'/'2026-06-07 10:23:48', YYYY-MM-DD 原样返回"""
if not s:
return ""
s = s.strip()
if re.match(r'^\d{4}-\d{2}-\d{2}', s):
return s
# 提取日期+可选时间: '6月7日 10:23:48' 或 '6月7日'
m = re.match(r'^(\d{1,2})月(\d{1,2})日(?:\s+(\d{1,2}:\d{2}:\d{2}))?', s)
if m:
year = datetime.now().year
date_part = f"{year}-{int(m.group(1)):02d}-{int(m.group(2)):02d}"
if m.group(3):
return f"{date_part} {m.group(3)}"
return date_part
return s
# ═══ Step 1: 解析三个销售 sheet ═══
def parse_sales_sheets(token):
all_data = {}
for sid, sname, rng in SALES_SHEETS:
rows = read_sheet(token, sid, rng)
entries = []
for idx, row in enumerate(rows):
row_num = idx + 3
if not row or all(not cell for cell in row):
continue
a_val = safe_cell(row, 0)
sales = None
for k, v in CS_MAP.items():
if k in a_val:
sales = v
break
if not sales:
continue
phone = ""
if len(row) > 4 and row[4]:
try:
phone = str(int(float(str(row[4]))))
except (ValueError, TypeError):
phone = str(row[4]).strip()
entries.append({
"row": row_num,
"sales": sales,
"nickname": safe_cell(row, 1),
"clue_date": safe_cell(row, 2),
"clue_date_parsed": parse_date_str(safe_cell(row, 2)),
"phone": phone,
"grade": safe_cell(row, 5),
"history": safe_cell(row, 6),
"existing": {
"D": safe_cell(row, 3),
"H": safe_cell(row, 7),
"I": safe_cell(row, 8),
"J": safe_cell(row, 9),
"K": safe_cell(row, 10),
"L": safe_cell(row, 11),
"M": safe_cell(row, 12),
"N": safe_cell(row, 13),
"O": safe_cell(row, 14),
"P": safe_cell(row, 15),
"Q": safe_cell(row, 16),
"R": safe_cell(row, 17),
"S": safe_cell(row, 18),
"T": safe_cell(row, 19),
"U": safe_cell(row, 20),
"X": safe_cell(row, 23),
"Y": safe_cell(row, 24),
"Z": safe_cell(row, 25),
},
})
all_data[sid] = entries
phone_cnt = sum(1 for e in entries if re.match(r'^\d{11}$', e["phone"]))
uid_cnt = sum(1 for e in entries if e["existing"]["H"] and e["existing"]["H"].isdigit())
log(f" [{sname}] {len(entries)}行, 手机号{phone_cnt}, 已有UID{uid_cnt}")
return all_data
# ═══ Step 2: XXTEA 加密 → PG tel_encrypt 精确匹配 ═══
def phone_to_uid_xxtea(all_entries):
phone_set = set()
for entries in all_entries.values():
for e in entries:
if re.match(r'^\d{11}$', e["phone"]):
phone_set.add(e["phone"])
if not phone_set:
log(" 无有效手机号")
return {}
log(f" XXTEA 加密匹配: {len(phone_set)} 个唯一手机号")
phone_enc_map = {}
for phone in phone_set:
try:
phone_enc_map[encrypt_phone(phone)] = phone
except Exception as ex:
log(f" 加密失败 {phone}: {ex}")
log(f" 加密完成, 唯一密文: {len(phone_enc_map)}")
conn = psycopg2.connect(
host="bj-postgres-16pob4sg.sql.tencentcdb.com", port=28591,
user="ai_member", password=get_secret("PG_ONLINE_PASSWORD"),
dbname="vala_bi", connect_timeout=30
)
cur = conn.cursor()
enc_list = list(phone_enc_map.keys())
phone_to_uid = {}
for i in range(0, len(enc_list), 500):
chunk = enc_list[i:i + 500]
ph = ",".join(["%s"] * len(chunk))
cur.execute(
f"SELECT id, tel_encrypt FROM bi_vala_app_account "
f"WHERE tel_encrypt IN ({ph}) AND status=1 AND deleted_at IS NULL",
chunk
)
for uid, tel_enc in cur.fetchall():
plain = phone_enc_map.get(tel_enc)
if plain:
phone_to_uid[plain] = str(uid)
time.sleep(0.05)
cur.close()
conn.close()
log(f" 精确匹配到 {len(phone_to_uid)} 个 UID (via XXTEA)")
return phone_to_uid
# ═══ Step 3: PostgreSQL 批量查询 ═══
def query_all_pg(all_entries, phone_map):
uid_set = set()
for entries in all_entries.values():
for e in entries:
if re.match(r'^\d{11}$', e["phone"]) and e["phone"] in phone_map:
uid_set.add(int(phone_map[e["phone"]]))
h_val = e["existing"]["H"]
if h_val and h_val.isdigit() and int(h_val) > 0:
uid_set.add(int(h_val))
uid_list = list(uid_set)
log(f" 有效 user_id: {len(uid_list)}")
if not uid_list:
return {}
conn = psycopg2.connect(
host="bj-postgres-16pob4sg.sql.tencentcdb.com", port=28591,
user="ai_member", password=get_secret("PG_ONLINE_PASSWORD"),
dbname="vala_bi", connect_timeout=30
)
cur = conn.cursor()
info = {uid: {
"reg_date": "", "download_channel": "", "trial_count": 0,
"has_order": False, "orders": [],
"activation": "", "lesson_progress": "", "lesson_time": "", "lesson_minutes": 0,
} for uid in uid_set}
# 3a. 注册信息
log(" 查询注册信息...")
reg_info = batch_in(cur,
"SELECT id, created_at, download_channel FROM bi_vala_app_account "
"WHERE id IN (%s) AND status=1 AND deleted_at IS NULL",
uid_list
)
for aid, created_at, dc in reg_info:
if aid in info:
info[aid]["reg_date"] = created_at.strftime("%Y-%m-%d") if created_at else ""
raw_ch = dc or ""
info[aid]["download_channel"] = CHANNEL_MAP.get(raw_ch, raw_ch)
# 3b. 体验节数
log(" 查询体验节数...")
trial_info = batch_in(cur,
"SELECT account_id, COUNT(*) FROM bi_user_course_detail "
"WHERE account_id IN (%s) AND expire_time IS NULL AND deleted_at IS NULL "
"GROUP BY account_id",
uid_list
)
for aid, cnt in trial_info:
if aid in info:
info[aid]["trial_count"] = cnt
# 3c. 订单信息
log(" 查询订单信息...")
orders = batch_in(cur,
"SELECT account_id, trade_no, pay_success_date, key_from, goods_id, pay_amount_int, order_status "
"FROM bi_vala_order WHERE account_id IN (%s) AND pay_success_date IS NOT NULL "
"AND order_status IN (3,4) ORDER BY pay_success_date DESC",
uid_list
)
user_orders = defaultdict(list)
for o in orders:
user_orders[o[0]].append(o)
trade_nos = [o[1] for o in orders if o[1]]
refund_map = {}
if trade_nos:
refunds = batch_in(cur,
"SELECT trade_no, refund_amount_int FROM bi_refund_order "
"WHERE trade_no IN (%s) AND status=3",
trade_nos
)
for tn, amt in refunds:
refund_map[tn] = amt
for aid, olist in user_orders.items():
if aid not in info:
continue
info[aid]["has_order"] = True
# 存储逐单数据(按 pay_success_date DESC供后续选取有效单
orders_data = []
for o in olist:
trade_no = o[1] or ""
pay_dt = o[2]
key_from = o[3] or ""
goods_id = o[4]
pay_amount = o[5] / 100.0
refund_amount = refund_map.get(trade_no, 0) / 100.0
gsv = pay_amount - refund_amount
orders_data.append({
"trade_no": trade_no,
"pay_dt": pay_dt,
"pay_dt_raw": pay_dt.strftime("%Y-%m-%d %H:%M:%S") if pay_dt else "",
"key_from": key_from,
"goods_id": goods_id,
"product": GOODS_NAMES.get(goods_id, f"商品{goods_id}"),
"pay_amount": pay_amount,
"refund_amount": refund_amount,
"gsv": gsv,
})
info[aid]["orders"] = orders_data
# 3d. 激活课程
log(" 查询激活课程...")
try:
activations = batch_in(cur,
"SELECT account_id, season_package_level FROM bi_vala_seasonal_ticket "
"WHERE account_id IN (%s) AND status=1 AND deleted_at IS NULL "
"AND season_package_level IN ('A1','A2')",
uid_list
)
for aid, lvl in activations:
if aid in info:
info[aid]["activation"] = lvl
except Exception as ex:
log(f" 激活查询异常: {ex}")
# 3e. 角色信息
log(" 查询角色信息...")
char_info = batch_in(cur,
"SELECT account_id, id FROM bi_vala_app_character "
"WHERE account_id IN (%s) AND deleted_at IS NULL",
uid_list
)
account_chars = defaultdict(list)
char_to_account = {}
for aid, cid in char_info:
account_chars[aid].append(cid)
char_to_account[cid] = aid
char_ids = list(char_to_account.keys())
log(f" 角色数: {len(char_ids)}")
# 3f. 课程映射
cur.execute("SELECT id, course_level, course_season, course_unit, course_lesson FROM bi_level_unit_lesson")
chapter_map = {}
for ch_id, cl, cs, cu, cl2 in cur.fetchall():
chapter_map[ch_id] = (cl or "", cs or "", cu or "", cl2 or "")
# 3g. 课时完成记录
log(" 查询课时完成记录...")
char_plays = defaultdict(lambda: {"latest_time": None, "latest_chapter": None, "total_ms": 0})
for tbl_idx in range(8):
table = f"bi_user_chapter_play_record_{tbl_idx}"
try:
cur.execute(
f"SELECT user_id, chapter_id, created_at FROM {table} "
f"WHERE play_status=1 AND deleted_at IS NULL AND user_id = ANY(%s)",
(char_ids,)
)
for uid, ch_id, created_at in cur.fetchall():
ch_data = chapter_map.get(ch_id)
if not ch_data:
continue
rec = char_plays[uid]
if rec["latest_time"] is None or created_at > rec["latest_time"]:
rec["latest_time"] = created_at
rec["latest_chapter"] = (ch_id, ch_data)
except Exception as ex:
log(f" 警告 {table}: {ex}")
# 3h. 学习总耗时
log(" 查询学习耗时...")
for tbl_idx in range(8):
table = f"bi_user_component_play_record_{tbl_idx}"
try:
cur.execute(
f"SELECT user_id, SUM(COALESCE(interval_time,0)) FROM {table} "
f"WHERE user_id = ANY(%s) AND deleted_at IS NULL GROUP BY user_id",
(char_ids,)
)
for uid, total_ms in cur.fetchall():
if uid in char_plays:
char_plays[uid]["total_ms"] += (total_ms or 0)
except Exception as ex:
log(f" 警告 {table}: {ex}")
cur.close()
conn.close()
# 汇总到 account 级别
for aid in uid_set:
chars = account_chars.get(aid, [])
best_time = None
best_ch = None
total_ms = 0
for cid in chars:
play = char_plays.get(cid)
if not play:
continue
if play["latest_chapter"]:
if best_time is None or play["latest_time"] > best_time:
best_time = play["latest_time"]
best_ch = play["latest_chapter"]
total_ms += play["total_ms"]
info[aid]["lesson_minutes"] = round(total_ms / 60000, 1)
if info[aid]["lesson_minutes"] == int(info[aid]["lesson_minutes"]):
info[aid]["lesson_minutes"] = int(info[aid]["lesson_minutes"])
if best_ch:
ch_id, (cl, cs, cu, cl2) = best_ch
info[aid]["lesson_progress"] = f"{cl}-{cs}-{cu}-{cl2}"
info[aid]["lesson_time"] = best_time.strftime("%Y-%m-%d") if best_time else ""
log(f" 数据库查询完成")
return info
# ═══ Step 4: 写入销售三表 D/H/I/J/K~U/X/Y/Z 列 ═══
def pick_valid_order(orders, clue_date):
"""
从订单列表中选取有效主单
规则: GSV>0 · 非全额退 · KC
返回: (order_dict, is_valid) (None, False)
"""
if not orders:
return None, False
valid = []
for o in orders:
gsv = o["gsv"]
pay_amount = o["pay_amount"]
refund_amount = o["refund_amount"]
is_full_refund = (pay_amount > 0 and pay_amount == refund_amount)
if gsv <= 0 or is_full_refund:
continue
pay_dt = o["pay_dt"]
if pay_dt and clue_date:
if pay_dt.strftime("%Y-%m-%d %H:%M:%S") < clue_date:
continue
valid.append(o)
if not valid:
return None, False
# 取最新一笔有效单
valid.sort(key=lambda o: o["pay_dt"] or datetime.min, reverse=True)
return valid[0], True
def write_sales_sheets(token, all_entries, phone_map, db_info):
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for sid, sname, _ in SALES_SHEETS:
entries = all_entries[sid]
log(f" 写入 {sname} ({sid})...")
# 按连续行分组
groups = []
cur_grp = []
for e in entries:
if not cur_grp or e["row"] == cur_grp[-1]["row"] + 1:
cur_grp.append(e)
else:
groups.append(cur_grp)
cur_grp = [e]
if cur_grp:
groups.append(cur_grp)
for g in groups:
sr, er = g[0]["row"], g[-1]["row"]
d_vals, h_vals, i_vals, j_vals = [], [], [], []
k_vals, l_vals, m_vals, n_vals = [], [], [], []
o_vals, p_vals, q_vals, r_vals = [], [], [], []
s_vals, t_vals, u_vals = [], [], []
x_vals, y_vals, z_vals = [], [], []
for e in g:
phone = e["phone"]
existing = e["existing"]
clue_date = e["clue_date_parsed"]
# 确定 UID
aid = 0
uid_str = ""
if re.match(r'^\d{11}$', phone) and phone in phone_map:
uid_str = phone_map[phone]
aid = int(uid_str)
elif existing["H"] and existing["H"].isdigit() and int(existing["H"]) > 0:
uid_str = existing["H"]
aid = int(existing["H"])
# H: UID — XXTEA 匹配到就写,否则留空
if re.match(r'^\d{11}$', phone) and phone in phone_map:
h_vals.append([phone_map[phone]])
elif re.match(r'^\d{11}$', phone):
h_vals.append([""])
elif existing["H"] and existing["H"].isdigit():
h_vals.append([existing["H"]])
else:
h_vals.append([""])
if aid > 0 and aid in db_info:
di = db_info[aid]
# D: 体验节数 — 只补空
if existing["D"]:
d_vals.append([existing["D"]])
else:
tc = di["trial_count"]
d_vals.append([tc if tc > 0 else ""])
# I: 注册日 — 只补空
if existing["I"]:
i_vals.append([existing["I"]])
else:
i_vals.append([di["reg_date"]])
# J: 下载渠道 — 只补空
if existing["J"]:
j_vals.append([existing["J"]])
else:
j_vals.append([di["download_channel"]])
# 选取有效主单
orders = di.get("orders", [])
valid_order, is_valid = pick_valid_order(orders, clue_date)
if is_valid and valid_order:
# Y=1: K/L/X/N/O/P/Z 全写该有效单真实值
pay_dt = valid_order["pay_dt"]
order_date = f"{pay_dt.month}{pay_dt.day}{pay_dt.strftime('%H:%M:%S')}" if pay_dt else ""
k_vals.append([order_date])
l_vals.append([valid_order["key_from"]])
m_vals.append([valid_order["product"]])
n_vals.append([int(valid_order["pay_amount"]) if valid_order["pay_amount"] > 0 else ""])
o_vals.append([int(valid_order["refund_amount"]) if valid_order["refund_amount"] > 0 else ""])
p_vals.append([int(valid_order["gsv"]) if valid_order["gsv"] > 0 else ""])
x_vals.append([valid_order["trade_no"]])
y_vals.append([1])
z_vals.append([classify_channel(valid_order["key_from"])])
elif di["has_order"]:
# 有订单但无有效单 → Y=0, K/L/X 留空
k_vals.append([""])
l_vals.append([""])
m_vals.append([""])
n_vals.append([""])
o_vals.append([""])
p_vals.append([""])
x_vals.append([""])
y_vals.append([""])
z_vals.append([""])
else:
k_vals.append([""])
l_vals.append([""])
m_vals.append([""])
n_vals.append([""])
o_vals.append([""])
p_vals.append([""])
x_vals.append([""])
y_vals.append([""])
z_vals.append([""])
# Q: 激活课程
act = di["activation"]
if act:
q_vals.append([f"{act}体验课" if act in ("A1", "A2") else act])
else:
q_vals.append([""])
# R: 行课进度, S: 最近行课时间, T: 学习时长
r_vals.append([di["lesson_progress"] if di["lesson_progress"] else ""])
s_vals.append([di["lesson_time"]])
lm = di["lesson_minutes"]
t_vals.append([lm if lm > 0 else ""])
else:
for arr in [d_vals, i_vals, j_vals, k_vals, l_vals, m_vals, n_vals,
o_vals, p_vals, q_vals, r_vals, s_vals, t_vals,
x_vals, y_vals, z_vals]:
arr.append([""])
# U: 更新时间
u_vals.append([now_str])
cols = [
("D", d_vals), ("H", h_vals), ("I", i_vals), ("J", j_vals),
("K", k_vals), ("L", l_vals), ("M", m_vals), ("N", n_vals),
("O", o_vals), ("P", p_vals), ("Q", q_vals), ("R", r_vals),
("S", s_vals), ("T", t_vals), ("U", u_vals),
("X", x_vals), ("Y", y_vals), ("Z", z_vals),
]
for col_letter, vals in cols:
put_values(token, sid, f"{col_letter}{sr}:{col_letter}{er}", vals)
time.sleep(0.1)
log(f" {sname}: {len(entries)} 行写入完成")
# ═══ Step 5: 汇总到「订单汇总」sheet ═══
def clear_summary_sheet(token):
"""先清空订单汇总 sheet 的旧数据A~W列从第3行开始再写入新数据。"""
log(" 检查订单汇总 sheet 现有数据...")
try:
rows = read_sheet(token, SUMMARY_SHEET_ID, "A3:A5000")
last_data_row = 2
for i, row in enumerate(rows):
if row and any(cell for cell in row if cell):
last_data_row = 3 + i
if last_data_row < 3:
log(" 订单汇总 sheet 无旧数据,跳过清空")
return
log(f" 清空 A3:W{last_data_row}{last_data_row - 2} 行旧数据)...")
writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token)
writer.clear(SUMMARY_SHEET_ID, start_row=3, end_row=last_data_row, cols=23)
log(" 清空完成")
except Exception as e:
log(f" 清空异常: {e}")
def write_summary_sheet(token, all_entries, phone_map, db_info):
"""
订单汇总: 唯一真源 = 三表 Y=1 gate unique X
gate 行全量重建A-U 镜像 gate V=Z(或classify L)W=X
X 多进线 只保留 1 行号最小
"""
clear_summary_sheet(token)
log(" 汇总订单数据gate 全量重建)...")
# 从三表收集 Y=1 行
gate_rows = []
for sid, sname, _ in SALES_SHEETS:
entries = all_entries[sid]
for e in entries:
# 读取当前行的 Y 和 X写入后的值
existing = e["existing"]
# Y 值已在上一步写入,这里用 phone_map + db_info 重新判断
phone = e["phone"]
clue_date = e["clue_date_parsed"]
aid = 0
if re.match(r'^\d{11}$', phone) and phone in phone_map:
aid = int(phone_map[phone])
elif existing["H"] and existing["H"].isdigit() and int(existing["H"]) > 0:
aid = int(existing["H"])
di = db_info.get(aid, {}) if aid > 0 else {}
orders = di.get("orders", [])
if not orders:
continue
# 用 pick_valid_order 判断该线索是否有有效单
valid_order, is_valid = pick_valid_order(orders, clue_date)
if not is_valid or not valid_order:
continue
trade_no = valid_order["trade_no"]
pay_dt = valid_order["pay_dt"]
order_date = f"{pay_dt.month}{pay_dt.day}{pay_dt.strftime('%H:%M:%S')}" if pay_dt else ""
row_data = [
e["sales"], # A: 销售归属
e["nickname"], # B: 微信昵称
e["clue_date"], # C: 进线日期
di.get("trial_count", 0) or "", # D: 体验节数
phone, # E: 手机号
e["grade"], # F: 用户年级
e["history"], # G: 课史/跟进
str(aid) if aid > 0 else "", # H: 用户ID
di.get("reg_date", ""), # I: 注册日期
di.get("download_channel", ""), # J: 下载渠道
order_date, # K: 下单日期
valid_order["key_from"], # L: 成交渠道
valid_order["product"], # M: 产品
int(valid_order["pay_amount"]) if valid_order["pay_amount"] > 0 else "", # N: GMV
int(valid_order["refund_amount"]) if valid_order["refund_amount"] > 0 else "", # O: 退款
int(valid_order["gsv"]) if valid_order["gsv"] > 0 else "", # P: GSV
(f"{di['activation']}体验课" if di.get("activation") in ("A1", "A2") else di.get("activation", "")), # Q: 激活课程
di.get("lesson_progress", ""), # R: 行课进度
di.get("lesson_time", ""), # S: 最近行课时间
di.get("lesson_minutes", 0) or "", # T: 学习时长
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # U: 更新时间
classify_channel(valid_order["key_from"]), # V: 渠道归属
trade_no, # W: 订单号
]
gate_rows.append((trade_no, e["row"], row_data))
# 同 X 多进线 → 只保留行号最小的 1 行
seen_trade = {}
deduped = []
for trade_no, row_num, row_data in sorted(gate_rows, key=lambda x: x[1]):
if trade_no and trade_no in seen_trade:
continue
if trade_no:
seen_trade[trade_no] = True
deduped.append(row_data)
log(f"{len(gate_rows)} 条 gate 行, 去重后 {len(deduped)} 条, 唯一订单号 {len(seen_trade)}")
if not deduped:
log(" 无有效订单,跳过汇总")
return
# 写入订单汇总 sheet从第3行开始A~W 共23列
writer = FeishuSheetWriter(SPREADSHEET_TOKEN, token)
# 构建 A~W 的值数组23列确保每行长度一致
values = []
for row_data in deduped:
padded = row_data[:23]
while len(padded) < 23:
padded.append("")
values.append(padded)
writer.write(SUMMARY_SHEET_ID, start_row=3, rows=values, cols=23)
log(f" 订单汇总写入完成, 共 {len(deduped)}")
# ═══ Main ═══
def main():
log("=" * 60)
log("销售线索全量刷新 (XXTEA精确匹配版) 启动")
try:
token = get_fs_token()
log("Step 1: 解析销售三表")
all_entries = parse_sales_sheets(token)
log("Step 2: XXTEA 加密 → PG tel_encrypt 精确匹配")
phone_map = phone_to_uid_xxtea(all_entries)
log("Step 3: PostgreSQL 批量查询")
db_info = query_all_pg(all_entries, phone_map)
log("Step 4: 写入销售三表 H~V 列")
write_sales_sheets(token, all_entries, phone_map, db_info)
log("Step 5: 汇总到「订单汇总」sheet")
write_summary_sheet(token, all_entries, phone_map, db_info)
log("✅ 全量刷新完成")
return 0
except Exception as e:
log(f"❌ ERROR: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())

1
secrets.env Symbolic link
View File

@ -0,0 +1 @@
/root/.openclaw/workspace/secrets.env

View File

@ -0,0 +1,220 @@
---
name: full-data-refresh
description: |
细水入海 — 销售数据全量刷新技能。
从飞书销售三表(小龙/吴迪/成都)读取手机号 → XXTEA 加密匹配 PG 数据库 UID →
查询订单/退费/行课数据 → 回填销售表各列 → 汇总写入订单汇总。
触发词细水入海、全量刷新、跑全量、full refresh、刷新销售数据
metadata:
openclaw:
requires: { "tools": ["exec"] }
categories: ["data", "sales", "feishu"]
---
# 细水入海 — 销售数据全量刷新
> **版本:** v22026-06-16 定稿 · 陈逸鸫 + Cursor 对齐)
> **协作契约:** `xhs-ark-dashboard/docs/bot-full-refresh-v2.md`
> **大麦侧主文档:** `xhs-ark-dashboard/docs/damai-full-refresh-skill.md`
## 核心架构规则(写死,不可漂移)
### 1. Step 4 + Step 5 同一 run · 共用数据源
- 三表写入Step 4和订单汇总重建Step 5在**同一个 `sales_leads_full_refresh.py` 执行**中完成
- 共用 `pick_valid_order()` 函数 + 同一份 `db_info` 数据
- 禁止分两次 run 或使用不同数据源
### 2. 汇总 W = 三表 Xgate 同源)
- 订单汇总 W 列取的是 `valid_order["trade_no"]`,与三表 X 列写入的值**完全一致**
- **不是** merge 时再查 DB 写 W
- 唯一真源 = 三表 Y=1 gate 的 unique X
### 3. 2smjwAclear → gate 全量覆盖
- 先 `clear_summary_sheet()` 清空旧数据区
- 再 gate 全量重建:从三表 Y=1 行收集 unique X → 每 X 一行
- **不保留旧 W**,不 append
- 同 X 多进线 → 只保留行号最小的 1 行
### 4. 「保留 X/Y」只三表 mirror不含汇总
- 三表 D/I/J 只补空、G 列不动 → 这些是 mirror 规则
- 汇总 2smjwA **永远全量清空重写**,不保留任何旧行
### 5. 线索 1进线=1行 · 只绑有效单
- 三表永远 1 次进线 = 1 行,不因多单拆行
- Y=1 时 K/L/X/N/O/P/Z 全写有效主单真实值
- 已退单不写旧 X/L`pick_valid_order` 自动跳过全额退 + K<C 的订单
- 无有效单 → Y=0K/L/X 留空
### 6. 完成后群回「full_refresh 完成」
- 在数据更新群(`oc_4687e8f9f38e4f5c819238c78f97ae44`)回复
- 附带验证结果gate X 数 = 汇总 W 数
## 验收标准
Cursor 侧跑(大麦可自测):
- `audit_xishui_orders.py --strict`
- `audit_lead_primary_order_bind.py`
PASS 条件:
- unique X 三表 = 汇总(当前 406=406
- 绑单审计 E1E9 全部 0
- 孤儿 X有 X 但 Y≠1= 0
## 脚本位置
所有脚本位于大麦 workspace`/root/.openclaw/workspace-xiaoban/scripts/`
| 脚本 | 功能 | 覆盖范围 |
|------|------|---------|
| `bot_sales_step2_refresh.py` | S2 刷新手机号→UID→数据回填 | 销售三表 D/H/I/J + K-U + X/Y/Z 列 |
| `sales_leads_full_refresh.py` | 全量刷新S2 + 订单汇总gate 全量覆盖) | 销售三表 + 订单汇总 sheet |
| `refresh_order_summary.py` | 仅订单汇总镜像刷新 | 订单汇总 sheet A-W 列 |
| `audit_lead_primary_order_bind.py` | 线索绑单审计 | 交叉验证用 |
依赖脚本:`phone_encrypt.py`、`feishu_sheet_utils.py`
## 飞书表格配置
- **Spreadsheet Token:** `NoZqsFi47hIOHEt9j8WcfRtbnug`
- **销售三表:**
- `qJF4I` — 小龙 (A1:Z1200)
- `f975f0` — 吴迪 (A1:Z700)
- `qJF4J` — 成都 (A1:Z2500)
- **订单汇总:** `2smjwA`
- **过程数据:** `3aOvV6`
- **凭据目录:** `/root/.openclaw/credentials/xiaoxi`
## 销售表列结构 (A-Z)
| 列 | 字段 | 说明 |
|----|------|------|
| A | 销售归属 | 小龙/吴迪/Tom/Bob |
| B | 微信昵称 | |
| C | 进线日期 | 格式: M月D日 或 M月D日 HH:MM:SS |
| D | 体验节数 | 只补空,不覆盖已有值 |
| E | 手机号 | 11位明文 |
| F | 用户年级 | |
| G | 课史/跟进 | 不动 |
| H | 用户ID (UID) | XXTEA 精确匹配 |
| I | 注册日期 | 只补空 |
| J | 下载渠道 | 只补空 |
| K | 下单日期 | 有效主单的下单时间 |
| L | 成交渠道 | 有效主单的 key_from |
| M | 产品 | |
| N | GMV | 有效主单的实付金额 |
| O | 退款金额 | 有效主单的退款 |
| P | GSV | 有效主单的 GSV |
| Q | 激活课程 | |
| R | 行课进度 | |
| S | 最近行课时间 | |
| T | 学习时长(分钟) | |
| U | 更新时间 | |
| V | 微伴 | Cursor 维护,勿覆盖 |
| W | 时序公式 | Cursor 维护,勿覆盖 |
| X | 订单号 | 仅 Y=1 时写入有效主单号 |
| Y | 有效订单 | 1=是, 空=否 |
| Z | 渠道归属 | 端内/销转/达人/直购 |
## 数据规则
### 匹配规则
1. **E→H:** 11位手机号 → XXTEA 加密 → `bi_vala_app_account.tel_encrypt` 精确匹配,查不到留空
2. **H→D/I/J:** 只补空,不覆盖已有值
3. **G 列:** 不动
4. **V/W 列:** 不覆盖Cursor 维护)
### 有效订单判定 (Y=1)
- `pick_valid_order()`: GSV>0 · 非全额退 · K≥C
- 一手机多单 → 取最新一笔满足门禁的有效单
- 已退单不出现在线索行
### 全额退清处理
- 所有订单都退费 (GMV == 退款) → 该单不参与有效单选取
### 订单汇总进表条件
- Y=1已在三表筛选汇总默认全是有效单
- GSV>0 · 非全额退 · K≥C
- 同 X 多进线 → 汇总只保留 1 行
### 渠道归属分类 (Z列) [王虹茗确认 2026-06-15]
基于 `key_from`L列成交渠道精确分类
| 分类 | 匹配规则 |
|------|---------|
| **端内** | 精确匹配 `app-active-h5-0-0` / `app-sales-bj-qhm-0` / `app-sales-bj-wd-0` |
| **销转** | 以 `sales-adp-` 开头 |
| **达人** | 以 `newmedia-daren-` 开头,或精确匹配 `newmedia-dianpu-wwxx-0-0` |
| **直购** | 其余全部dianpu不含wwxx / partner / stream / miniprogram / jingxuan / 空 / shuadan 等) |
> 此规则同时适用于线索表 Z 列和订单汇总 V 列(渠道归属)。
## 执行流程
### 模式一:仅 S2 刷新(推荐日常使用)
```bash
cd /root/.openclaw/workspace-xiaoban && python3 scripts/bot_sales_step2_refresh.py
```
- 刷新销售三表的 D/H/I/J + K-U + X/Y/Z 列
- 不写订单汇总(订单汇总由后续步骤处理)
- 日志: `/var/log/xiaoxi_step2_refresh.log`
### 模式二全量刷新S2 + 订单汇总)← 推荐
```bash
cd /root/.openclaw/workspace-xiaoban && python3 scripts/sales_leads_full_refresh.py
```
- Step 4: 刷新销售三表(同 S2
- Step 5: gate 全量覆盖订单汇总
- 日志: `/var/log/xiaoxi_full_refresh.log`
### 模式三:仅订单汇总刷新
```bash
cd /root/.openclaw/workspace-xiaoban && python3 scripts/refresh_order_summary.py
```
- 从销售三表筛选有效订单 → 全量覆盖订单汇总 sheet
- 日志输出到 stdout
## 执行规范
1. **执行前确认:** 告知用户将执行的操作范围S2刷新 / 全量 / 仅汇总)
2. **执行中监控:** 关注脚本输出,检查各步骤的行数和匹配率
3. **执行后验证:**
- 检查日志末尾确认 `✅` 完成标记
- 跑 `audit_lead_primary_order_bind.py` 确认 E1E9 全 0
- 确认 gate X = 汇总 W当前 406=406
4. **执行后通知:** 群回「**full_refresh 完成**」
5. **权限遵守:** 仅执行业务负责人(陈逸鸫、刘庆逊、李应瑛、刘彦江)的刷新请求
## 订单汇总列结构 (A-W, 23列)
| 列 | 字段 | 说明 |
|----|------|------|
| A-U | 镜像三表 | 与销售三表 A-U 列一致 |
| V | 渠道归属 | 端内/销转/达人/直购 |
| W | 订单号 | = 三表 Xgate 同源) |
> 2026-06-16 新契约:去掉原 W「有效成单」列订单号从 X 左移到 W。汇总唯一真源 = 三表 Y=1 gate 的 unique X。全量清空重写不保留旧行。
## 常见问题
| 问题 | 原因 | 处理 |
|------|------|------|
| 手机号匹配率低 | 手机号未注册或格式不对 | 检查 E 列是否为 11 位纯数字 |
| 写入失败 code≠0 | API 限流或权限问题 | 脚本自带重试,检查飞书应用权限 |
| 汇总多 X | merge 未全量覆盖 | 确认 Step 5 走 gate 全量重建,非 DB 扩行 |
| 孤儿 X有 X 但 Y≠1 | 旧数据残留 | full_refresh 后自动清零 |
| 体验节数为空 | bi_user_course_detail 无记录 | 正常,该用户未激活体验课 |
## 注意事项
- 脚本使用小溪 (xiaoxi) 的飞书应用凭据,不要修改 CRED_DIR
- 数据库连接使用 ai_member 只读账号,安全
- 飞书 API 单次写入上限 5000 格,`feishu_sheet_utils.py` 已自动分批处理
- 执行时间约 2-5 分钟,取决于数据量
- secrets.env 需要软链接:`ln -sf /root/.openclaw/workspace/secrets.env /root/.openclaw/workspace-xiaoban/secrets.env`

View File

@ -0,0 +1,87 @@
---
name: lark-send-message-as-bot
description: |
以小斑 Bot 身份执行所有飞书操作:发送消息、读取群消息、下载群文件。
所有 lark-cli 命令必须通过本技能规定的凭据执行。
触发场景发消息、通知某人、推送到群、读群消息、下载群文件、Bot 发消息。
---
# 小斑 Bot 飞书操作规范
## ⚠️ 强制规则
**所有 `lark-cli` 命令前必须加上:**
```bash
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban
```
不加此前缀会使用错误的默认凭据xiaoxi导致 230002 / 234040 错误。
---
## 读取群消息
```bash
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
lark-cli --as bot im +chat-messages-list \
--chat-id <chat_id> --page-size 20
```
## 获取指定消息详情(含文件 key
```bash
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
lark-cli --as bot im +messages-mget \
--message-ids <message_id>
```
## 下载群消息中的文件
```bash
cd /tmp && LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
lark-cli --as bot im +messages-resources-download \
--message-id <message_id> \
--file-key <file_key> \
--type file \
--output <文件名>
```
> 注意:`--output` 不支持绝对路径,必须先 `cd` 到目标目录再执行。
## 发送文本消息
### 给个人user_id
```bash
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
lark-cli --as bot im +messages-send \
--user-id <user_id> \
--text "消息内容"
```
### 给群组chat_id
```bash
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
lark-cli --as bot im +messages-send \
--chat-id <chat_id> \
--text "消息内容"
```
## 回复消息
```bash
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
lark-cli --as bot im +messages-reply \
--message-id <reply_to_message_id> \
--text "回复内容"
```
## 常见错误
| 错误码 | 含义 | 原因 |
|-------|------|------|
| 230002 | Bot/User can NOT be out of the chat | 使用了错误的凭据(默认 xiaoxi**必须加 LARKSUITE_CLI_CONFIG_DIR** |
| 234040 | Message invisible to operator | 同上,凭据错误或消息在 bot 加入前发送 |
| 230013 | Bot has NO availability to this user | 用户不在 bot 可用范围内 |

5
tmp_daily_summary.md Normal file
View File

@ -0,0 +1,5 @@
=== 每日总结 20260617 ===
## 昨日关键进展
- **大麦**: full_refresh · 手机/UID/行课回填 · 订单汇总 merge · 完成后群回「full_refresh 完成」
- 新增 `audit_lead_primary_order_bind.py`: 线索绑单审计脚本
### 7. 环境修复

@ -1 +1 @@
Subproject commit 9e627059afef5e497165f519d4feb13885ebfbb6
Subproject commit 3179e1913ee1d9d09a5ea0db4edaa9717328692e