sino-bc-cli 1.8.19 → 1.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ # 业务线策略生成
2
+
3
+ ## Purpose
4
+
5
+ 为业务线策略生成技能规定可复用的分析、Excel 导出与批量执行行为,保证多条业务线运行时有明确的并发上限、独立结果和失败信息,并在每条任务结束后仅保留验证通过的最终文件,避免临时数据持续堆积或误删其他任务产物。
6
+
7
+ ## Requirements
8
+
9
+ ### Requirement: 统一生成入口与业务线隔离
10
+
11
+ 技能 MUST 提供覆盖查询、拉数、分析、校验、导出和清理的统一入口。单业务线与多业务线 MUST 使用相同分析规则。输入 MUST 支持名称查找和显式业务线 ID 列表;批量候选 MUST 按 ID 去重。名称匹配不唯一时 MUST 明确报告歧义而非自动取第一条。业务请求 MUST 复用现有 CLI,MUST NOT 手写网关 HTTP 或绕过身份约束。
12
+
13
+ #### Scenario: 批量重复 ID 不重复生成
14
+ - **GIVEN** 输入列表包含相同业务线 ID 两次
15
+ - **WHEN** 执行统一入口
16
+ - **THEN** 该业务线只执行一次,拥有独立工作目录和唯一最终 Excel
17
+
18
+ #### Scenario: 名称存在歧义
19
+ - **GIVEN** 名称查询返回多条候选
20
+ - **WHEN** 解析单业务线目标
21
+ - **THEN** 返回候选与歧义错误,不启动这些候选的投放拉数任务
22
+
23
+ ### Requirement: 单次执行最多并发三条业务线
24
+
25
+ 一次技能调用 MUST 默认最多同时运行 3 条业务线任务,允许设置 1 至 3,超出范围 MUST 在启动业务任务前拒绝。一个业务线从拉数到清理结束 MUST 占用同一个并发名额。该业务线内部的 CLI 请求和分页 MUST 继续顺序执行,MUST NOT 叠加子级并发。待处理业务线在名额释放后继续启动。
26
+
27
+ #### Scenario: 十条业务线受并发上限约束
28
+ - **GIVEN** 请求生成 10 条不同业务线
29
+ - **WHEN** 默认执行批量入口
30
+ - **THEN** 任意时刻运行中的业务线数不超过 3,且有名额后继续处理队列
31
+ - **AND** 已完成业务线立即交付并清理,不等待全部 10 条结束
32
+
33
+ #### Scenario: 非法并发参数不发起请求
34
+ - **GIVEN** 并发参数为 0、4 或非整数
35
+ - **WHEN** 启动统一入口
36
+ - **THEN** 返回参数错误,不创建业务输出或调用业务接口
37
+
38
+ ### Requirement: 分页与失败回包必须完整校验
39
+
40
+ 每页 CLI 回包 MUST 直写文件并检查退出码、信封和分页总数。异常、解析失败、未到总数即遇空页、达到分页上限仍未取全,MUST 阻止该业务线生成成功报告。CLI stderr 错误 MUST 保留到本次任务结果摘要,而非当作无数据。其他业务线 MUST 可以继续执行。
41
+
42
+ #### Scenario: 后续页失败不导出部分结果
43
+ - **GIVEN** 首页成功且声明仍有下一页
44
+ - **WHEN** 后续页调用失败或返回空页但累计数量不足
45
+ - **THEN** 该业务线失败,不将不完整数据生成最终 Excel,其他业务线继续执行
46
+
47
+ ### Requirement: 指标与周期口径固定
48
+
49
+ 分析 MUST 从同粒度基础指标先求和再计算比率,分母为零或所需指标缺失时明确不可计算。安装 CPI MUST 标注为美元消耗除以平台安装量,MUST NOT 直接改称客户激活或留存成本。缺少同币种、同转化定义或留存数据时,客户 KPI MUST 标为不可判定,MUST NOT 使用固定 CPM 阈值替代。首期按可判定 KPI 输出,否则待观察;第二期按可计算核心指标环比判定,绝对变化不超过 10% 为稳定;成本下降、收益指标上升为改善。无可比核心指标时 MUST 待观察并说明原因。
50
+
51
+ 请求周期与实际返回的数据覆盖范围 MUST 分别展示。数据未覆盖完整请求周期时 MUST 提示不完整,MUST NOT 将较短周期消耗下降直接解释为主动减预算。新增部分成效 MUST 单独聚合,MUST NOT 将业务线整体环比宣称为单一动作的因果效果。
52
+
53
+ #### Scenario: 人民币 CPA 与美元安装数据不可直接对标
54
+ - **GIVEN** 客户目标是人民币 CPA 与次日留存,接口返回美元消耗和平台安装量
55
+ - **WHEN** 缺少汇率、CPA 定义或留存数据
56
+ - **THEN** 仅计算已支持的观察指标,明确客户 KPI 暂不可判定,不生成 CPM 默认达标结论
57
+
58
+ #### Scenario: 本期六天与上期七天
59
+ - **GIVEN** 请求两个七天周期,但本期只返回六个日期
60
+ - **WHEN** 生成策略及 learning
61
+ - **THEN** 展示请求日期和实际覆盖日期,并提示消耗总额比较存在周期差异
62
+
63
+ ### Requirement: 对象与明细按稳定标识统计
64
+
65
+ 广告和广告组 MUST 按渠道及 ID 判断新增与本期未出现,MUST NOT 仅按名称去重。完整明细 MUST 保留同名不同 ID 的记录。两期集合差异 MUST NOT 被描述为已确认的创建或暂停操作。出价变化 MUST 按广告组日快照计算,并区分上调、下调、期内变化但首末持平;无值 MUST NOT 默认为零。国家全量与过消耗门槛的新增成效 MUST 可在明细追溯。
66
+
67
+ #### Scenario: 同名广告不可合并
68
+ - **GIVEN** 两个不同广告 ID 使用同一名称
69
+ - **WHEN** 统计新增广告并导出明细
70
+ - **THEN** 分别计数并输出 ID,不因名称相同而丢失其中一条
71
+
72
+ #### Scenario: 期内调价后恢复原价
73
+ - **GIVEN** 一个广告组期内出价变化但首末有效快照一致
74
+ - **WHEN** 生成出价摘要
75
+ - **THEN** 计为首末持平的期内变化,不归类为下调
76
+
77
+ ### Requirement: 每条业务线只交付一份校验通过的最终 Excel
78
+
79
+ 每条业务线 MUST 独立产出一份包含固定 12 列策略摘要及完整明细的 Excel。分析与排版 MUST 由固定脚本完成;正常流程 MUST 一次生成最终样式,MUST NOT 依赖 Agent 临时生成修订脚本或二次导入重排。文件发布前 MUST 检查 Excel 容器、两张表、列名及记录完整性。最终路径 MUST 位于待清理工作目录之外,名称 MUST 能区分业务线与请求,MUST NOT 覆盖已存在的其他文件。
80
+
81
+ #### Scenario: 并发生成不覆盖结果
82
+ - **GIVEN** 两条业务线在同一秒完成导出
83
+ - **WHEN** 发布最终文件
84
+ - **THEN** 获得两个不同且可读取的文件路径,各自包含对应业务线数据
85
+
86
+ #### Scenario: 校验失败不发布损坏文件
87
+ - **GIVEN** Excel 缺少明细表或明细行数不一致
88
+ - **WHEN** 执行发布前校验
89
+ - **THEN** 该任务失败,不对外声明最终 Excel 已生成
90
+
91
+ ### Requirement: 任务结束后按归属清理全部临时产物
92
+
93
+ 每条业务线任务完成后,MUST 清理本次任务创建的全部非最终产物,包括但不限于 JSON、请求/响应、日志副本、预览图片、临时脚本、临时 Excel、检查文件及运行时缓存。成功时 MUST 先确认最终文件已发布且验证通过,再清理;失败时 MUST 先保存可返回的错误摘要到内存/输出,再清理其临时和不完整产物。所有任务结束后 MUST 清理本次批次共享临时目录。MUST NOT 保留额外磁盘分析报告或将已删除目录作为交付物。
94
+
95
+ 清理 MUST 以本次创建的独立目录及登记文件为边界,校验规范化绝对路径及所有权,MUST NOT 按扩展名或桌面 glob 广泛扫描。MUST NOT 删除历史请求、其他并行业务线、仓库源码、技能目录、平台凭证或 CLI 全局审计日志。符号链接、Windows junction/reparse point MUST NOT 被递归跟随到边界外。存在清理失败时 MUST 明确报告残留路径和原因,不宣称已完全清理。
96
+
97
+ #### Scenario: 成功后只保留最终文件
98
+ - **GIVEN** 本次业务线目录中存在 JSON、PNG、TXT、无扩展名文件和临时子目录
99
+ - **WHEN** 最终 Excel 发布并校验成功
100
+ - **THEN** 上述临时产物及业务线临时目录均被清理,最终 Excel 仍可读取
101
+
102
+ #### Scenario: 失败任务不残留临时文件
103
+ - **GIVEN** 拉数或分析失败且已创建部分回包和临时输出
104
+ - **WHEN** 该业务线任务结束
105
+ - **THEN** 返回业务线 ID、失败阶段和错误摘要,清理其拥有的临时产物,继续处理其他任务
106
+
107
+ #### Scenario: 清理链接时保护外部目录
108
+ - **GIVEN** 本次临时目录中有链接指向另一任务或用户目录
109
+ - **WHEN** 执行清理
110
+ - **THEN** 不遍历或删除链接目标,外部文件及其他任务目录保持不变
111
+
112
+ #### Scenario: 文件占用导致清理失败
113
+ - **GIVEN** 最终 Excel 已验证,但一个临时文件被占用且删除失败
114
+ - **WHEN** 工作流结束清理
115
+ - **THEN** 返回最终文件路径及清理失败状态和残留路径,批次以非成功状态结束
116
+
117
+ ### Requirement: 批次结果可观察且失败互不掩盖
118
+
119
+ 工作流 MUST 按业务线返回阶段状态、最终 Excel 路径或失败原因,并提供阶段耗时和批次成功/失败数量。结果 MUST 区分生成失败、导出失败、清理失败;任一任务失败时批次 MUST 非零退出,同时保留其他任务的成功文件。耗时结果 MUST 通过输出或最终 Excel 内已有适当区域提供,MUST NOT 为此保留额外临时报告。用户可处理的中断 MUST 停止启动新任务并等待/终止子进程后执行边界内清理;进程被强制杀死或断电的情形不得宣称已完成清理。
120
+
121
+ #### Scenario: 混合结果完整汇报
122
+ - **GIVEN** 三条业务线分别成功、拉数失败、清理失败
123
+ - **WHEN** 批次收尾
124
+ - **THEN** 输出三条各自结果与阶段耗时,保留成功的最终 Excel,批次非零退出
@@ -4,7 +4,7 @@
4
4
  把业务线策略生成结果写成 .xlsx(业务线策略生成 skill 专用,纯标准库实现,无第三方依赖)。
