refactor(api): 重构为模块化路由架构

- 新增routers目录,按功能划分路由模块
  - health.py: 健康检查端点
  - chat.py: 聊天会话与消息查询
  - media.py: 媒体资源解密
  - decrypt.py: 数据库解密
  - wechat_detection.py: 微信安装检测
  - chat_media.py: 聊天媒体资源访问
This commit is contained in:
2977094657
2025-12-19 16:17:46 +08:00
parent 7d911b4580
commit 8679f090ce
7 changed files with 2064 additions and 0 deletions

View File

@@ -0,0 +1,717 @@
import os
import sqlite3
from pathlib import Path
from typing import Any, Optional
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from ..logging_config import get_logger
from ..chat_helpers import (
_build_avatar_url,
_decode_message_content,
_decode_sqlite_text,
_extract_sender_from_group_xml,
_extract_xml_attr,
_extract_xml_tag_or_attr,
_extract_xml_tag_text,
_format_session_time,
_infer_last_message_brief,
_infer_message_brief_by_local_type,
_infer_transfer_status_text,
_iter_message_db_paths,
_list_decrypted_accounts,
_load_contact_rows,
_load_latest_message_previews,
_lookup_resource_md5,
_parse_app_message,
_parse_pat_message,
_pick_avatar_url,
_pick_display_name,
_query_head_image_usernames,
_quote_ident,
_resolve_account_dir,
_resolve_msg_table_name,
_resource_lookup_chat_id,
_should_keep_session,
_split_group_sender_prefix,
)
from ..media_helpers import _try_find_decrypted_resource
from ..path_fix import PathFixRoute
logger = get_logger(__name__)
_DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1"
router = APIRouter(route_class=PathFixRoute)
@router.get("/api/chat/accounts", summary="列出已解密账号")
async def list_chat_accounts():
"""列出 output/databases 下可用于聊天预览的账号目录"""
accounts = _list_decrypted_accounts()
if not accounts:
return {
"status": "error",
"accounts": [],
"default_account": None,
"message": "No decrypted databases found. Please decrypt first.",
}
return {
"status": "success",
"accounts": accounts,
"default_account": accounts[0],
}
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
async def list_chat_sessions(
request: Request,
account: Optional[str] = None,
limit: int = 400,
include_hidden: bool = False,
include_official: bool = False,
):
"""从 session.db + contact.db 读取会话列表,用于前端聊天界面动态渲染联系人"""
if limit <= 0:
raise HTTPException(status_code=400, detail="Invalid limit.")
if limit > 2000:
limit = 2000
account_dir = _resolve_account_dir(account)
session_db_path = account_dir / "session.db"
contact_db_path = account_dir / "contact.db"
head_image_db_path = account_dir / "head_image.db"
base_url = str(request.base_url).rstrip("/")
sconn = sqlite3.connect(str(session_db_path))
sconn.row_factory = sqlite3.Row
try:
rows = sconn.execute(
"""
SELECT
username,
unread_count,
is_hidden,
summary,
draft,
last_timestamp,
sort_timestamp,
last_msg_type,
last_msg_sub_type
FROM SessionTable
ORDER BY sort_timestamp DESC
LIMIT ?
""",
(int(limit),),
).fetchall()
finally:
sconn.close()
filtered: list[sqlite3.Row] = []
usernames: list[str] = []
for r in rows:
username = r["username"] or ""
if not username:
continue
if not include_hidden and int(r["is_hidden"] or 0) == 1:
continue
if not _should_keep_session(username, include_official=include_official):
continue
filtered.append(r)
usernames.append(username)
contact_rows = _load_contact_rows(contact_db_path, usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
latest_previews = _load_latest_message_previews(account_dir, usernames)
if _DEBUG_SESSIONS:
logger.info(
f"[sessions.preview] endpoint account={account_dir.name} sessions={len(usernames)} previews={len(latest_previews)}"
)
sessions: list[dict[str, Any]] = []
for r in filtered:
username = r["username"]
c_row = contact_rows.get(username)
display_name = _pick_display_name(c_row, username)
avatar_url = _pick_avatar_url(c_row)
if not avatar_url and username in local_avatar_usernames:
avatar_url = base_url + _build_avatar_url(account_dir.name, username)
if str(latest_previews.get(username) or "").strip():
last_message = str(latest_previews.get(username) or "").strip()
else:
last_message = _infer_last_message_brief(r["last_msg_type"], r["last_msg_sub_type"])
last_time = _format_session_time(r["sort_timestamp"] or r["last_timestamp"])
sessions.append(
{
"id": username,
"username": username,
"name": display_name,
"avatar": avatar_url,
"lastMessage": last_message,
"lastMessageTime": last_time,
"unreadCount": int(r["unread_count"] or 0),
"isGroup": bool(username.endswith("@chatroom")),
}
)
return {
"status": "success",
"account": account_dir.name,
"total": len(sessions),
"sessions": sessions,
}
@router.get("/api/chat/messages", summary="获取会话消息列表")
async def list_chat_messages(
request: Request,
username: str,
account: Optional[str] = None,
limit: int = 50,
offset: int = 0,
order: str = "asc",
):
if not username:
raise HTTPException(status_code=400, detail="Missing username.")
if limit <= 0:
raise HTTPException(status_code=400, detail="Invalid limit.")
if limit > 500:
limit = 500
if offset < 0:
offset = 0
account_dir = _resolve_account_dir(account)
db_paths = _iter_message_db_paths(account_dir)
contact_db_path = account_dir / "contact.db"
head_image_db_path = account_dir / "head_image.db"
message_resource_db_path = account_dir / "message_resource.db"
base_url = str(request.base_url).rstrip("/")
if not db_paths:
return {
"status": "error",
"account": account_dir.name,
"username": username,
"total": 0,
"messages": [],
"message": "No message databases found for this account.",
}
resource_conn: Optional[sqlite3.Connection] = None
resource_chat_id: Optional[int] = None
try:
if message_resource_db_path.exists():
resource_conn = sqlite3.connect(str(message_resource_db_path))
resource_conn.row_factory = sqlite3.Row
resource_chat_id = _resource_lookup_chat_id(resource_conn, username)
except Exception:
if resource_conn is not None:
try:
resource_conn.close()
except Exception:
pass
resource_conn = None
resource_chat_id = None
want_asc = str(order or "").lower() != "desc"
take = int(limit) + int(offset)
take_probe = take + 1
merged: list[dict[str, Any]] = []
sender_usernames: list[str] = []
pat_usernames: set[str] = set()
is_group = bool(username.endswith("@chatroom"))
has_more_any = False
for db_path in db_paths:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
table_name = _resolve_msg_table_name(conn, username)
if not table_name:
continue
my_wxid = account_dir.name
my_rowid = None
try:
r = conn.execute(
"SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1",
(my_wxid,),
).fetchone()
if r is not None:
my_rowid = int(r[0])
except Exception:
my_rowid = None
quoted_table = _quote_ident(table_name)
sql_with_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, n.user_name AS sender_username "
f"FROM {quoted_table} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
"ORDER BY m.create_time DESC, m.sort_seq DESC, m.local_id DESC "
"LIMIT ?"
)
sql_no_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, '' AS sender_username "
f"FROM {quoted_table} m "
"ORDER BY m.create_time DESC, m.sort_seq DESC, m.local_id DESC "
"LIMIT ?"
)
# Force sqlite3 to return TEXT as raw bytes for this query, so we can zstd-decompress
# compress_content reliably.
conn.text_factory = bytes
try:
rows = conn.execute(sql_with_join, (take_probe,)).fetchall()
except Exception:
rows = conn.execute(sql_no_join, (take_probe,)).fetchall()
if len(rows) > take:
has_more_any = True
rows = rows[:take]
for r in rows:
local_id = int(r["local_id"] or 0)
create_time = int(r["create_time"] or 0)
sort_seq = int(r["sort_seq"] or 0) if r["sort_seq"] is not None else 0
local_type = int(r["local_type"] or 0)
sender_username = _decode_sqlite_text(r["sender_username"]).strip()
is_sent = False
if my_rowid is not None:
try:
is_sent = int(r["real_sender_id"] or 0) == int(my_rowid)
except Exception:
is_sent = False
raw_text = _decode_message_content(r["compress_content"], r["message_content"])
raw_text = raw_text.strip()
sender_prefix = ""
if is_group and not raw_text.startswith("<") and not raw_text.startswith('"<'):
sender_prefix, raw_text = _split_group_sender_prefix(raw_text)
if is_group and sender_prefix:
sender_username = sender_prefix
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')):
xml_sender = _extract_sender_from_group_xml(raw_text)
if xml_sender:
sender_username = xml_sender
if is_sent:
sender_username = account_dir.name
elif (not is_group) and (not sender_username):
sender_username = username
if sender_username:
sender_usernames.append(sender_username)
render_type = "text"
content_text = raw_text
title = ""
url = ""
image_md5 = ""
emoji_md5 = ""
emoji_url = ""
thumb_url = ""
image_url = ""
video_md5 = ""
video_thumb_md5 = ""
video_url = ""
video_thumb_url = ""
voice_length = ""
quote_title = ""
quote_content = ""
amount = ""
cover_url = ""
file_size = ""
pay_sub_type = ""
transfer_status = ""
file_md5 = ""
transfer_id = ""
if local_type == 10000:
render_type = "system"
if "revokemsg" in raw_text:
content_text = "撤回了一条消息"
else:
import re
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
elif local_type == 49:
parsed = _parse_app_message(raw_text)
render_type = str(parsed.get("renderType") or "text")
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
amount = str(parsed.get("amount") or "")
cover_url = str(parsed.get("coverUrl") or "")
thumb_url = str(parsed.get("thumbUrl") or "")
file_size = str(parsed.get("size") or "")
pay_sub_type = str(parsed.get("paySubType") or "")
file_md5 = str(parsed.get("fileMd5") or "")
transfer_id = str(parsed.get("transferId") or "")
if render_type == "transfer":
# 直接从原始 XML 提取 transferid可能在 wcpayinfo 内)
if not transfer_id:
transfer_id = _extract_xml_tag_or_attr(raw_text, "transferid") or ""
transfer_status = _infer_transfer_status_text(
is_sent=is_sent,
paysubtype=pay_sub_type,
receivestatus=str(parsed.get("receiveStatus") or ""),
sendertitle=str(parsed.get("senderTitle") or ""),
receivertitle=str(parsed.get("receiverTitle") or ""),
senderdes=str(parsed.get("senderDes") or ""),
receiverdes=str(parsed.get("receiverDes") or ""),
)
if not content_text:
content_text = transfer_status or "转账"
elif local_type == 266287972401:
render_type = "system"
template = _extract_xml_tag_text(raw_text, "template")
if template:
import re
pat_usernames.update({m.group(1) for m in re.finditer(r"\$\{([^}]+)\}", template) if m.group(1)})
content_text = "[拍一拍]"
else:
content_text = "[拍一拍]"
elif local_type == 244813135921:
render_type = "quote"
parsed = _parse_app_message(raw_text)
content_text = str(parsed.get("content") or "[引用消息]")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
elif local_type == 3:
render_type = "image"
image_md5 = _extract_xml_attr(raw_text, "md5")
# Extract CDN URL and validate it looks like a proper URL
_cdn_url = (
_extract_xml_attr(raw_text, "cdnthumburl")
or _extract_xml_attr(raw_text, "cdnmidimgurl")
or _extract_xml_attr(raw_text, "cdnbigimgurl")
)
image_url = _cdn_url if _cdn_url.startswith(("http://", "https://")) else ""
if (not image_md5) and resource_conn is not None:
image_md5 = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=local_type,
server_id=int(r["server_id"] or 0),
local_id=local_id,
create_time=create_time,
)
content_text = "[图片]"
elif local_type == 34:
render_type = "voice"
duration = _extract_xml_attr(raw_text, "voicelength")
voice_length = duration
content_text = f"[语音 {duration}秒]" if duration else "[语音]"
elif local_type == 43 or local_type == 62:
render_type = "video"
video_md5 = _extract_xml_attr(raw_text, "md5")
video_thumb_md5 = _extract_xml_attr(raw_text, "cdnthumbmd5")
video_thumb_url = _extract_xml_attr(raw_text, "cdnthumburl")
video_url = _extract_xml_attr(raw_text, "cdnvideourl")
if (not video_thumb_md5) and resource_conn is not None:
video_thumb_md5 = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=local_type,
server_id=int(r["server_id"] or 0),
local_id=local_id,
create_time=create_time,
)
content_text = "[视频]"
elif local_type == 47:
render_type = "emoji"
emoji_md5 = _extract_xml_attr(raw_text, "md5")
if not emoji_md5:
emoji_md5 = _extract_xml_tag_text(raw_text, "md5")
emoji_url = _extract_xml_attr(raw_text, "cdnurl")
if not emoji_url:
emoji_url = _extract_xml_tag_text(raw_text, "cdn_url")
if (not emoji_md5) and resource_conn is not None:
emoji_md5 = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=local_type,
server_id=int(r["server_id"] or 0),
local_id=local_id,
create_time=create_time,
)
content_text = "[表情]"
elif local_type != 1:
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
if "<appmsg" in content_text.lower():
parsed = _parse_app_message(content_text)
rt = str(parsed.get("renderType") or "")
if rt and rt != "text":
render_type = rt
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
amount = str(parsed.get("amount") or amount)
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
file_size = str(parsed.get("size") or file_size)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
transfer_id = str(parsed.get("transferId") or transfer_id)
if render_type == "transfer":
# 如果 transferId 仍为空,尝试从原始 XML 提取
if not transfer_id:
transfer_id = _extract_xml_tag_or_attr(content_text, "transferid") or ""
transfer_status = _infer_transfer_status_text(
is_sent=is_sent,
paysubtype=pay_sub_type,
receivestatus=str(parsed.get("receiveStatus") or ""),
sendertitle=str(parsed.get("senderTitle") or ""),
receivertitle=str(parsed.get("receiverTitle") or ""),
senderdes=str(parsed.get("senderDes") or ""),
receiverdes=str(parsed.get("receiverDes") or ""),
)
if not content_text:
content_text = transfer_status or "转账"
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
merged.append(
{
"id": f"{db_path.stem}:{table_name}:{local_id}",
"localId": local_id,
"serverId": int(r["server_id"] or 0),
"type": local_type,
"createTime": create_time,
"sortSeq": sort_seq,
"senderUsername": sender_username,
"isSent": bool(is_sent),
"renderType": render_type,
"content": content_text,
"title": title,
"url": url,
"imageMd5": image_md5,
"emojiMd5": emoji_md5,
"emojiUrl": emoji_url,
"thumbUrl": thumb_url,
"imageUrl": image_url,
"videoMd5": video_md5,
"videoThumbMd5": video_thumb_md5,
"videoUrl": video_url,
"videoThumbUrl": video_thumb_url,
"voiceLength": voice_length,
"quoteTitle": quote_title,
"quoteContent": quote_content,
"amount": amount,
"coverUrl": cover_url,
"fileSize": file_size,
"fileMd5": file_md5,
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
"_rawText": raw_text if local_type == 266287972401 else "",
}
)
finally:
conn.close()
if resource_conn is not None:
try:
resource_conn.close()
except Exception:
pass
# 后处理:关联转账消息的最终状态
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
# paysubtype 含义1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
# 收集已退还和已收款的转账ID和金额
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
for m in merged:
if m.get("renderType") == "transfer":
pst = str(m.get("paySubType") or "")
tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0)
if pst in ("4", "9"): # 退还状态
if tid:
returned_transfer_ids.add(tid)
if amt:
returned_amounts_with_time.append((amt, ts))
elif pst == "3": # 已收款状态
if tid:
received_transfer_ids.add(tid)
if amt:
received_amounts_with_time.append((amt, ts))
# 更新原始转账消息的状态
for m in merged:
if m.get("renderType") == "transfer":
pst = str(m.get("paySubType") or "")
# 只更新未确定状态的原始转账消息paysubtype=1 或 8
if pst in ("1", "8"):
tid = str(m.get("transferId") or "").strip()
amt = str(m.get("amount") or "")
ts = int(m.get("createTime") or 0)
# 优先检查退还状态(退还优先于收款)
should_mark_returned = False
should_mark_received = False
# 策略1精确 transferId 匹配
if tid:
if tid in returned_transfer_ids:
should_mark_returned = True
elif tid in received_transfer_ids:
should_mark_received = True
# 策略2回退到金额+时间窗口匹配24小时内同金额
if not should_mark_returned and not should_mark_received and amt:
for ret_amt, ret_ts in returned_amounts_with_time:
if ret_amt == amt and abs(ret_ts - ts) <= 86400:
should_mark_returned = True
break
if not should_mark_returned:
for rec_amt, rec_ts in received_amounts_with_time:
if rec_amt == amt and abs(rec_ts - ts) <= 86400:
should_mark_received = True
break
if should_mark_returned:
m["paySubType"] = "9"
m["transferStatus"] = "已被退还"
elif should_mark_received:
m["paySubType"] = "3"
# 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收"
is_sent = m.get("isSent", False)
m["transferStatus"] = "已收款" if is_sent else "已被接收"
uniq_senders = list(dict.fromkeys([u for u in (sender_usernames + list(pat_usernames)) if u]))
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
for m in merged:
su = str(m.get("senderUsername") or "")
if not su:
continue
row = sender_contact_rows.get(su)
m["senderDisplayName"] = _pick_display_name(row, su)
avatar_url = _pick_avatar_url(row)
if not avatar_url and su in local_sender_avatars:
avatar_url = base_url + _build_avatar_url(account_dir.name, su)
m["senderAvatar"] = avatar_url
# Media URL fallback: if CDN URLs missing, use local media endpoints.
try:
rt = str(m.get("renderType") or "")
if rt == "image":
if (not str(m.get("imageUrl") or "")) and str(m.get("imageMd5") or ""):
md5 = str(m.get("imageMd5") or "")
m["imageUrl"] = (
base_url
+ f"/api/chat/media/image?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
elif rt == "emoji":
md5 = str(m.get("emojiMd5") or "")
if md5:
existing_local: Optional[Path] = None
try:
existing_local = _try_find_decrypted_resource(account_dir, str(md5).lower())
except Exception:
existing_local = None
if existing_local:
try:
import re
cur = str(m.get("emojiUrl") or "")
if cur and re.match(r"^https?://", cur, flags=re.I) and ("/api/chat/media/emoji" not in cur):
m["emojiRemoteUrl"] = cur
except Exception:
pass
m["emojiUrl"] = (
base_url
+ f"/api/chat/media/emoji?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
elif (not str(m.get("emojiUrl") or "")):
m["emojiUrl"] = (
base_url
+ f"/api/chat/media/emoji?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
elif rt == "video":
if (not str(m.get("videoThumbUrl") or "")) and str(m.get("videoThumbMd5") or ""):
md5 = str(m.get("videoThumbMd5") or "")
m["videoThumbUrl"] = (
base_url
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
if (not str(m.get("videoUrl") or "")) and str(m.get("videoMd5") or ""):
md5 = str(m.get("videoMd5") or "")
m["videoUrl"] = (
base_url
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
)
elif rt == "voice":
if str(m.get("serverId") or ""):
sid = int(m.get("serverId") or 0)
if sid:
m["voiceUrl"] = base_url + f"/api/chat/media/voice?account={quote(account_dir.name)}&server_id={sid}"
except Exception:
pass
if int(m.get("type") or 0) == 266287972401:
raw = str(m.get("_rawText") or "")
if raw:
m["content"] = _parse_pat_message(raw, sender_contact_rows)
if "_rawText" in m:
m.pop("_rawText", None)
def sort_key(m: dict[str, Any]) -> tuple[int, int, int]:
sseq = int(m.get("sortSeq") or 0)
cts = int(m.get("createTime") or 0)
lid = int(m.get("localId") or 0)
return (cts, sseq, lid)
merged.sort(key=sort_key, reverse=True)
has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit))))
page = merged[int(offset) : int(offset) + int(limit)]
if want_asc:
page = list(reversed(page))
return {
"status": "success",
"account": account_dir.name,
"username": username,
"total": int(offset) + len(page) + (1 if has_more_global else 0),
"hasMore": bool(has_more_global),
"messages": page,
}

