diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index c7b1aab..c82725b 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1,33 +1,43 @@ -# WeFlow HTTP API 接口文档 +# WeFlow HTTP API 文档 -WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。 +WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件。 -## 启用 API 服务 +## 启用方式 -在设置页面 → API 服务 → 点击「启动服务」按钮。 +在应用设置页启用 `API 服务`。 -默认端口:`5031` - -## 基础地址 - -``` -http://127.0.0.1:5031 -``` - ---- +- 默认监听地址:`127.0.0.1` +- 默认端口:`5031` +- 基础地址:`http://127.0.0.1:5031` ## 接口列表 -### 1. 健康检查 +- `GET /health` +- `GET /api/v1/health` +- `GET /api/v1/messages` +- `GET /api/v1/sessions` +- `GET /api/v1/contacts` +- `GET /api/v1/group-members` +- `GET /api/v1/media/*` -检查 API 服务是否正常运行。 +--- + +## 1. 健康检查 **请求** -``` + +```http GET /health ``` +或 + +```http +GET /api/v1/health +``` + **响应** + ```json { "status": "ok" @@ -36,211 +46,180 @@ GET /health --- -### 2. 获取消息列表 +## 2. 获取消息 -获取指定会话的消息,支持 ChatLab 格式输出。 +读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 **请求** -``` + +```http GET /api/v1/messages ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) | -| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` | -| `offset` | number | ❌ | 偏移量,用于分页,默认 0 | -| `start` | string | ❌ | 开始时间,格式 YYYYMMDD | -| `end` | string | ❌ | 结束时间,格式 YYYYMMDD | -| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) | -| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 | -| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` | -| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 | -| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) | -| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) | -| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` | -| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | +| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | +| `offset` | number | 否 | 分页偏移,默认 `0` | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | +| `keyword` | string | 否 | 基于消息显示文本过滤 | +| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | +| `format` | string | 否 | `json` 或 `chatlab` | +| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | +| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | +| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | +| `video` | string | 否 | 在 `media=1` 时控制视频导出 | +| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | -默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media` - -**示例请求** +### 示例 ```bash -# 获取消息(原始格式) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50 - -# 获取消息(ChatLab 格式) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1 - -# 带时间范围查询 -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100 - -# 开启媒体导出(只导出图片和语音) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0 - -# 关键词过滤 -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50 +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20" +curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1" +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131" +curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0" ``` -**响应(原始格式)** +### JSON 响应字段 + +顶层字段: + +- `success` +- `talker` +- `count` +- `hasMore` +- `media.enabled` +- `media.exportPath` +- `media.count` +- `messages` + +单条消息字段: + +- `localId` +- `serverId` +- `localType` +- `createTime` +- `isSend` +- `senderUsername` +- `content` +- `rawContent` +- `parsedContent` +- `mediaType` +- `mediaFileName` +- `mediaUrl` +- `mediaLocalPath` + +**示例响应** + ```json { "success": true, - "talker": "wxid_xxx", - "count": 50, + "talker": "xxx@chatroom", + "count": 2, "hasMore": true, "media": { "enabled": true, "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 12 + "count": 1 }, "messages": [ { "localId": 123, + "serverId": "456", + "localType": 1, + "createTime": 1738713600, + "isSend": 0, + "senderUsername": "wxid_member", + "content": "你好", + "rawContent": "你好", + "parsedContent": "你好" + }, + { + "localId": 124, "localType": 3, + "createTime": 1738713660, + "isSend": 0, + "senderUsername": "wxid_member", "content": "[图片]", - "createTime": 1738713600000, - "senderUsername": "wxid_sender", "mediaType": "image", - "mediaFileName": "image_123.jpg", - "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg", - "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaFileName": "abc123.jpg", + "mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg", + "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg" } ] } ``` -**响应(ChatLab 格式)** -```json -{ - "chatlab": { - "version": "0.0.2", - "exportedAt": 1738713600000, - "generator": "WeFlow", - "description": "Exported from WeFlow" - }, - "meta": { - "name": "会话名称", - "platform": "wechat", - "type": "private", - "ownerId": "wxid_me" - }, - "members": [ - { - "platformId": "wxid_xxx", - "accountName": "用户名", - "groupNickname": "群昵称" - } - ], - "messages": [ - { - "sender": "wxid_xxx", - "accountName": "用户名", - "timestamp": 1738713600000, - "type": 0, - "content": "消息内容", - "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg" - } - ], - "media": { - "enabled": true, - "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 12 - } -} -``` +### ChatLab 响应 + +当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构: + +- `chatlab.version` +- `chatlab.exportedAt` +- `chatlab.generator` +- `meta.name` +- `meta.platform` +- `meta.type` +- `meta.groupId` +- `meta.groupAvatar` +- `meta.ownerId` +- `members[].platformId` +- `members[].accountName` +- `members[].groupNickname` +- `members[].avatar` +- `messages[].sender` +- `messages[].accountName` +- `messages[].groupNickname` +- `messages[].timestamp` +- `messages[].type` +- `messages[].content` +- `messages[].platformMessageId` +- `messages[].mediaPath` + +群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。 --- -### 3. 访问导出媒体文件 - -通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。 +## 3. 获取会话列表 **请求** -``` -GET /api/v1/media/{relativePath} -``` -**路径参数** - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` | - -**支持的媒体类型** - -| 扩展名 | Content-Type | -|--------|-------------| -| `.png` | image/png | -| `.jpg` / `.jpeg` | image/jpeg | -| `.gif` | image/gif | -| `.webp` | image/webp | -| `.wav` | audio/wav | -| `.mp3` | audio/mpeg | -| `.mp4` | video/mp4 | - -**示例请求** -```bash -# 访问导出的图片 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg - -# 访问导出的语音 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav - -# 访问导出的视频 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4 -``` - -**响应** - -成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。 - -失败时返回: -```json -{ "error": "Media not found" } -``` - -> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。 - ---- - -### 4. 获取会话列表 - -获取所有会话列表。 - -**请求** -``` +```http GET /api/v1/sessions ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | +| `limit` | number | 否 | 默认 `100` | -**示例请求** -```bash -GET http://127.0.0.1:5031/api/v1/sessions +### 响应字段 -GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 -``` +- `success` +- `count` +- `sessions[].username` +- `sessions[].displayName` +- `sessions[].type` +- `sessions[].lastTimestamp` +- `sessions[].unreadCount` + +**示例响应** -**响应** ```json { "success": true, - "count": 50, - "total": 100, + "count": 1, "sessions": [ { - "username": "wxid_xxx", - "displayName": "用户名", - "lastMessage": "最后一条消息", - "lastTime": 1738713600000, + "username": "xxx@chatroom", + "displayName": "项目群", + "type": 2, + "lastTimestamp": 1738713600, "unreadCount": 0 } ] @@ -249,40 +228,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 --- -### 4. 获取联系人列表 - -获取所有联系人信息。 +## 4. 获取联系人列表 **请求** -``` + +```http GET /api/v1/contacts ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `keyword` | string | ❌ | 搜索关键词 | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` | +| `limit` | number | 否 | 默认 `100` | -**示例请求** -```bash -GET http://127.0.0.1:5031/api/v1/contacts +### 响应字段 -GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 -``` +- `success` +- `count` +- `contacts[].username` +- `contacts[].displayName` +- `contacts[].remark` +- `contacts[].nickname` +- `contacts[].alias` +- `contacts[].avatarUrl` +- `contacts[].type` + +**示例响应** -**响应** ```json { "success": true, - "count": 50, + "count": 1, "contacts": [ { - "userName": "wxid_xxx", - "alias": "微信号", - "nickName": "昵称", - "remark": "备注名" + "username": "wxid_xxx", + "displayName": "张三", + "remark": "客户张三", + "nickname": "张三", + "alias": "zhangsan", + "avatarUrl": "https://example.com/avatar.jpg", + "type": "friend" } ] } @@ -290,60 +277,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 --- -## ChatLab 格式说明 +## 5. 获取群成员列表 -ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。 +返回群成员的 `wxid`、群昵称、备注、微信号等信息。 -### 消息类型映射 +**请求** -| ChatLab Type | 值 | 说明 | -|--------------|-----|------| -| TEXT | 0 | 文本消息 | -| IMAGE | 1 | 图片 | -| VOICE | 2 | 语音 | -| VIDEO | 3 | 视频 | -| FILE | 4 | 文件 | -| EMOJI | 5 | 表情 | -| LINK | 7 | 链接 | -| LOCATION | 8 | 位置 | -| RED_PACKET | 20 | 红包 | -| TRANSFER | 21 | 转账 | -| CALL | 23 | 通话 | -| SYSTEM | 80 | 系统消息 | -| RECALL | 81 | 撤回消息 | -| OTHER | 99 | 其他 | +```http +GET /api/v1/group-members +``` + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 | +| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | +| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | +| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | + +### 响应字段 + +- `success` +- `chatroomId` +- `count` +- `fromCache` +- `updatedAt` +- `members[].wxid` +- `members[].displayName` +- `members[].nickname` +- `members[].remark` +- `members[].alias` +- `members[].groupNickname` +- `members[].avatarUrl` +- `members[].isOwner` +- `members[].isFriend` +- `members[].messageCount` + +**示例请求** + +```bash +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1" +``` + +**示例响应** + +```json +{ + "success": true, + "chatroomId": "xxx@chatroom", + "count": 2, + "fromCache": false, + "updatedAt": 1760000000000, + "members": [ + { + "wxid": "wxid_member_a", + "displayName": "客户A", + "nickname": "阿甲", + "remark": "客户A", + "alias": "kehua", + "groupNickname": "甲方", + "avatarUrl": "https://example.com/a.jpg", + "isOwner": true, + "isFriend": true, + "messageCount": 128 + }, + { + "wxid": "wxid_member_b", + "displayName": "李四", + "nickname": "李四", + "remark": "", + "alias": "", + "groupNickname": "", + "avatarUrl": "", + "isOwner": false, + "isFriend": false, + "messageCount": 0 + } + ] +} +``` + +说明: + +- `displayName` 是当前应用内的主展示名。 +- `groupNickname` 是成员在该群里的群昵称。 +- `remark` 是你对该联系人的备注。 +- `alias` 是微信号。 +- 当微信源数据里没有群昵称时,`groupNickname` 会为空。 --- -## 使用示例 +## 6. 访问导出媒体 + +通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 + +**请求** + +```http +GET /api/v1/media/{relativePath} +``` + +### 示例 + +```bash +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" +``` + +### 支持的 Content-Type + +| 扩展名 | Content-Type | +| --- | --- | +| `.png` | `image/png` | +| `.jpg` / `.jpeg` | `image/jpeg` | +| `.gif` | `image/gif` | +| `.webp` | `image/webp` | +| `.wav` | `audio/wav` | +| `.mp3` | `audio/mpeg` | +| `.mp4` | `video/mp4` | + +常见错误响应: + +```json +{ + "error": "Media not found" +} +``` + +--- + +## 7. 使用示例 ### PowerShell ```powershell -# 健康检查 Invoke-RestMethod http://127.0.0.1:5031/health - -# 获取会话列表 Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions - -# 获取消息 Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10" - -# 获取 ChatLab 格式 -Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10 +Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1" ``` ### cURL ```bash -# 健康检查 curl http://127.0.0.1:5031/health - -# 获取会话列表 -curl http://127.0.0.1:5031/api/v1/sessions - -# 获取消息(ChatLab 格式) curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" +curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三" +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" ``` ### Python @@ -353,39 +437,26 @@ import requests BASE_URL = "http://127.0.0.1:5031" -# 获取会话列表 -sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json() -print(sessions) +messages = requests.get( + f"{BASE_URL}/api/v1/messages", + params={"talker": "xxx@chatroom", "limit": 50} +).json() + +members = requests.get( + f"{BASE_URL}/api/v1/group-members", + params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1} +).json() -# 获取消息 -messages = requests.get(f"{BASE_URL}/api/v1/messages", params={ - "talker": "wxid_xxx", - "limit": 100, - "chatlab": 1 -}).json() print(messages) -``` - -### JavaScript / Node.js - -```javascript -const BASE_URL = "http://127.0.0.1:5031"; - -// 获取会话列表 -const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json()); -console.log(sessions); - -// 获取消息(ChatLab 格式) -const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`) - .then(r => r.json()); -console.log(messages); +print(members) ``` --- -## 注意事项 +## 8. 注意事项 -1. API 仅监听本地地址 `127.0.0.1`,不对外网开放 -2. 需要先连接数据库才能查询数据 -3. 时间参数格式为 `YYYYMMDD`(如 20260205) -4. 支持 CORS,可从浏览器前端直接调用 +1. API 仅监听本机 `127.0.0.1`,不对外网开放。 +2. 使用前需要先在 WeFlow 中完成数据库连接。 +3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。 +4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。 +5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。 diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index cb62352..0b8b5bc 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -562,23 +562,50 @@ class ExportService { } /** - * 通过 contact.chat_room.ext_buffer 解析群昵称(纯 SQL) + * 获取群成员群昵称。优先使用 DLL,必要时回退到 `contact.chat_room.ext_buffer` 解析。 */ async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { + const nicknameMap = new Map() + + try { + const dllResult = await wcdbService.getGroupNicknames(chatroomId) + if (dllResult.success && dllResult.nicknames) { + this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) + } + } catch (e) { + console.error('getGroupNicknamesForRoom dll error:', e) + } + try { - // 使用参数化查询防止SQL注入 const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) if (!result.success || !result.rows || result.rows.length === 0) { - return new Map() + return nicknameMap } const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) - if (!extBuffer) return new Map() - return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates) + if (!extBuffer) return nicknameMap + this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) + return nicknameMap } catch (e) { console.error('getGroupNicknamesForRoom error:', e) - return new Map() + return nicknameMap + } + } + + private mergeGroupNicknameEntries( + target: Map, + entries: Iterable<[string, string]> + ): void { + for (const [memberIdRaw, nicknameRaw] of entries) { + const nickname = this.normalizeGroupNickname(nicknameRaw || '') + if (!nickname) continue + for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) { + if (!alias) continue + if (!target.has(alias)) target.set(alias, nickname) + const lower = alias.toLowerCase() + if (!target.has(lower)) target.set(lower, nickname) + } } } diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 01c012d..7a03d37 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -261,20 +261,47 @@ class GroupAnalyticsService { * 从 DLL 获取群成员的群昵称 */ private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { + const nicknameMap = new Map() + try { - const escapedChatroomId = chatroomId.replace(/'/g, "''") - const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1` - const result = await wcdbService.execQuery('contact', null, sql) + const dllResult = await wcdbService.getGroupNicknames(chatroomId) + if (dllResult.success && dllResult.nicknames) { + this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) + } + } catch (e) { + console.error('getGroupNicknamesForRoom dll error:', e) + } + + try { + const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' + const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) if (!result.success || !result.rows || result.rows.length === 0) { - return new Map() + return nicknameMap } const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) - if (!extBuffer) return new Map() - return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates) + if (!extBuffer) return nicknameMap + this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) + return nicknameMap } catch (e) { console.error('getGroupNicknamesForRoom error:', e) - return new Map() + return nicknameMap + } + } + + private mergeGroupNicknameEntries( + target: Map, + entries: Iterable<[string, string]> + ): void { + for (const [memberIdRaw, nicknameRaw] of entries) { + const nickname = this.normalizeGroupNickname(nicknameRaw || '') + if (!nickname) continue + for (const alias of this.buildIdCandidates([memberIdRaw])) { + if (!alias) continue + if (!target.has(alias)) target.set(alias, nickname) + const lower = alias.toLowerCase() + if (!target.has(lower)) target.set(lower, nickname) + } } } diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 6e3423f..47f3f8c 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { videoService } from './videoService' import { imageDecryptService } from './imageDecryptService' +import { groupAnalyticsService } from './groupAnalyticsService' // ChatLab 格式定义 interface ChatLabHeader { @@ -238,6 +239,8 @@ class HttpService { await this.handleSessions(url, res) } else if (pathname === '/api/v1/contacts') { await this.handleContacts(url, res) + } else if (pathname === '/api/v1/group-members') { + await this.handleGroupMembers(url, res) } else if (pathname.startsWith('/api/v1/media/')) { this.handleMediaRequest(pathname, res) } else { @@ -589,6 +592,54 @@ class HttpService { } } + /** + * 处理群成员查询 + * GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0 + */ + private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise { + const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim() + const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false) + const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false) + + if (!chatroomId) { + this.sendError(res, 400, 'Missing chatroomId') + return + } + + try { + const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, { + forceRefresh, + includeMessageCounts + }) + if (!result.success || !result.data) { + this.sendError(res, 500, result.error || 'Failed to get group members') + return + } + + this.sendJson(res, { + success: true, + chatroomId, + count: result.data.length, + fromCache: result.fromCache, + updatedAt: result.updatedAt, + members: result.data.map((member) => ({ + wxid: member.username, + displayName: member.displayName, + nickname: member.nickname || '', + remark: member.remark || '', + alias: member.alias || '', + groupNickname: member.groupNickname || '', + avatarUrl: member.avatarUrl, + isOwner: Boolean(member.isOwner), + isFriend: Boolean(member.isFriend), + messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0 + })) + }) + } catch (error) { + this.sendError(res, 500, String(error)) + } + } + private getApiMediaExportPath(): string { return path.join(this.configService.getCacheBasePath(), 'api-media') } @@ -886,7 +937,12 @@ class HttpService { private lookupGroupNickname(groupNicknamesMap: Map, sender: string): string { if (!sender) return '' - return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '' + const cleaned = this.normalizeAccountId(sender) + return groupNicknamesMap.get(sender) + || groupNicknamesMap.get(sender.toLowerCase()) + || groupNicknamesMap.get(cleaned) + || groupNicknamesMap.get(cleaned.toLowerCase()) + || '' } private resolveChatLabSenderInfo( @@ -957,7 +1013,21 @@ class HttpService { try { const result = await wcdbService.getGroupNicknames(talkerId) if (result.success && result.nicknames) { - groupNicknamesMap = new Map(Object.entries(result.nicknames)) + groupNicknamesMap = new Map() + for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) { + const memberId = String(memberIdRaw || '').trim() + const nickname = String(nicknameRaw || '').trim() + if (!memberId || !nickname) continue + + groupNicknamesMap.set(memberId, nickname) + groupNicknamesMap.set(memberId.toLowerCase(), nickname) + + const cleaned = this.normalizeAccountId(memberId) + if (cleaned) { + groupNicknamesMap.set(cleaned, nickname) + groupNicknamesMap.set(cleaned.toLowerCase(), nickname) + } + } } } catch (e) { console.error('[HttpService] Failed to get group nicknames:', e)