409 lines
16 KiB
Python
409 lines
16 KiB
Python
|
|
"""woc-bridge 数据模型与错误码定义。
|
|||
|
|
|
|||
|
|
本模块定义规格中所有接口的请求/响应 Pydantic v2 schema、预定义错误码常量,
|
|||
|
|
以及统一的 BridgeError 异常类。路由层直接 raise BridgeError,由 server.py 中的
|
|||
|
|
全局异常处理器统一捕获并转换为 ErrorResponse。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from enum import Enum
|
|||
|
|
from typing import Any, Optional
|
|||
|
|
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 登录态枚举
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class LoginState(str, Enum):
|
|||
|
|
"""微信登录状态枚举。"""
|
|||
|
|
|
|||
|
|
NOT_RUNNING = "not_running"
|
|||
|
|
NOT_LOGGED_IN = "not_logged_in"
|
|||
|
|
LOGGING_IN = "logging_in"
|
|||
|
|
LOGGED_IN = "logged_in"
|
|||
|
|
LOGGED_OUT = "logged_out"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 错误码定义(与 spec 一致)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 每个错误码对应一个 HTTP 状态码
|
|||
|
|
ERROR_CODES: dict[str, int] = {
|
|||
|
|
"WECHAT_NOT_RUNNING": 503,
|
|||
|
|
"WECHAT_NOT_LOGGED_IN": 401,
|
|||
|
|
"WINDOW_NOT_FOUND": 503,
|
|||
|
|
"CONTACT_NOT_FOUND": 404,
|
|||
|
|
"SEND_FAILED": 500,
|
|||
|
|
"DB_LOCKED": 503,
|
|||
|
|
"DB_NOT_FOUND": 500,
|
|||
|
|
"INVALID_PARAMS": 400,
|
|||
|
|
"BRIDGE_INTERNAL_ERROR": 500,
|
|||
|
|
"LOGIN_TIMEOUT": 408,
|
|||
|
|
"MEDIA_NOT_FOUND": 404,
|
|||
|
|
"RATE_LIMITED": 429,
|
|||
|
|
"DB_ENCRYPTED": 503,
|
|||
|
|
"DB_NEED_INIT": 503,
|
|||
|
|
"DB_INIT_IN_PROGRESS": 503,
|
|||
|
|
"DB_KEY_INVALID": 503,
|
|||
|
|
"LOGOUT_FAILED": 500,
|
|||
|
|
"RESTART_TIMEOUT": 408,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BridgeError(Exception):
|
|||
|
|
"""bridge 统一业务异常。
|
|||
|
|
|
|||
|
|
路由层直接 raise BridgeError(code="...", message="...", details=...),
|
|||
|
|
由全局异常处理器捕获后转换为统一 ErrorResponse 并设置对应 HTTP 状态码。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
code: str = "BRIDGE_INTERNAL_ERROR",
|
|||
|
|
message: str = "bridge internal error",
|
|||
|
|
http_status: Optional[int] = None,
|
|||
|
|
details: Optional[Any] = None,
|
|||
|
|
) -> None:
|
|||
|
|
self.code = code
|
|||
|
|
self.message = message
|
|||
|
|
# 若未显式指定 http_status,则查表取默认值;查不到则回落到 500
|
|||
|
|
self.http_status = http_status if http_status is not None else ERROR_CODES.get(code, 500)
|
|||
|
|
self.details = details
|
|||
|
|
super().__init__(f"[{code}] {message}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 通用错误响应
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class ErrorResponse(BaseModel):
|
|||
|
|
"""统一错误响应结构。"""
|
|||
|
|
|
|||
|
|
success: bool = Field(default=False, description="固定为 false")
|
|||
|
|
error: dict[str, Any] = Field(
|
|||
|
|
description="错误详情,含 code/message/details 三个字段"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 状态接口(GET /api/status)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class StatusResponse(BaseModel):
|
|||
|
|
"""bridge 状态响应。
|
|||
|
|
|
|||
|
|
新增字段(1.1.0):
|
|||
|
|
- db_error_code:DB 不可达时的具体原因(encrypted / not_found / unreadable),
|
|||
|
|
db_accessible=true 时为 None
|
|||
|
|
- send_queue_pending:发送队列当前积压任务数,客户端据此决定是否退避
|
|||
|
|
- media_supported:图片/文件发送是否已实现真实传输(与 BRIDGE_CAPABILITIES
|
|||
|
|
中 image_send/file_send 同步)
|
|||
|
|
- bridge_capabilities:当前 bridge 支持的能力集合,客户端据此协商
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
bridge_version: str = Field(description="bridge 版本号")
|
|||
|
|
wechat_running: bool = Field(description="微信进程是否运行")
|
|||
|
|
wechat_window_found: bool = Field(description="是否找到微信窗口")
|
|||
|
|
login_state: LoginState = Field(description="登录态")
|
|||
|
|
db_accessible: bool = Field(description="微信本地 DB 是否可读")
|
|||
|
|
db_error_code: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="DB 不可达原因:not_found / unreadable / encrypted_no_key / encrypted_key_ok / key_extract_failed / need_init / init_in_progress / key_invalid;db_accessible=true 时为 None",
|
|||
|
|
)
|
|||
|
|
init_in_progress: bool = Field(default=False, description="是否正在进行后台 DB 初始化(密钥提取/预解密)")
|
|||
|
|
init_progress_pct: Optional[float] = Field(default=None, description="初始化进度百分比 0-100")
|
|||
|
|
init_message: Optional[str] = Field(default=None, description="初始化阶段描述")
|
|||
|
|
current_wxid: str = Field(default="", description="当前登录 wxid,未登录时为空")
|
|||
|
|
current_nickname: str = Field(default="", description="当前登录昵称,未登录时为空")
|
|||
|
|
uptime_seconds: float = Field(description="bridge 已运行秒数")
|
|||
|
|
display: str = Field(description="当前 DISPLAY 环境变量值")
|
|||
|
|
max_batch_size: int = Field(default=50, description="客户端单次拉取建议上限")
|
|||
|
|
poll_interval_ms: int = Field(default=2000, description="客户端轮询建议间隔(毫秒)")
|
|||
|
|
send_queue_pending: int = Field(default=0, description="发送队列积压任务数")
|
|||
|
|
media_supported: bool = Field(default=False, description="图片/文件真实传输是否已实现")
|
|||
|
|
bridge_capabilities: list[str] = Field(
|
|||
|
|
default_factory=list,
|
|||
|
|
description="bridge 支持的能力集合,如 ['text_send','image_send','long_poll']",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 消息接口(GET /api/messages/since)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class Message(BaseModel):
|
|||
|
|
"""单条微信消息。"""
|
|||
|
|
|
|||
|
|
msg_id: str = Field(description="消息 ID")
|
|||
|
|
talker: str = Field(description="会话对方 wxid(群消息为 chatroom id)")
|
|||
|
|
sender: str = Field(default="", description="实际发送者 wxid(群消息中为成员 wxid)")
|
|||
|
|
is_sender: bool = Field(default=False, description="是否为本机发送")
|
|||
|
|
type: int = Field(description="微信原始消息类型")
|
|||
|
|
render_type: str = Field(description="渲染类型:text/image/voice/video/file/system")
|
|||
|
|
content: str = Field(default="", description="消息内容文本")
|
|||
|
|
create_time: int = Field(description="消息时间戳(Unix 秒)")
|
|||
|
|
session_type: str = Field(description="会话类型:p2p / group")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MessagesResponse(BaseModel):
|
|||
|
|
"""消息拉取响应。"""
|
|||
|
|
|
|||
|
|
messages: list[Message] = Field(default_factory=list)
|
|||
|
|
next_cursor: int = Field(description="下次拉取应使用的 cursor")
|
|||
|
|
has_more: bool = Field(default=False, description="是否可能还有更多消息")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 发送接口
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class SendTextRequest(BaseModel):
|
|||
|
|
"""发送文本消息请求。"""
|
|||
|
|
|
|||
|
|
to_wxid: str = Field(description="目标 wxid")
|
|||
|
|
content: str = Field(description="文本内容")
|
|||
|
|
display_name: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="可选:用于微信搜索框定位会话的显示名。不传时 bridge 自动按备注->昵称->wxid 查找",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SendFileRequest(BaseModel):
|
|||
|
|
"""发送图片/文件请求。"""
|
|||
|
|
|
|||
|
|
to_wxid: str = Field(description="目标 wxid")
|
|||
|
|
file_path: str = Field(description="容器内文件绝对路径")
|
|||
|
|
display_name: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="可选:用于微信搜索框定位会话的显示名。不传时 bridge 自动按备注->昵称->wxid 查找",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SendResponse(BaseModel):
|
|||
|
|
"""发送消息响应。
|
|||
|
|
|
|||
|
|
字段说明:
|
|||
|
|
- local_send_id:bridge 本地生成的发送 ID,格式 local_<unix秒>_<随机>。
|
|||
|
|
仅用于客户端幂等去重,**不对应微信原生 msg_id**,禁止用于
|
|||
|
|
/api/media/{msg_id} 查媒体。
|
|||
|
|
- placeholder:是否为占位实现(true 表示真实文件/图片内容未传输,
|
|||
|
|
仅发了提示文本;客户端应走降级处理)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
success: bool = Field(default=False)
|
|||
|
|
local_send_id: str = Field(default="", description="本地生成的发送 ID,非微信原生 msg_id")
|
|||
|
|
placeholder: bool = Field(default=False, description="是否为占位实现(真实内容未传输)")
|
|||
|
|
error: Optional[str] = Field(default=None, description="失败时的错误描述")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 联系人接口
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class Contact(BaseModel):
|
|||
|
|
"""联系人。"""
|
|||
|
|
|
|||
|
|
wxid: str
|
|||
|
|
nickname: str = ""
|
|||
|
|
remark: str = ""
|
|||
|
|
avatar_url: str = ""
|
|||
|
|
type: str = Field(default="person", description="person / group / official")
|
|||
|
|
|
|||
|
|
# 扩展字段:从 contact.db 的 contact 表读取
|
|||
|
|
alias: Optional[str] = Field(default=None, description="微信号/别名")
|
|||
|
|
encrypt_username: Optional[str] = Field(default=None, description="加密用户名")
|
|||
|
|
quan_pin: Optional[str] = Field(default=None, description="全拼")
|
|||
|
|
pin_yin_initial: Optional[str] = Field(default=None, description="拼音首字母")
|
|||
|
|
big_head_url: Optional[str] = Field(default=None, description="高清头像 URL")
|
|||
|
|
small_head_url: Optional[str] = Field(default=None, description="缩略头像 URL")
|
|||
|
|
description: Optional[str] = Field(default=None, description="个性签名/描述")
|
|||
|
|
local_type: Optional[int] = Field(default=None, description="联系人类型标记")
|
|||
|
|
verify_flag: Optional[int] = Field(default=None, description="认证标记")
|
|||
|
|
delete_flag: Optional[int] = Field(default=None, description="删除标记")
|
|||
|
|
chat_room_type: Optional[int] = Field(default=None, description="群类型标记")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ContactsResponse(BaseModel):
|
|||
|
|
"""联系人列表响应。"""
|
|||
|
|
|
|||
|
|
contacts: list[Contact] = Field(default_factory=list)
|
|||
|
|
total: int = 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
class GroupsResponse(BaseModel):
|
|||
|
|
"""群聊列表响应。"""
|
|||
|
|
|
|||
|
|
groups: list[Contact] = Field(default_factory=list)
|
|||
|
|
total: int = 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 群成员接口
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class GroupMember(BaseModel):
|
|||
|
|
"""群成员。"""
|
|||
|
|
|
|||
|
|
wxid: str
|
|||
|
|
nickname: str = ""
|
|||
|
|
display_name: str = ""
|
|||
|
|
is_admin: bool = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class GroupMembersResponse(BaseModel):
|
|||
|
|
"""群成员列表响应。"""
|
|||
|
|
|
|||
|
|
group_wxid: str
|
|||
|
|
members: list[GroupMember] = Field(default_factory=list)
|
|||
|
|
total: int = 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 登录二维码接口
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class QrLoginStartResult(BaseModel):
|
|||
|
|
"""启动扫码登录结果。"""
|
|||
|
|
|
|||
|
|
qr_data_url: str = Field(default="", description="data:image/png;base64,... 形式的二维码图片")
|
|||
|
|
message: str = ""
|
|||
|
|
connected: bool = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class QrLoginWaitResult(BaseModel):
|
|||
|
|
"""扫码等待结果。"""
|
|||
|
|
|
|||
|
|
connected: bool = False
|
|||
|
|
message: str = ""
|
|||
|
|
qr_data_url: str = ""
|
|||
|
|
credentials: Optional[dict[str, str]] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="登录成功时含 wxid/nickname",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 退出登录接口(POST /api/login/logout)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class LogoutResponse(BaseModel):
|
|||
|
|
"""退出登录响应。"""
|
|||
|
|
|
|||
|
|
success: bool = Field(description="是否成功")
|
|||
|
|
message: str = Field(description="结果描述")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 重启微信接口(POST /api/wechat/restart)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class RestartResponse(BaseModel):
|
|||
|
|
"""重启微信响应。"""
|
|||
|
|
|
|||
|
|
success: bool
|
|||
|
|
message: str
|
|||
|
|
pid: Optional[int] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 诊断接口
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class ConnectivityResponse(BaseModel):
|
|||
|
|
"""连通性检查响应。"""
|
|||
|
|
|
|||
|
|
reachable: bool = False
|
|||
|
|
latency_ms: int = 0
|
|||
|
|
error: Optional[str] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DiagnosticItem(BaseModel):
|
|||
|
|
"""诊断项定义。"""
|
|||
|
|
|
|||
|
|
check_id: str
|
|||
|
|
name: str
|
|||
|
|
severity: str = Field(default="info", description="info / warning / critical")
|
|||
|
|
description: str = ""
|
|||
|
|
auto_repairable: bool = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DiagnosticRunResult(BaseModel):
|
|||
|
|
"""诊断执行结果。"""
|
|||
|
|
|
|||
|
|
check_id: str
|
|||
|
|
passed: bool = False
|
|||
|
|
severity: str = "info"
|
|||
|
|
message: str = ""
|
|||
|
|
auto_repairable: bool = False
|
|||
|
|
repair_plan: Optional[str] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# DB 解密接口(POST /api/db/decrypt, GET /api/db/key/status)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class DbDecryptRequest(BaseModel):
|
|||
|
|
"""手动 key 注入请求。
|
|||
|
|
|
|||
|
|
外部系统(如 ForcePilot)或管理员通过 POST /api/db/decrypt 传入 64 位
|
|||
|
|
十六进制密钥,bridge 验证并缓存,后续所有 DB 查询接口立即恢复可用。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
key: str = Field(description="64 位十六进制 SQLCipher 密钥")
|
|||
|
|
salt: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="可选:该 key 对应的 DB salt(32 位十六进制)。未指定时 bridge 自动读取当前 DB 的 salt",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DbDecryptResponse(BaseModel):
|
|||
|
|
"""key 注入结果。
|
|||
|
|
|
|||
|
|
- verified=true 时 key 已缓存,后续 DB 查询接口立即可用
|
|||
|
|
- verified=false 时 key 不匹配,未缓存
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
success: bool = Field(description="请求是否处理成功(不代表 key 正确)")
|
|||
|
|
verified: bool = Field(default=False, description="key 是否通过验证并缓存")
|
|||
|
|
key_mode: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="key 形态:raw_enc_key / sqlcipher_passphrase;验证失败时为 None",
|
|||
|
|
)
|
|||
|
|
error: Optional[str] = Field(default=None, description="验证失败或特殊状态:key_mismatch / file_too_small / db_not_encrypted")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DbKeyStatusResponse(BaseModel):
|
|||
|
|
"""key 缓存状态查询响应。
|
|||
|
|
|
|||
|
|
供调用方判断是否需要注入 key 或等待自动提取。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
cached: bool = Field(default=False, description="是否有缓存的 key")
|
|||
|
|
source: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="key 来源:env(环境变量)/ api(POST 注入)/ auto_extract(内存扫描)/ file(持久化文件)",
|
|||
|
|
)
|
|||
|
|
verified: bool = Field(default=False, description="缓存 key 是否已通过验证")
|
|||
|
|
key_prefix: Optional[str] = Field(
|
|||
|
|
default=None,
|
|||
|
|
description="key 前 4 + ... + 后 4 字符,用于确认是哪个 key(不泄露完整 key)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DbInitRequest(BaseModel):
|
|||
|
|
"""DB 初始化请求(显式触发密钥提取)。"""
|
|||
|
|
|
|||
|
|
pid: Optional[int] = Field(default=None, description="可选:指定微信进程 PID")
|
|||
|
|
db_dir: Optional[str] = Field(default=None, description="可选:指定微信数据目录,默认自动检测")
|
|||
|
|
force: bool = Field(default=False, description="是否强制重新提取,忽略已有 keys 文件")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DbInitResponse(BaseModel):
|
|||
|
|
"""DB 初始化响应。"""
|
|||
|
|
|
|||
|
|
success: bool = Field(description="请求是否受理")
|
|||
|
|
state: str = Field(description="状态:started / already_done / in_progress / failed")
|
|||
|
|
message: str = Field(description="阶段描述")
|
|||
|
|
key_count: Optional[int] = Field(default=None, description="已提取到的 salt→key 映射数量")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DbInitStatusResponse(BaseModel):
|
|||
|
|
"""DB 初始化后台任务状态。"""
|
|||
|
|
|
|||
|
|
state: str = Field(description="状态:idle / running / success / failed")
|
|||
|
|
progress_pct: Optional[float] = Field(default=None, description="进度百分比 0-100")
|
|||
|
|
message: Optional[str] = Field(default=None, description="当前阶段描述")
|
|||
|
|
key_count: Optional[int] = Field(default=None, description="成功提取的密钥数")
|
|||
|
|
error: Optional[str] = Field(default=None, description="失败原因")
|