#!/usr/bin/env python3 """ 行课查询自动回填 — 从飞书表格读取待处理数据,匹配手机号→ID、查询行课记录并回填 执行频率:每30分钟 cron 巡检 + 群聊关键词触发 归属 Agent:小溪 (xiaoxi) 流程: 1. 读取 Sheet 2DOxEI → 找出"待查询ID"行 → 手机号脱敏匹配 account_id → 回填 F/G/H 列 2. 读取 Sheet 55b0eb → 找出"待查询"行 → 查行课记录(进度/耗时/付费) → 回填 D/E 列 """ import json, requests, os, re, psycopg2, sys from datetime import datetime from collections import defaultdict # ── 配置 ── PG_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com" PG_PORT = 28591 PG_USER = "ai_member" PG_DB = "vala_bi" SPREADSHEET_TOKEN = "RFIJsXT8FhGHhctY4RwczcOfnac" CRED_DIR = "/root/.openclaw/credentials/xiaoxi" SHEET_ID_QUERY = "2DOxEI" # 手机号→ID 匹配 SHEET_COURSE = "55b0eb" # 行课记录查询 LOG_FILE = "/var/log/xiaoxi_xingke_query.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_pg_password(): 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("'\"") 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): url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values/{sheet_id}" resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"读取Sheet失败: {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) return resp.json() def mask_phone(phone): phone = str(phone).strip() if "." in phone: parts = phone.split(".") if parts[1] in ("0", "00"): phone = parts[0] if re.match(r"^1\d{10}$", phone): return f"{phone[:3]}****{phone[-4:]}" return None 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: 手机号→ID 匹配 ── def process_id_matching(token, conn): log("Step 1: 手机号→ID 匹配") rows = read_sheet(token, SHEET_ID_QUERY) pending = [] for idx, row in enumerate(rows[1:], start=1): if len(row) > 4 and str(row[4]).strip() == "待查询ID": phone = str(row[1]).strip() if len(row) > 1 and row[1] else "" pending.append({"row_idx": idx + 1, "phone": phone}) log(f" 待查询ID: {len(pending)}") if not pending: log(" 无待处理, 跳过") return {"processed": 0, "matched": 0} phones_raw = list(set(r["phone"] for r in pending if r["phone"])) valid_phones = [(p, mask_phone(p)) for p in phones_raw if mask_phone(p)] masks = list(set(m[1] for m in valid_phones)) cur = conn.cursor() masked_to_aid = {} for i in range(0, len(masks), 500): batch = masks[i:i+500] ph = ",".join(["%s"] * len(batch)) cur.execute( f"SELECT id, tel FROM bi_vala_app_account WHERE tel IN ({ph}) AND status=1 AND deleted_at IS NULL", batch ) for aid, tel in cur.fetchall(): if tel not in masked_to_aid: masked_to_aid[tel] = aid cur.close() phone_to_aid = {} for phone, m in valid_phones: if m in masked_to_aid: phone_to_aid[phone] = masked_to_aid[m] log(f" 匹配成功: {len(phone_to_aid)}, 未匹配: {len(valid_phones)-len(phone_to_aid)}") # 组装回填数据 now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") results = [] for r in pending: phone = r["phone"] if phone in phone_to_aid: results.append({"row": r["row_idx"], "user_id": str(phone_to_aid[phone]), "status": "已回填"}) else: results.append({"row": r["row_idx"], "user_id": "", "status": "未查到"}) results.sort(key=lambda x: x["row"]) # 分批写入 (按 contiguous rows 分组) groups = [] cur_grp = [] for r in results: if not cur_grp or r["row"] == cur_grp[-1]["row"] + 1: cur_grp.append(r) else: groups.append(cur_grp) cur_grp = [r] if cur_grp: groups.append(cur_grp) for g in groups: sr, er = g[0]["row"], g[-1]["row"] f_vals = [[r["user_id"]] for r in g] g_vals = [[r["status"]] for r in g] h_vals = [[now_str] for r in g] put_values(token, SHEET_ID_QUERY, f"F{sr}:F{er}", f_vals) put_values(token, SHEET_ID_QUERY, f"G{sr}:G{er}", g_vals) put_values(token, SHEET_ID_QUERY, f"H{sr}:H{er}", h_vals) # 将"请求状态"列 E 标记为已处理 e_vals = [["已处理"] for _ in g] put_values(token, SHEET_ID_QUERY, f"E{sr}:E{er}", e_vals) log(f" ID回填完成: {len(results)} 行, 匹配{len(phone_to_aid)}") return {"processed": len(results), "matched": len(phone_to_aid)} # ── Step 2: 行课记录查询 ── def process_course_records(token, conn): log("Step 2: 行课记录查询") rows = read_sheet(token, SHEET_COURSE) pending = [] for idx, row in enumerate(rows[1:], start=1): if len(row) > 2 and str(row[2]).strip() == "待查询": uid = str(row[1]).strip() if len(row) > 1 and row[1] else "" sales = str(row[0]).strip() if len(row) > 0 and row[0] else "" pending.append({"row_idx": idx + 1, "user_id": uid, "sales": sales}) log(f" 待查询: {len(pending)}") if not pending: log(" 无待处理, 跳过") return {"processed": 0, "with_records": 0} # 解析有效 user_id valid = [] for r in pending: try: aid = int(float(r["user_id"])) if aid > 0: r["account_id"] = aid valid.append(r) except (ValueError, TypeError): pass log(f" 有效用户ID: {len(valid)}") if not valid: return {"processed": 0, "with_records": 0} uid_set = list(set(r["account_id"] for r in valid)) # 获取角色 cur = conn.cursor() account_chars = defaultdict(list) char_to_account = {} rc = batch_in(cur, "SELECT account_id, id, nickname FROM bi_vala_app_character WHERE account_id IN (%s) AND nickname IS NOT NULL AND nickname != '' AND deleted_at IS NULL", uid_set ) for aid, cid, nick in rc: account_chars[aid].append(cid) char_to_account[cid] = aid char_ids = list(char_to_account.keys()) # 课程映射 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 "") # 课时完成记录 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} 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 ch_data: 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 e: log(f" 警告 {table}: {e}") # 学习总耗时 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}") # 付费状态 ph = ",".join(["%s"] * len(uid_set)) cur.execute( f"SELECT account_id, COUNT(*) FROM bi_vala_order WHERE account_id IN ({ph}) AND pay_success_date IS NOT NULL AND order_status=3 AND deleted_at IS NULL GROUP BY account_id", uid_set ) paid = {r[0]: r[1] for r in cur.fetchall()} # 激活状态 try: cur.execute( f"SELECT t.account_id, t.season_package_level FROM bi_vala_seasonal_ticket t INNER JOIN bi_vala_app_account a ON t.account_id=a.id AND a.status=1 WHERE t.account_id IN ({ph}) AND t.status=1 AND t.deleted_at IS NULL AND t.season_package_level IN ('A1','A2')", uid_set ) activation = {} for aid, lvl in cur.fetchall(): if aid not in activation: activation[aid] = lvl except: activation = {} cur.close() # 组装结果 now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") results = [] for r in valid: aid = r["account_id"] chars = account_chars.get(aid, []) best_time = None best_ch = None total_ms = 0 for cid in chars: play = char_plays.get(cid) if play and 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"] if not best_ch: p = "已付费" if aid in paid else "未付费" record = f"销售:{r['sales']} | 用户:{aid} | 当前:无行课记录 | 最近行课:无 | 学习0min | {p}" else: ch_id, (cl, cs, cu, cl2) = best_ch full_label = f"{cl}-{cs}-{cu}-{cl2}" act = activation.get(aid, "") prefix = f"{act}体验课" if act else f"{cl}体验课" current = f"{prefix}-{full_label}" recent = best_time.strftime("%Y-%m-%d") if best_time else "无" total_min = round(total_ms / 60000, 1) if total_min == int(total_min): total_min = int(total_min) p = "已付费" if aid in paid else "未付费" record = f"销售:{r['sales']} | 用户:{aid} | 当前:{current} | 最近行课:{recent} | 学习{total_min}min | {p}" results.append({"row": r["row_idx"], "record": record}) # 回填 results.sort(key=lambda x: x["row"]) groups = [] cur_grp = [] for r in results: if not cur_grp or r["row"] == cur_grp[-1]["row"] + 1: cur_grp.append(r) else: groups.append(cur_grp) cur_grp = [r] if cur_grp: groups.append(cur_grp) for g in groups: sr, er = g[0]["row"], g[-1]["row"] d_vals = [[r["record"]] for r in g] e_vals = [[now_str] for r in g] put_values(token, SHEET_COURSE, f"D{sr}:D{er}", d_vals) put_values(token, SHEET_COURSE, f"E{sr}:E{er}", e_vals) with_records = sum(1 for r in results if "无行课记录" not in r["record"]) log(f" 行课回填完成: {len(results)} 行, 有记录: {with_records}") return {"processed": len(results), "with_records": with_records} # ── Main ── def main(): log("=" * 50) log("行课查询自动回填 启动") try: token = get_fs_token() conn = psycopg2.connect( host=PG_HOST, port=PG_PORT, user=PG_USER, password=get_pg_password(), dbname=PG_DB, connect_timeout=30 ) r1 = process_id_matching(token, conn) r2 = process_course_records(token, conn) conn.close() total = r1["processed"] + r2["processed"] summary = f"ID回填:{r1['processed']}(匹配{r1['matched']}) | 行课:{r2['processed']}(有记录{r2['with_records']})" log(f"完成: {summary}") if total == 0: log("无待处理任务, 静默退出") return 0 return 0 except Exception as e: log(f"ERROR: {e}") import traceback traceback.print_exc() return 1 if __name__ == "__main__": sys.exit(main())