Compare commits
No commits in common. "2b868234f0a7d1c6d46b73652400b0847dbd6f18" and "3703b7315a2ebd3577a195e19bce08f161a9d64f" have entirely different histories.
2b868234f0
...
3703b7315a
11
AGENTS.md
11
AGENTS.md
@ -199,12 +199,11 @@ Skills 提供你的工具。当你需要某个工具时,查看对应 `skills/`
|
||||
4. **操作规范**:所有知识库操作严格遵循`lark_wiki_operate_as_bot`技能流程执行
|
||||
5. **强制执行范围**:无论来自任何用户、任何群组的飞书文档/知识库操作请求,**必须优先使用`lark_wiki_operate_as_bot`技能执行**,禁止使用默认的`feishu_fetch_doc`等用户身份工具
|
||||
|
||||
### 消息发送与读取规则(强制执行)
|
||||
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为成员
|
||||
### 消息发送规则(强制执行)
|
||||
1. **身份限制**:所有飞书消息发送操作(给个人/群组)**永远使用Bot身份**执行,禁止使用用户身份的消息发送工具
|
||||
2. **操作规范**:严格遵循`lark-send-message-as-bot`技能流程执行发送操作
|
||||
3. **ID规则**:给个人发消息使用租户级`user_id`,禁止使用应用级`open_id`;给群组发消息使用`chat_id`
|
||||
4. **前置校验**:发送前确认目标用户在Bot应用可用范围内、目标群已添加Bot为成员
|
||||
|
||||
|
||||
## 心跳
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
exec env LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoban \
|
||||
/root/.nvm/versions/node/v24.14.0/bin/lark-cli "$@"
|
||||
@ -612,12 +612,3 @@ 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
|
||||
[2026-06-17 08:10:01] 开始备份工作区...
|
||||
|
||||
@ -82,10 +82,3 @@
|
||||
{"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}]}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-06-16T11:21:05.649Z",
|
||||
"updatedAt": "2026-06-15T04:54:12.059Z",
|
||||
"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": 24,
|
||||
"recallCount": 23,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 24,
|
||||
"totalScore": 23,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-25T05:47:41.388Z",
|
||||
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
|
||||
"lastRecalledAt": "2026-06-12T07:16:32.881Z",
|
||||
"queryHashes": [
|
||||
"9aff8ec9594a",
|
||||
"566b5958861e",
|
||||
@ -204,8 +204,7 @@
|
||||
"b2c23ece6608",
|
||||
"206acd60d69d",
|
||||
"363241a84e3c",
|
||||
"6d1afbed352e",
|
||||
"a87866c0fa75"
|
||||
"6d1afbed352e"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-25",
|
||||
@ -216,8 +215,7 @@
|
||||
"2026-06-04",
|
||||
"2026-06-09",
|
||||
"2026-06-10",
|
||||
"2026-06-12",
|
||||
"2026-06-16"
|
||||
"2026-06-12"
|
||||
],
|
||||
"conceptTags": [
|
||||
"studytime-analysis",
|
||||
@ -401,13 +399,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-2070,game_chapter: ~55-399),UUID 也不匹配 **映射方案**: - 每个 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-48(49 个单元),与 big_map_chapter 的 unit_index 范围一致 - A2: Unit 0-49(50 个单元,比 A1 多 1 个) *",
|
||||
"recallCount": 12,
|
||||
"recallCount": 10,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 12,
|
||||
"totalScore": 10,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-28T09:07:57.953Z",
|
||||
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
|
||||
"lastRecalledAt": "2026-06-12T07:16:32.881Z",
|
||||
"queryHashes": [
|
||||
"c6c7ff4ed75d",
|
||||
"c59410788b42",
|
||||
@ -418,9 +416,7 @@
|
||||
"6e3a2daa0a9f",
|
||||
"5d71b876843a",
|
||||
"363241a84e3c",
|
||||
"6d1afbed352e",
|
||||
"02e468d377f4",
|
||||
"a87866c0fa75"
|
||||
"6d1afbed352e"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-28",
|
||||
@ -428,8 +424,7 @@
|
||||
"2026-05-30",
|
||||
"2026-06-04",
|
||||
"2026-06-09",
|
||||
"2026-06-12",
|
||||
"2026-06-16"
|
||||
"2026-06-12"
|
||||
],
|
||||
"conceptTags": [
|
||||
"vala-game-chapter",
|
||||
@ -647,13 +642,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": 14,
|
||||
"recallCount": 11,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 14,
|
||||
"totalScore": 11,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-28T20:51:03.908Z",
|
||||
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
|
||||
"lastRecalledAt": "2026-06-12T07:16:32.880Z",
|
||||
"queryHashes": [
|
||||
"f22544a8757c",
|
||||
"2af907cea93d",
|
||||
@ -665,10 +660,7 @@
|
||||
"c5fe8ced03de",
|
||||
"a70555b22269",
|
||||
"78873c102522",
|
||||
"363241a84e3c",
|
||||
"b65a5ab98ae7",
|
||||
"a87866c0fa75",
|
||||
"5bcdd0ab32df"
|
||||
"363241a84e3c"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
@ -676,8 +668,7 @@
|
||||
"2026-06-05",
|
||||
"2026-06-08",
|
||||
"2026-06-10",
|
||||
"2026-06-12",
|
||||
"2026-06-16"
|
||||
"2026-06-12"
|
||||
],
|
||||
"conceptTags": [
|
||||
"3a/3c",
|
||||
@ -697,13 +688,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 张 PNG:U1-L1~U1-L5 - FUNCLOUD_API_KEY 在小研 workspace `.env` 中可用(`fc_eea138933b02b4797ce0779ffb637d8b8a6368db7b435dfdab7b4be1cd254d98`) - Brief 文档 `/docx/KsVadUTmooO7yYxHaGmc1R0Bn5b` + 投放手册 `/do",
|
||||
"recallCount": 13,
|
||||
"recallCount": 12,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 13,
|
||||
"totalScore": 12,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-28T20:51:03.908Z",
|
||||
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
|
||||
"lastRecalledAt": "2026-06-12T07:16:32.880Z",
|
||||
"queryHashes": [
|
||||
"f22544a8757c",
|
||||
"2af907cea93d",
|
||||
@ -716,8 +707,7 @@
|
||||
"c5fe8ced03de",
|
||||
"a70555b22269",
|
||||
"a90ba76a41cf",
|
||||
"363241a84e3c",
|
||||
"a87866c0fa75"
|
||||
"363241a84e3c"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
@ -726,8 +716,7 @@
|
||||
"2026-06-03",
|
||||
"2026-06-08",
|
||||
"2026-06-09",
|
||||
"2026-06-12",
|
||||
"2026-06-16"
|
||||
"2026-06-12"
|
||||
],
|
||||
"conceptTags": [
|
||||
"gpt",
|
||||
@ -858,14 +847,19 @@
|
||||
"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 张 PNG:U1-L1~U1-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": 40,
|
||||
"recallCount": 35,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 40,
|
||||
"totalScore": 35,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-28T20:51:03.908Z",
|
||||
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
|
||||
"lastRecalledAt": "2026-06-15T04:54:12.059Z",
|
||||
"queryHashes": [
|
||||
"f7ae50ae228d",
|
||||
"3737f6af1445",
|
||||
"cf12fd62a5e5",
|
||||
"833509d09ccb",
|
||||
"5b675d96f1da",
|
||||
"b8b71654e7aa",
|
||||
"f0e8e3da16e8",
|
||||
"771aaeed7600",
|
||||
@ -892,12 +886,7 @@
|
||||
"cfa4c0443735",
|
||||
"d248105a4ef8",
|
||||
"363241a84e3c",
|
||||
"4c8704b4fc00",
|
||||
"ff112f97114e",
|
||||
"d7be4ba41be4",
|
||||
"02e468d377f4",
|
||||
"f6f7815a1493",
|
||||
"a87866c0fa75"
|
||||
"4c8704b4fc00"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
@ -911,8 +900,7 @@
|
||||
"2026-06-10",
|
||||
"2026-06-11",
|
||||
"2026-06-12",
|
||||
"2026-06-15",
|
||||
"2026-06-16"
|
||||
"2026-06-15"
|
||||
],
|
||||
"conceptTags": [
|
||||
"gpt",
|
||||
@ -1040,13 +1028,13 @@
|
||||
"endLine": 132,
|
||||
"source": "memory",
|
||||
"snippet": "- HTML 模板使用 f-string 时,CSS 中的 `{` 必须双写 `{{`,且不能在 f-string 内做字符串拼接(会打断 f-string) - 年龄从 birthday(varchar \"YYYY-MM-DD\")计算,非 DATE 类型 - 手机号在 MySQL 已脱敏(如 186****1625),直接取后4位 ### 已测试角色 - **32009 (zyl)**:26 条完课,651 条中互动(Perfect 82.6%、Good 2.9%),26 节巩固全部满分。课程 A2,Apple App Store 购买,渠道 newmedia-daren-xhs-宣儿麻麻。设备小课屏E3。学习时间 2026-04-24 ~ 2026-05-24。 ### 同步 - 已推送到 SkillHub(`studycourse-analysis.xiaoban`) - 已 commit 8648b7b 到 Git 远程仓库 - 已通知 Cris(李若松)",
|
||||
"recallCount": 13,
|
||||
"recallCount": 9,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 13,
|
||||
"totalScore": 9,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-28T20:57:42.055Z",
|
||||
"lastRecalledAt": "2026-06-16T11:18:05.577Z",
|
||||
"lastRecalledAt": "2026-06-09T03:15:16.782Z",
|
||||
"queryHashes": [
|
||||
"f139ebdae100",
|
||||
"f7ae50ae228d",
|
||||
@ -1056,18 +1044,13 @@
|
||||
"f0e8e3da16e8",
|
||||
"fbe762c60bc6",
|
||||
"1382c5611b17",
|
||||
"a90ba76a41cf",
|
||||
"ff112f97114e",
|
||||
"d7be4ba41be4",
|
||||
"02e468d377f4",
|
||||
"a87866c0fa75"
|
||||
"a90ba76a41cf"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
"2026-06-02",
|
||||
"2026-06-03",
|
||||
"2026-06-09",
|
||||
"2026-06-16"
|
||||
"2026-06-09"
|
||||
],
|
||||
"conceptTags": [
|
||||
"f-string",
|
||||
@ -1201,27 +1184,25 @@
|
||||
"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=9746532):refresh 验证通过 ✅ - 9181(谦禾 adv=9013261,9598861 / YTL adv=7242040,9891870,10157917,10562529):refresh 验证通过 ✅ - OAuth 回调地址:`https://odourless-demetra-cany.ngrok-free.dev/callback.html`(ngrok 隧道) - `jugu",
|
||||
"recallCount": 7,
|
||||
"recallCount": 6,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 7,
|
||||
"totalScore": 6,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-29T02:53:52.924Z",
|
||||
"lastRecalledAt": "2026-06-16T09:36:31.306Z",
|
||||
"lastRecalledAt": "2026-06-09T09:31:49.829Z",
|
||||
"queryHashes": [
|
||||
"d9f1601110da",
|
||||
"117aaedb584d",
|
||||
"c0b581bfb144",
|
||||
"d24884bfecf1",
|
||||
"50b1cd5a5d08",
|
||||
"c3aab736911e",
|
||||
"f6f7815a1493"
|
||||
"c3aab736911e"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
"2026-06-04",
|
||||
"2026-06-09",
|
||||
"2026-06-16"
|
||||
"2026-06-09"
|
||||
],
|
||||
"conceptTags": [
|
||||
"3a/3c/3d/4c",
|
||||
@ -1241,13 +1222,13 @@
|
||||
"endLine": 580,
|
||||
"source": "memory",
|
||||
"snippet": "- 18 单 | GMV ¥44,375 | 退款 ¥0 - 渠道:销转 11 / 达人 4 / 端内 3 - 客单价:¥599×2 / ¥1,999×9 / ¥3,598×7 - 订单日期分布在 11 天(04/23–05/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.com,user_id 未获取 - 尝试 `lark-cli contact +search-user wanghongming@makee.com` → 失败:bot API 缺少 `search:user` scope - 替代方案:让她发消息给大麦(系统自动获取 user_id),或陈逸鸫截图资料页 **同事查询三级场景定稿:** 1. 常规只读 → 直接查,append 输出表 2. 权限外用户 → 先通知业务负责人,再决定是否返回 3. 写操作 → 回复「这会影响生产数据,已转 @陈逸鸫 确认」 ### 20:20 行课转化分析 [陈逸鸫需求] **需求:行课记录新增当日进线→当天行课 + 7天首课率 + 销售排名 + 日报展示** **",
|
||||
"recallCount": 29,
|
||||
"recallCount": 25,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 29,
|
||||
"totalScore": 25,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-29T06:11:40.432Z",
|
||||
"lastRecalledAt": "2026-06-16T09:36:31.306Z",
|
||||
"lastRecalledAt": "2026-06-15T04:54:12.059Z",
|
||||
"queryHashes": [
|
||||
"82be33d1f911",
|
||||
"2aa08c6652fb",
|
||||
@ -1273,11 +1254,7 @@
|
||||
"206acd60d69d",
|
||||
"cfa4c0443735",
|
||||
"d248105a4ef8",
|
||||
"4c8704b4fc00",
|
||||
"ff112f97114e",
|
||||
"d7be4ba41be4",
|
||||
"02e468d377f4",
|
||||
"f6f7815a1493"
|
||||
"4c8704b4fc00"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
@ -1289,8 +1266,7 @@
|
||||
"2026-06-09",
|
||||
"2026-06-10",
|
||||
"2026-06-11",
|
||||
"2026-06-15",
|
||||
"2026-06-16"
|
||||
"2026-06-15"
|
||||
],
|
||||
"conceptTags": [
|
||||
"04/23",
|
||||
@ -1349,23 +1325,20 @@
|
||||
"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": 4,
|
||||
"recallCount": 2,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 4,
|
||||
"totalScore": 2,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-05-29T06:12:52.521Z",
|
||||
"lastRecalledAt": "2026-06-16T06:22:11.809Z",
|
||||
"lastRecalledAt": "2026-06-12T07:16:32.881Z",
|
||||
"queryHashes": [
|
||||
"2aa08c6652fb",
|
||||
"6d1afbed352e",
|
||||
"ff112f97114e",
|
||||
"d7be4ba41be4"
|
||||
"6d1afbed352e"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-05-29",
|
||||
"2026-06-12",
|
||||
"2026-06-16"
|
||||
"2026-06-12"
|
||||
],
|
||||
"conceptTags": [
|
||||
"3.0",
|
||||
@ -1832,13 +1805,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": 13,
|
||||
"recallCount": 12,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 13,
|
||||
"totalScore": 12,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-06-03T06:24:53.892Z",
|
||||
"lastRecalledAt": "2026-06-16T06:22:32.406Z",
|
||||
"lastRecalledAt": "2026-06-15T04:54:12.059Z",
|
||||
"queryHashes": [
|
||||
"faadf692331b",
|
||||
"c2163a583b15",
|
||||
@ -1851,8 +1824,7 @@
|
||||
"ffe9b822f4a2",
|
||||
"235546f84d27",
|
||||
"d248105a4ef8",
|
||||
"4c8704b4fc00",
|
||||
"02e468d377f4"
|
||||
"4c8704b4fc00"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-06-03",
|
||||
@ -1860,8 +1832,7 @@
|
||||
"2026-06-05",
|
||||
"2026-06-09",
|
||||
"2026-06-11",
|
||||
"2026-06-15",
|
||||
"2026-06-16"
|
||||
"2026-06-15"
|
||||
],
|
||||
"conceptTags": [
|
||||
"6-12岁最佳",
|
||||
@ -2084,23 +2055,19 @@
|
||||
"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=9746532):refresh 验证通过 ✅ - 9181(谦禾 adv=9013261,9598861 / YTL adv=7242040,9891870,10157917,10562529):refresh 验证通过 ✅ - OAu",
|
||||
"recallCount": 5,
|
||||
"recallCount": 2,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 5,
|
||||
"totalScore": 2,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-06-09T09:31:36.077Z",
|
||||
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
|
||||
"lastRecalledAt": "2026-06-09T09:31:49.829Z",
|
||||
"queryHashes": [
|
||||
"50b1cd5a5d08",
|
||||
"c3aab736911e",
|
||||
"f6f7815a1493",
|
||||
"b65a5ab98ae7",
|
||||
"5bcdd0ab32df"
|
||||
"c3aab736911e"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-06-09",
|
||||
"2026-06-16"
|
||||
"2026-06-09"
|
||||
],
|
||||
"conceptTags": [
|
||||
"5.1s",
|
||||
@ -2120,23 +2087,19 @@
|
||||
"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=9746532):refresh 验证通过 ✅ - 9181(谦禾 adv=9013261,9598861 / YTL adv=7242040,9891870,10157917,10562529):refresh 验证通过 ✅ - OAu",
|
||||
"recallCount": 5,
|
||||
"recallCount": 2,
|
||||
"dailyCount": 0,
|
||||
"groundedCount": 0,
|
||||
"totalScore": 5,
|
||||
"totalScore": 2,
|
||||
"maxScore": 1,
|
||||
"firstRecalledAt": "2026-06-09T09:31:36.077Z",
|
||||
"lastRecalledAt": "2026-06-16T11:21:05.649Z",
|
||||
"lastRecalledAt": "2026-06-09T09:31:49.829Z",
|
||||
"queryHashes": [
|
||||
"50b1cd5a5d08",
|
||||
"c3aab736911e",
|
||||
"f6f7815a1493",
|
||||
"b65a5ab98ae7",
|
||||
"5bcdd0ab32df"
|
||||
"c3aab736911e"
|
||||
],
|
||||
"recallDays": [
|
||||
"2026-06-09",
|
||||
"2026-06-16"
|
||||
"2026-06-09"
|
||||
],
|
||||
"conceptTags": [
|
||||
"5.1s",
|
||||
@ -2179,69 +2142,6 @@
|
||||
"分析",
|
||||
"增加"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
# 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=0,K/L/X 留空
|
||||
|
||||
### 3. 汇总 gate 全量覆盖(非 DB 扩行)
|
||||
- 唯一真源 = 三表 Y=1 gate 的 unique X
|
||||
- Step 4 + Step 5 同一 run,共用 `pick_valid_order()` + `db_info`
|
||||
- 汇总 W = 三表 X(gate 同源,不是 merge 再查 DB)
|
||||
- clear → gate 全量覆盖,不保留旧 W
|
||||
- 同 X 多进线 → 只保留行号最小的 1 行
|
||||
|
||||
### 4. 分工定稿
|
||||
- **Cursor**: 微伴/旧表同步 · 三键去重 · V/W 公式 · 验单 · 撞库消解
|
||||
- **大麦**: full_refresh · 手机/UID/行课回填 · 订单汇总 merge · 完成后群回「full_refresh 完成」
|
||||
- **小溪**: 不再参与 Bot 刷新
|
||||
|
||||
### 5. 验收标准
|
||||
- gate X = 汇总 W(当前 406=406)
|
||||
- 绑单审计 E1–E9 全部 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`
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,189 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
线索绑单审计 — 交叉验证「线索只绑有效单」规则。
|
||||
与 Cursor 同口径,输出 fingerprint + E1–E9 计数。
|
||||
"""
|
||||
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()
|
||||
@ -1,657 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bot 销转看板 Step2 刷新 — XXTEA 精确匹配版 (v3)
|
||||
E列11位明文手机号 → XXTEA加密 → bi_vala_app_account.tel_encrypt精确匹配 → H列UID
|
||||
|
||||
S2 规则:
|
||||
① E→H: phone_encrypt.py XXTEA 精确匹配, 查不到留空
|
||||
② H→D/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 · 非全额退 · K≥C · 有时序
|
||||
返回: (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())
|
||||
@ -1,142 +0,0 @@
|
||||
#!/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)
|
||||
@ -1,66 +0,0 @@
|
||||
#!/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:
|
||||
"""明文手机号 → MD5(32位小写十六进制)"""
|
||||
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}")
|
||||
@ -1,285 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
订单汇总 A–X 全量镜像刷新
|
||||
触发:Step2(Cursor Step1 完成后 @小溪)
|
||||
归属:小溪 (xiaoxi)
|
||||
|
||||
进表条件:K=是 · O>0 · 非全额退(P空或P<O) · L≥C
|
||||
全额退 → 整行不进订单表(销售行清 K/O/P/Q)
|
||||
镜像 A–V 原样 + 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: 构建 A–W 行(23列)──
|
||||
# 新契约: A-U镜像 + V=渠道归属 + W=订单号
|
||||
summary_rows = []
|
||||
for r in order_rows:
|
||||
raw = r["raw"]
|
||||
# A–U 原样镜像(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()
|
||||
@ -1,843 +0,0 @@
|
||||
#!/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 规则):
|
||||
① E→H: XXTEA 精确匹配, 查不到留空
|
||||
② H→D/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 · 非全额退 · K≥C
|
||||
返回: (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 +0,0 @@
|
||||
/root/.openclaw/workspace/secrets.env
|
||||
@ -1,220 +0,0 @@
|
||||
---
|
||||
name: full-data-refresh
|
||||
description: |
|
||||
细水入海 — 销售数据全量刷新技能。
|
||||
从飞书销售三表(小龙/吴迪/成都)读取手机号 → XXTEA 加密匹配 PG 数据库 UID →
|
||||
查询订单/退费/行课数据 → 回填销售表各列 → 汇总写入订单汇总。
|
||||
触发词:细水入海、全量刷新、跑全量、full refresh、刷新销售数据
|
||||
metadata:
|
||||
openclaw:
|
||||
requires: { "tools": ["exec"] }
|
||||
categories: ["data", "sales", "feishu"]
|
||||
---
|
||||
|
||||
# 细水入海 — 销售数据全量刷新
|
||||
|
||||
> **版本:** v2(2026-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 = 三表 X(gate 同源)
|
||||
- 订单汇总 W 列取的是 `valid_order["trade_no"]`,与三表 X 列写入的值**完全一致**
|
||||
- **不是** merge 时再查 DB 写 W
|
||||
- 唯一真源 = 三表 Y=1 gate 的 unique X
|
||||
|
||||
### 3. 2smjwA:clear → 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=0,K/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)
|
||||
- 绑单审计 E1–E9 全部 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` 确认 E1–E9 全 0
|
||||
- 确认 gate X = 汇总 W(当前 406=406)
|
||||
4. **执行后通知:** 群回「**full_refresh 完成**」
|
||||
5. **权限遵守:** 仅执行业务负责人(陈逸鸫、刘庆逊、李应瑛、刘彦江)的刷新请求
|
||||
|
||||
## 订单汇总列结构 (A-W, 23列)
|
||||
|
||||
| 列 | 字段 | 说明 |
|
||||
|----|------|------|
|
||||
| A-U | 镜像三表 | 与销售三表 A-U 列一致 |
|
||||
| V | 渠道归属 | 端内/销转/达人/直购 |
|
||||
| W | 订单号 | = 三表 X(gate 同源) |
|
||||
|
||||
> 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`
|
||||
@ -1,87 +0,0 @@
|
||||
---
|
||||
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 可用范围内 |
|
||||
@ -1 +1 @@
|
||||
Subproject commit 3179e1913ee1d9d09a5ea0db4edaa9717328692e
|
||||
Subproject commit 9e627059afef5e497165f519d4feb13885ebfbb6
|
||||
Loading…
Reference in New Issue
Block a user