tb-order-sync 0.3.0

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.
package/cli/setup.py ADDED
@@ -0,0 +1,698 @@
1
+ """Interactive setup wizard for one-stop configuration.
2
+
3
+ Usage:
4
+ tb setup # Full guided setup
5
+ tb setup --check # Validate current config
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getpass
11
+ import re
12
+ import shutil
13
+ import sys
14
+ import webbrowser
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Optional, Sequence
18
+
19
+ try:
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from rich.table import Table
23
+ from rich.text import Text
24
+ from rich import box
25
+ except ImportError:
26
+ sys.exit("缺少 rich 库,请先安装: pip install rich")
27
+
28
+ from dotenv import dotenv_values
29
+
30
+ from config.settings import PROJECT_ROOT
31
+
32
+ # ── UI 文案 ────────────────────────────────────────────────────────────────
33
+ BANNER_TITLE = "多表格同步服务 — 配置向导"
34
+ BANNER_SUBTITLE = "按照提示逐步完成配置,按 Ctrl+C 随时退出"
35
+
36
+ STEP_TENCENT = "第 1 步:腾讯文档凭证"
37
+ STEP_SHEETS = "第 2 步:表格 ID 配置"
38
+ STEP_FEISHU = "第 3 步:飞书配置(可选)"
39
+ STEP_RUNTIME = "第 4 步:运行参数"
40
+ STEP_COLUMNS = "第 5 步:列映射"
41
+ STEP_SUMMARY = "配置总览"
42
+ STEP_WRITE = "写入配置"
43
+ STEP_TEST = "连接测试"
44
+
45
+ ENV_PATH = PROJECT_ROOT / ".env"
46
+ ENV_EXAMPLE_PATH = PROJECT_ROOT / ".env.example"
47
+
48
+ # Column letter validator
49
+ _COL_RE = re.compile(r"^[A-Z]{1,3}$")
50
+
51
+ TENCENT_DOCS_GUIDE_URL = "https://docs.qq.com/open/document/app/"
52
+ TENCENT_DEVELOPER_CONSOLE_URL = "https://docs.qq.com/open/developers/"
53
+ FEISHU_DEVELOPER_CONSOLE_URL = "https://open.feishu.cn/app"
54
+ FEISHU_TOKEN_DOC_URL = (
55
+ "https://open.feishu.cn/document/server-docs/"
56
+ "authentication-management/access-token/tenant_access_token_internal"
57
+ )
58
+ FEISHU_TOKEN_TUTORIAL_URL = "https://www.feishu.cn/content/000214591773"
59
+
60
+
61
+ # ── Validators ─────────────────────────────────────────────────────────────
62
+ def _not_empty(value: str) -> bool:
63
+ return len(value.strip()) > 0
64
+
65
+
66
+ def _is_positive_int(value: str) -> bool:
67
+ try:
68
+ return int(value) > 0
69
+ except ValueError:
70
+ return False
71
+
72
+
73
+ def _is_non_negative_int(value: str) -> bool:
74
+ try:
75
+ return int(value) >= 0
76
+ except ValueError:
77
+ return False
78
+
79
+
80
+ def _is_col_letter(value: str) -> bool:
81
+ return bool(_COL_RE.match(value.upper().strip()))
82
+
83
+
84
+ def _is_bool_str(value: str) -> bool:
85
+ return value.strip().lower() in ("true", "false")
86
+
87
+
88
+ def _is_sync_mode(value: str) -> bool:
89
+ return value.strip().lower() in ("incremental", "full")
90
+
91
+
92
+ def _mask_secret(value: str) -> str:
93
+ """Mask a secret for display: show first 4 chars + ****."""
94
+ if not value:
95
+ return "(未设置)"
96
+ if len(value) <= 6:
97
+ return "****"
98
+ return value[:4] + "****"
99
+
100
+
101
+ # ── Setup Wizard ───────────────────────────────────────────────────────────
102
+ class SetupWizard:
103
+ def __init__(self, console: Optional[Console] = None) -> None:
104
+ self.console = console or Console()
105
+ self.values: dict[str, str] = {}
106
+ self._existing = self._load_existing()
107
+
108
+ def _load_existing(self) -> dict[str, Optional[str]]:
109
+ """Load existing .env values as defaults."""
110
+ if ENV_PATH.exists():
111
+ return dotenv_values(ENV_PATH) # type: ignore
112
+ return {}
113
+
114
+ # ── Prompt helpers ─────────────────────────────────────────────────────
115
+
116
+ def _prompt(
117
+ self,
118
+ label: str,
119
+ key: str,
120
+ default: str = "",
121
+ secret: bool = False,
122
+ validator: Optional[Callable[[str], bool]] = None,
123
+ error_msg: str = "输入无效,请重新输入",
124
+ ) -> str:
125
+ """Prompt user for a value with optional validation and masking."""
126
+ existing = self._existing.get(key, "")
127
+ effective_default = existing or default
128
+
129
+ while True:
130
+ if secret:
131
+ hint = f" [当前: {_mask_secret(effective_default)}]" if effective_default else ""
132
+ self.console.print(f" {label}{hint}")
133
+ raw = getpass.getpass(" > ")
134
+ if not raw and effective_default:
135
+ raw = effective_default
136
+ else:
137
+ hint = f" [默认: {effective_default}]" if effective_default else ""
138
+ self.console.print(f" {label}{hint}")
139
+ raw = input(" > ").strip()
140
+ if not raw and effective_default:
141
+ raw = effective_default
142
+
143
+ if validator and not validator(raw):
144
+ self.console.print(f" [red]{error_msg}[/red]")
145
+ continue
146
+
147
+ return raw
148
+
149
+ def _prompt_bool(self, label: str, default: bool = False) -> bool:
150
+ hint = "Y/n" if default else "y/N"
151
+ self.console.print(f" {label} [{hint}]")
152
+ raw = input(" > ").strip().lower()
153
+ if not raw:
154
+ return default
155
+ return raw in ("y", "yes", "是")
156
+
157
+ def _offer_open_links(self, title: str, links: Sequence[tuple[str, str]]) -> None:
158
+ """Optionally open one or more documentation links in the default browser."""
159
+ if not links:
160
+ return
161
+
162
+ self.console.print(f" [dim]{title}:[/dim]")
163
+ for idx, (label, url) in enumerate(links, start=1):
164
+ self.console.print(f" {idx}. {label}: [cyan]{url}[/cyan]")
165
+
166
+ if not self._prompt_bool("是否现在用浏览器打开这些链接?", default=False):
167
+ return
168
+
169
+ opened = 0
170
+ for _, url in links:
171
+ try:
172
+ if webbrowser.open_new_tab(url):
173
+ opened += 1
174
+ except webbrowser.Error as exc:
175
+ self.console.print(f" [yellow]打开失败: {url} ({exc})[/yellow]")
176
+ except Exception as exc: # pragma: no cover - defensive guard
177
+ self.console.print(f" [yellow]打开失败: {url} ({exc})[/yellow]")
178
+
179
+ if opened:
180
+ self.console.print(f" [green]已尝试打开 {opened} 个链接[/green]")
181
+ else:
182
+ self.console.print(" [yellow]未能自动打开浏览器,请手动复制上面的链接[/yellow]")
183
+
184
+ def _show_tencent_guide(self) -> None:
185
+ """Display beginner guidance for Tencent Docs Open API setup."""
186
+ body = (
187
+ "1. 打开腾讯文档开放平台开发文档,先确认你要接的是 Open API。\n"
188
+ f" 文档入口: {TENCENT_DOCS_GUIDE_URL}\n"
189
+ "2. 打开开发者平台,创建应用并进入应用详情页。\n"
190
+ f" 开发者平台: {TENCENT_DEVELOPER_CONSOLE_URL}\n"
191
+ "3. 在应用详情中获取 Client ID / Client Secret。\n"
192
+ "4. 按官方 OAuth2 授权流程获取 Access Token。\n"
193
+ "5. 本项目当前 MVP 需要你先手工提供有效 Access Token;"
194
+ "自动刷新 token 还没接入。\n"
195
+ "6. Open ID 目前是可选项,部分接口或企业场景可能会用到。"
196
+ )
197
+ self.console.print(Panel(
198
+ body,
199
+ title="[bold]腾讯文档 API 获取指引[/bold]",
200
+ border_style="cyan",
201
+ expand=False,
202
+ ))
203
+ self._offer_open_links("腾讯文档相关链接", [
204
+ ("开发文档", TENCENT_DOCS_GUIDE_URL),
205
+ ("开发者平台", TENCENT_DEVELOPER_CONSOLE_URL),
206
+ ])
207
+
208
+ def _show_sheet_id_guide(self) -> None:
209
+ """Display how to locate file id and sheet id."""
210
+ body = (
211
+ "1. 先在浏览器打开目标腾讯文档 A 表 / B 表。\n"
212
+ "2. File ID / Sheet ID 的展示形式可能因腾讯文档产品类型不同而不同。\n"
213
+ "3. 第一版请以你在官方链接、页面参数或开发者文档中实际看到的 ID 为准。\n"
214
+ "4. 如果你不确定,请先在腾讯文档开发文档里核对在线表格 API 的文件和 sheet 标识规则。\n"
215
+ f" 文档入口: {TENCENT_DOCS_GUIDE_URL}\n"
216
+ "5. 本项目代码里已把腾讯文档 endpoint 标为 TODO / NEED_VERIFY,"
217
+ "如果你的表格类型不是标准在线表格,后续可能需要再补对接。"
218
+ )
219
+ self.console.print(Panel(
220
+ body,
221
+ title="[bold]A / B 表 ID 获取说明[/bold]",
222
+ border_style="blue",
223
+ expand=False,
224
+ ))
225
+ self._offer_open_links("表格 ID 参考链接", [
226
+ ("腾讯文档开发文档", TENCENT_DOCS_GUIDE_URL),
227
+ ])
228
+
229
+ def _show_feishu_guide(self) -> None:
230
+ """Display beginner guidance for Feishu Open Platform setup."""
231
+ body = (
232
+ "1. 打开飞书开放平台,创建自建应用。\n"
233
+ f" 开发者平台: {FEISHU_DEVELOPER_CONSOLE_URL}\n"
234
+ "2. 在应用凭证页获取 App ID 和 App Secret。\n"
235
+ "3. 按官方服务端认证文档获取 tenant_access_token。\n"
236
+ f" 官方文档: {FEISHU_TOKEN_DOC_URL}\n"
237
+ "4. 如果你是第一次接飞书 API,可以先看一遍官方教程示例,"
238
+ "它演示了 token 的获取和后续 API 调用链路。\n"
239
+ f" 教程文章: {FEISHU_TOKEN_TUTORIAL_URL}\n"
240
+ "5. C 表的 File Token / Sheet ID 请以你实际接入的飞书文档或表格链接规则为准。\n"
241
+ "6. 当前项目里飞书 connector 还是 skeleton,先录入配置,后续第二阶段接通。"
242
+ )
243
+ self.console.print(Panel(
244
+ body,
245
+ title="[bold]飞书 API 获取指引[/bold]",
246
+ border_style="green",
247
+ expand=False,
248
+ ))
249
+ self._offer_open_links("飞书相关链接", [
250
+ ("飞书开发者平台", FEISHU_DEVELOPER_CONSOLE_URL),
251
+ ("tenant_access_token 官方文档", FEISHU_TOKEN_DOC_URL),
252
+ ("动态 Token 教程", FEISHU_TOKEN_TUTORIAL_URL),
253
+ ])
254
+
255
+ # ── Steps ──────────────────────────────────────────────────────────────
256
+
257
+ def _step_tencent_creds(self) -> None:
258
+ self.console.print(f"\n[bold cyan]📋 {STEP_TENCENT}[/bold cyan]")
259
+ self.console.print(" 用于访问腾讯文档 Open API\n")
260
+ self._show_tencent_guide()
261
+ self.console.print("")
262
+
263
+ self.values["TENCENT_CLIENT_ID"] = self._prompt(
264
+ "Client ID", "TENCENT_CLIENT_ID", secret=True, validator=_not_empty,
265
+ error_msg="Client ID 不能为空",
266
+ )
267
+ self.values["TENCENT_CLIENT_SECRET"] = self._prompt(
268
+ "Client Secret", "TENCENT_CLIENT_SECRET", secret=True, validator=_not_empty,
269
+ error_msg="Client Secret 不能为空",
270
+ )
271
+ self.values["TENCENT_OPEN_ID"] = self._prompt(
272
+ "Open ID(可选,部分接口需要)", "TENCENT_OPEN_ID", secret=True,
273
+ )
274
+ self.values["TENCENT_ACCESS_TOKEN"] = self._prompt(
275
+ "Access Token", "TENCENT_ACCESS_TOKEN", secret=True, validator=_not_empty,
276
+ error_msg="Access Token 不能为空",
277
+ )
278
+
279
+ def _step_sheet_ids(self) -> None:
280
+ self.console.print(f"\n[bold cyan]📊 {STEP_SHEETS}[/bold cyan]")
281
+ self.console.print(" 从腾讯文档链接中提取 File ID 和 Sheet ID\n")
282
+ self._show_sheet_id_guide()
283
+ self.console.print("")
284
+
285
+ self.console.print(" [bold]A 表(订单表)[/bold]")
286
+ self.values["TENCENT_A_FILE_ID"] = self._prompt(
287
+ "A 表 File ID", "TENCENT_A_FILE_ID", validator=_not_empty,
288
+ error_msg="File ID 不能为空",
289
+ )
290
+ self.values["TENCENT_A_SHEET_ID"] = self._prompt(
291
+ "A 表 Sheet ID", "TENCENT_A_SHEET_ID", validator=_not_empty,
292
+ error_msg="Sheet ID 不能为空",
293
+ )
294
+
295
+ self.console.print("\n [bold]B 表(退款表)[/bold]")
296
+ self.values["TENCENT_B_FILE_ID"] = self._prompt(
297
+ "B 表 File ID", "TENCENT_B_FILE_ID", validator=_not_empty,
298
+ error_msg="File ID 不能为空",
299
+ )
300
+ self.values["TENCENT_B_SHEET_ID"] = self._prompt(
301
+ "B 表 Sheet ID", "TENCENT_B_SHEET_ID", validator=_not_empty,
302
+ error_msg="Sheet ID 不能为空",
303
+ )
304
+
305
+ def _step_feishu_creds(self) -> None:
306
+ self.console.print(f"\n[bold cyan]🐦 {STEP_FEISHU}[/bold cyan]")
307
+ self._show_feishu_guide()
308
+ self.console.print("")
309
+ if not self._prompt_bool("是否现在配置飞书(C 表)?", default=False):
310
+ self.values.setdefault("FEISHU_APP_ID", "")
311
+ self.values.setdefault("FEISHU_APP_SECRET", "")
312
+ self.values.setdefault("FEISHU_C_FILE_TOKEN", "")
313
+ self.values.setdefault("FEISHU_C_SHEET_ID", "")
314
+ self.console.print(" [dim]已跳过飞书配置,后续可重新运行 setup 补充[/dim]")
315
+ return
316
+
317
+ self.values["FEISHU_APP_ID"] = self._prompt(
318
+ "飞书 App ID", "FEISHU_APP_ID", secret=True, validator=_not_empty,
319
+ )
320
+ self.values["FEISHU_APP_SECRET"] = self._prompt(
321
+ "飞书 App Secret", "FEISHU_APP_SECRET", secret=True, validator=_not_empty,
322
+ )
323
+ self.values["FEISHU_C_FILE_TOKEN"] = self._prompt(
324
+ "C 表 File Token", "FEISHU_C_FILE_TOKEN", validator=_not_empty,
325
+ )
326
+ self.values["FEISHU_C_SHEET_ID"] = self._prompt(
327
+ "C 表 Sheet ID", "FEISHU_C_SHEET_ID", validator=_not_empty,
328
+ )
329
+
330
+ def _step_runtime(self) -> None:
331
+ self.console.print(f"\n[bold cyan]⚙️ {STEP_RUNTIME}[/bold cyan]\n")
332
+
333
+ self.values["APP_ENV"] = self._prompt(
334
+ "运行环境 (dev / staging / prod)", "APP_ENV", default="dev",
335
+ )
336
+ self.values["LOG_LEVEL"] = self._prompt(
337
+ "日志级别 (DEBUG / INFO / WARNING / ERROR)", "LOG_LEVEL", default="INFO",
338
+ )
339
+ self.values["STATE_DIR"] = self._prompt(
340
+ "状态文件目录", "STATE_DIR", default="state",
341
+ )
342
+ self.values["GROSS_PROFIT_MODE"] = self._prompt(
343
+ "毛利计算模式 (incremental / full)", "GROSS_PROFIT_MODE", default="incremental",
344
+ validator=_is_sync_mode, error_msg="请输入 incremental 或 full",
345
+ )
346
+ self.values["REFUND_MATCH_MODE"] = self._prompt(
347
+ "退款匹配模式 (incremental / full)", "REFUND_MATCH_MODE", default="incremental",
348
+ validator=_is_sync_mode, error_msg="请输入 incremental 或 full",
349
+ )
350
+ self.values["C_SYNC_MODE"] = self._prompt(
351
+ "C 表同步模式 (incremental / full)", "C_SYNC_MODE", default="incremental",
352
+ validator=_is_sync_mode, error_msg="请输入 incremental 或 full",
353
+ )
354
+ self.values["TASK_INTERVAL_MINUTES"] = self._prompt(
355
+ "定时任务间隔(分钟)", "TASK_INTERVAL_MINUTES", default="10",
356
+ validator=_is_positive_int, error_msg="请输入正整数",
357
+ )
358
+ self.values["STARTUP_JITTER_SECONDS"] = self._prompt(
359
+ "启动抖动时间(秒,防止并发冲突)", "STARTUP_JITTER_SECONDS", default="15",
360
+ validator=_is_non_negative_int, error_msg="请输入非负整数",
361
+ )
362
+ self.values["WRITE_BATCH_SIZE"] = self._prompt(
363
+ "批量写入大小", "WRITE_BATCH_SIZE", default="100",
364
+ validator=_is_positive_int, error_msg="请输入正整数",
365
+ )
366
+ self.values["RETRY_TIMES"] = self._prompt(
367
+ "失败重试次数", "RETRY_TIMES", default="3",
368
+ validator=_is_positive_int, error_msg="请输入正整数",
369
+ )
370
+ self.values["DRY_RUN"] = self._prompt(
371
+ "是否默认 dry-run 模式 (true / false)", "DRY_RUN", default="false",
372
+ validator=_is_bool_str, error_msg="请输入 true 或 false",
373
+ )
374
+ self.values["ENABLE_STYLE_UPDATE"] = self._prompt(
375
+ "是否启用行样式更新 (true / false)", "ENABLE_STYLE_UPDATE", default="false",
376
+ validator=_is_bool_str, error_msg="请输入 true 或 false",
377
+ )
378
+
379
+ def _step_column_mapping(self) -> None:
380
+ self.console.print(f"\n[bold cyan]📐 {STEP_COLUMNS}[/bold cyan]\n")
381
+
382
+ defaults = {
383
+ "A_COL_PRODUCT_PRICE": ("A表 - 产品价格", "C"),
384
+ "A_COL_PACKAGING_PRICE": ("A表 - 包装价格", "D"),
385
+ "A_COL_FREIGHT": ("A表 - 运费", "E"),
386
+ "A_COL_CUSTOMER_QUOTE": ("A表 - 客户报价", "F"),
387
+ "A_COL_GROSS_PROFIT": ("A表 - 毛利", "G"),
388
+ "A_COL_ORDER_NO": ("A表 - 单号", "H"),
389
+ "A_COL_REFUND_STATUS": ("A表 - 退款状态", "I"),
390
+ "B_COL_ORDER_NO": ("B表 - 单号", "A"),
391
+ }
392
+
393
+ # Show current mapping table
394
+ table = Table(title="当前列映射", box=box.SIMPLE_HEAVY)
395
+ table.add_column("配置项", style="cyan")
396
+ table.add_column("含义", style="white")
397
+ table.add_column("列", style="green", justify="center")
398
+ for key, (desc, default) in defaults.items():
399
+ current = self._existing.get(key, default)
400
+ table.add_row(key, desc, current or default)
401
+ self.console.print(table)
402
+
403
+ if not self._prompt_bool("是否需要修改列映射?", default=False):
404
+ for key, (_, default) in defaults.items():
405
+ self.values[key] = self._existing.get(key, default) or default
406
+ self.console.print(" [dim]保持默认列映射[/dim]")
407
+ return
408
+
409
+ for key, (desc, default) in defaults.items():
410
+ self.values[key] = self._prompt(
411
+ desc, key, default=default,
412
+ validator=_is_col_letter, error_msg="请输入大写列字母(如 A、B、AA)",
413
+ ).upper()
414
+
415
+ # Business text
416
+ self.values["REFUND_STATUS_TEXT"] = self._prompt(
417
+ "退款状态文案", "REFUND_STATUS_TEXT", default="进入退款流程",
418
+ )
419
+ self.values["DATA_ERROR_TEXT"] = self._prompt(
420
+ "数据异常文案", "DATA_ERROR_TEXT", default="数据异常",
421
+ )
422
+
423
+ # ── Summary ────────────────────────────────────────────────────────────
424
+
425
+ def _show_summary(self) -> None:
426
+ self.console.print(f"\n[bold cyan]📝 {STEP_SUMMARY}[/bold cyan]\n")
427
+
428
+ secret_keys = {
429
+ "TENCENT_CLIENT_ID", "TENCENT_CLIENT_SECRET", "TENCENT_OPEN_ID",
430
+ "TENCENT_ACCESS_TOKEN", "FEISHU_APP_ID", "FEISHU_APP_SECRET",
431
+ }
432
+
433
+ sections = [
434
+ ("腾讯文档凭证", [
435
+ "TENCENT_CLIENT_ID", "TENCENT_CLIENT_SECRET",
436
+ "TENCENT_OPEN_ID", "TENCENT_ACCESS_TOKEN",
437
+ ]),
438
+ ("表格 ID", [
439
+ "TENCENT_A_FILE_ID", "TENCENT_A_SHEET_ID",
440
+ "TENCENT_B_FILE_ID", "TENCENT_B_SHEET_ID",
441
+ ]),
442
+ ("飞书配置", [
443
+ "FEISHU_APP_ID", "FEISHU_APP_SECRET",
444
+ "FEISHU_C_FILE_TOKEN", "FEISHU_C_SHEET_ID",
445
+ ]),
446
+ ("运行参数", [
447
+ "APP_ENV", "LOG_LEVEL", "STATE_DIR",
448
+ "GROSS_PROFIT_MODE", "REFUND_MATCH_MODE", "C_SYNC_MODE",
449
+ "TASK_INTERVAL_MINUTES", "STARTUP_JITTER_SECONDS",
450
+ "WRITE_BATCH_SIZE", "RETRY_TIMES",
451
+ "DRY_RUN", "ENABLE_STYLE_UPDATE",
452
+ ]),
453
+ ("列映射", [
454
+ "A_COL_PRODUCT_PRICE", "A_COL_PACKAGING_PRICE",
455
+ "A_COL_FREIGHT", "A_COL_CUSTOMER_QUOTE",
456
+ "A_COL_GROSS_PROFIT", "A_COL_ORDER_NO",
457
+ "A_COL_REFUND_STATUS", "B_COL_ORDER_NO",
458
+ ]),
459
+ ]
460
+
461
+ for section_name, keys in sections:
462
+ table = Table(title=section_name, box=box.ROUNDED, show_lines=False)
463
+ table.add_column("配置项", style="cyan", min_width=28)
464
+ table.add_column("值", style="white")
465
+ for key in keys:
466
+ val = self.values.get(key, "")
467
+ display = _mask_secret(val) if key in secret_keys else (val or "[dim](空)[/dim]")
468
+ table.add_row(key, display)
469
+ self.console.print(table)
470
+
471
+ # ── Write .env ─────────────────────────────────────────────────────────
472
+
473
+ def _write_env(self) -> None:
474
+ """Write .env using .env.example as template to preserve comments."""
475
+ # Backup existing .env
476
+ if ENV_PATH.exists():
477
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
478
+ backup = ENV_PATH.with_suffix(f".backup.{ts}")
479
+ shutil.copy2(ENV_PATH, backup)
480
+ self.console.print(f" 已备份旧配置到 [cyan]{backup.name}[/cyan]")
481
+
482
+ # Read template
483
+ if ENV_EXAMPLE_PATH.exists():
484
+ template_lines = ENV_EXAMPLE_PATH.read_text(encoding="utf-8").splitlines()
485
+ else:
486
+ template_lines = []
487
+
488
+ # Substitute values
489
+ output_lines: list[str] = []
490
+ used_keys: set[str] = set()
491
+
492
+ for line in template_lines:
493
+ stripped = line.strip()
494
+ # Preserve comments and blank lines
495
+ if not stripped or stripped.startswith("#"):
496
+ output_lines.append(line)
497
+ continue
498
+
499
+ if "=" in stripped:
500
+ key = stripped.split("=", 1)[0].strip()
501
+ if key in self.values:
502
+ output_lines.append(f"{key}={self.values[key]}")
503
+ used_keys.add(key)
504
+ else:
505
+ output_lines.append(line)
506
+ else:
507
+ output_lines.append(line)
508
+
509
+ # Append any extra keys not in template
510
+ extra_keys = set(self.values.keys()) - used_keys
511
+ if extra_keys:
512
+ output_lines.append("")
513
+ output_lines.append("# ── 额外配置 ──")
514
+ for key in sorted(extra_keys):
515
+ output_lines.append(f"{key}={self.values[key]}")
516
+
517
+ ENV_PATH.write_text("\n".join(output_lines) + "\n", encoding="utf-8")
518
+ self.console.print(f"\n [bold green]✅ 配置已写入 {ENV_PATH}[/bold green]")
519
+
520
+ # ── Connection test ────────────────────────────────────────────────────
521
+
522
+ def _test_connection(self) -> bool:
523
+ """Try reading 1 row from A table to verify credentials."""
524
+ self.console.print(f"\n[bold cyan]🔌 {STEP_TEST}[/bold cyan]")
525
+ self.console.print(" 正在尝试读取 A 表第一行...\n")
526
+
527
+ try:
528
+ from connectors.tencent_docs import TencentDocsConnector
529
+
530
+ conn = TencentDocsConnector(
531
+ client_id=self.values.get("TENCENT_CLIENT_ID", ""),
532
+ client_secret=self.values.get("TENCENT_CLIENT_SECRET", ""),
533
+ access_token=self.values.get("TENCENT_ACCESS_TOKEN", ""),
534
+ open_id=self.values.get("TENCENT_OPEN_ID", ""),
535
+ )
536
+ rows = conn.read_rows(
537
+ self.values.get("TENCENT_A_FILE_ID", ""),
538
+ self.values.get("TENCENT_A_SHEET_ID", ""),
539
+ start_row=0,
540
+ end_row=2,
541
+ )
542
+ self.console.print(f" [bold green]✅ 连接成功!读取到 {len(rows)} 行数据[/bold green]")
543
+ if rows:
544
+ self.console.print(f" 表头: {rows[0][:5]}...")
545
+ return True
546
+ except Exception as exc:
547
+ self.console.print(f" [bold red]❌ 连接失败: {exc}[/bold red]")
548
+ self.console.print(" [dim]请检查凭证和网络,稍后可重新运行 setup[/dim]")
549
+ return False
550
+
551
+ # ── Check mode ─────────────────────────────────────────────────────────
552
+
553
+ def run_check(self) -> None:
554
+ """Validate the current .env configuration."""
555
+ self.console.print(Panel(
556
+ "验证当前配置状态",
557
+ title="[bold]配置检查[/bold]",
558
+ border_style="cyan",
559
+ ))
560
+
561
+ if not ENV_PATH.exists():
562
+ self.console.print("[bold red]❌ .env 文件不存在[/bold red]")
563
+ self.console.print("请运行 [cyan]tb setup[/cyan] 进行配置")
564
+ return
565
+
566
+ values = dotenv_values(ENV_PATH)
567
+
568
+ required = {
569
+ "TENCENT_CLIENT_ID": "腾讯文档 Client ID",
570
+ "TENCENT_CLIENT_SECRET": "腾讯文档 Client Secret",
571
+ "TENCENT_ACCESS_TOKEN": "腾讯文档 Access Token",
572
+ "TENCENT_A_FILE_ID": "A 表 File ID",
573
+ "TENCENT_A_SHEET_ID": "A 表 Sheet ID",
574
+ "TENCENT_B_FILE_ID": "B 表 File ID",
575
+ "TENCENT_B_SHEET_ID": "B 表 Sheet ID",
576
+ }
577
+
578
+ optional = {
579
+ "TENCENT_OPEN_ID": "腾讯文档 Open ID",
580
+ "FEISHU_APP_ID": "飞书 App ID",
581
+ "FEISHU_APP_SECRET": "飞书 App Secret",
582
+ "FEISHU_C_FILE_TOKEN": "飞书 C 表 Token",
583
+ "FEISHU_C_SHEET_ID": "飞书 C 表 Sheet ID",
584
+ }
585
+
586
+ secret_keys = {
587
+ "TENCENT_CLIENT_ID", "TENCENT_CLIENT_SECRET", "TENCENT_OPEN_ID",
588
+ "TENCENT_ACCESS_TOKEN", "FEISHU_APP_ID", "FEISHU_APP_SECRET",
589
+ }
590
+
591
+ table = Table(title="配置状态", box=box.ROUNDED)
592
+ table.add_column("配置项", style="cyan", min_width=28)
593
+ table.add_column("说明", style="white")
594
+ table.add_column("状态", justify="center")
595
+ table.add_column("值", style="dim")
596
+
597
+ all_ok = True
598
+ for key, desc in required.items():
599
+ val = values.get(key, "")
600
+ if val:
601
+ display = _mask_secret(val) if key in secret_keys else val
602
+ table.add_row(key, desc, "[green]✅[/green]", display)
603
+ else:
604
+ table.add_row(key, desc, "[red]❌ 缺失[/red]", "")
605
+ all_ok = False
606
+
607
+ for key, desc in optional.items():
608
+ val = values.get(key, "")
609
+ if val:
610
+ display = _mask_secret(val) if key in secret_keys else val
611
+ table.add_row(key, desc, "[green]✅[/green]", display)
612
+ else:
613
+ table.add_row(key, desc, "[yellow]⚠ 未配置[/yellow]", "[dim]可选[/dim]")
614
+
615
+ self.console.print(table)
616
+
617
+ # Show runtime config
618
+ runtime_keys = [
619
+ "GROSS_PROFIT_MODE", "REFUND_MATCH_MODE", "TASK_INTERVAL_MINUTES",
620
+ "DRY_RUN", "ENABLE_STYLE_UPDATE", "WRITE_BATCH_SIZE",
621
+ ]
622
+ rt_table = Table(title="运行参数", box=box.ROUNDED)
623
+ rt_table.add_column("配置项", style="cyan")
624
+ rt_table.add_column("当前值", style="green")
625
+ for key in runtime_keys:
626
+ rt_table.add_row(key, values.get(key, "(默认)"))
627
+ self.console.print(rt_table)
628
+
629
+ if all_ok:
630
+ self.console.print("\n[bold green]✅ 必填配置项均已设置[/bold green]")
631
+ if self._prompt_bool("是否测试连接?", default=True):
632
+ self.values = dict(values) # type: ignore
633
+ self._test_connection()
634
+ else:
635
+ self.console.print("\n[bold red]❌ 有必填配置项缺失,请运行 tb setup 补充[/bold red]")
636
+
637
+ # ── Main flow ──────────────────────────────────────────────────────────
638
+
639
+ def run_full(self) -> None:
640
+ """Run the complete setup wizard."""
641
+ try:
642
+ self.console.print(Panel(
643
+ f"[bold]{BANNER_TITLE}[/bold]\n{BANNER_SUBTITLE}",
644
+ border_style="cyan",
645
+ padding=(1, 2),
646
+ ))
647
+
648
+ if self._existing:
649
+ self.console.print("[dim]检测到已有 .env 配置,将作为默认值显示[/dim]")
650
+
651
+ self._step_tencent_creds()
652
+ self._step_sheet_ids()
653
+ self._step_feishu_creds()
654
+ self._step_runtime()
655
+ self._step_column_mapping()
656
+
657
+ # Ensure business text defaults
658
+ self.values.setdefault("REFUND_STATUS_TEXT", "进入退款流程")
659
+ self.values.setdefault("DATA_ERROR_TEXT", "数据异常")
660
+
661
+ self._show_summary()
662
+
663
+ if not self._prompt_bool("确认写入 .env 文件?", default=True):
664
+ self.console.print("[yellow]已取消写入[/yellow]")
665
+ return
666
+
667
+ self._write_env()
668
+
669
+ if self._prompt_bool("是否测试腾讯文档连接?", default=True):
670
+ self._test_connection()
671
+
672
+ # Final tips
673
+ self.console.print(Panel(
674
+ "[bold green]配置完成![/bold green]\n\n"
675
+ "接下来你可以:\n"
676
+ " 1. [cyan]tb all --dry-run[/cyan] — 模拟执行\n"
677
+ " 2. [cyan]tb all[/cyan] — 正式执行\n"
678
+ " 3. [cyan]tb start[/cyan] — 启动定时任务\n"
679
+ " 4. [cyan]tb check[/cyan] — 验证配置",
680
+ title="[bold]下一步[/bold]",
681
+ border_style="green",
682
+ padding=(1, 2),
683
+ ))
684
+
685
+ except KeyboardInterrupt:
686
+ self.console.print("\n\n[yellow]已取消配置向导[/yellow]")
687
+ sys.exit(0)
688
+
689
+
690
+ # ── CLI handlers ───────────────────────────────────────────────────────────
691
+
692
+ def cmd_setup(args) -> None:
693
+ """Handle `tb setup [--check]`."""
694
+ wizard = SetupWizard()
695
+ if getattr(args, "check", False):
696
+ wizard.run_check()
697
+ else:
698
+ wizard.run_full()