sdtech_xlsx 原理与实现详解
sdtech_xlsx 3.1 是纯 C++ 的 Excel
.xlsx读写库:OPC 容器 + SpreadsheetML 解析 + 工作簿对象模型 + JSON Bridge + C ABI。架构与姊妹库 sdtech_docx 同构;语义层对标 openpyxl 3.x / Apache POI XSSF。模块映射见python-openpyxl-port-map.md。
本文只讲 库内部 从磁盘字节到内存模型、再写回 Part 的完整链路;集成层(Electron、Backend 会话)见 OfficeExcel 功能介绍。
一、分层与源码入口
库按 依赖方向 分为五层,数据始终沿「ZIP → Part → XML → Model → Part → ZIP」流动:
Impl/XlsxWorkbook ← 门面:loadFromPath / createBlank / saveToPath
└─ model/WorkbookModel ← 唯一权威内存状态
├─ package (opc::Package) ← 全 Part blob 字典
├─ sheets[] (WorksheetModel)
└─ sharedStrings / definedNames / …
CApi/sdtech_xlsx_api.cpp ← C ABI:解析 JSON 入参,改 Model,调 Writer/Loader
bridge/BridgeJson.cpp ← Model → preview/structure/consistency JSON 字符串
| 目录 | 核心类型 | 职责 |
|---|---|---|
opc/ |
Package, Part, ContentTypes, OoxmlCrypto |
ZIP 读写、Part 字典、关系解析、加密包装 |
Impl/ |
MinimalZip, XlsxWorkbook |
裸 ZIP 算法、工作簿生命周期 |
oxml/ |
OxmlUtil |
pugixml 封装:local-name、命名空间无关属性 |
model/ |
WorkbookLoader/Writer, StyleResolver, FormulaEngine, … |
SpreadsheetML ↔ 对象模型 |
bridge/ |
buildPreviewJson 等 |
内存模型序列化为 JSON(库内协议) |
CApi/ |
sdtech_xlsx_* |
导出符号、错误码、thread_local last_error |
第三方以 源码 vendoring 编入:pugixml.cpp、miniz_tinfl.c(deflate)。Windows 加密路径链接 bcrypt(SHA-512 + AES-256-CBC)。
二、xlsx 在磁盘上是什么
.xlsx 是 OOXML OPC 包:ZIP 内每个 entry 是一个 Part(路径即 Part 名,如 xl/worksheets/sheet1.xml)。SpreadsheetML 相关 Part 包括:
| Part | SpreadsheetML 要点 |
|---|---|
xl/workbook.xml |
<sheets> 列表、definedNames、pivotCaches |
xl/worksheets/sheetN.xml |
sheetData/row/c 单元格、mergeCells、sheetViews/pane 冻结 |
xl/sharedStrings.xml |
t="s" 单元格的值索引表 |
xl/styles.xml |
fonts / fills / borders / cellXfs / numFmts |
xl/theme/theme1.xml |
主题色索引(color theme="N" 解析) |
xl/drawings/drawingN.xml |
图片 anchor、chart graphicFrame |
xl/charts/chartN.xml |
图表 plotArea |
xl/pivotTables/ + xl/pivotCache/ |
透视表定义与缓存 |
保真策略(与 sdtech_docx 相同):Package::loadFromZipMap 把 ZIP 里 所有 entry 放进 parts_;WorkbookLoader 只解析需要建模的 XML 到 WorkbookModel;WorkbookWriter::syncParts 只 重写 被 touch 的 Part(worksheets、styles、sharedStrings、drawings…);其余 Part(未知扩展、未改 theme 等)blob 原样在 save 时写回。
三、OPC 层:Package 与 Part
3.1 Part 结构
Part.h 中 Part 是库的最小存储单元:
class Part {
std::string partName; // ZIP 内路径,已 normalize
std::string contentType; // 来自 [Content_Types].xml
std::vector<uint8_t> blob; // 原始字节(XML 或 PNG 等)
std::vector<Relationship> relationships; // 从 xxx.rels 解析
bool dirty;
};
文本 Part 通过 text() / setText() 在 blob 与 string 间转换;二进制 Part(图片)用 setBlob。
3.2 打开:Package::open
Package.cpp 流程:
zipReadFile(path, zipMap)→ 路径名 → bytes- 若
isEncryptedZipMap(存在EncryptedPackageentry)→decryptOfficePackage得内层 zipMap loadFromZipMap:每个 entry 建Part,跳过目录项(名以/结尾)- 解析
[Content_Types].xml→ContentTypes,为各 Part 填contentType - 对每个 Part,若存在
xl/worksheets/_rels/sheet1.xml.rels这类 sibling rels 文件,则parseRelationshipsXml填入relationships wireRelationships()补全尚未挂关系的 Part- 从
_rels/.rels找RelType::OFFICE_DOCUMENT目标,设为mainDocumentPartName_(xlsx 即xl/workbook.xml)
关系解析后,Sheet 的 r:id 可解析为 xl/worksheets/sheet1.xml 等绝对 Part 路径(resolvePartTarget 处理 ../ 相对路径)。
3.3 空白包:Package::createBlank
createBlank() 手写最小合法 OPC 骨架(不读模板文件):
[Content_Types].xml通过ContentTypes::ensureOverride注册 MIME_rels/.rels→rId1→xl/workbook.xmlxl/workbook.xml:空<sheets>含 Sheet1 占位(后续 Writer 重写)xl/_rels/workbook.xml.rels:worksheet / styles / sharedStrings 三条关系xl/worksheets/sheet1.xml:空<sheetData/>xl/styles.xml:1 font、1 fill、1 border、1 cellXf(Calibri 11pt)xl/sharedStrings.xml:空sst
XlsxWorkbook::createBlank 在此基础上往 WorkbookModel.sheets 推入一个 WorksheetModel{ name="Sheet1", partPath="xl/worksheets/sheet1.xml", loaded=true }。
3.4 保存:Package::save
- 遍历
parts_,对每个非[Content_Types].xml的 Part 调用contentTypes.ensureOverride(partName, contentType) ContentTypes::serialize()生成新的[Content_Types].xml- 组装
innerZipmap(content types + 全部 Part blob) - 无密码:逐 entry 写
ZipEntry向量 →zipWriteFile - 有密码:
encryptOfficePackage(innerZip, password, outerEntries)→ 外层仅含加密容器四件套
WorkbookWriter::save 先 syncParts(model) 把 Model 写进 model.package,再 package->save(path, model.encryptionPassword)。
四、MinimalZip:不依赖 libzip 的读写
MinimalZip.cpp 只实现 OPC 需要的子集。
读:
- 整文件读入内存
- 从尾部扫描 EOCD 签名
0x06054b50 - 读 Central Directory,对每个 entry 读 local header
0x04034b50 extractZipEntry:- method 0:stored,直接 memcpy
- method 8:deflate,用 miniz
tinfl_decompress_mem_to_mem(raw deflate,无 zlib header)
写:
- 当前实现 仅 method 0(stored):local header + 明文 data + central directory
- CRC32 写 0(未计算);对 OPC 文本/XML Part 足够,体积略大
这与 sdtech_docx 共用同一套 MinimalZip 思路:读能解 Excel 默认 deflate 包,写用 store 保证实现简单、round-trip 稳定。
五、OoxmlCrypto:加密包装
OoxmlCrypto.cpp 实现 库内 round-trip 的简化 Agile 风格,非完整 MS-OFFCRYPTO。
识别:外层 ZIP 含 EncryptedPackage entry。
解密:
EncryptedPackage前 8 字节 = 明文 ZIP 长度(uint64 LE),其后为密文EncryptionInfo前 16 字节 salt、后 16 字节 IV(缺失则用固定占位0x5A/0xA5)deriveKeyAgile:SHA512(salt || password)取前 32 字节为 AES-256 密钥aes256CbcDecrypt(bcrypt)→ 明文 ZIP 写临时文件 →zipReadFile得内层 map
加密(encryptOfficePackage):
- 内层 map 先
zipWriteFile到临时文件 - 随机 salt/IV,
aes256CbcEncrypt(PKCS#7 padding) - 外层 ZIP 四个 Part:
[Content_Types].xml、_rels/.rels(指向 EncryptedPackage + EncryptionInfo)、EncryptionInfo、EncryptedPackage
WorkbookModel::encryptionPassword 非空时 Package::save 走加密分支。Excel 原生打开未完全保证——GTest 验证的是库内加解密后再 WorkbookLoader 可读。
六、OxmlUtil:命名空间无关的 XML 访问
SpreadsheetML 根节点带 xmlns,子元素可能是 c 或前缀形式。 OxmlUtil.cpp 用 local-name 匹配:
isElement(node, "row"):nameIs比较完整名或*:row后缀childElement(parent, "sheetData"):遍历 child,找第一个 local-name 匹配attributeLocalVal(node, "ref"):先无命名空间属性,再扫r:ref这类
Loader/Writer 不依赖 XPath;全部 imperative 遍历 pugixml DOM。parseXmlBytes(Part::blob) 直接 parse buffer,避免多余 string 拷贝。
七、打开链路:WorkbookLoader
入口:XlsxWorkbook::loadFromPath → Package::open → WorkbookLoader::loadFromPackage(std::move(pkg), model, lazyLoad)。
7.1 工作簿级解析
- 样式表:
StyleResolver::loadFromPackage读xl/styles.xml(见第八节) - 共享字符串:遍历
xl/sharedStrings.xml的<si>,readSharedString支持 plain<t>或 rich<r><t> - workbook.xml:
<definedNames>→DefinedNameModel{name, formula, sheetIndex}<sheets><sheet name sheetId r:id state>→ 用workbook.xml.rels把r:id映射为ws.partPath
- lazyLoad 分支:
false:对每个 sheet Part 调loadWorksheetXml+loadWorksheetExtensionstrue:只填 Sheet 元数据,ws.loaded = false,不解析sheetData(Part blob 仍在 Package 里)
7.2 工作表级:loadWorksheetXml
WorkbookLoader.cpp 顺序解析 worksheet 子树:
| XML 节点 | 写入 WorksheetModel |
|---|---|
sheetViews/sheetView/pane |
freezeRow ← ySplit,freezeCol ← xSplit |
autoFilter@ref |
autoFilterRef |
cols/col@min,width |
colWidths[col] = width * 7(内部像素近似) |
mergeCells/mergeCell@ref |
merges[],再 applyMergeMetadata |
hyperlinks/hyperlink |
hyperlinks[] |
sheetData/row@r,ht |
rowHeights[row] = ht * 96/72 |
sheetData/row/c |
见下 |
单元格 <c> 解析:
r → CellRef,得到 col/row
t → valueType: s/n/b/inlineStr
s → styleIndex → StyleResolver::resolve → cell.format
f → formula 文本
v → 按 t 解释:
s → sharedStrings[idx]
b → boolValue + text TRUE/FALSE
n/空 → numericValue,若 numFmt 像日期则 isDate=true
inlineStr → is/r 或单 t → text / richText[]
缺 r 时从 row@r + 列序推断;最后 cells[ref] = cell。键为 A1 字符串,值为 CellModel。
合并元数据 WorkbookOps.cpp applyMergeMetadata:
- 对每个
MergeRange(如B2:D4),范围内每个格都在cells中有条目 - 左上角:
colSpan/rowSpan为合并宽高,isMergeOrigin=true - 其余格:
isMergeOrigin=false;Writer 的writeCellXml跳过非 origin(Excel 只写一个<c>)
7.3 扩展 Part:loadWorksheetExtensions
drawing@r:id→ sheet rels → drawing Part →loadChartsFromDrawingloadPivotTablesFromWorksheet读 pivotTable rel
图表/透视 读 路径在 ChartPivotOps.cpp 反向解析 XML 填 ChartModel / PivotTableModel。
7.4 按需加载:loadSheet
WorkbookLoader::loadSheet(model, index):若 ws.loaded 已 true 则返回;否则从 package 取 sheet Part blob,重新 StyleResolver::loadFromPackage,执行与 7.2 相同的 loadWorksheetXml。lazy 打开的大文件只有调用 load_sheet 时才分配 cells map。
八、样式:读 StyleResolver、写 StyleSheetBuilder
8.1 读取:扁平化为 CellFormat
StyleResolver 在 Loader 内解析 xl/styles.xml 五表:
fonts[] / fills[] / borders[] / cellXfs[] / customNumFmts(id≥164)
每个 cellXfs[i] 通过 fontId/fillId/borderId/numFmtId 索引子表,合并 alignment 子节点,得到 resolved_[i] 即 CellFormat(bold、fontFamily、bgColor、四边 border、numFmt…)。
颜色 colorFromNode:
rgb="FFRRGGBB"→#RRGGBBtheme="N" tint="…"→ThemeResolver::resolveThemeIndex
日期识别:looksLikeDateNumFmt(numFmt) 检查格式串是否含 y/d/h。
8.2 写入:去重生成 styles.xml
写路径 不 保留原 styles.xml 结构,而是根据内存中所有单元格的 CellFormat 重新构建:
StyleSheetBuilder::assignStyleIndices(model)遍历每个cell.format,indexFor(format)分配styleIndexindexFor用formatKey(fmt)字符串做 dedup map;新格式则:internFont/internFill/internBorder/internNumFmt各自表内去重- 追加
RawXf到xfs_
buildStylesXml()输出完整styleSheetXML;自定义 numFmt 从 id 164 递增(nextCustomNumFmtId_)
内存里样式是 富结构 CellFormat;磁盘上是 索引 s="3"。打开时 expand,保存时 collapse——与 openpyxl 懒样式思路一致。
九、内存模型:WorkbookModel 与 WorksheetModel
定义于 WorkbookTypes.h。
WorkbookModel
├── package: unique_ptr<Package> // 全 Part;save 的数据源
├── sheets: vector<WorksheetModel>
├── sharedStrings: vector<string> // 与 sst 同步;Writer save 时重建
├── definedNames[]
├── activeSheetIndex, styleCount
├── lazyLoad, streamingMode, streamingWindowRows
├── encryptionPassword // 非空则 save 加密
└── nextPivotCacheId
WorksheetModel
├── name, index, sheetId, partPath // partPath 来自 workbook rel
├── active, hidden, veryHidden, tabColor
├── cells: map<string, CellModel> // key = "B3"
├── merges, colWidths, rowHeights
├── freezeRow/Col, autoFilterRef
├── hyperlinks, comments, images, charts, pivotTables
├── dataValidations, conditionalFormats, tables, printSetup
├── loaded // lazy 标志
├── streamingMinRow // 流式窗口下界
└── flushedRows: map<row, map<col, CellModel>> // 已刷出窗口的行
CellModel 同时持有 展示(text)、类型(valueType/numericValue/boolValue/isDate)、样式(format + styleIndex)、公式(formula)、合并(colSpan/rowSpan/isMergeOrigin)、富文本(richText[])。
CellRef(CellRef.cpp):列字母 bijective 映射(A→1, Z→26, AA→27);fromA1/toA1 供合并区域、公式引址使用。
日期 DateUtil.cpp:Excel 序列日基准 1899-12-30,isoDateToExcelSerial / excelSerialToIsoDate 用 civil calendar 算法,与 Loader 识别 isDate 配合。
十、C API 如何改 Model(库边界)
sdtech_xlsx_api.cpp 不持有第二份状态:每个 void* 句柄即 XlsxWorkbook*,其 model 成员即权威数据。
典型写单元格 set_cell 路径:
resolveSheetIndex:按 JSON"sheet":0或"sheet":"Name"找WorksheetModel;若 lazy 且未加载则loadSheetapplyCellFromJson:根据type填valueType/text/numericValue/boolValue/isDate/formulaws->cells[ref] = cellmaybeFlushStreamingRows(model, *ws)(流式模式)
set_cell_style 只改已有或新建格的 CellFormat 字段;styleIndex 在 save 前由 StyleSheetBuilder::assignStyleIndices 统一分配。
JSON 解析为 轻量 strstr(jsonGetString/jsonGetInt),避免链接完整 JSON 库;C API 层用 mutex 保护 init,错误信息 thread_local g_last_error。
十一、保存链路:WorkbookWriter::syncParts
WorkbookWriter::save → syncParts → Package::save。syncParts 是库内最复杂的函数,按 依赖顺序 写 Part:
11.1 共享字符串表
两遍扫描所有 已加载 Sheet 的 cells 与 flushedRows:
cellNeedsSharedString:非公式、非数字/布尔/日期、非 inlineStr 的文本 → 需要 sstintern(text)去重,生成xl/sharedStrings.xml
公式单元格 不 进 sst(值在 <f>/<v>)。
11.2 样式表
StyleSheetBuilder::assignStyleIndices + buildStylesXml() → xl/styles.xml。
11.3 工作簿与关系
重写 xl/workbook.xml(sheets 列表、definedNames、pivotCaches 占位)和 xl/_rels/workbook.xml.rels(每个 sheet 一个 worksheet rel + styles + sharedStrings + pivotCache rels)。
11.4 每个 Sheet 的 worksheet.xml
对每个 ws.loaded==true 的 Sheet:
- 合并 grid:
cells+flushedRows放入map<row, map<col, CellModel*>> - 流式行界:
rowBegin = streamingMode ? max(1, ws.streamingMinRow) : 1,只输出窗口内及 flushed 行 - 序列化子节:sheetViews(冻结)、pageSetup、cols、sheetData(按 row 排序,每 row 内
writeCellXml)、autoFilter、mergeCells、hyperlinks、dataValidations、conditionalFormatting、table、drawing/pivot 占位 ref writeCellXml逻辑摘要:!isMergeOrigin→ 跳过- 有
s属性:styleIndex - 公式:
<f>+ 可选<v> - 布尔
t="b"、数字/日期 bare<v>、richTextinlineStr+<is>、普通文本t="s"+sst 索引
列宽写回:width = colWidths[col] / 7(与读路径互逆)。行高:ht = rowHeights[row] * 72/96。
11.5 图片、图表、透视 Part
若 Sheet 有 images 或 charts:
- 写
xl/drawings/drawingN.xml(xdr:twoCellAnchor+ pic 或 graphicFrame) - 图片 blob →
xl/media/image{sheet}_{idx}.png - 图表 XML →
xl/charts/chart{sheet}_{idx}.xml,buildChartXml生成c:barChart/lineChart/pieChart xl/drawings/_rels/drawingN.xml.rels链 image/chart
透视表:预先收集 PivotWritePlan(cacheId、cacheDef/Records/pivotTable 路径),写 buildPivotCacheDefinitionXml、buildPivotTableXml、占位 buildPivotCacheRecordsXml。
批注:独立 xl/commentsN.xml Part。
最后 model.sharedStrings = sharedStrings 保持内存与磁盘 sst 一致。
十二、流式写入:StreamingOps
对标 POI SXSSF:StreamingOps.cpp maybeFlushStreamingRows:
streamingMode && (maxRow - streamingMinRow + 1) > streamingWindowRows
→ 把 streamingMinRow 那一行从 cells 移到 flushedRows[row]
→ streamingMinRow++
- 窗口内(
streamingMinRow~ 当前最大行):cellsmap 可随机读写 - 窗口外:只在
flushedRows保留,Writer 写 sheet 时与 cells 合并进 grid flush_streaming_rowsAPI 对全部 Sheet 调用上述逻辑,强制刷到flushedRows
createBlank(..., streaming=true, windowRows) 设置 WorkbookModel::streamingMode 与 streamingWindowRows(默认 100)。限制: flushed 行不再支持随机改(库未禁止,但 Excel 语义上已「落盘行」)。
十三、公式引擎:FormulaEngine
FormulaEngine.cpp 是 递归 descent 子集,不是完整计算引擎:
- 入口
evalExpression:去 leading=,按函数名 dispatch splitTopLevelArgs:括号深度计数切参,支持IF(a,b,c)嵌套resolveRef:从ws.cells或 flushedRows 取数值(流式场景公式仍可引用已刷行)- 已实现:
SUM/AVERAGE区域、IF、VLOOKUP、DATE/TODAY/YEAR/MONTH/DAY、CONCAT、简单+/-
evaluateCell:求值结果写回 cell.text、valueType="n"、numericValue(覆盖缓存显示,不改 <f> 字符串)。未识别函数:evaluateFormula 仍返回 true 但表达式当数字 parse,保存时公式原文保留。
十四、图表与透视:ChartPivotOps
写(Writer 调用):
buildChartXml:最小c:chartSpace,series 用strRef/numRef存 区域字符串(如Sheet1!$A$2:$A$10),不内联数据点buildPivotCacheDefinitionXml:cacheSource/worksheetSource@ref+cacheFieldsbuildPivotTableXml:location@ref+pivotFields角色(row/col/data)
读(Loader 调用 loadChartsFromDrawing / pivot 解析):从 drawing rel 找 chart Part,反向填 ChartModel;供 get_charts 与 round-trip 测试。
MVP 边界:cache records 用固定占位 XML;不在库内 刷新 透视数据。
十五、Bridge:Model → JSON(库内序列化)
BridgeJson.cpp 三套输出,供 C API get_preview / get_structure / get_open_consistency 返回:
| 函数 | 内容 |
|---|---|
buildStructureJson |
workbook → sheets → cells 的 {ref,text,formula,row,col} 扁平列表 |
buildPreviewJson |
每 sheet:rows 二维稀疏数组(仅 isMergeOrigin 格)、colWidths/rowHeights 数组、format 对象、freeze/chartCount 等 |
buildConsistencyJson |
sheet_count、cell_count、shared_string_count、style_count、part_count 与 Model 自检 |
Preview 的 rows 构造:扫 cells 得 maxRow/maxCol,逐行逐列查 map,跳过 merge 非 origin 格;appendFormat 输出 CellFormat 字段。JSON 手写拼接 + jsonEscape,与 C API 入参解析对称。
十六、Sheet 生命周期与其它 Model 操作
removeWorksheet:至少保留 1 个 Sheet;删除后重排index/partPath(sheet1.xml命名与 index 对齐)renameWorksheet/setActiveSheet:只改内存,save 时写入 workbook.xmlunmergeCells:删 merges 项,范围内非 origin 空 cell 删除,span 重置
超链接、批注、数据验证、条件格式、Table、打印:CApi 层构造对应 *Model push 到 WorksheetModel 向量,Writer 在 11.4 节序列化为对应 XML 子树。
insert_image:读文件 bytes → ImageModel{data, mimeType, fromCol/Row, toCol/Row};save 时进 drawing + media。
十七、测试与能力边界
| 手段 | 验证点 |
|---|---|
GTest XlsxPhase0~XlsxPhase6Deep |
API、流式、加密、图表透视 |
python_ref/dump_workbook.py |
openpyxl 黄金 JSON |
structure_golden_test.py / preview_format_test.py |
Bridge 字段契约 |
已实现(3.1):OPC 全 Part 保真、Sheet/Cell 类型化、样式去重、合并/冻结/筛选、富文本/图片/批注/超链接、公式子集、图表透视 MVP、SXSSF 式 flush、lazy sheet、库内加密 round-trip。
未实现:完整 Excel 函数、透视刷新、复杂 chart、VBA、与 Excel 100% 加密互操作。
十八、建议阅读顺序(跟代码走一遍)
Package.cpp— 理解 Part 如何进内存WorkbookLoader.cpp—loadFromPackage+loadWorksheetXmlWorkbookTypes.h— 内存形状sdtech_xlsx_api.cpp—set_cell/set_cell_style如何改 ModelWorkbookWriter.cpp—syncParts+writeCellXmlStyleSheetBuilder.cpp— 样式去重StreamingOps.cpp/FormulaEngine.cpp— 扩展行为BridgeJson.cpp— JSON 输出契约OoxmlCrypto.cpp— 加密包装
附录:目录速查
Libs/sdtech_xlsx/
├── include/sdtech_xlsx_c.h
├── Dll/Src/
│ ├── opc/Package.cpp Part.cpp ContentTypes.cpp OoxmlCrypto.cpp OpcConstants.cpp
│ ├── oxml/OxmlUtil.cpp
│ ├── model/
│ │ WorkbookLoader.cpp WorkbookWriter.cpp WorkbookTypes.h WorkbookOps.cpp
│ │ StyleSheetBuilder.cpp CellRef.cpp DateUtil.cpp ThemeResolver.cpp
│ │ FormulaEngine.cpp ChartPivotOps.cpp StreamingOps.cpp
│ ├── bridge/BridgeJson.cpp
│ ├── CApi/sdtech_xlsx_api.cpp internal.h
│ └── Impl/MinimalZip.cpp XlsxWorkbook.cpp
├── Docs/python-openpyxl-port-map.md
└── Tests/
版本:3.1.0(SDTECH_XLSX_VERSION_* = 3 / 1 / 0)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)