diff --git a/logs/backup.log b/logs/backup.log index 45f5c63..de5c5e3 100644 --- a/logs/backup.log +++ b/logs/backup.log @@ -414,3 +414,11 @@ To https://git.valavala.com/ai_member_only/ai_member_xiaoban 906af79..e279bc7 master -> master [2026-05-24 08:10:02] 工作区备份成功:自动备份 2026-05-24 08:10:01 [2026-05-25 08:10:01] 开始备份工作区... +[master 5ace2cc] 自动备份 2026-05-25 08:10:01 + 2 files changed, 1 insertion(+), 11 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 + 8f74b8e..5ace2cc master -> master +[2026-05-25 08:10:01] 工作区备份成功:自动备份 2026-05-25 08:10:01 diff --git a/memory/.dreams/events.jsonl b/memory/.dreams/events.jsonl index 542452f..e27a703 100644 --- a/memory/.dreams/events.jsonl +++ b/memory/.dreams/events.jsonl @@ -1 +1,2 @@ {"type":"memory.recall.recorded","timestamp":"2026-05-24T02:48:04.923Z","query":"PostgreSQL online database password PG_DB_PASSWORD","resultCount":4,"results":[{"path":"memory/2026-05-24.md","startLine":1,"endLine":30,"score":1},{"path":"memory/2026-05-24.md","startLine":23,"endLine":52,"score":1},{"path":"memory/2026-03-01.md","startLine":1,"endLine":11,"score":1},{"path":"memory/2026-05-24.md","startLine":46,"endLine":61,"score":1}]} +{"type":"memory.recall.recorded","timestamp":"2026-05-25T05:47:41.388Z","query":"账号信息查询 account_id 14157 数据库","resultCount":4,"results":[{"path":"memory/2026-05-24.md","startLine":46,"endLine":71,"score":1},{"path":"memory/2026-05-24.md","startLine":85,"endLine":110,"score":1},{"path":"memory/2026-05-24.md","startLine":23,"endLine":52,"score":1},{"path":"memory/2026-05-24.md","startLine":1,"endLine":30,"score":1}]} diff --git a/memory/.dreams/short-term-recall.json b/memory/.dreams/short-term-recall.json index 1f6be32..c60d40c 100644 --- a/memory/.dreams/short-term-recall.json +++ b/memory/.dreams/short-term-recall.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-05-24T02:48:04.923Z", + "updatedAt": "2026-05-25T05:47:41.388Z", "entries": { "memory:memory/2026-05-24.md:1:30": { "key": "memory:memory/2026-05-24.md:1:30", @@ -9,18 +9,20 @@ "endLine": 30, "source": "memory", "snippet": "# 2026-05-24 工作日志 ## 新建技能: studytime-analysis [刘庆逊提出] 创建学习时间分析技能,分析角色完课记录的规律。 ### 技能结构 - `skills/studytime-analysis/SKILL.md` — 技能定义 - `skills/studytime-analysis/scripts/studytime_analysis.py` — Python 分析脚本 ### 分析维度 1. **一周时间分布**(排除寒暑假1-2月、7-8月):周一~周日各天完课数、时段分布(上午/中午/下午/晚上)、周末是否上课 2. **跨周学习趋势**(包含寒暑假全部数据):总周数、周均完课数、连续性、中断周检测、前后半段趋势对比、突增/骤降检测 3. **完课记录明细表**(全部数据):日期/时间/星期/时段/级别/课程ID ### 数据源 - PostgreSQL Online(vala 库) - 核心表:`user_chapter_play_record_0~7`(8张分表,无 `bi_` 前缀) - 筛选:`play_status = 1` - 注意:表在 PostgreSQL 而非 MySQL,表名无 `bi_` 前缀 ### 寒暑假规则 - 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色", - "recallCount": 1, + "recallCount": 2, "dailyCount": 0, "groundedCount": 0, - "totalScore": 1, + "totalScore": 2, "maxScore": 1, "firstRecalledAt": "2026-05-24T02:48:04.923Z", - "lastRecalledAt": "2026-05-24T02:48:04.923Z", + "lastRecalledAt": "2026-05-25T05:47:41.388Z", "queryHashes": [ - "c2d15f7574fb" + "c2d15f7574fb", + "9aff8ec9594a" ], "recallDays": [ - "2026-05-24" + "2026-05-24", + "2026-05-25" ], "conceptTags": [ "studytime-analysis", @@ -40,18 +42,20 @@ "endLine": 52, "source": "memory", "snippet": "- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色 - 2343、2344:无完课记录(play_status=2,未完成) - 2840:276条记录,秋季集中型用户 - 25976:265条,246条在W16周一天完成(A2批量),疑似系统批量标记 - 2895:188条,长期稳定学习型用户,36周几乎不间断,非寒暑假晚上为主,寒暑假上午为主 ### 技术要点 - psycopg2 的 `%(param_name)s` 命名参数必须正确匹配,UNION ALL 多个子查询需要不同参数名 - PostgreSQL 返回的 `updated_at` 是 tz-aware datetime - `datetime.fromisocalendar(year, week, 1)` 获取某周周一的日期 ### 同步 - 已推送到 SkillHub(`studytime-analysis.xiaoban`) - 已 commit 到 Git 远程仓库 - 已通知 Cris(李若松) ### 增强: 报告开头加入角色基本信息 (2026-05-24) [刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: - 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 **数据源(新增)**: - MySQL Onli", - "recallCount": 1, + "recallCount": 2, "dailyCount": 0, "groundedCount": 0, - "totalScore": 1, + "totalScore": 2, "maxScore": 1, "firstRecalledAt": "2026-05-24T02:48:04.923Z", - "lastRecalledAt": "2026-05-24T02:48:04.923Z", + "lastRecalledAt": "2026-05-25T05:47:41.388Z", "queryHashes": [ - "c2d15f7574fb" + "c2d15f7574fb", + "9aff8ec9594a" ], "recallDays": [ - "2026-05-24" + "2026-05-24", + "2026-05-25" ], "conceptTags": [ "1-2", @@ -125,6 +129,68 @@ "vala-app-account", "get-mysql-connection" ] + }, + "memory:memory/2026-05-24.md:46:71": { + "key": "memory:memory/2026-05-24.md:46:71", + "path": "memory/2026-05-24.md", + "startLine": 46, + "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": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-25T05:47:41.388Z", + "lastRecalledAt": "2026-05-25T05:47:41.388Z", + "queryHashes": [ + "9aff8ec9594a" + ], + "recallDays": [ + "2026-05-25" + ], + "conceptTags": [ + "studytime-analysis", + "vala-user", + "vala-app-character", + "account-id", + "女/1", + "yyyy-mm-dd", + "vala-app-account", + "get-mysql-connection" + ] + }, + "memory:memory/2026-05-24.md:85:110": { + "key": "memory:memory/2026-05-24.md:85:110", + "path": "memory/2026-05-24.md", + "startLine": 85, + "endLine": 110, + "source": "memory", + "snippet": "- `skills/studytime-analysis/scripts/studytime_analysis.py` — 重写 `fetch_chapter_info_map()`,新增全局 unit_index 计算;HTML 模板更新为 Level/Unit/Lesson 三列 - 已为角色 32009(zyl)重新生成 HTML 并发送 - 已同步 Git + SkillHub ## 新建技能: studycourse-analysis (2026-05-24) [刘庆逊提出] 创建角色上课情况分析技能,从四维度分析角色学习数据。 ### 技能结构 - `skills/studycourse-analysis/SKILL.md` — 技能定义 - `skills/studycourse-analysis/scripts/studycourse_analysis.py` — Python 分析脚本 ### 四步分析 1. **基础信息**:角色姓名/年龄/账号ID/手机号后4位/注册时间/购买渠道/设备/首末次完课 2. **完课耗时**:平均值/中位数、异常检测(<10min / >20min)、前后半段趋势 3. **中互动正确率**:Perfect/Good/Oops/Pass/Failed 占比和趋势 4. **知识巩固**:完成率、正确率得分分布 ### 数据源 | 类型 | 库 | 表 | 用途 | |------|-----|-----|------| | MySQL vala_user | vala_app_character | 角色信息、pu", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-25T05:47:41.388Z", + "lastRecalledAt": "2026-05-25T05:47:41.388Z", + "queryHashes": [ + "9aff8ec9594a" + ], + "recallDays": [ + "2026-05-25" + ], + "conceptTags": [ + "fetch-chapter-info-map", + "unit-index", + "level/unit/lesson", + "studycourse-analysis", + "平均值/中位数", + "perfect/good/oops/pass/failed", + "vala-user", + "vala-app-character" + ] } } } diff --git a/memory/2026-05-25.md b/memory/2026-05-25.md new file mode 100644 index 0000000..8386c37 --- /dev/null +++ b/memory/2026-05-25.md @@ -0,0 +1,25 @@ +# 2026-05-25 工作日志 + +## user-info 技能重写 + +[刘庆逊提出] 修复 `user-info` 技能,使其匹配线上实际数据库结构。 + +### 问题 +旧脚本引用的表(`bi_vala_app_account`、`account_login`、`account_detail_info`、`bi_vala_order`、`bi_level_unit_lesson`)在线上数据库均不存在。 + +### 修复内容 +- **scripts/query_user_info.py** 完整重写: + - 表名改为实际线上表:`vala_user.vala_app_account`、`vala_user.vala_app_character`、`vala_order.vala_seasonal_ticket`、PG `user_chapter_play_record_0~7` + - 手机号查询通过 `tel LIKE '前缀%后缀'` 脱敏匹配 + - Chapter → Level/Unit/Lesson 映射复用 studytime-analysis 的 `fetch_chapter_info_map()` 逻辑 + - 订单数据改用 `vala_seasonal_ticket`(赛季通票),因线上无标准订单表 + - 设备/地域信息标注为暂不可用(线上无对应表) + - PG 时区处理:`created_at` 为 tz-aware,统一转 naive 比较 +- **SKILL.md** 更新至 v2.0.0,补充数据覆盖说明 +- **references/database_schema.md** 重写为实际线上表结构 + +### 已验证的查询方式 +- `--account-id 14157` ✓ +- `--role-id 18556` ✓ +- `--phone 18000007778` ✓(脱敏匹配) +- `--order-id ` ✓(含账号有效性校验) diff --git a/skills/user-info/SKILL.md b/skills/user-info/SKILL.md new file mode 100644 index 0000000..ae924f4 --- /dev/null +++ b/skills/user-info/SKILL.md @@ -0,0 +1,79 @@ +--- +name: user-info +version: 2.0.0 +description: Query complete user info from online databases — resolve account/role/phone/ticket ID to unified account profile with tickets, characters, learning progress and activity levels. +--- + +# user-info — 用户信息查询 + +## When to Use + +用户提到以下任意一种请求时触发: +- "帮我查询一下账号 [ID] 的信息" +- "查一下角色 [ID] 的信息" +- "查一下手机号 [号码] 的信息" +- "查一下订单号 [ID] 的信息" +- "查一下用户信息" / "用户信息查询" + +## Core Rules + +1. 从用户输入中提取查询条件(账号ID / 角色ID / 手机号 / 订单号),数字型优先匹配 ID,11 位数字优先匹配手机号 +2. 调用 `scripts/query_user_info.py` 执行查询,传入对应参数 +3. 将脚本输出的格式化结果原样返回给用户 +4. 若脚本报错(如未找到账号),将错误信息如实反馈给用户 +5. 所有查询均为只读操作,不修改任何数据 + +## 执行命令 + +```bash +python3 skills/user-info/scripts/query_user_info.py --account-id +python3 skills/user-info/scripts/query_user_info.py --role-id +python3 skills/user-info/scripts/query_user_info.py --phone <号码> +python3 skills/user-info/scripts/query_user_info.py --order-id +``` + +支持 `--json` 参数输出 JSON 格式。 + +## 查询内容 + +脚本自动完成以下五步查询并格式化输出: + +| 步骤 | 内容 | +|------|------| +| 第1步 | 解析输入 → 定位唯一 account_id | +| 第2步 | 账号基本信息:注册时间、最近活跃、角色数、赛季通票数、下载渠道、注册来源 | +| 第3步 | 全部赛季通票(订单):类型、套餐名、发放/过期/使用时间、状态 | +| 第4步 | 全部角色:名称、性别、年龄、当前课程位置(Level/Unit/Lesson)、上次完课时间、活跃度 | +| 第5步 | 汇总格式化输出 | + +## 活跃度判定规则 + +- 无完课记录 → **无完课记录** +- 最近 14 天无完课记录 → **流失** +- 最近 14 天完课 > 10 次 → **高活跃** +- 最近 14 天完课 < 5 次 → **低活跃** +- 其余 → **正常活跃** + +## 数据覆盖说明 + +线上数据库表结构与测试环境不同,以下字段暂不可用: + +| 字段 | 原因 | +|------|------| +| 登录设备记录 | 线上无 `account_login` 表 | +| 地域信息 | 线上无 `account_detail_info` 表 | +| 商品订单(金额、渠道) | 线上仅 `vala_seasonal_ticket`(赛季通票),无标准订单表 | + +## Quick Reference + +| 内容 | 文件 | +|------|------| +| 数据库表结构参考 | `references/database_schema.md` | +| 查询脚本 | `scripts/query_user_info.py` | + +## 依赖 + +- Python 3 +- pymysql, psycopg2-binary +- MySQL Online:vala_user(账号/角色)、vala_order(赛季通票)、vala(章节/赛季映射) +- PostgreSQL Online vala(user_chapter_play_record_0~7) diff --git a/skills/user-info/references/database_schema.md b/skills/user-info/references/database_schema.md new file mode 100644 index 0000000..d0dd070 --- /dev/null +++ b/skills/user-info/references/database_schema.md @@ -0,0 +1,103 @@ +# 数据库表结构参考 — user-info 技能 + +## 涉及数据源 + +| 数据库 | 数据库名 | 用途 | 表 | +|--------|---------|------|-----| +| MySQL Online | vala_user | 账号/角色 | vala_app_account, vala_app_character | +| MySQL Online | vala_order | 赛季通票(订单) | vala_seasonal_ticket | +| MySQL Online | vala | 课程结构 | vala_game_chapter, vala_game_season_package | +| PostgreSQL Online | vala | 学习行为 | user_chapter_play_record_0~7 | + +## MySQL 核心表 + +### vala_user.vala_app_account — 用户账号表 +| 字段 | 用途 | +|------|------| +| id | 账号ID | +| tel | 手机号(脱敏,格式 138****1234) | +| created_at | 注册时间 | +| updated_at | 最近活跃时间 | +| login_times | 登录次数 | +| key_from | 注册来源 | +| download_channel | 下载渠道编码 | +| status | 1=有效 | +| deleted_at | 软删除标记 | + +### vala_user.vala_app_character — 角色表 +| 字段 | 用途 | +|------|------| +| id | 角色ID | +| account_id | 所属账号ID | +| nickname | 角色名称 | +| gender | 0=女, 1=男 | +| birthday | 生日 (YYYY-MM-DD) | +| latest_login | 最后登录时间 | +| created_at | 创建时间 | +| purchase_season_package | 已购赛季包列表 | +| deleted_at | 软删除标记 | + +### vala_order.vala_seasonal_ticket — 赛季通票表(订单替代) +| 字段 | 用途 | +|------|------| +| id | 通票ID | +| account_id | 账号ID | +| season_package_name | 套餐名称 | +| ticket_type | 1=赛季通票, 2=兑换卡 | +| status | -1=已过期, 0=未使用, 1=已使用 | +| give_time | 发放时间 (Unix时间戳) | +| expire_time | 过期时间 (Unix时间戳) | +| used_time | 使用时间 (Unix时间戳) | +| created_at | 创建时间 | + +### vala.vala_game_chapter — 课程章节表 +| 字段 | 用途 | +|------|------| +| id | 章节ID(关联 play_record.chapter_id) | +| cn_name | 中文名称 | +| season_package_id | 所属赛季包ID | +| lesson_type | 1=常规课(用于单元分组) | +| index | 课程序号 | + +### vala.vala_game_season_package — 赛季包表 +| 字段 | 用途 | +|------|------| +| id | 赛季包ID | +| level | 课程级别 (A1/A2/B1...) | +| season_of_quarter | 季度序号 | +| cn_name | 中文名称 | + +## PostgreSQL 核心表 + +### vala.user_chapter_play_record_0~7 — 章节完课记录(分8表) +| 字段 | 用途 | +|------|------| +| user_id | 角色ID | +| chapter_id | 章节ID(关联 MySQL vala_game_chapter.id) | +| chapter_unique_id | 完课唯一标识 | +| level | 课程级别 (如 "A2") | +| play_status | 1=完成 | +| created_at | 创建时间 (tz-aware) | +| updated_at | 更新时间 (tz-aware) | + +## 单元计算规则 + +Chapter → Level/Unit/Lesson 映射通过 `vala_game_chapter` + `vala_game_season_package` 计算: + +- 每 5 个连续 lesson_type=1 的章节组成一个 Unit +- season_of_quarter=0 全部属 Unit 0 +- season_of_quarter≥1 时基础 Unit = 1+12×(s-1),再叠加组内偏移 + +## 渠道编码映射 + +| 编码 | 渠道 | 编码 | 渠道 | +|------|------|------|------| +| 11 | 苹果 | 12 | 华为 | +| 13 | 小米 | 14 | 荣耀 | +| 15 | 应用宝 | 17 | 魅族 | +| 18 | VIVO | 19 | OPPO | +| 21 | 学而思 | 22 | 讯飞 | +| 23 | 步步高 | 24 | 作业帮 | +| 25 | 小度 | 26 | 希沃 | +| 27 | 京东方 | 41 | 官网 | +| 71 | 小程序 | 其他 | 站外 | diff --git a/skills/user-info/scripts/query_user_info.py b/skills/user-info/scripts/query_user_info.py new file mode 100644 index 0000000..f4aed56 --- /dev/null +++ b/skills/user-info/scripts/query_user_info.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +""" +用户信息查询脚本 — user-info 技能核心引擎 +查询指定用户(按账号ID/角色ID/手机号/订单号)的完整信息: + 1. 解析到唯一 account_id + 2. 账号基本信息(注册时间、最近活跃、角色数、订单数、设备、地域) + 3. 全部订单(赛季通票)信息 + 4. 全部角色信息(含学习进度、活跃度) + 5. 格式化输出 +""" + +import argparse +import sys +import json +import warnings +from datetime import datetime, timedelta, date +from collections import OrderedDict as OD + +warnings.filterwarnings("ignore") + +# ── 数据库连接配置 ────────────────────────────────────────────── +MYSQL_CONFIG = { + "host": "bj-cdb-dh2fkqa0.sql.tencentcdb.com", + "port": 27751, + "user": "read_only", + "password": "fsdo45ijfmfmuu77$%^&", + "charset": "utf8mb4", +} + +PG_CONFIG = { + "host": "bj-postgres-16pob4sg.sql.tencentcdb.com", + "port": 28591, + "user": "ai_member", + "password": "LdfjdjL83h3h3^$&**YGG*", + "dbname": "vala", +} + +# ── 映射表 ────────────────────────────────────────────────────── +CHANNEL_MAP = { + "11": "苹果", "12": "华为", "13": "小米", "14": "荣耀", + "15": "应用宝", "17": "魅族", "18": "VIVO", "19": "OPPO", + "21": "学而思", "22": "讯飞", "23": "步步高", "24": "作业帮", + "25": "小度", "26": "希沃", "27": "京东方", + "41": "官网", "71": "小程序", +} + +GENDER_MAP = {"0": "女", "1": "男"} +TICKET_STATUS_MAP = {-1: "已过期", 0: "未使用", 1: "已使用"} +TICKET_TYPE_MAP = {1: "赛季通票", 2: "兑换卡"} + +# ── 数据库连接 ────────────────────────────────────────────────── +def get_mysql_conn(db="vala_user"): + import pymysql + return pymysql.connect( + host=MYSQL_CONFIG["host"], port=MYSQL_CONFIG["port"], + user=MYSQL_CONFIG["user"], password=MYSQL_CONFIG["password"], + database=db, charset=MYSQL_CONFIG["charset"], + cursorclass=pymysql.cursors.DictCursor, + ) + +def get_pg_conn(): + import psycopg2 + return psycopg2.connect( + host=PG_CONFIG["host"], port=PG_CONFIG["port"], + user=PG_CONFIG["user"], password=PG_CONFIG["password"], + dbname=PG_CONFIG["dbname"], + ) + +# ── Chapter Info Map(复刻 studytime-analysis 逻辑)───────────── +_chapter_map_cache = None + +def fetch_chapter_info_map(): + """建立 chapter_id → {level, unit_index, lesson_index, lesson_type} 映射 + 单元计算规则:每 5 个连续 lesson 章节(lesson_type=1)组成一个单元; + season_of_quarter=0 全部属 unit 0; s>=1 时 base=1+12*(s-1) + """ + global _chapter_map_cache + if _chapter_map_cache is not None: + return _chapter_map_cache + + conn = get_mysql_conn("vala") + try: + with conn.cursor() as cur: + cur.execute(""" + SELECT gc.id, IFNULL(sp.level,''), IFNULL(sp.season_of_quarter,-1), + gc.`index`, gc.lesson_type + FROM vala_game_chapter gc + LEFT JOIN vala_game_season_package sp ON gc.season_package_id = sp.id + ORDER BY sp.season_of_quarter, gc.id + """) + rows = cur.fetchall() + finally: + conn.close() + + # 按 (level, season_of_quarter) 分组 + seasons = OD() + for row in rows: + ch_id = int(row["id"]) + lv = (row["IFNULL(sp.level,'')"] or "").strip() + sq = int(row["IFNULL(sp.season_of_quarter,-1)"] or -1) + li = int(row["index"] or 0) + lt = int(row["lesson_type"] or 1) + key = (lv, sq) + seasons.setdefault(key, []).append((ch_id, lv, li, lt)) + + def base(s): + return 0 if s <= 0 else 1 + 12 * (s - 1) + + _chapter_map_cache = {} + for (lv_key, sq), ch_list in seasons.items(): + regular = [(cid, lv, li) for cid, lv, li, lt in ch_list if lt == 1] + uid_map = {} + if sq <= 0: + for cid, lv, li in regular: + uid_map[cid] = 0 + else: + for pos, (cid, lv, li) in enumerate(regular): + uid_map[cid] = pos // 5 + for cid, lv, li, lt in ch_list: + u = uid_map.get(cid, -1) + _chapter_map_cache[cid] = dict( + level=lv, unit_index=base(sq) + u if u >= 0 else -1, + lesson_index=li, lesson_type=lt) + return _chapter_map_cache + + +# ══════════════════════════════════════════════════════════════════ +# 第1步:解析 account_id +# ══════════════════════════════════════════════════════════════════ +def resolve_account_id(mysql, account_id=None, role_id=None, phone=None, order_id=None): + """从任意输入定位到唯一的 account_id""" + given = [x for x in [account_id, role_id, phone, order_id] if x is not None] + if len(given) != 1: + print("错误:请指定 --account-id、--role-id、--phone、--order-id 中的恰好一个", file=sys.stderr) + sys.exit(1) + + aid = None + with mysql.cursor() as cur: + if account_id is not None: + cur.execute( + "SELECT id FROM vala_app_account WHERE id = %s AND status = 1 AND deleted_at IS NULL", + (account_id,)) + row = cur.fetchone() + if row: + aid = row["id"] + else: + print(f"错误:未找到账号 ID={account_id},可能已删除或不存在", file=sys.stderr) + sys.exit(1) + + elif role_id is not None: + cur.execute( + "SELECT account_id FROM vala_app_character WHERE id = %s AND deleted_at IS NULL", + (role_id,)) + row = cur.fetchone() + if row: + aid = row["account_id"] + else: + print(f"错误:未找到角色 ID={role_id},可能已删除或不存在", file=sys.stderr) + sys.exit(1) + + elif phone is not None: + phone = str(phone).strip() + if len(phone) < 7: + print(f"错误:手机号太短,请输入完整号码", file=sys.stderr) + sys.exit(1) + prefix = phone[:3] + suffix = phone[-4:] + cur.execute( + "SELECT id, tel FROM vala_app_account " + "WHERE tel LIKE %s AND status = 1 AND deleted_at IS NULL", + (f"{prefix}%{suffix}",)) + rows = cur.fetchall() + if len(rows) == 0: + print(f"错误:未找到手机号 {phone} 对应的有效账号", file=sys.stderr) + sys.exit(1) + elif len(rows) == 1: + aid = rows[0]["id"] + else: + ids = [str(r["id"]) for r in rows] + print(f"错误:手机号 {phone} 匹配到 {len(rows)} 个账号 (ID: {', '.join(ids)}),请使用账号ID查询", file=sys.stderr) + sys.exit(1) + + elif order_id is not None: + order_mysql = get_mysql_conn("vala_order") + try: + with order_mysql.cursor() as oc: + oc.execute( + "SELECT account_id FROM vala_seasonal_ticket WHERE id = %s", + (order_id,)) + row = oc.fetchone() + if row: + tmp_aid = row["account_id"] + # 验证账号有效性 + cur.execute( + "SELECT id FROM vala_app_account WHERE id = %s AND status = 1 AND deleted_at IS NULL", + (tmp_aid,)) + if cur.fetchone(): + aid = tmp_aid + else: + print(f"错误:订单 ID={order_id} 对应的账号 {tmp_aid} 已失效或不存在", file=sys.stderr) + sys.exit(1) + else: + print(f"错误:未找到订单 ID={order_id}", file=sys.stderr) + sys.exit(1) + finally: + order_mysql.close() + + return aid + + +# ══════════════════════════════════════════════════════════════════ +# 第2步:账号基本信息 +# ══════════════════════════════════════════════════════════════════ +def fetch_account_basic_info(mysql, account_id): + info = {} + with mysql.cursor() as cur: + cur.execute( + "SELECT id, tel, created_at, updated_at, login_times, key_from, download_channel " + "FROM vala_app_account WHERE id = %s", (account_id,)) + acct = cur.fetchone() + if not acct: + return None + + info["account_id"] = acct["id"] + info["tel"] = acct.get("tel") or "未知" + info["register_time"] = str(acct["created_at"]) if acct["created_at"] else "未知" + info["last_active"] = str(acct["updated_at"]) if acct["updated_at"] else "未知" + info["login_times"] = acct.get("login_times") or 0 + info["key_from"] = acct.get("key_from") or "未知" + raw_ch = str(acct.get("download_channel") or "") + info["download_channel"] = CHANNEL_MAP.get(raw_ch, raw_ch) if raw_ch else "未知" + + # 角色数 + cur.execute( + "SELECT COUNT(*) AS cnt FROM vala_app_character " + "WHERE account_id = %s AND deleted_at IS NULL", (account_id,)) + info["role_count"] = cur.fetchone()["cnt"] + + # 订单数(在 vala_order 库) + order_mysql = get_mysql_conn("vala_order") + try: + with order_mysql.cursor() as oc: + oc.execute( + "SELECT COUNT(*) AS cnt FROM vala_seasonal_ticket WHERE account_id = %s", + (account_id,)) + info["ticket_count"] = oc.fetchone()["cnt"] + finally: + order_mysql.close() + + # 设备和地域:线上数据库无 account_login / account_detail_info 表 + info["recent_devices"] = [] + info["region"] = "暂无数据(线上无地域详情表)" + + return info + + +# ══════════════════════════════════════════════════════════════════ +# 第3步:全部订单(赛季通票)信息 +# ══════════════════════════════════════════════════════════════════ +def fetch_tickets(account_id): + order_mysql = get_mysql_conn("vala_order") + try: + with order_mysql.cursor() as cur: + cur.execute( + "SELECT id, season_package_name, ticket_type, status, " + "give_time, expire_time, used_time, created_at " + "FROM vala_seasonal_ticket " + "WHERE account_id = %s ORDER BY created_at DESC", (account_id,)) + rows = cur.fetchall() + finally: + order_mysql.close() + + tickets = [] + for r in rows: + tickets.append({ + "id": r["id"], + "name": r["season_package_name"] or "未知套餐", + "ticket_type": TICKET_TYPE_MAP.get(r["ticket_type"], f"类型{r['ticket_type']}"), + "status": TICKET_STATUS_MAP.get(r["status"], f"状态{r['status']}"), + "give_time": _ts_to_str(r["give_time"]), + "expire_time": _ts_to_str(r["expire_time"]), + "used_time": _ts_to_str(r["used_time"]), + "created_at": str(r["created_at"]) if r.get("created_at") else "未知", + }) + return tickets + + +def _ts_to_str(ts): + if not ts or ts == 0: + return "未知" + try: + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return str(ts) + + +# ══════════════════════════════════════════════════════════════════ +# 第4步:全部角色信息(含学习进度、活跃度) +# ══════════════════════════════════════════════════════════════════ +def fetch_characters(mysql, account_id, cmap): + with mysql.cursor() as cur: + cur.execute( + "SELECT id, nickname, gender, birthday, latest_login, created_at " + "FROM vala_app_character " + "WHERE account_id = %s AND deleted_at IS NULL ORDER BY id", (account_id,)) + chars = cur.fetchall() + + if not chars: + return [] + + from psycopg2.tz import FixedOffsetTimezone + now = datetime.now() + today = now.date() + cutoff_14d = now - timedelta(days=14) + # PG 时区为 Asia/Shanghai (UTC+8) + cutoff_14d_aware = cutoff_14d.replace(tzinfo=FixedOffsetTimezone(offset=8*60, name="CST")) + + pg = get_pg_conn() + try: + with pg.cursor() as cur: + result = [] + for ch in chars: + ch_id = ch["id"] + + # ── 汇总 8 张分表的所有完课记录 ── + last_completion = None + last_chapter_id = None + last_chapter_level = None + recent_count = 0 + total_count = 0 + + # 构造 UNION ALL 查询,参数需要不同名称 + union_sql = " UNION ALL ".join( + [f"SELECT user_id, chapter_id, level, created_at, updated_at " + f"FROM user_chapter_play_record_{i} " + f"WHERE user_id = %(uid_{i})s AND play_status = 1" + for i in range(8)]) + params = {} + for i in range(8): + params[f"uid_{i}"] = ch_id + + cur.execute( + f"SELECT chapter_id, level, created_at, updated_at FROM ({union_sql}) sub " + f"ORDER BY updated_at DESC", params) + rows = cur.fetchall() + + for r in rows: + dt = r[2] or r[3] # created_at or updated_at + if dt: + total_count += 1 + dt_naive = dt.replace(tzinfo=None) if dt.tzinfo else dt + if last_completion is None or dt_naive > last_completion: + last_completion = dt_naive + last_chapter_id = r[0] + last_chapter_level = r[1] + if dt_naive >= cutoff_14d: + recent_count += 1 + + # ── 当前课程位置 ── + course_info = "-" + if last_chapter_id and cmap: + ci = cmap.get(last_chapter_id) + if ci: + lv = ci.get("level") or last_chapter_level or "" + ui = ci.get("unit_index", -1) + lsn = ci.get("lesson_index", -1) + parts = [] + if lv: + parts.append(f"Level {lv}") + if ui >= 0: + parts.append(f"Unit {ui}") + if lsn >= 0: + parts.append(f"Lesson {lsn}") + course_info = " > ".join(parts) if parts else str(last_chapter_id) + elif last_chapter_level: + course_info = f"Level {last_chapter_level} (Chapter {last_chapter_id})" + else: + course_info = f"Chapter {last_chapter_id}" + elif last_chapter_level: + course_info = f"Level {last_chapter_level}" + + # ── 年龄 ── + age = "未知" + birthday = ch.get("birthday") + if birthday and str(birthday).strip(): + try: + bd_str = str(birthday).strip()[:10] + bd = datetime.strptime(bd_str, "%Y-%m-%d").date() + age = str(today.year - bd.year - ((today.month, today.day) < (bd.month, bd.day))) + except Exception: + pass + + # ── 活跃度判定 ── + if total_count == 0: + activity = "无完课记录" + elif last_completion and last_completion < cutoff_14d: + activity = "流失" + elif recent_count > 10: + activity = "高活跃" + elif recent_count < 5: + activity = "低活跃" + else: + activity = "正常活跃" + + result.append({ + "id": ch_id, + "nickname": ch.get("nickname") or "未命名", + "gender": GENDER_MAP.get(str(ch.get("gender")), "未知"), + "age": age, + "birthday": birthday or "未知", + "course": course_info, + "last_completion": last_completion.strftime("%Y-%m-%d %H:%M:%S") if last_completion else "无记录", + "total_completions": total_count, + "recent_14d": recent_count, + "activity": activity, + "latest_login": str(ch["latest_login"]) if ch.get("latest_login") else "未知", + "created_at": str(ch["created_at"]) if ch.get("created_at") else "未知", + }) + finally: + pg.close() + + return result + + +# ══════════════════════════════════════════════════════════════════ +# 第5步:格式化输出 +# ══════════════════════════════════════════════════════════════════ +def format_output_text(account_id, basics, tickets, characters): + lines = [] + lines.append("=" * 70) + lines.append(f" 用户信息查询结果") + lines.append("=" * 70) + + if basics is None: + lines.append("未查询到该账号信息") + return "\n".join(lines) + + # ── 账号基本信息 ── + lines.append("") + lines.append("📋 账号基本信息") + lines.append(f" 账号ID: {basics['account_id']}") + lines.append(f" 手机号: {basics['tel']}") + lines.append(f" 注册时间: {basics['register_time']}") + lines.append(f" 最近活跃: {basics['last_active']}") + lines.append(f" 登录次数: {basics['login_times']}") + lines.append(f" 注册来源: {basics['key_from']}") + lines.append(f" 下载渠道: {basics['download_channel']}") + lines.append(f" 角色数量: {basics['role_count']}") + lines.append(f" 赛季通票数: {basics['ticket_count']}") + lines.append(f" 所属地区: {basics['region']}") + + if basics["recent_devices"]: + lines.append("") + lines.append("📱 最近登录设备 (最近10次):") + for i, dev in enumerate(basics["recent_devices"], 1): + lines.append(f" {i}. {dev}") + else: + lines.append(f" 登录设备: 暂无数据") + + # ── 订单(通票)信息 ── + lines.append("") + lines.append("─" * 70) + lines.append(f"🎫 赛季通票 (共 {len(tickets)} 张)") + lines.append("─" * 70) + if tickets: + for i, t in enumerate(tickets, 1): + lines.append(f"\n [{i}] {t['name']}") + lines.append(f" 类型: {t['ticket_type']} | 状态: {t['status']}") + lines.append(f" 发放时间: {t['give_time']}") + lines.append(f" 过期时间: {t['expire_time']}") + lines.append(f" 使用时间: {t['used_time']}") + lines.append(f" 创建时间: {t['created_at']}") + else: + lines.append(" (无记录)") + + # ── 角色信息 ── + lines.append("") + lines.append("─" * 70) + lines.append(f"🎮 角色信息 (共 {len(characters)} 个)") + lines.append("─" * 70) + if characters: + for i, c in enumerate(characters, 1): + lines.append(f"\n [{i}] {c['nickname']} (角色ID: {c['id']})") + lines.append(f" 性别: {c['gender']} | 年龄: {c['age']} 岁 | 生日: {c['birthday']}") + lines.append(f" 当前课程: {c['course']}") + lines.append(f" 上次完课: {c['last_completion']}") + lines.append(f" 累计完课: {c['total_completions']} 节 | 近14天: {c['recent_14d']} 节") + lines.append(f" 活跃程度: {c['activity']}") + lines.append(f" 最后登录: {c['latest_login']}") + else: + lines.append(" (无角色)") + + lines.append("") + lines.append("=" * 70) + return "\n".join(lines) + + +# ══════════════════════════════════════════════════════════════════ +# 主流程 +# ══════════════════════════════════════════════════════════════════ +def main(): + parser = argparse.ArgumentParser(description="查询用户完整信息") + parser.add_argument("--account-id", type=int, help="账号ID") + parser.add_argument("--role-id", type=int, help="角色ID") + parser.add_argument("--phone", type=str, help="手机号") + parser.add_argument("--order-id", type=int, help="订单(通票)ID") + parser.add_argument("--json", action="store_true", help="输出JSON格式") + args = parser.parse_args() + + # 第1步:解析 account_id + mysql = get_mysql_conn("vala_user") + try: + account_id = resolve_account_id(mysql, args.account_id, args.role_id, args.phone, args.order_id) + + # 第2步:账号基本信息 + basics = fetch_account_basic_info(mysql, account_id) + + # 第3步:订单(通票)信息 + tickets = fetch_tickets(account_id) + + # 第4步:角色信息(含学习进度) + cmap = fetch_chapter_info_map() + characters = fetch_characters(mysql, account_id, cmap) + finally: + mysql.close() + + # 第5步:输出 + if args.json: + result = { + "account_basics": basics, + "tickets": tickets, + "characters": characters, + } + class DateTimeEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, (datetime, date)): + return str(obj) + return super().default(obj) + print(json.dumps(result, ensure_ascii=False, indent=2, cls=DateTimeEncoder)) + else: + print(format_output_text(account_id, basics, tickets, characters)) + + +if __name__ == "__main__": + main() diff --git a/tmp_daily_summary.md b/tmp_daily_summary.md new file mode 100644 index 0000000..eca036e --- /dev/null +++ b/tmp_daily_summary.md @@ -0,0 +1,4 @@ +=== 每日总结 20260526 === +## 昨日关键进展 +[刘庆逊提出] 修复 `user-info` 技能,使其匹配线上实际数据库结构。 +### 修复内容