资讯动态

SQLite聊天记录解析:MSG/MediaMSG/FTSMSG与解密

发布时间:2026/10/29 21:18:39 来源:尧图企业网站定制
1. 先把 Multi 文件夹的作用弄清楚1.1 三类数据库的分工MSG、MediaMSG、FTSMSG很多人第一次打开聊天软件的个人数据目录看到Multi文件夹下面一大堆MSG0.db、MediaMSG3.db、FTSMSG7.db第一反应是为什么同一个东西要拆成这么多份。我当初也一样以为这是历史遗留的垃圾文件直到自己动手写脚本导出聊天记录才发现这套设计其实相当克制——它把消息主体、媒体索引、全文检索这三类读写特征完全不同的数据硬生生拆到了三个独立的库文件里。MSG*.db存的是消息本体。一条消息是谁发的、发给谁、什么类型、什么时候、正文是什么全在这里。这是整个体系的主干也是我们解析的重心。MediaMSG*.db存的是媒体类消息的附加信息。语音、图片、视频、文件这些消息在MSG库里只会留下一个类型标记 指向真正的媒体元数据比如文件大小、时长、存储路径的散列值放在MediaMSG库里。FTSMSG*.db全文索引库。它的表结构是全文检索引擎的虚拟表专门用来做关键词搜索。你在客户端里搜一句话能秒出结果靠的就是它而不是去扫MSG主表。这三个库的关系可以类比成图书馆MSG是书架上的实体书MediaMSG是每本书附带的光盘存放位置登记册FTSMSG则是按关键词排好的索引卡片。你要查某个作者写了什么走索引卡片最快你要看书的完整内容必须回到实体书你要找配套光盘得查登记册。三个库各司其职谁也替代不了谁。理解这个分工非常关键因为后面的所有操作都建立在这个认知上不要试图只解析 MSG 库就把事情做全。我见过不少人写完MSG的查询就以为大功告成结果发现语音消息只有一行类型 34的占位正文空白然后开始怀疑人生。其实内容没丢只是你还没去MediaMSG和附件目录里取。1.2 目录结构与分片规则以常见的 Windows 桌面客户端版本为例Multi文件夹典型结构大致是这样不同版本号会有差异但主体框架稳定Multi/ ├── MSG0.db ├── MSG1.db ├── ... ├── MediaMSG0.db ├── MediaMSG1.db ├── ... ├── FTSMSG0.db ├── FTSMSG1.db ├── ... ├── Media.db └── 同级还有 Contact、Favorite、Sns 等目录这里有两个细节值得单独说。第一为什么是 0 到 9 这样的编号。这套编号本质上是分片sharding而不是按会话或按时间切分。当当前活跃的库文件增长到一定体量经验值在两百多 MB 这个量级客户端就会把新消息写进下一个编号的库如此滚动。所以你经常会看到MSG4.db只有几 MB 而MSG3.db有两百多 MB这不是文件损坏而是滚动写入的正常结果。第二.db-wal和.db-shm文件不能忽略。这是预写日志WAL模式的副产物。如果你只复制.db文件而不带上同名的-wal可能会丢掉最近一段时间尚未合并进主文件的数据表现为导出来的记录少了几天的量。我在第一次做备份的时候就踩过这个坑导出结果比客户端界面上显示的少了大概两天的消息查了半天才想起来 WAL 文件没带走。注意复制数据库文件之前先让客户端完全退出。进程还在运行时文件处于被占用状态复制出来的可能是一个内部状态不一致的快照。宁可多等几秒也别图省事。1.3 拆库设计背后的取舍如果让我来评价这套设计我觉得核心动机是读写分离与故障隔离。消息写入是高频、小体积、强顺序的适合用紧凑的事务批量提交全文索引的写入是低频但每次都很重需要重构倒排表如果和主表放在同一个文件里每次发消息都要等索引更新完体验会很差媒体元数据的写入量小但单条记录大而且经常需要跨库关联查询。三种负载模式混在一个库里锁竞争会非常明显。拆开之后还有一个隐性收益单库损坏的影响面被限制住了。索引库出问题删掉重建即可主数据毫发无伤某个分片被误删损失的是那一段时间窗口的消息而不是全部。代价也很明显——跨库关联查询变得麻烦。你不能一条 SQL 从消息表直接 join 到语音时长必须在应用层做两段查询再拼装。这就是为什么自己写解析脚本时代码里会有一堆先查 MSG 拿到 localId再拿去 MediaMSG 匹配的逻辑。这不是代码写得丑是数据结构决定的只能认。2. 动手之前先把数据模型想明白2.1 三张核心表怎么串起来在MSG*.db里真正需要吃透的是三张表MSG、Name2ID、Session。它们构成了一个很朴素但极其有效的模型。Name2ID是一张映射表把会话标识一串字符 ID映射成一个自增整数。MSG表里的TalkerId字段存的就是这个整数而不是原始字符串。这么做的原因很直接——整数占 4 字节字符串动辄二三十字节一张表里有几百万条消息光这一项就能省下可观的存储空间同时整数比较也比字符串比较快得多。Session表存的是会话列表信息会话标识、最后一条消息时间、最后一条消息摘要。它相当于客户端的最近聊天列表的数据源。三者的关联路径是这样的-- 第一步从映射表拿到整数 ID SELECT rowid FROM Name2ID WHERE UsrName 某个会话标识; -- 第二步用整数 ID 去 MSG 表捞消息 SELECT localId, Type, SubType, IsSender, CreateTime, StrContent FROM MSG WHERE TalkerId ? ORDER BY CreateTime ASC; -- 第三步需要媒体信息时再去 MediaMSG 库 -- 注意这是另一个数据库连接 SELECT * FROM Media WHERE TalkerId ? AND localId ?;这套流程看起来简单但实际写代码时有几个点很容易出错。比如Name2ID表在不同版本里的列名可能叫UsrName也可能是UserName字段数量也不完全一致。稳妥的做法是先跑一次PRAGMA table_info(Name2ID)把列名打出来看一眼别硬编码。2.2 MSG 表字段逐个过一遍下面这张表是我自己整理并反复验证过的字段说明覆盖了绝大部分实际用得到的列。不同版本会有增删但核心字段是稳定的。字段名类型含义与使用要点localIdINTEGER库内自增主键只在单库内唯一跨库比较没有意义TalkerIdINTEGER会话整数 ID需通过Name2ID反查MsgSvrIDINTEGER服务端消息唯一标识跨设备、跨库都唯一是最可靠的去重依据TypeINTEGER消息大类文本、图片、语音、系统消息等都靠它区分SubTypeINTEGER子类型Type为应用消息时才有实际意义IsSenderINTEGER0 表示对方发出1 表示自己发出CreateTimeINTEGER秒级 Unix 时间戳注意不是毫秒SequenceINTEGER会话内顺序号用于同秒消息排序StrTalkerTEXT会话标识的冗余副本省去一次映射查询StrContentTEXT文本类消息的正文CompressContentBLOB压缩后的正文部分版本对大文本会走这个字段BytesExtraBLOB序列化后的附加信息媒体路径、引用关系常藏在这里BytesTransBLOB转发来源等附加信息多数场景可以不解析这里要特别强调localId和MsgSvrID的区别。localId是库内局部主键MsgSvrID才是全局身份。我曾经天真地以为localId唯一用它做去重键结果在合并多个分片库的数据时出现了大量同一条消息被误判为两条或者两条不同消息被合并成一条的问题。后来全部改用MsgSvrID做去重配合StrTalker和CreateTime做二次校验问题才消失。2.3 时间、序号、去重三者的配合关系时间戳这块看似没什么好讲的但实操中问题不少。CreateTime是秒级时间戳这个没问题。但同一个会话里同一秒内发出多条消息是常态尤其是快速连发的时候。这时候光靠CreateTime排序顺序会乱。必须叠加Sequence字段而这个字段在同一会话内是递增的。正确的排序语句应该是ORDER BY CreateTime ASC, Sequence ASC, localId ASC最后那个localId是兜底因为极少数情况下Sequence可能出现重复或缺失。还有一个容易忽略的点不同分片库之间的时间不是严格单调的。因为分片是按体积滚动的理论上MSG2里的消息都比MSG3早但如果你做过数据迁移、恢复备份一类的操作这个假设就不一定成立。稳妥的合并策略是把所有分片的记录捞出来统一按CreateTime加Sequence排序而不是简单地把MSG0拼到MSG1前面。提示合并多分片数据时先按MsgSvrID建立哈希集合去重再做全局排序比边查边插要快一个数量级也更不容易出错。3. MSG 库的解析实操3.1 建立连接与第一轮探查拿到一个.db文件后第一件事不是急着写业务查询而是先做结构探查。我通常按这个顺序走一遍-- 1. 看有哪些表 SELECT name, type FROM sqlite_master WHERE type IN (table,view) ORDER BY name; -- 2. 看 MSG 表结构 PRAGMA table_info(MSG); -- 3. 看索引情况判断哪些查询会快 SELECT name, tbl_name FROM sqlite_master WHERE type index; -- 4. 粗看数据量 SELECT COUNT(*) FROM MSG;第四步的COUNT(*)在大库上可能跑十几秒如果只是想估算规模可以用SELECT MAX(localId) FROM MSG代替快得多。这里有个实用性很强的建议把探查结果落成一个结构快照文件。因为不同客户端版本的表结构会有细微差异你的解析脚本很可能要面对多个版本的数据。每次遇到新版本先跑一遍探查把PRAGMA table_info的结果存下来脚本里做字段存在性判断而不是假设某个字段一定存在。这个习惯能帮你省掉大量为什么在别人电脑上跑不通的麻烦。3.2 消息类型判定与内容分发Type字段是解析的分水岭。我做过的项目里绝大多数代码量都花在这个字段的分支处理上。下面是实践中覆盖到的主要类型Type含义正文获取方式1文本直接读StrContent3图片读StrContent里的 XML 描述实际文件在附件目录34语音存于MediaMSG需解码音频格式42名片StrContent为 XML43视频XML 描述加缩略图路径47表情XML含 CDN 标识48位置XML含经纬度与地址文本49应用消息由SubType二次分发最常见也最复杂10000系统提示直接读StrContent多为撤回入群类提示10002系统操作与 10000 类似处理时机不同Type 49这一类需要单独展开因为它是万能容器。SubType常见取值包括 5链接或文件、6文件、19合并转发的聊天记录、33 与 36小程序卡片、57引用回复、2000转账、2001红包等。处理策略上我推荐分层解耦先用Type分出大类再用SubType细分每一类写一个独立的解析函数统一返回一个标准化的结构体包含发送者、时间、文本摘要、附件路径列表。这样即使以后新增了子类型也只需要加一个函数不用动主流程。# 伪代码示意分层分发的骨架 def parse_message(row): t row[Type] if t 1: return {kind: text, text: row[StrContent]} if t 49: return parse_app_message(row[SubType], row[StrContent]) if t 34: return {kind: voice, ref: row[localId]} return {kind: other, raw: row[StrContent]}注意不要试图用正则去一把梭解析StrContent里的 XML。消息体里的内容经常包含用户自己输入的尖括号、换行、转义字符正则很容易在边界上翻车。老老实实用 XML 解析器遇到解析异常就降级成原始文本保存这条兜底策略救过我很多次。3.3 引用、合并转发与系统消息的识别引用回复SubType 57的结构是外层一条消息 内嵌一段被引用的原文。外层正文里通常包含title标签存放引用摘要refermsg节点存放被引用消息的详细字段。解析时要注意被引用的内容可能是任意类型包括图片和语音所以不能只取文本。合并转发SubType 19更麻烦一些。它在外层只存一个标题和一段记录列表的描述真正的完整聊天记录往往以附加数据的形式存在需要去附件目录里找对应的记录文件。如果只看数据库你只能得到某人转发了 N 条聊天记录这个信息内容和它下面的展开项拿不到。系统消息Type 10000看起来最简单其实文本格式很不统一。同一类事件在不同版本里措辞完全不一样你撤回了一条消息和XX 撤回了一条消息在结构上是同一回事但字符串匹配规则完全不同。我的做法是只做归类不做精确提取把系统消息按关键词粗略分成撤回、入群、退群、改名、其他几类具体原文原样保留。这样既够用又不用跟着版本无限打补丁。3.4 压缩字段与附加二进制字段的处理CompressContent这个字段值得单独说。部分版本里较长的文本内容会被压缩后存进这个 BLOB 字段而StrContent反而是空的或者只存一个占位。如果你发现某些消息StrContent是空的但确实有内容八成就是走了压缩路径。处理流程是先判断CompressContent是否非空非空则尝试解压。压缩算法以 zstd 为主也有版本用其他通用无损算法。解压前先读前几个字节判断魔数避免用错算法导致解压失败。# 思路示意先探测魔数再决定算法 def maybe_decompress(blob): if not blob: return None if blob[:4] b\x28\xb5\x2f\xfd: # zstd 帧魔数 return zstd_decompress(blob) return None # 未知格式交回上层处理BytesExtra是另一个重点。它的内容是序列化后的键值对媒体文件的相对路径、引用关系、部分消息的扩展属性都藏在里面。解析它需要先确认序列化格式不同版本用过不同的编码方式再逐字段提取。我的经验是只取需要的键不要全量反序列化——全量反序列化在几百万条消息上会非常慢而且经常因为个别字段格式异常整条记录解析失败。提示解析二进制字段时永远用 try/except 包住失败就记录一条日志并跳过。一条脏数据不应该让整个导出任务崩掉。4. MediaMSG 库与媒体资源的对应关系4.1 MediaMSG 的表结构与关联方式MediaMSG*.db的结构比MSG简单得多核心就是一张Media表。它的关键字段包括Key、TalkerId、localId、MsgSvrID、CreateTime、Sequence以及若干保留字段。关联方式有两种各有适用场景用localId关联最快但只在同一个分片内有效。也就是说MSG3.db里的localId必须去MediaMSG3.db里查跨分片查不到。用MsgSvrID关联慢一点但全局唯一适合跨分片合并后的查询。我一般先用localId在同分片内做批量关联一次查一批而不是逐条查处理不了的再退回MsgSvrID。批量查询这个优化很重要逐条查询在十万级数据上能跑几个小时改成每批五百条之后缩短到几分钟。4.2 语音消息的定位与格式转换语音消息是很多人卡住的地方。MSG表里它只有一行Type 34的记录StrContent里通常是一段包含时长和格式标识的 XML真正的音频文件在附件目录下文件名是一串散列值。定位路径的逻辑是从BytesExtra或者StrContent里取到相对路径片段拼接附件根目录再按两级散列子目录去找。实际目录层级类似附件根/会话散列/年-月/文件名具体层级随版本变化稳妥做法是先用文件名做一次全目录索引再反查而不是硬编码路径拼接规则。拿到音频文件后还要面对格式问题。桌面端的语音普遍采用一种为低码率语音设计的专用编码不是常见的 WAV 或 MP3直接用播放器打不开。转换方式是用对应的解码库先解成裸 PCM再封装成 WAV。# 思路示意先解码再封装 # 输入是专用编码语音输出标准 WAV decode_voice input.audio - | pcm_to_wav -o output.wav --rate 24000 --channels 1采样率一般是 24kHz 单声道这个参数错了会导致声音变调听起来像快进或慢放。我第一次转换时用了默认的 16kHz结果所有语音听起来都像被拉长了排查了好一阵。4.3 图片、视频、文件的存储与命名规律图片和视频在数据库里留下的是 XML 描述加一个指向实际文件在FileStorage一类的目录下按年月分文件夹文件名是内容散列加扩展名。这里有个绕不开的环节部分图片文件落盘时做了轻量混淆处理文件头被改写过直接改扩展名打不开。处理方式是根据文件头特征做还原常见做法是识别出实际格式后按对应规则还原字节。还原逻辑不复杂但要针对不同格式分别处理写的时候注意保留原始文件副本别在原文件上直接改。文件类消息SubType 6在 XML 里通常带有文件名、大小、扩展名实际文件同样按散列命名存放。映射关系是数据库里的散列值 → 目录索引 → 实际文件。建议第一次处理时花几分钟建一张文件名到完整路径的哈希表常驻内存后续查起来就是 O(1)。注意处理媒体文件时务必先做完整备份。还原、转码这类操作一旦写错就是不可逆的而原始文件往往只有一份。5. FTSMSG 库的检索能力5.1 全文索引表的结构特征FTSMSG*.db里的核心对象是一张全文检索虚拟表。它的特点是你查询它的时候它返回的是匹配结果和相关性排序而不是让你逐行扫描。表里通常包含消息文本、所属会话标识、对应的消息引用这几个关键列。具体列名在不同版本里会变但结构思路一致用倒排索引换来毫秒级的模糊匹配。一个很实用的判断如果你在客户端里搜索某个词结果是秒出的那说明走的是索引库如果搜索历史很久远的消息时明显变慢那可能是索引没有覆盖到旧分片退回了主表扫描。5.2 用索引做快速检索的正确姿势直接对虚拟表做MATCH查询比你写LIKE %关键词%快几个数量级。原因是LIKE带前置通配符时无法使用任何索引只能全表扫描而全文索引是先查词典再定位文档。-- 推荐走全文索引 SELECT * FROM 索引表 WHERE 索引表 MATCH 关键词 LIMIT 50; -- 不推荐全表扫描几十万条以上就没法看了 SELECT * FROM MSG WHERE StrContent LIKE %关键词%;需要注意的是全文索引的匹配规则和LIKE不完全等价。它默认按词切分中文场景下分词质量直接影响召回率。有些词的组合搜不到不是数据缺失而是分词把它切开了。遇到这种情况可以尝试缩短关键词或者改用前缀匹配语法。5.3 索引与主表的一致性维护索引库和主表不是强一致的。消息写进主表之后索引更新是异步的中间存在一个时间窗口。正常情况下这个窗口很短但如果客户端异常退出可能出现消息在但搜不到的情况。我自己遇到过一次几百条消息在界面上正常显示但怎么搜都搜不出来后来对比索引库和主表的记录数才定位到问题——索引落后了。这种情况的处理方式是不要自己手改索引表虚拟表的手工写入很容易把索引搞坏而是通过客户端自身的机制触发重建或者干脆在解析脚本里绕开索引直接对主表做分批扫描。如果需要大批量检索我的建议是自己建索引把主表数据导出成结构化格式后用成熟的检索库重新建一套可控性更强也不受客户端索引状态的影响。6. 打开加密数据库的原理与正确姿势6.1 为什么直接打开会报错用一个普通数据库客户端去打开这些.db文件大概率会得到一句file is not a database。这不是文件损坏而是文件本身是整库加密的页数据在落盘前被逐页加密过明文结构完全不可见。这类加密方案的基本做法是以固定大小的页为单位通常是 4096 字节每页独立加密页尾预留一小段空间存放校验值首页开头放一段随机盐值。密钥不是直接用你手里的原始字符串而是经过多轮派生计算得到的。6.2 密钥派生与页结构的关键参数要正确解密需要知道四个参数页大小、派生迭代轮数、加密算法、校验算法。这几个参数在社区里有成熟的经验值常见组合是页大小 4096、派生算法为带 HMAC 的 PBKDF2、加密用 AES-256 的分组模式、每页尾部的校验值占 20 字节左右。派生迭代轮数这一项最容易出错。不同版本用过不同的值常见的几个量级分别对应不同年代的版本。逐个试的成本不高因为参数错误时解密出来的首页数据一眼就能看出是乱码试一次只需几毫秒。这里我必须说清楚一件事本文只讨论原理不提供密钥获取的具体手段。密钥由客户端在本地持有具体怎么拿到不属于本文范围也不应该用在任何不属于自己的数据上。下面所有的操作前提都是——你对自己设备上的数据拥有完整处置权且只用于个人数据整理与学习。6.3 一份可靠的操作流程我总结下来比较稳的流程是这样的完全退出客户端确认相关进程已经结束。整体复制目录把Multi连同-wal、-shm文件一起拷到独立的工作目录。这一步是硬性要求后面所有操作都在副本上做。先解密后分析。把加密库还原成标准明文数据库文件后续所有查询都用普通数据库工具跑效率高得多。校验完整性。解密后跑一次PRAGMA integrity_check返回ok才算成功。如果不 ok多半是页大小或校验参数不对。建工作副本。分析脚本一律读这份明文副本绝不直接改。# 流程示意参数需按实际版本调整此处仅为结构演示 step1_copy() { cp -a $SRC/. $WORK/; } step2_decrypt() { decrypt_db --page 4096 --kdf-iter N --in $WORK/MSG0.db --out $WORK/MSG0.plain.db; } step3_check() { sqlite3 $WORK/MSG0.plain.db PRAGMA integrity_check;; }注意-wal文件一定要一起处理。有些解密流程会忽略 WAL导致解密后的库缺少最近的数据。如果解密工具不支持 WAL可以先在明文副本上做一次检查点操作把它合并进主文件。6.4 工具选型的取舍社区里有几种思路一是用支持该加密方案的数据库分支版本直接带参数打开二是写脚本按页解密再拼装成标准文件三是用通用解密工具处理。我个人的偏好是第二种原因很实际可控。写脚本虽然前期要多花一两个小时但页大小、迭代轮数、偏移量这些参数全部掌握在自己手里遇到版本差异时改一行就行。用现成工具的好处是快坏处是参数对不上时你只能干瞪眼看不到中间过程。如果只是偶尔处理一两个库用现成工具完全够用如果要做批量、多版本的解析自己写一套解密模块是值得的投资。我现在的做法是把解密逻辑封装成一个独立模块输入输出都是文件路径上层业务代码完全不用关心加密这回事。7. 常见问题与排查实录7.1 报错速查表下面这些是我在实际操作中反复遇到的按出现频率排序现象最可能的原因处理方式file is not a database库仍是加密状态确认解密参数检查首页盐值是否正确读出解密成功但integrity_check失败页大小或校验参数错逐个试常见页大小优先 4096记录数比客户端显示少WAL 未一并处理带上-wal文件或先做检查点合并中文全是乱码编码判断错误内容多为 UTF-8按此解码后再处理异常项时间显示为 1970 年把秒当毫秒用CreateTime是秒级乘以 1000 才是毫秒语音打不开未做格式转换先解码为 PCM 再封装为 WAV图片改扩展名后仍打不开文件头被混淆识别实际格式后按规则还原多分片合并后顺序错乱直接按分片号拼接改为全局按时间加序号排序同一消息出现两次用localId去重改用MsgSvrID去重某类消息正文为空走了压缩字段检查CompressContent并解压7.2 三个我踩过的深坑坑一以为localId全局唯一。这个前面提过但值得再强调一次。它只在单库内唯一跨库直接比较会得到完全错误的结果。修正方式很简单但发现问题的过程很痛苦——我对着两份看起来一样的数据查了整整一个下午。坑二XML 解析没做异常兜底。消息体里的 XML 偶尔会包含用户输入的非法字符导致解析器直接抛异常。一开始我让异常往外冒结果一个坏字符就让整个批次的导出中断。后来改成每条消息独立 try/except失败的记录写入一个单独的失败清单事后人工核查稳定性立刻上了一个台阶。坑三忽略时区。时间戳本身是 UTC 基准的秒数转换成本地时间时要用正确的时区偏移。我早期直接用服务器的默认时区输出导致导出的记录整体偏移了几个小时跟客户端界面对不上。修正后专门写了一个统一的转换函数所有时间输出都走它避免各处散落硬编码。7.3 一些不太起眼但很有用的经验第一先小样本验证再全量跑。拿一个最旧的、只有一个会话的小分片库做验证跑通了再上大库。全量跑一次动辄半小时边跑边改的代价太高。第二日志要记到字段级。解析失败时不要只记第 1234 条失败要记清楚是哪张表、哪个字段、原始字节的前若干位。这些信息在事后定位时价值极高而多写这几行日志的成本几乎为零。第三输出格式选宽松的中间格式。不要一步到位直接生成最终报表先落成结构化的中间文件每条消息一行需要什么格式再从中间文件转换。这样做的好处是改需求时不用重新解析一遍数据库几分钟就能产出新格式。第四给字段留扩展位。你永远会遇到某个版本多出个不认识的字段。解析脚本里对未知字段采取原样保留策略别丢掉说不定哪天就要用到。第五注意磁盘空间。解密是等体积的媒体文件还原也是等体积的一个中等规模的账号搞下来几十 GB 很正常。开工前先看一眼剩余空间中途磁盘满导致的半成品文件非常难清理。我个人在反复折腾这套东西的过程中最大的体会是数据结构的理解成本远高于代码本身。真正花时间的不是写查询语句而是搞明白这三个库为什么这么拆、哪些字段之间有隐含的对应关系、哪些看起来冗余的列其实承担着关键作用。把这些想通之后代码反而写得飞快。所以如果你也要动手别急着打开编辑器先花半天时间把表结构和几段样本数据仔仔细细看一遍后面会省下好几倍的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