Compare commits

...

2 Commits

Author SHA1 Message Date
xiaoban
48953c0dbd 自动备份 2026-05-26 08:10:01 2026-05-26 08:10:02 +08:00
xiaoban
bfbd649f9f 每日总结更新 20260526 2026-05-26 08:00:01 +08:00
7 changed files with 839 additions and 11 deletions

View File

@ -414,3 +414,12 @@ To https://git.valavala.com/ai_member_only/ai_member_xiaoban
906af79..e279bc7 master -> master 906af79..e279bc7 master -> master
[2026-05-24 08:10:02] 工作区备份成功:自动备份 2026-05-24 08:10:01 [2026-05-24 08:10:02] 工作区备份成功:自动备份 2026-05-24 08:10:01
[2026-05-25 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
[2026-05-26 08:10:01] 开始备份工作区...

View File

@ -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-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}]}

View File

@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-05-24T02:48:04.923Z", "updatedAt": "2026-05-25T05:47:41.388Z",
"entries": { "entries": {
"memory:memory/2026-05-24.md:1:30": { "memory:memory/2026-05-24.md:1:30": {
"key": "memory:memory/2026-05-24.md:1:30", "key": "memory:memory/2026-05-24.md:1:30",
@ -9,18 +9,20 @@
"endLine": 30, "endLine": 30,
"source": "memory", "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 Onlinevala 库) - 核心表:`user_chapter_play_record_0~7`8张分表无 `bi_` 前缀) - 筛选:`play_status = 1` - 注意:表在 PostgreSQL 而非 MySQL表名无 `bi_` 前缀 ### 寒暑假规则 - 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色", "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 Onlinevala 库) - 核心表:`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, "dailyCount": 0,
"groundedCount": 0, "groundedCount": 0,
"totalScore": 1, "totalScore": 2,
"maxScore": 1, "maxScore": 1,
"firstRecalledAt": "2026-05-24T02:48:04.923Z", "firstRecalledAt": "2026-05-24T02:48:04.923Z",
"lastRecalledAt": "2026-05-24T02:48:04.923Z", "lastRecalledAt": "2026-05-25T05:47:41.388Z",
"queryHashes": [ "queryHashes": [
"c2d15f7574fb" "c2d15f7574fb",
"9aff8ec9594a"
], ],
"recallDays": [ "recallDays": [
"2026-05-24" "2026-05-24",
"2026-05-25"
], ],
"conceptTags": [ "conceptTags": [
"studytime-analysis", "studytime-analysis",
@ -40,18 +42,20 @@
"endLine": 52, "endLine": 52,
"source": "memory", "source": "memory",
"snippet": "- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色 - 2343、2344无完课记录play_status=2未完成 - 2840276条记录秋季集中型用户 - 25976265条246条在W16周一天完成A2批量疑似系统批量标记 - 2895188条长期稳定学习型用户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", "snippet": "- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色 - 2343、2344无完课记录play_status=2未完成 - 2840276条记录秋季集中型用户 - 25976265条246条在W16周一天完成A2批量疑似系统批量标记 - 2895188条长期稳定学习型用户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, "dailyCount": 0,
"groundedCount": 0, "groundedCount": 0,
"totalScore": 1, "totalScore": 2,
"maxScore": 1, "maxScore": 1,
"firstRecalledAt": "2026-05-24T02:48:04.923Z", "firstRecalledAt": "2026-05-24T02:48:04.923Z",
"lastRecalledAt": "2026-05-24T02:48:04.923Z", "lastRecalledAt": "2026-05-25T05:47:41.388Z",
"queryHashes": [ "queryHashes": [
"c2d15f7574fb" "c2d15f7574fb",
"9aff8ec9594a"
], ],
"recallDays": [ "recallDays": [
"2026-05-24" "2026-05-24",
"2026-05-25"
], ],
"conceptTags": [ "conceptTags": [
"1-2", "1-2",
@ -125,6 +129,68 @@
"vala-app-account", "vala-app-account",
"get-mysql-connection" "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 三列 - 已为角色 32009zyl重新生成 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"
]
} }
} }
} }

25
memory/2026-05-25.md Normal file
View File

@ -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 <id>` ✓(含账号有效性校验)

79
skills/user-info/SKILL.md Normal file
View File

@ -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 / 手机号 / 订单号),数字型优先匹配 ID11 位数字优先匹配手机号
2. 调用 `scripts/query_user_info.py` 执行查询,传入对应参数
3. 将脚本输出的格式化结果原样返回给用户
4. 若脚本报错(如未找到账号),将错误信息如实反馈给用户
5. 所有查询均为只读操作,不修改任何数据
## 执行命令
```bash
python3 skills/user-info/scripts/query_user_info.py --account-id <ID>
python3 skills/user-info/scripts/query_user_info.py --role-id <ID>
python3 skills/user-info/scripts/query_user_info.py --phone <号码>
python3 skills/user-info/scripts/query_user_info.py --order-id <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 Onlinevala_user账号/角色、vala_order赛季通票、vala章节/赛季映射)
- PostgreSQL Online valauser_chapter_play_record_0~7

View File

@ -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 | 小程序 | 其他 | 站外 |

View File

@ -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()