Files
姚章浩 9155bd3170
build / build (push) Successful in 23s
fix(artifact): 类型误标回退 + 服务端阻断判可重试 + 空白单元格重读
压测/取件实测暴露的三类问题(均在真实 worker 容器里复现并已验证修复):
- 名为 .axls 实为 docx 的节点(清单扩展名与真实类型不符):dws 明确回报真实类型
  → 回退 drive download 取原始字节(实测该节点 243,949B docx 可正常取到),
  不再直接失败
- server model operate block 判可重试:同一节点 5 连测 3/5 成功(另一支 timeout),
  原判终态会把可成功节点永久失败
- axls 大表偶发返回全空单元格(12 次取件出现 1 次):全空时重读一次再上报,
  避免被平台空壳判定当「空文档」终态

新增 3 个回归测试(18 tests / 19 断言全绿)
2026-10-09 12:15:22 +08:00

217 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""dws axls → xlsx 转换器(纯标准库,无 openpyxl)。
用途:dws 没有 axls 直出接口,只能 sheet range read 拿单元格;本脚本把单元格写成
合法的 .xlsx,供平台 artifact_fetch 的 dingtalk dws 取件器落盘入库。
兼容平台既有检查:
- `exported_doc_is_blank()` 对 spreadsheet 只读 `xl/sharedStrings.xml` → 本脚本**把文本写进 sharedStrings**,
否则会被判定为"空壳文档"而拒收。
- 必须 PK magic、能被子解析器识别为 xlsx。
归一化约定(与平台既有 xlsx 产物对齐):
- 单元格换行 CRLF/CR → LF
- 日期保持 dws 返回的显示文本(平台旧产物是 Excel 序列号,转换后语义一致)
dws 可执行文件解析顺序:$DWS_BIN → PATH 里的 dws。
"""
import argparse, json, os, re, shutil, subprocess, sys, zipfile
from xml.sax.saxutils import escape
NUM_RE = re.compile(r"^-?\d+(\.\d+)?$")
class DwsCallError(RuntimeError):
"""dws 调用失败(供取件器映射成平台错误码,而不是让进程直接退出)。"""
def dws_binary():
return os.environ.get("DWS_BIN") or shutil.which("dws") or "/usr/local/bin/dws"
def run_json(args, timeout=180):
# dws 的文件型 keychain 在 $HOME/.local/share/dws-cli,容器里用 DWS_HOME 单独指定
env = dict(os.environ)
dws_home = os.environ.get("DWS_HOME")
if dws_home:
env["HOME"] = dws_home
p = subprocess.run([dws_binary(), *args, "-f", "json"], capture_output=True, text=True,
timeout=timeout, env=env)
try:
payload = json.loads(p.stdout)
except Exception:
raise DwsCallError(f"dws 调用失败: {' '.join(args)} :: {(p.stdout or p.stderr)[:300]}")
failed = (p.returncode != 0 or payload.get("success") is False or payload.get("ok") is False
or payload.get("status") in ("failure", "failed"))
if failed:
message = ""
err = payload.get("error")
if isinstance(err, dict):
message = str(err.get("message") or err.get("cause") or "")
raise DwsCallError(message or f"dws 调用失败: {' '.join(args)}")
return payload
def fetch_sheets(node):
"""返回 [{'name':str,'cells':[[...]]}]"""
out = _read_sheets(node)
if all(not s.get("cells") for s in out):
# 实测:大表(220MB/15 表)偶发返回全空单元格,重读一次即正常;空白结果
# 一旦上报会被平台的空壳判定当作"空文档"终态处理,所以这里先重读一遍。
retry = _read_sheets(node)
if any(s.get("cells") for s in retry):
return retry
return out
def _read_sheets(node):
listing = run_json(["sheet", "list", "--node", node])
sheets = listing.get("sheets") or []
if not sheets:
raise DwsCallError(f"sheet list 无工作表: {json.dumps(listing, ensure_ascii=False)[:200]}")
out = []
for s in sheets:
sid = s.get("sheetId") or s.get("name")
r = run_json(["sheet", "range", "read", "--node", node, "--sheet-id", sid])
out.append({"name": s.get("name") or sid, "cells": r.get("cells") or []})
return out
def norm(v):
if v is None:
return None
s = str(v)
if "\r" in s:
s = s.replace("\r\n", "\n").replace("\r", "\n")
return s
def col_letter(idx):
"""0-based 列号 → A/B/.../AA"""
s = ""
idx += 1
while idx:
idx, rem = divmod(idx - 1, 26)
s = chr(65 + rem) + s
return s
def sheet_name(name, used):
n = re.sub(r"[\[\]:*?/\\]", "_", str(name or "Sheet")).strip()[:31] or "Sheet"
base, i = n, 1
while n.lower() in used:
suffix = f"_{i}"
n = base[: 31 - len(suffix)] + suffix
i += 1
used.add(n.lower())
return n
def build_xlsx(sheets, path):
# 1) 收集共享字符串 + 每表 XML
shared, shared_idx = [], {}
def sidx(text):
if text not in shared_idx:
shared_idx[text] = len(shared)
shared.append(text)
return shared_idx[text]
used, sheet_xml, names = set(), [], []
for s in sheets:
sname = sheet_name(s.get("name"), used)
names.append(sname)
rows = s.get("cells") or []
chunks = []
for ri, row in enumerate(rows, start=1):
cells = []
for ci, cell in enumerate(row):
v = cell.get("value") if isinstance(cell, dict) else cell
v = norm(v)
if v is None or v == "":
continue
ref = f"{col_letter(ci)}{ri}"
if NUM_RE.match(v):
cells.append(f'<c r="{ref}"><v>{v}</v></c>')
else:
cells.append(f'<c r="{ref}" t="s"><v>{sidx(v)}</v></c>')
if cells:
chunks.append(f'<row r="{ri}">{"".join(cells)}</row>')
sheet_xml.append(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<sheetData>{"".join(chunks)}</sheetData></worksheet>')
n = len(names)
ct = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>'
+ "".join(f'<Override PartName="/xl/worksheets/sheet{i+1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' for i in range(n))
+ '</Types>')
rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
'</Relationships>')
wb = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>'
+ "".join(f'<sheet name="{escape(names[i])}" sheetId="{i+1}" r:id="rId{i+1}"/>' for i in range(n))
+ '</sheets></workbook>')
wb_rels = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ "".join(f'<Relationship Id="rId{i+1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i+1}.xml"/>' for i in range(n))
+ f'<Relationship Id="rId{n+1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
+ f'<Relationship Id="rId{n+2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>'
+ '</Relationships>')
styles = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>'
'<borders count="1"><border/></borders>'
'<cellStyleXfs count="1"><xf/></cellStyleXfs>'
'<cellXfs count="1"><xf/></cellXfs></styleSheet>')
sst = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="{len(shared)}" uniqueCount="{len(shared)}">'
+ "".join(f"<si><t xml:space=\"preserve\">{escape(t)}</t></si>" for t in shared)
+ '</sst>')
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", ct)
z.writestr("_rels/.rels", rels)
z.writestr("xl/workbook.xml", wb)
z.writestr("xl/_rels/workbook.xml.rels", wb_rels)
z.writestr("xl/styles.xml", styles)
z.writestr("xl/sharedStrings.xml", sst)
for i, xml in enumerate(sheet_xml):
z.writestr(f"xl/worksheets/sheet{i+1}.xml", xml)
return {"sheets": names, "shared_strings": len(shared)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--node")
ap.add_argument("--cells-json")
ap.add_argument("--out", required=True)
a = ap.parse_args()
if a.cells_json:
sheets = json.load(open(a.cells_json))["sheets"]
elif a.node:
sheets = fetch_sheets(a.node)
else:
raise SystemExit("需要 --node 或 --cells-json")
info = build_xlsx(sheets, a.out)
rows = sum(len(s.get("cells") or []) for s in sheets)
print(json.dumps({"success": True, "out": a.out, "sheets": info["sheets"],
"sheet_count": len(sheets), "rows": rows,
"shared_strings": info["shared_strings"]}, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except DwsCallError as error:
print(json.dumps({"success": False, "error": str(error)[:300]}, ensure_ascii=False), file=sys.stderr)
sys.exit(2)