5
5
 
6
6
  职责边界:本脚本只负责「结构化数据 -> Excel 文件」的机械落盘,
7
- 不做任何数据拉取、策略文案生成或判断——那些都由调用方(Agent)在此之前完成。
7
+ 不做数据拉取和业务判断;统一入口先通过固定汇总脚本生成内容。
8
8
 
9
9
  用法:
10
10
  python3 build_report.py --input payload.json [--output /path/to/file.xlsx]
@@ -13,16 +13,15 @@
13
13
  payload.json 结构:
14
14
  {
15
15
  "output_path": "可选,未传时默认写到桌面 业务线策略生成_YYYYMMDD_HHMMSS.xlsx",
16
- "rows": [
16
+ "rows": [ { "...策略摘要行..." } ],
17
+ "details": [
17
18
  {
18
- "group": "组别",
19
- "bizLine": "业务线名称",
20
- "period": "本期:2026-09-08~2026-09-10\n上期:2026-09-06~2026-09-08",
21
- "project": "项目A\n项目B",
22
- "budgetStrategy": "预算策略内容", "budgetLearning": "预算策略learning",
23
- "productStrategy": "广告产品策略内容", "productLearning": "广告产品策略learning",
24
- "audienceStrategy": "受众策略内容", "audienceLearning": "受众策略learning",
25
- "creativeStrategy": "素材策略内容", "creativeLearning": "素材策略learning"
19
+ "bizLine": "业务线",
20
+ "kind": "国家|广告组|出价|兴趣词|关键词|广告",
21
+ "action": "新增|本期未出现|上调|下调|当期",
22
+ "channel": "Meta|Google|TikTok",
23
+ "name": "名称",
24
+ "extra": "补充"
26
25
  }
27
26
  ]
28
27
  }
@@ -39,6 +38,11 @@ import json
39
38
  import os
40
39
  import sys
41
40
  import zipfile
41
+ import math
42
+ import unicodedata
43
+ import uuid
44
+ from pathlib import Path
45
+ from xml.etree import ElementTree as ET
42
46
  from datetime import datetime
43
47
  from xml.sax.saxutils import escape as xml_escape
44
48
 
@@ -58,11 +62,20 @@ COLUMNS = [
58
62
  ]
59
63
 
60
64
  # 策略内容列文字量最大,给最宽的列宽(前 4 列是基础信息列)
65
+ DETAIL_COLUMNS = [
66
+ ("bizLine", "业务线"),
67
+ ("kind", "明细类型"),
68
+ ("action", "动作"),
69
+ ("channel", "渠道"),
70
+ ("name", "名称"),
71
+ ("extra", "补充"),
72
+ ]
73
+
61
74
  CONTENT_KEYS = {key for key, _label in COLUMNS[4:]}
62
75
 
63
76
  # 列宽:策略内容列最宽;项目名称列因单元格内多行罗列项目而适度加宽
64
77
  COL_WIDTH_DEFAULT = 14
65
- COL_WIDTH_CONTENT = 40
78
+ COL_WIDTH_CONTENT = 48
66
79
  COL_WIDTH_MULTILINE = 22
67
80
  MULTILINE_KEYS = {"period", "project"}
68
81
 
@@ -72,6 +85,7 @@ CONTENT_TYPES_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
72
85
  <Default Extension="xml" ContentType="application/xml"/>
73
86
  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
74
87
  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
88
+ <Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
75
89
  <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
76
90
  <Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
77
91
  </Types>"""
@@ -86,11 +100,15 @@ WORKBOOK_RELS_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
86
100
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
87
101
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
88
102
  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
103
+ <Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
89
104
  </Relationships>"""
90
105
 
91
106
  WORKBOOK_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
92
107
  <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
93
- <sheets><sheet name="业务线策略生成" sheetId="1" r:id="rId1"/></sheets>
108
+ <sheets>
109
+ <sheet name="策略" sheetId="1" r:id="rId1"/>
110
+ <sheet name="明细" sheetId="2" r:id="rId4"/>
111
+ </sheets>
94
112
  </workbook>"""
95
113
 
96
114
  # cellXfs: 0=默认, 1=表头(粗体+浅底), 2=内容(自动换行+顶对齐)
@@ -129,8 +147,7 @@ def col_letter(index: int) -> str:
129
147
  return letters
130
148
 
131
149
 
132
- def build_shared_strings(rows):
133
- """收集全部字符串去重,返回 (sst_xml, header_indices, row_indices)"""
150
+ def _intern_factory():
134
151
  seen = {}
135
152
  order = []
136
153
 
@@ -140,34 +157,47 @@ def build_shared_strings(rows):
140
157
  order.append(value)
141
158
  return seen[value]
142
159
 
160
+ return intern, order
161
+
162
+
163
+ def build_shared_strings(rows, details=None):
164
+ """收集策略表 + 明细表全部字符串去重。"""
165
+ intern, order = _intern_factory()
143
166
  header_indices = [intern(label) for _key, label in COLUMNS]
144
- row_indices = []
145
- for row in rows:
146
- row_indices.append(
147
- [intern(str(row.get(key, "") or "")) for key, _label in COLUMNS]
167
+ row_indices = [
168
+ [intern(str(row.get(key, "") or "")) for key, _label in COLUMNS] for row in rows
169
+ ]
170
+ detail_header = [intern(label) for _key, label in DETAIL_COLUMNS]
171
+ detail_rows = []
172
+ for row in details or []:
173
+ detail_rows.append(
174
+ [intern(str(row.get(key, "") or "")) for key, _label in DETAIL_COLUMNS]
148
175
  )
149
176
 
177
+ # XML 会把原始 CR/CRLF 归一化为 LF;用字符引用保留客户文本,保证逐字回读。
150
178
  items = "".join(
151
- f'<si><t xml:space="preserve">{xml_escape(text)}</t></si>' for text in order
179
+ f'<si><t xml:space="preserve">{xml_escape(text, {chr(13): "&#13;"})}</t></si>' for text in order
152
180
  )
153
181
  sst_xml = (
154
182
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
155
183
  f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
156
184
  f'count="{len(order)}" uniqueCount="{len(order)}">{items}</sst>'
157
185
  )
158
- return sst_xml, header_indices, row_indices
186
+ return sst_xml, header_indices, row_indices, detail_header, detail_rows
159
187
 
160
188
 
161
- def build_sheet_xml(header_indices, row_indices):
162
- """生成 sheet1.xml:首行表头样式 s="1",其余行内容样式 s="2"(自动换行)"""
163
- col_count = len(COLUMNS)
189
+ def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, multiline_keys=None, values=None):
190
+ """生成 worksheet xml:首行表头样式 s="1",其余行内容样式 s="2"(自动换行)"""
191
+ content_keys = content_keys or set()
192
+ multiline_keys = multiline_keys or set()
193
+ col_count = len(columns)
164
194
  dimension_ref = f"A1:{col_letter(col_count - 1)}{len(row_indices) + 1}"
165
195
 
166
196
  cols_xml_parts = []
167
- for i, (key, _label) in enumerate(COLUMNS):
168
- if key in CONTENT_KEYS:
197
+ for i, (key, _label) in enumerate(columns):
198
+ if key in content_keys:
169
199
  width = COL_WIDTH_CONTENT
170
- elif key in MULTILINE_KEYS:
200
+ elif key in multiline_keys:
171
201
  width = COL_WIDTH_MULTILINE
172
202
  else:
173
203
  width = COL_WIDTH_DEFAULT
@@ -181,7 +211,14 @@ def build_sheet_xml(header_indices, row_indices):
181
211
  for i, sst_index in enumerate(indices):
182
212
  ref = f"{col_letter(i)}{row_num}"
183
213
  cells.append(f'<c r="{ref}" s="{style_id}" t="s"><v>{sst_index}</v></c>')
184
- return f'<row r="{row_num}">{"".join(cells)}</row>'
214
+ # Excel 不会可靠地对程序生成的行自动增高,按中文显示宽度确定行高。
215
+ if row_num == 1:
216
+ height = 32
217
+ else:
218
+ record = (values or [{}] * len(row_indices))[row_num - 2]
219
+ lines = max((visual_lines(str(record.get(key) or ""), COL_WIDTH_CONTENT if key in content_keys else (COL_WIDTH_MULTILINE if key in multiline_keys else COL_WIDTH_DEFAULT)) for key, _ in columns), default=1)
220
+ height = min(409, max(30, lines * 15 + 10))
221
+ return f'<row r="{row_num}" ht="{height}" customHeight="1">{"".join(cells)}</row>'
185
222
 
186
223
  rows_xml_parts = [row_xml(1, header_indices, 1)]
187
224
  for offset, indices in enumerate(row_indices):
@@ -196,7 +233,7 @@ def build_sheet_xml(header_indices, row_indices):
196
233
  '<sheetViews><sheetView workbookViewId="0">'
197
234
  '<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
198
235
  '</sheetView></sheetViews>'
199
- f'{cols_xml}{sheet_data}'
236
+ f'{cols_xml}{sheet_data}<autoFilter ref="{dimension_ref}"/>'
200
237
  '</worksheet>'
201
238
  )
202
239
 
@@ -209,18 +246,89 @@ def default_output_path() -> str:
209
246
  # 部分中文 Windows / OneDrive 环境桌面路径不同,找不到就退回主目录
210
247
  desktop = home
211
248
  stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
212
- return os.path.join(desktop, f"业务线策略生成_{stamp}.xlsx")
249
+ return os.path.join(desktop, f"业务线策略生成_{stamp}_{uuid.uuid4().hex[:8]}.xlsx")
250
+
251
+
252
+ def visual_lines(text, width):
253
+ """中日韩字符占两格,保守估算换行后高度。"""
254
+ return sum(max(1, math.ceil(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in line) / max(1, width - 2))) for line in text.split('\n'))
255
+
256
+
257
+ def chunks(text, width, max_lines=20):
258
+ """按可见行宽拆分,续行不丢字符,避免 Excel 行高及单元格字数上限。"""
259
+ result, part = [], ''
260
+ # 线性维护行宽,不对越来越长的片段重复扫描。
261
+ lines, used = 1, 0
262
+ for char in text:
263
+ size = 2 if unicodedata.east_asian_width(char) in 'WF' else 1
264
+ next_line = char == '\n' or used + size > width - 2
265
+ if (next_line and lines >= max_lines) or len(part) >= 16000:
266
+ result.append(part)
267
+ part, lines, used = '', 1, 0
268
+ if char == '\n':
269
+ lines, used = lines + 1, 0
270
+ elif used + size > width - 2:
271
+ lines, used = lines + 1, size
272
+ else:
273
+ used += size
274
+ part += char
275
+ return result + [part] if part or not result else result
213
276
 
214
277
 
215
- def write_xlsx(rows, output_path: str) -> str:
216
- sst_xml, header_indices, row_indices = build_shared_strings(rows)
217
- sheet_xml = build_sheet_xml(header_indices, row_indices)
278
+ def prepare_layout(rows, details):
279
+ """主表只放可见摘要,超长原文转完整明细;明细用续行保持全量。"""
280
+ rows = [dict(r) for r in rows]
281
+ details = [dict(r) for r in details or []]
282
+ for row in rows:
283
+ for key, label in COLUMNS:
284
+ text = str(row.get(key) or '')
285
+ width = COL_WIDTH_CONTENT if key in CONTENT_KEYS else (COL_WIDTH_MULTILINE if key in MULTILINE_KEYS else COL_WIDTH_DEFAULT)
286
+ parts = chunks(text, width, 18)
287
+ if len(parts) > 1:
288
+ details.append({'bizLine': row.get('bizLine', ''), 'kind': '完整说明', 'action': label, 'name': label, 'extra': text})
289
+ row[key] = parts[0] + '\n(完整内容见明细/完整说明)'
290
+ expanded = []
291
+ for record in details:
292
+ split = {key: chunks(str(record.get(key) or ''), COL_WIDTH_CONTENT if key in ('name', 'extra') else COL_WIDTH_DEFAULT, 18) for key, _ in DETAIL_COLUMNS}
293
+ count = max(map(len, split.values()))
294
+ for index in range(count):
295
+ # 续行有行号标识,身份短字段重复展示便于筛选;长字段逐段保留。
296
+ row = {key: (parts[index] if index < len(parts) else (parts[0] if len(parts) == 1 and key not in ('name', 'extra') else '')) for key, parts in split.items()}
297
+ if count > 1:
298
+ row['action'] += f' [续 {index + 1}/{count}]'
299
+ expanded.append(row)
300
+ return rows, expanded
301
+
302
+
303
+ def write_xlsx(rows, output_path: str, details=None) -> str:
304
+ if not rows:
305
+ raise ValueError('策略数据为空')
306
+ rows, details = prepare_layout(rows, details)
307
+ sst_xml, header_indices, row_indices, detail_header, detail_rows = build_shared_strings(
308
+ rows, details
309
+ )
310
+ sheet_xml = build_sheet_xml(
311
+ header_indices,
312
+ row_indices,
313
+ COLUMNS,
314
+ content_keys=CONTENT_KEYS,
315
+ multiline_keys=MULTILINE_KEYS,
316
+ values=rows,
317
+ )
318
+ detail_xml = build_sheet_xml(
319
+ detail_header,
320
+ detail_rows,
321
+ DETAIL_COLUMNS,
322
+ content_keys={"name", "extra"},
323
+ multiline_keys=set(),
324
+ values=details,
325
+ )
218
326
 
219
327
  out_dir = os.path.dirname(output_path)
220
328
  if out_dir and not os.path.isdir(out_dir):
221
329
  os.makedirs(out_dir, exist_ok=True)
222
330
 
223
- with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
331
+ with zipfile.ZipFile(output_path, "x", zipfile.ZIP_DEFLATED) as zf:
224
332
  zf.writestr("[Content_Types].xml", CONTENT_TYPES_XML)
225
333
  zf.writestr("_rels/.rels", ROOT_RELS_XML)
226
334
  zf.writestr("xl/workbook.xml", WORKBOOK_XML)
@@ -228,10 +336,31 @@ def write_xlsx(rows, output_path: str) -> str:
228
336
  zf.writestr("xl/styles.xml", STYLES_XML)
229
337
  zf.writestr("xl/sharedStrings.xml", sst_xml)
230
338
  zf.writestr("xl/worksheets/sheet1.xml", sheet_xml)
339
+ zf.writestr("xl/worksheets/sheet2.xml", detail_xml)
231
340
 
232
341
  return output_path
233
342
 
234
343
 
344
+ def validate_xlsx(path, rows, details):
345
+ """逐单元格回读,与布局后的完整输入比较,避免只检验文件存在。"""
346
+ ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
347
+ expected = prepare_layout(rows, details)
348
+ with zipfile.ZipFile(path) as archive:
349
+ if archive.testzip():
350
+ raise ValueError('Excel ZIP 校验失败')
351
+ workbook = ET.fromstring(archive.read('xl/workbook.xml'))
352
+ if [s.attrib['name'] for s in workbook.findall('s:sheets/s:sheet', ns)] != ['策略', '明细']:
353
+ raise ValueError('Excel 必须包含策略和明细两张表')
354
+ strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
355
+ for index, (records, columns) in enumerate(zip(expected, (COLUMNS, DETAIL_COLUMNS)), 1):
356
+ sheet = ET.fromstring(archive.read(f'xl/worksheets/sheet{index}.xml'))
357
+ actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row] for row in sheet.findall('s:sheetData/s:row', ns)]
358
+ target = [[label for _, label in columns]] + [[str(r.get(key) or '') for key, _ in columns] for r in records]
359
+ if actual != target:
360
+ raise ValueError(f'Excel 第 {index} 张表内容或行列数不完整')
361
+ return {'rows': len(expected[0]), 'details': len(expected[1])}
362
+
363
+
235
364
  def main() -> int:
236
365
  parser = argparse.ArgumentParser(description=__doc__)
237
366
  parser.add_argument(
@@ -258,12 +387,20 @@ def main() -> int:
258
387
 
259
388
  output_path = args.output or payload.get("output_path") or default_output_path()
260
389
  output_path = os.path.abspath(output_path)
390
+ details = payload.get("details") or []
391
+ if not isinstance(details, list):
392
+ details = []
261
393
 
262
- write_xlsx(rows, output_path)
394
+ write_xlsx(rows, output_path, details=details)
263
395
 
264
396
  print(
265
397
  json.dumps(
266
- {"ok": True, "output_path": output_path, "row_count": len(rows)},
398
+ {
399
+ "ok": True,
400
+ "output_path": output_path,
401
+ "row_count": len(rows),
402
+ "detail_count": len(details),
403
+ },
267
404
  ensure_ascii=False,
268
405
  )
269
406
  )
@@ -1,96 +1,87 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """只清理「本次请求」登记过的临时目录和 Excel。
4
-
5
- 不会按桌面文件名 glob,也不会删除未出现在 --written-excel 里的文件。
6
- """
3
+ """仅清理本次进程创建并登记的目录,所有类型临时文件统一处理。"""
7
4
  from __future__ import annotations
8
5
 
9
- import argparse
10
6
  import json
11
7
  import os
12
- import shutil
13
- import sys
14
-
8
+ import stat
9
+ import tempfile
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
15
13
 
16
- def rm_request_dir(path: str) -> dict:
17
- if not path:
18
- return {"ok": False, "error": "empty --request-dir"}
19
- abs_path = os.path.abspath(path)
20
- if not os.path.isdir(abs_path):
21
- return {"ok": True, "removed_dir": abs_path, "existed": False}
22
- # 只允许删 sino-bc-strategy 下的某一层 requestId 目录,防止误传桌面/主目录
23
- parent = os.path.basename(os.path.dirname(abs_path)).lower()
24
- if parent != "sino-bc-strategy":
25
- return {
26
- "ok": False,
27
- "error": "refuse: --request-dir 必须是 .../sino-bc-strategy/<requestId>",
28
- }
29
- shutil.rmtree(abs_path, ignore_errors=False)
30
- return {"ok": True, "removed_dir": abs_path, "existed": True}
14
+ MARKER = '.strategy-owner.json'
31
15
 
32
16
 
33
- def rm_extra_excels(keep: str | None, written: list[str]) -> dict:
34
- keep_abs = os.path.abspath(keep) if keep else None
35
- deleted = []
36
- skipped = []
37
- for raw in written:
38
- if not raw:
39
- continue
40
- target = os.path.abspath(raw)
41
- if keep_abs and os.path.normcase(target) == os.path.normcase(keep_abs):
42
- skipped.append({"path": target, "reason": "keep"})
43
- continue
44
- if not os.path.isfile(target):
45
- skipped.append({"path": target, "reason": "missing"})
46
- continue
47
- os.remove(target)
48
- deleted.append(target)
49
- return {"ok": True, "deleted": deleted, "skipped": skipped, "kept": keep_abs}
17
+ def is_link(path: Path) -> bool:
18
+ """Windows junction 等重解析点与符号链接都不允许递归跟随。"""
19
+ info = path.lstat()
20
+ return stat.S_ISLNK(info.st_mode) or bool(getattr(info, 'st_file_attributes', 0) & 0x400)
50
21
 
51
22
 
52
- def main() -> int:
53
- parser = argparse.ArgumentParser(description=__doc__)
54
- parser.add_argument(
55
- "--request-dir",
56
- default=None,
57
- help="本次请求工作目录 TEMP/sino-bc-strategy/<requestId>,整目录删除",
58
- )
59
- parser.add_argument(
60
- "--keep-excel",
61
- default=None,
62
- help="最终保留的 xlsx 绝对路径",
63
- )
64
- parser.add_argument(
65
- "--written-excel",
66
- action="append",
67
- default=[],
68
- help="本次请求成功写出过的 xlsx,可重复;只从这份名单里删",
69
- )
70
- args = parser.parse_args()
23
+ @dataclass(frozen=True)
24
+ class OwnedDirectory:
25
+ """创建时保存真实父路径与随机令牌,清理时不能只相信目录名。"""
26
+ path: Path
27
+ parent: Path
28
+ token: str
71
29
 
72
- if not args.request_dir and not args.written_excel:
73
- print(
74
- json.dumps(
75
- {"ok": False, "error": "至少传 --request-dir 或 --written-excel"},
76
- ensure_ascii=False,
77
- )
78
- )
79
- return 1
30
+ @classmethod
31
+ def create(cls, parent: Path, prefix: str):
32
+ parent = parent.resolve()
33
+ parent.mkdir(parents=True, exist_ok=True)
34
+ path = Path(tempfile.mkdtemp(prefix=prefix, dir=parent))
35
+ token = uuid.uuid4().hex
36
+ (path / MARKER).write_text(json.dumps({'token': token, 'path': str(path)}), encoding='utf-8')
37
+ return cls(path, parent, token)
80
38
 
81
- result = {"ok": True}
82
- if args.written_excel:
83
- result["excels"] = rm_extra_excels(args.keep_excel, args.written_excel)
84
- if result["excels"].get("ok") is False:
85
- result["ok"] = False
86
- if args.request_dir:
87
- result["temps"] = rm_request_dir(args.request_dir)
88
- if result["temps"].get("ok") is False:
89
- result["ok"] = False
39
+ def validate(self):
40
+ """根目录或父路径被替换后拒绝删除,防止越界。"""
41
+ if is_link(self.path) or self.path.resolve() != self.path or self.path.parent.resolve() != self.parent:
42
+ raise ValueError(f'清理范围已变化:{self.path}')
43
+ marker = self.path / MARKER
44
+ if is_link(marker):
45
+ raise ValueError('所有权文件不能是链接')
46
+ data = json.loads(marker.read_text(encoding='utf-8'))
47
+ if data != {'token': self.token, 'path': str(self.path)}:
48
+ raise ValueError(f'所有权不匹配:{self.path}')
90
49
 
91
- print(json.dumps(result, ensure_ascii=False))
92
- return 0 if result["ok"] else 1
50
+ def cleanup(self, final: Path | None = None) -> dict:
51
+ """不按后缀筛选;保留可返回的失败清单,绝不静默忽略残留。"""
52
+ errors = []
53
+ try:
54
+ self.validate()
55
+ if final is not None and final.resolve().is_relative_to(self.path):
56
+ raise ValueError('最终 Excel 位于临时目录内,拒绝清理')
57
+ except Exception as exc:
58
+ return {'ok': False, 'residual': [{'path': str(self.path), 'error': str(exc)}]}
93
59
 
60
+ def remove(path):
61
+ try:
62
+ # 先 lstat 再决定删除方式;对 junction 仅解除链接自身。
63
+ if is_link(path):
64
+ if path.is_symlink():
65
+ path.unlink()
66
+ else:
67
+ os.rmdir(path)
68
+ elif path.is_dir():
69
+ for child in path.iterdir():
70
+ remove(child)
71
+ path.rmdir()
72
+ else:
73
+ path.unlink()
74
+ except Exception as exc:
75
+ errors.append({'path': str(path), 'error': str(exc)})
94
76
 
95
- if __name__ == "__main__":
96
- sys.exit(main())
77
+ # 若某子项未能删除,留下所有权标记供诊断,不伪报清理成功。
78
+ for child in self.path.iterdir():
79
+ if child.name != MARKER:
80
+ remove(child)
81
+ if not errors:
82
+ remove(self.path / MARKER)
83
+ try:
84
+ self.path.rmdir()
85
+ except Exception as exc:
86
+ errors.append({'path': str(self.path), 'error': str(exc)})
87
+ return {'ok': not errors, 'residual': errors}