View File

@@ -0,0 +1,728 @@
import asyncio
import ipaddress
import mimetypes
import os
import sqlite3
import subprocess
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import requests
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from ..logging_config import get_logger
from ..media_helpers import (
_convert_silk_to_wav,
_detect_image_extension,
_detect_image_media_type,
_ensure_decrypted_resource_for_md5,
_fallback_search_media_by_md5,
_get_decrypted_resource_path,
_get_resource_dir,
_guess_media_type_by_path,
_iter_emoji_source_candidates,
_read_and_maybe_decrypt_media,
_resolve_account_db_storage_dir,
_resolve_account_dir,
_resolve_account_wxid_dir,
_resolve_media_path_for_kind,
_resolve_media_path_from_hardlink,
_try_find_decrypted_resource,
_try_strip_media_prefix,
)
from ..path_fix import PathFixRoute
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
@router.get("/api/chat/avatar", summary="获取联系人头像")
async def get_chat_avatar(username: str, account: Optional[str] = None):
if not username:
raise HTTPException(status_code=400, detail="Missing username.")
account_dir = _resolve_account_dir(account)
head_image_db_path = account_dir / "head_image.db"
if not head_image_db_path.exists():
raise HTTPException(status_code=404, detail="head_image.db not found.")
conn = sqlite3.connect(str(head_image_db_path))
try:
row = conn.execute(
"SELECT image_buffer FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
(username,),
).fetchone()
finally:
conn.close()
if not row or row[0] is None:
raise HTTPException(status_code=404, detail="Avatar not found.")
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
media_type = _detect_image_media_type(data)
return Response(content=data, media_type=media_type)
class EmojiDownloadRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
md5: str = Field(..., description="表情 MD5")
emoji_url: str = Field(..., description="表情 CDN URL")
force: bool = Field(False, description="是否强制重新下载并覆盖")
def _is_valid_md5(s: str) -> bool:
import re
v = str(s or "").strip().lower()
return bool(re.fullmatch(r"[0-9a-f]{32}", v))
def _is_safe_http_url(url: str) -> bool:
u = str(url or "").strip()
if not u:
return False
try:
p = urlparse(u)
except Exception:
return False
if p.scheme not in ("http", "https"):
return False
host = (p.hostname or "").strip()
if not host:
return False
if host in {"localhost"}:
return False
try:
ip = ipaddress.ip_address(host)
if ip.is_private or ip.is_loopback or ip.is_link_local:
return False
except Exception:
pass
return True
def _detect_media_type_and_ext(data: bytes) -> tuple[bytes, str, str]:
payload = data
media_type = "application/octet-stream"
ext = "dat"
try:
payload2, mt2 = _try_strip_media_prefix(payload)
if mt2 != "application/octet-stream":
payload = payload2
media_type = mt2
except Exception:
pass
if media_type == "application/octet-stream":
mt0 = _detect_image_media_type(payload[:32])
if mt0 != "application/octet-stream":
media_type = mt0
if media_type == "application/octet-stream":
try:
if len(payload) >= 8 and payload[4:8] == b"ftyp":
media_type = "video/mp4"
except Exception:
pass
if media_type.startswith("image/"):
ext = _detect_image_extension(payload)
elif media_type == "video/mp4":
ext = "mp4"
else:
ext = "dat"
return payload, media_type, ext
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()
emoji_url = str(req.emoji_url or "").strip()
if not _is_valid_md5(md5):
raise HTTPException(status_code=400, detail="Invalid md5.")
if not _is_safe_http_url(emoji_url):
raise HTTPException(status_code=400, detail="Invalid emoji_url (only public http/https allowed).")
account_dir = _resolve_account_dir(req.account)
existing = _try_find_decrypted_resource(account_dir, md5)
if existing and existing.exists() and (not req.force):
return {
"status": "success",
"account": account_dir.name,
"md5": md5,
"saved": True,
"already_exists": True,
"path": str(existing),
"resource_dir": str(existing.parent),
}
def _download_bytes() -> bytes:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "*/*",
}
r = requests.get(emoji_url, headers=headers, timeout=20, stream=True)
try:
r.raise_for_status()
max_bytes = 30 * 1024 * 1024
chunks: list[bytes] = []
total = 0
for ch in r.iter_content(chunk_size=64 * 1024):
if not ch:
continue
chunks.append(ch)
total += len(ch)
if total > max_bytes:
raise HTTPException(status_code=400, detail="Emoji download too large (>30MB).")
return b"".join(chunks)
finally:
try:
r.close()
except Exception:
pass
try:
data = await asyncio.to_thread(_download_bytes)
except HTTPException:
raise
except Exception as e:
logger.warning(f"emoji_download failed: md5={md5} url={emoji_url} err={e}")
raise HTTPException(status_code=500, detail=f"Emoji download failed: {e}")
if not data:
raise HTTPException(status_code=500, detail="Emoji download returned empty body.")
payload, media_type, ext = _detect_media_type_and_ext(data)
out_path = _get_decrypted_resource_path(account_dir, md5, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
if out_path.exists() and (not req.force):
return {
"status": "success",
"account": account_dir.name,
"md5": md5,
"saved": True,
"already_exists": True,
"path": str(out_path),
"resource_dir": str(out_path.parent),
"media_type": media_type,
"bytes": len(payload),
}
try:
out_path.write_bytes(payload)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to write emoji file: {e}")
logger.info(f"emoji_download: md5={md5} url={emoji_url} -> {out_path} bytes={len(payload)} mt={media_type}")
return {
"status": "success",
"account": account_dir.name,
"md5": md5,
"saved": True,
"already_exists": False,
"path": str(out_path),
"resource_dir": str(out_path.parent),
"media_type": media_type,
"bytes": len(payload),
}
@router.get("/api/chat/media/image", summary="获取图片消息资源")
async def get_chat_image(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
account_dir = _resolve_account_dir(account)
# 优先从解密资源目录读取(更快)
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type == "application/octet-stream":
guessed = mimetypes.guess_type(str(decrypted_path))[0]
if guessed:
media_type = guessed
return Response(content=data, media_type=media_type)
# 回退到原始逻辑:从微信数据目录实时解密
wxid_dir = _resolve_account_wxid_dir(account_dir)
hardlink_db_path = account_dir / "hardlink.db"
extra_roots: list[Path] = []
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir:
extra_roots.append(db_storage_dir)
roots: list[Path] = []
if wxid_dir:
roots.append(wxid_dir)
roots.append(wxid_dir / "msg" / "attach")
roots.append(wxid_dir / "msg" / "file")
roots.append(wxid_dir / "msg" / "video")
roots.append(wxid_dir / "cache")
if db_storage_dir:
roots.append(db_storage_dir)
if not roots:
raise HTTPException(
status_code=404,
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
)
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="image",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
if hit:
p = Path(hit)
if not p:
raise HTTPException(status_code=404, detail="Image not found.")
logger.info(f"chat_image: md5={md5} resolved_source={p}")
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
if media_type.startswith("image/"):
try:
out_md5 = str(md5).lower()
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
if not out_path.exists():
out_path.write_bytes(data)
except Exception:
pass
logger.info(f"chat_image: md5={md5} media_type={media_type} bytes={len(data)}")
return Response(content=data, media_type=media_type)
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
async def get_chat_emoji(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
account_dir = _resolve_account_dir(account)
# 优先从解密资源目录读取(更快)
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type == "application/octet-stream":
guessed = mimetypes.guess_type(str(decrypted_path))[0]
if guessed:
media_type = guessed
return Response(content=data, media_type=media_type)
wxid_dir = _resolve_account_wxid_dir(account_dir)
p = _resolve_media_path_for_kind(account_dir, kind="emoji", md5=str(md5), username=username)
if not p:
raise HTTPException(status_code=404, detail="Emoji not found.")
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
if media_type.startswith("image/"):
try:
out_md5 = str(md5).lower()
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
if not out_path.exists():
out_path.write_bytes(data)
except Exception:
pass
return Response(content=data, media_type=media_type)
@router.get("/api/chat/media/video_thumb", summary="获取视频缩略图资源")
async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
account_dir = _resolve_account_dir(account)
# 优先从解密资源目录读取(更快)
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
return Response(content=data, media_type=media_type)
# 回退到原始逻辑
wxid_dir = _resolve_account_wxid_dir(account_dir)
hardlink_db_path = account_dir / "hardlink.db"
extra_roots: list[Path] = []
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir:
extra_roots.append(db_storage_dir)
roots: list[Path] = []
if wxid_dir:
roots.append(wxid_dir)
if db_storage_dir:
roots.append(db_storage_dir)
if not roots:
raise HTTPException(
status_code=404,
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
)
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="video_thumb",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
if hit:
p = Path(hit)
if not p:
raise HTTPException(status_code=404, detail="Video thumbnail not found.")
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
return Response(content=data, media_type=media_type)
@router.get("/api/chat/media/video", summary="获取视频资源")
async def get_chat_video(md5: str, account: Optional[str] = None, username: Optional[str] = None):
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
hardlink_db_path = account_dir / "hardlink.db"
extra_roots: list[Path] = []
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir:
extra_roots.append(db_storage_dir)
roots: list[Path] = []
if wxid_dir:
roots.append(wxid_dir)
if db_storage_dir:
roots.append(db_storage_dir)
if not roots:
raise HTTPException(
status_code=404,
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
)
p = _resolve_media_path_from_hardlink(
hardlink_db_path,
roots[0],
md5=str(md5),
kind="video",
username=username,
extra_roots=roots[1:],
)
if (not p) and wxid_dir:
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
if hit:
p = Path(hit)
if not p:
raise HTTPException(status_code=404, detail="Video not found.")
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
return FileResponse(str(p), media_type=media_type)
@router.get("/api/chat/media/voice", summary="获取语音消息资源")
async def get_chat_voice(server_id: int, account: Optional[str] = None):
if not server_id:
raise HTTPException(status_code=400, detail="Missing server_id.")
account_dir = _resolve_account_dir(account)
media_db_path = account_dir / "media_0.db"
if not media_db_path.exists():
raise HTTPException(status_code=404, detail="media_0.db not found.")
conn = sqlite3.connect(str(media_db_path))
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT voice_data FROM VoiceInfo WHERE svr_id = ? ORDER BY create_time DESC LIMIT 1",
(int(server_id),),
).fetchone()
except Exception:
row = None
finally:
conn.close()
if not row or row[0] is None:
raise HTTPException(status_code=404, detail="Voice not found.")
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
# Try to convert SILK to WAV for browser playback
wav_data = _convert_silk_to_wav(data)
if wav_data != data:
return Response(
content=wav_data,
media_type="audio/wav",
)
# Fallback to raw SILK if conversion fails
return Response(
content=data,
media_type="audio/silk",
headers={"Content-Disposition": f"attachment; filename=voice_{int(server_id)}.silk"},
)
@router.post("/api/chat/media/open_folder", summary="在资源管理器中打开媒体文件所在位置")
async def open_chat_media_folder(
kind: str,
md5: Optional[str] = None,
server_id: Optional[int] = None,
account: Optional[str] = None,
username: Optional[str] = None,
):
account_dir = _resolve_account_dir(account)
kind_key = str(kind or "").strip().lower()
if kind_key not in {"image", "emoji", "video", "video_thumb", "file", "voice"}:
raise HTTPException(status_code=400, detail="Unsupported kind.")
p: Optional[Path] = None
if kind_key == "voice":
if not server_id:
raise HTTPException(status_code=400, detail="Missing server_id.")
media_db_path = account_dir / "media_0.db"
if not media_db_path.exists():
raise HTTPException(status_code=404, detail="media_0.db not found.")
conn = sqlite3.connect(str(media_db_path))
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT voice_data FROM VoiceInfo WHERE svr_id = ? ORDER BY create_time DESC LIMIT 1",
(int(server_id),),
).fetchone()
except Exception:
row = None
finally:
conn.close()
if not row or row[0] is None:
raise HTTPException(status_code=404, detail="Voice not found.")
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
export_dir = account_dir / "_exports"
export_dir.mkdir(parents=True, exist_ok=True)
p = export_dir / f"voice_{int(server_id)}.silk"
try:
p.write_bytes(data)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
else:
if not md5:
raise HTTPException(status_code=400, detail="Missing md5.")
p = _resolve_media_path_for_kind(account_dir, kind=kind_key, md5=str(md5), username=username)
resolved_before_materialize = p
materialized_ok = False
opened_kind = "resolved"
if p and kind_key in {"image", "emoji", "video_thumb"}:
wxid_dir = _resolve_account_wxid_dir(account_dir)
source_path = p
if kind_key == "emoji":
candidates: list[Path] = []
try:
md5s = str(md5 or "").lower().strip()
except Exception:
md5s = str(md5)
try:
if p is not None and p.exists() and p.is_file():
if (not str(p.suffix or "")) and md5s and str(p.name).lower() == md5s:
candidates.extend(_iter_emoji_source_candidates(p.parent, md5s))
if p not in candidates:
candidates.append(p)
else:
candidates.append(p)
candidates.extend(_iter_emoji_source_candidates(p.parent, md5s))
else:
candidates = _iter_emoji_source_candidates(p, md5s)
except Exception:
candidates = _iter_emoji_source_candidates(p, str(md5))
# de-dup while keeping order
seen: set[str] = set()
uniq: list[Path] = []
for c in candidates:
try:
k = str(c.resolve())
except Exception:
k = str(c)
if k in seen:
continue
seen.add(k)
uniq.append(c)
candidates = uniq
try:
preferred: list[Path] = []
if md5s:
for c in candidates:
try:
if md5s in str(c.name).lower():
preferred.append(c)
except Exception:
continue
if preferred:
rest = [c for c in candidates if c not in preferred]
candidates = preferred + rest
except Exception:
pass
if not candidates and p is not None:
candidates = [p]
for cand in candidates:
source_path = cand
materialized = _ensure_decrypted_resource_for_md5(
account_dir,
md5=str(md5),
source_path=source_path,
weixin_root=wxid_dir,
)
if materialized:
p = materialized
materialized_ok = True
opened_kind = "decrypted"
break
if not materialized_ok:
try:
sz = -1
head_hex = ""
try:
if source_path and source_path.exists() and source_path.is_file():
sz = int(source_path.stat().st_size)
with open(source_path, "rb") as f:
head_hex = f.read(32).hex()
except Exception:
pass
logger.info(
f"open_folder: emoji materialize failed: resolved={str(resolved_before_materialize)} source={str(source_path)} size={sz} head32={head_hex}"
)
except Exception:
pass
try:
resource_dir = _get_resource_dir(account_dir)
sub_dir = str(md5).lower()[:2] if len(str(md5)) >= 2 else "00"
fallback_dir = resource_dir / sub_dir
fallback_dir.mkdir(parents=True, exist_ok=True)
p = fallback_dir
opened_kind = "resource_dir"
except Exception:
try:
resource_dir = _get_resource_dir(account_dir)
sub_dir = str(md5).lower()[:2] if len(str(md5)) >= 2 else "00"
fallback_dir = resource_dir / sub_dir
fallback_dir.mkdir(parents=True, exist_ok=True)
p = fallback_dir
opened_kind = "resource_dir"
except Exception:
pass
else:
materialized = _ensure_decrypted_resource_for_md5(
account_dir,
md5=str(md5),
source_path=source_path,
weixin_root=wxid_dir,
)
if materialized:
p = materialized
materialized_ok = True
opened_kind = "decrypted"
if kind_key == "emoji" and md5:
try:
existing2 = _try_find_decrypted_resource(account_dir, str(md5).lower())
if existing2:
p = existing2
opened_kind = "decrypted"
except Exception:
pass
if not p:
if kind_key == "emoji":
wxid_dir = _resolve_account_wxid_dir(account_dir)
resource_dir = _get_resource_dir(account_dir)
candidates: list[Path] = []
if md5:
sub_dir = str(md5).lower()[:2] if len(str(md5)) >= 2 else "00"
c1 = resource_dir / sub_dir
if c1.exists() and c1.is_dir():
candidates.append(c1)
if resource_dir.exists() and resource_dir.is_dir():
candidates.append(resource_dir)
if wxid_dir:
for c in [
wxid_dir / "msg" / "emoji",
wxid_dir / "msg" / "emoticon",
wxid_dir / "emoji",
wxid_dir,
]:
try:
if c.exists() and c.is_dir():
candidates.append(c)
except Exception:
continue
candidates.append(account_dir)
p = candidates[0]
else:
raise HTTPException(status_code=404, detail="File not found.")
try:
target = str(p.resolve())
except Exception:
target = str(p)
logger.info(f"open_folder: kind={kind_key} md5={md5} server_id={server_id} -> {target}")
if os.name != "nt":
raise HTTPException(status_code=400, detail="open_folder is only supported on Windows.")
try:
tp = Path(target)
if tp.exists() and tp.is_dir():
subprocess.Popen(["explorer.exe", str(tp)])
elif tp.exists():
subprocess.Popen(["explorer.exe", "/select,", str(tp)])
else:
subprocess.Popen(["explorer.exe", str(tp.parent)])
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to open explorer: {e}")
file_found = False
try:
tp2 = Path(target)
if kind_key == "emoji":
file_found = bool(tp2.exists())
else:
if tp2.exists() and tp2.is_file():
file_found = True
except Exception:
pass
resp = {"status": "success", "path": target}
if kind_key == "emoji":
resp["file_found"] = bool(file_found)
resp["materialized"] = bool(materialized_ok) if "materialized_ok" in locals() else bool(file_found)
resp["opened"] = str(opened_kind) if "opened_kind" in locals() else "unknown"
return resp

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
from ..wechat_decrypt import decrypt_wechat_databases
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
class DecryptRequest(BaseModel):
"""解密请求模型"""
key: str = Field(..., description="解密密钥64位十六进制字符串")
db_storage_path: str = Field(..., description="数据库存储路径,必须是绝对路径")
@router.post("/api/decrypt", summary="解密微信数据库")
async def decrypt_databases(request: DecryptRequest):
"""使用提供的密钥解密指定账户的微信数据库
参数:
- key: 解密密钥(必选)- 64位十六进制字符串
- db_storage_path: 数据库存储路径(必选),如 D:\\wechatMSG\\xwechat_files\\{微信id}\\db_storage
注意:
- 一个密钥只能解密对应账户的数据库
- 必须提供具体的db_storage_path不支持自动检测多账户
- 支持自动处理Windows路径中的反斜杠转义问题
"""
logger.info(f"开始解密请求: db_storage_path={request.db_storage_path}")
try:
# 验证密钥格式
if not request.key or len(request.key) != 64:
logger.warning(f"密钥格式无效: 长度={len(request.key) if request.key else 0}")
raise HTTPException(status_code=400, detail="密钥格式无效必须是64位十六进制字符串")
# 使用新的解密API
results = decrypt_wechat_databases(
db_storage_path=request.db_storage_path,
key=request.key,
)
if results["status"] == "error":
logger.error(f"解密失败: {results['message']}")
raise HTTPException(status_code=400, detail=results["message"])
logger.info(f"解密完成: 成功 {results['successful_count']}/{results['total_databases']} 个数据库")
return {
"status": "completed" if results["status"] == "success" else "failed",
"total_databases": results["total_databases"],
"success_count": results["successful_count"],
"failure_count": results["failed_count"],
"output_directory": results["output_directory"],
"message": results["message"],
"processed_files": results["processed_files"],
"failed_files": results["failed_files"],
"account_results": results.get("account_results", {}),
}
except Exception as e:
logger.error(f"解密API异常: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,22 @@
from fastapi import APIRouter
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
@router.get("/", summary="根端点")
async def root():
"""根端点"""
logger.info("访问根端点")
return {"message": "微信数据库解密工具 API"}
@router.get("/api/health", summary="健康检查端点")
async def health_check():
"""健康检查端点"""
logger.debug("健康检查请求")
return {"status": "healthy", "service": "微信解密工具"}

View File

@@ -0,0 +1,458 @@
import asyncio
import json
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field
from ..logging_config import get_logger
from ..media_helpers import (
_collect_all_dat_files,
_decrypt_and_save_resource,
_detect_image_media_type,
_extract_wechat_aes_key_from_process,
_find_wechat_xor_key,
_get_resource_dir,
_get_wechat_template_most_common_last2,
_get_wechat_v2_ciphertext,
_load_media_keys,
_resolve_account_dir,
_resolve_account_wxid_dir,
_save_media_keys,
_try_find_decrypted_resource,
)
from ..path_fix import PathFixRoute
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
class MediaKeysRequest(BaseModel):
"""媒体密钥请求模型"""
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
force_extract: bool = Field(False, description="是否强制从微信进程重新提取密钥")
class MediaDecryptRequest(BaseModel):
"""媒体解密请求模型"""
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
xor_key: Optional[str] = Field(None, description="XOR密钥十六进制如 0xA5 或 A5")
aes_key: Optional[str] = Field(None, description="AES密钥16字符ASCII字符串")
@router.get("/api/media/keys", summary="获取图片解密密钥")
async def get_media_keys(account: Optional[str] = None, force_extract: bool = False):
"""获取图片解密密钥XOR和AES
如果已缓存密钥且不强制提取,直接返回缓存的密钥。
否则尝试从微信进程中提取密钥。
注意提取AES密钥需要微信进程正在运行。
"""
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
# 尝试加载已缓存的密钥
cached_keys = _load_media_keys(account_dir)
if cached_keys and not force_extract:
xor_key = cached_keys.get("xor")
aes_key = cached_keys.get("aes")
if xor_key is not None and aes_key:
return {
"status": "success",
"source": "cache",
"xor_key": f"0x{int(xor_key):02X}",
"aes_key": str(aes_key)[:16] if aes_key else "",
"message": "已从缓存加载密钥",
}
if not wxid_dir:
return {
"status": "error",
"message": "未找到微信数据目录,请确保已正确配置 db_storage_path",
}
# 尝试提取XOR密钥
xor_key = _find_wechat_xor_key(str(wxid_dir))
if xor_key is None:
return {
"status": "error",
"message": "无法提取XOR密钥请确保微信数据目录中存在 _t.dat 模板文件",
}
# 尝试提取AES密钥需要微信进程运行
aes_key16: Optional[bytes] = None
aes_message = ""
most_common = _get_wechat_template_most_common_last2(str(wxid_dir))
if most_common:
ct = _get_wechat_v2_ciphertext(wxid_dir, most_common)
if ct:
aes_key16 = _extract_wechat_aes_key_from_process(ct)
if aes_key16:
aes_message = "已从微信进程提取AES密钥"
# 保存密钥到缓存
_save_media_keys(account_dir, xor_key, aes_key16)
else:
aes_message = "无法从微信进程提取AES密钥微信是否正在运行"
else:
aes_message = "未找到V2加密模板文件"
else:
aes_message = "未找到足够的模板文件用于提取AES密钥"
return {
"status": "success",
"source": "extracted",
"xor_key": f"0x{xor_key:02X}",
"aes_key": aes_key16.decode("ascii", errors="ignore") if aes_key16 else "",
"message": f"XOR密钥提取成功。{aes_message}",
}
@router.post("/api/media/keys", summary="保存图片解密密钥")
async def save_media_keys_api(request: MediaKeysRequest, xor_key: str, aes_key: str):
"""手动保存图片解密密钥
参数:
- xor_key: XOR密钥十六进制格式如 0xA5 或 A5
- aes_key: AES密钥16字符ASCII字符串
"""
account_dir = _resolve_account_dir(request.account)
# 解析XOR密钥
try:
xor_hex = xor_key.strip().lower().replace("0x", "")
xor_int = int(xor_hex, 16)
except Exception:
raise HTTPException(status_code=400, detail="XOR密钥格式无效请使用十六进制格式如 0xA5")
# 验证AES密钥
aes_str = aes_key.strip()
if len(aes_str) < 16:
raise HTTPException(status_code=400, detail="AES密钥长度不足需要至少16个字符")
# 保存密钥
_save_media_keys(account_dir, xor_int, aes_str[:16].encode("ascii", errors="ignore"))
return {
"status": "success",
"message": "密钥已保存",
"xor_key": f"0x{xor_int:02X}",
"aes_key": aes_str[:16],
}
@router.post("/api/media/decrypt_all", summary="批量解密所有图片资源")
async def decrypt_all_media(request: MediaDecryptRequest):
"""批量解密所有图片资源到 output/databases/{账号}/resource 目录
解密后的图片按MD5哈希命名存储在 resource/{md5前2位}/{md5}.{ext} 路径下。
这样可以快速通过MD5定位资源文件。
参数:
- account: 账号目录名(可选)
- xor_key: XOR密钥可选不提供则从缓存读取
- aes_key: AES密钥可选不提供则从缓存读取
"""
account_dir = _resolve_account_dir(request.account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
raise HTTPException(
status_code=400,
detail="未找到微信数据目录,请确保已正确配置 db_storage_path",
)
# 获取密钥
xor_key_int: Optional[int] = None
aes_key16: Optional[bytes] = None
if request.xor_key:
try:
xor_hex = request.xor_key.strip().lower().replace("0x", "")
xor_key_int = int(xor_hex, 16)
except Exception:
raise HTTPException(status_code=400, detail="XOR密钥格式无效")
if request.aes_key:
aes_str = request.aes_key.strip()
if len(aes_str) >= 16:
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
# 如果未提供密钥,尝试从缓存加载
if xor_key_int is None or aes_key16 is None:
cached = _load_media_keys(account_dir)
if xor_key_int is None:
xor_key_int = cached.get("xor")
if aes_key16 is None:
aes_str = str(cached.get("aes") or "").strip()
if len(aes_str) >= 16:
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
# 如果仍然没有XOR密钥尝试自动提取
if xor_key_int is None:
xor_key_int = _find_wechat_xor_key(str(wxid_dir))
if xor_key_int is None:
raise HTTPException(
status_code=400,
detail="未找到XOR密钥请先调用 /api/media/keys 获取密钥或手动提供",
)
# 收集所有.dat文件
logger.info(f"开始扫描 {wxid_dir} 中的.dat文件...")
dat_files = _collect_all_dat_files(wxid_dir)
total_files = len(dat_files)
logger.info(f"共发现 {total_files} 个.dat文件")
if total_files == 0:
return {
"status": "success",
"message": "未发现需要解密的.dat文件",
"total": 0,
"success_count": 0,
"skip_count": 0,
"fail_count": 0,
"output_dir": str(_get_resource_dir(account_dir)),
}
# 开始解密
success_count = 0
skip_count = 0
fail_count = 0
failed_files: list[dict] = []
resource_dir = _get_resource_dir(account_dir)
resource_dir.mkdir(parents=True, exist_ok=True)
for dat_path, md5 in dat_files:
# 检查是否已解密
existing = _try_find_decrypted_resource(account_dir, md5)
if existing:
skip_count += 1
continue
# 解密并保存
success, msg = _decrypt_and_save_resource(
dat_path, md5, account_dir, xor_key_int, aes_key16
)
if success:
success_count += 1
else:
fail_count += 1
if len(failed_files) < 100: # 只记录前100个失败
failed_files.append(
{
"file": str(dat_path),
"md5": md5,
"error": msg,
}
)
logger.info(f"解密完成: 成功={success_count}, 跳过={skip_count}, 失败={fail_count}")
return {
"status": "success",
"message": f"解密完成: 成功 {success_count}, 跳过 {skip_count}, 失败 {fail_count}",
"total": total_files,
"success_count": success_count,
"skip_count": skip_count,
"fail_count": fail_count,
"output_dir": str(resource_dir),
"failed_files": failed_files[:20] if failed_files else [],
}
@router.get("/api/media/resource/{md5}", summary="获取已解密的资源文件")
async def get_decrypted_resource(md5: str, account: Optional[str] = None):
"""直接从解密资源目录获取图片
如果资源已解密,直接返回解密后的文件。
这比实时解密更快,适合频繁访问的场景。
"""
if not md5 or len(md5) != 32:
raise HTTPException(status_code=400, detail="无效的MD5")
account_dir = _resolve_account_dir(account)
p = _try_find_decrypted_resource(account_dir, md5.lower())
if not p:
raise HTTPException(status_code=404, detail="资源未找到,请先执行批量解密")
data = p.read_bytes()
media_type = _detect_image_media_type(data[:32])
return Response(content=data, media_type=media_type)
@router.get("/api/media/decrypt_all_stream", summary="批量解密所有图片资源SSE实时进度")
async def decrypt_all_media_stream(
account: Optional[str] = None,
xor_key: Optional[str] = None,
aes_key: Optional[str] = None,
):
"""批量解密所有图片资源通过SSE实时推送进度
返回格式为Server-Sent Events每条消息包含:
- type: progress/complete/error
- current: 当前处理数量
- total: 总文件数
- success_count: 成功数
- skip_count: 跳过数(已解密)
- fail_count: 失败数
- current_file: 当前处理的文件名
- status: 当前文件状态success/skip/fail
- message: 状态消息
跳过原因:文件已经解密过
失败原因:
- 文件为空
- V4-V2版本需要AES密钥但未提供
- 未知加密版本
- 解密结果为空
- 解密后非有效图片格式
"""
async def generate_progress():
try:
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
yield f"data: {json.dumps({'type': 'error', 'message': '未找到微信数据目录'})}\n\n"
return
# 获取密钥
xor_key_int: Optional[int] = None
aes_key16: Optional[bytes] = None
if xor_key:
try:
xor_hex = xor_key.strip().lower().replace("0x", "")
xor_key_int = int(xor_hex, 16)
except Exception:
yield f"data: {json.dumps({'type': 'error', 'message': 'XOR密钥格式无效'})}\n\n"
return
if aes_key:
aes_str = aes_key.strip()
if len(aes_str) >= 16:
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
# 如果未提供密钥,尝试从缓存加载
if xor_key_int is None or aes_key16 is None:
cached = _load_media_keys(account_dir)
if xor_key_int is None:
xor_key_int = cached.get("xor")
if aes_key16 is None:
aes_str = str(cached.get("aes") or "").strip()
if len(aes_str) >= 16:
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
# 如果仍然没有XOR密钥尝试自动提取
if xor_key_int is None:
xor_key_int = _find_wechat_xor_key(str(wxid_dir))
if xor_key_int is None:
yield f"data: {json.dumps({'type': 'error', 'message': '未找到XOR密钥请先获取密钥'})}\n\n"
return
# 收集所有.dat文件
logger.info(f"[SSE] 开始扫描 {wxid_dir} 中的.dat文件...")
yield f"data: {json.dumps({'type': 'scanning', 'message': '正在扫描图片文件...'})}\n\n"
await asyncio.sleep(0)
dat_files = _collect_all_dat_files(wxid_dir)
total_files = len(dat_files)
logger.info(f"[SSE] 共发现 {total_files} 个.dat文件仅图片")
if total_files == 0:
yield f"data: {json.dumps({'type': 'complete', 'message': '未发现需要解密的图片文件', 'total': 0, 'success_count': 0, 'skip_count': 0, 'fail_count': 0})}\n\n"
return
# 发送总数信息
yield f"data: {json.dumps({'type': 'start', 'total': total_files, 'message': f'开始解密 {total_files} 个图片文件'})}\n\n"
await asyncio.sleep(0)
# 开始解密
success_count = 0
skip_count = 0
fail_count = 0
failed_files: list[dict] = []
resource_dir = _get_resource_dir(account_dir)
resource_dir.mkdir(parents=True, exist_ok=True)
for i, (dat_path, md5) in enumerate(dat_files):
current = i + 1
file_name = dat_path.name
# 检查是否已解密
existing = _try_find_decrypted_resource(account_dir, md5)
if existing:
skip_count += 1
# 每100个跳过的文件发送一次进度减少消息量
if skip_count % 100 == 0 or current == total_files:
yield (
f"data: {json.dumps({'type': 'progress', 'current': current, 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'current_file': file_name, 'status': 'skip', 'message': '已存在'})}\n\n"
)
await asyncio.sleep(0)
continue
# 解密并保存
success, msg = _decrypt_and_save_resource(
dat_path, md5, account_dir, xor_key_int, aes_key16
)
if success:
success_count += 1
status = "success"
status_msg = "解密成功"
logger.debug(f"[SSE] 解密成功: {file_name}")
else:
fail_count += 1
status = "fail"
status_msg = msg
logger.warning(f"[SSE] 解密失败: {file_name} - {msg}")
if len(failed_files) < 100:
failed_files.append(
{
"file": file_name,
"md5": md5,
"error": msg,
}
)
# 每处理一个文件发送进度(成功或失败都发送)
yield (
f"data: {json.dumps({'type': 'progress', 'current': current, 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'current_file': file_name, 'status': status, 'message': status_msg})}\n\n"
)
# 每处理10个文件让出一次控制权避免阻塞
if current % 10 == 0:
await asyncio.sleep(0)
logger.info(f"[SSE] 解密完成: 成功={success_count}, 跳过={skip_count}, 失败={fail_count}")
# 发送完成消息
yield (
f"data: {json.dumps({'type': 'complete', 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'output_dir': str(resource_dir), 'failed_files': failed_files[:20], 'message': f'解密完成: 成功 {success_count}, 跳过 {skip_count}, 失败 {fail_count}'})}\n\n"
)
except Exception as e:
logger.error(f"[SSE] 解密过程出错: {e}")
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(
generate_progress(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,73 @@
from typing import Optional
from fastapi import APIRouter
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
@router.get("/api/wechat-detection", summary="详细检测微信安装信息")
async def detect_wechat_detailed(data_root_path: Optional[str] = None):
"""详细检测微信安装信息,包括版本、路径、消息目录等。"""
logger.info("开始执行微信检测")
try:
from ..wechat_detection import detect_wechat_installation, detect_current_logged_in_account
info = detect_wechat_installation(data_root_path=data_root_path)
# 检测当前登录账号
current_account_info = detect_current_logged_in_account(data_root_path)
info['current_account'] = current_account_info
# 添加一些统计信息
stats = {
'total_databases': len(info['databases']),
'total_user_accounts': len(info['user_accounts']),
'total_message_dirs': len(info['message_dirs']),
'has_wechat_installed': info['wechat_install_path'] is not None,
'detection_time': __import__('datetime').datetime.now().isoformat(),
}
logger.info(f"微信检测完成: 检测到 {stats['total_user_accounts']} 个账户, {stats['total_databases']} 个数据库")
return {
'status': 'success',
'data': info,
'statistics': stats,
}
except Exception as e:
logger.error(f"微信检测失败: {str(e)}")
return {
'status': 'error',
'error': str(e),
'data': None,
'statistics': None,
}
@router.get("/api/current-account", summary="检测当前登录账号")
async def detect_current_account(data_root_path: Optional[str] = None):
"""检测当前登录的微信账号"""
logger.info("开始检测当前登录账号")
try:
from ..wechat_detection import detect_current_logged_in_account
result = detect_current_logged_in_account(data_root_path)
logger.info(f"当前账号检测完成: {result.get('message', '无结果')}")
return {
'status': 'success',
'data': result,
}
except Exception as e:
logger.error(f"当前账号检测失败: {str(e)}")
return {
'status': 'error',
'error': str(e),
'data': None,
}