tb-order-sync 0.3.0 → 0.4.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/dashboard.py CHANGED
@@ -37,7 +37,7 @@ class DashboardApp:
37
37
  choice = self._ask(
38
38
  "选择操作",
39
39
  default="1",
40
- choices={"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "0"},
40
+ choices={"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "0"},
41
41
  )
42
42
  if not self._handle_choice(choice):
43
43
  break
@@ -54,6 +54,8 @@ class DashboardApp:
54
54
  def _build_screen(self) -> Group:
55
55
  daemon_status = self._daemon.status()
56
56
  state = self._state_svc.load(quiet=True)
57
+ last_run = self._state_svc.load_last_run(quiet=True)
58
+ autostart_status = self._daemon.autostart_status()
57
59
 
58
60
  title = Text("多表格同步与退款标记服务", style="bold white")
59
61
  subtitle = Text("Scheduler Console", style="bold #8ecae6")
@@ -71,13 +73,13 @@ class DashboardApp:
71
73
  box=box.ROUNDED,
72
74
  )
73
75
  daemon_panel = Panel(
74
- self._build_daemon_table(daemon_status),
76
+ self._build_daemon_table(daemon_status, autostart_status),
75
77
  title="[bold #023047]守护进程[/bold #023047]",
76
78
  border_style="#90be6d" if daemon_status.running else "#f4a261",
77
79
  box=box.ROUNDED,
78
80
  )
79
81
  state_panel = Panel(
80
- self._build_state_table(state),
82
+ self._build_state_table(state, last_run),
81
83
  title="[bold #023047]同步状态[/bold #023047]",
82
84
  border_style="#ffb703",
83
85
  box=box.ROUNDED,
@@ -126,22 +128,27 @@ class DashboardApp:
126
128
  table.add_row("Dry Run", "开启" if self._settings.dry_run else "关闭")
127
129
  return table
128
130
 
129
- def _build_daemon_table(self, status) -> Table:
131
+ def _build_daemon_table(self, status, autostart_status) -> Table:
130
132
  table = Table(box=None, show_header=False, pad_edge=False)
131
133
  table.add_column(style="bold white")
132
134
  table.add_column(style="#023047")
133
135
  table.add_row("状态", "[green]运行中[/green]" if status.running else "[yellow]未运行[/yellow]")
136
+ table.add_row("登录自启", "[green]已启用[/green]" if autostart_status.enabled else "[yellow]未启用[/yellow]")
134
137
  table.add_row("PID", str(status.pid or "-"))
135
138
  table.add_row("启动时间", status.started_at or "-")
136
139
  table.add_row("日志文件", status.log_file.name)
137
140
  table.add_row("PID 文件", status.pid_file.name)
138
141
  return table
139
142
 
140
- def _build_state_table(self, state) -> Table:
143
+ def _build_state_table(self, state, last_run) -> Table:
141
144
  table = Table(box=None, show_header=False, pad_edge=False)
142
145
  table.add_column(style="bold white")
143
146
  table.add_column(style="#023047")
144
147
  table.add_row("上次运行", self._fmt_time(state.last_run_at))
148
+ if last_run is not None:
149
+ table.add_row("最近结果", "[green]成功[/green]" if last_run.success else "[red]失败[/red]")
150
+ table.add_row("最近变更", str(last_run.rows_changed))
151
+ table.add_row("最近异常", str(last_run.rows_error))
145
152
  table.add_row("A 表指纹", str(len(state.a_table_fingerprints)))
146
153
  table.add_row("退款快照", str(len(state.b_table_refund_set)))
147
154
  table.add_row("退款哈希", state.b_table_refund_hash[:12] if state.b_table_refund_hash else "-")
@@ -176,6 +183,9 @@ class DashboardApp:
176
183
  table.add_row("9", "配置向导", "打开交互式 setup")
177
184
  table.add_row("10", "配置检查", "检查 .env 完整性")
178
185
  table.add_row("11", "前台调度", "当前终端直接运行 scheduler")
186
+ table.add_row("12", "启用登录自启", "登录系统后自动拉起后台调度")
187
+ table.add_row("13", "停用登录自启", "移除当前用户的自启配置")
188
+ table.add_row("14", "查看自启状态", "检查当前用户的登录自启状态")
179
189
  table.add_row("0", "退出", "返回系统")
180
190
  return table
181
191
 
@@ -204,6 +214,12 @@ class DashboardApp:
204
214
  self._run_setup(check=True)
205
215
  elif choice == "11":
206
216
  self._run_foreground_scheduler()
217
+ elif choice == "12":
218
+ self._daemon_action("autostart-enable")
219
+ elif choice == "13":
220
+ self._daemon_action("autostart-disable")
221
+ elif choice == "14":
222
+ self._daemon_action("autostart-status")
207
223
  return True
208
224
 
209
225
  def _run_task(self, task: str, *, dry_run: bool = False) -> None:
@@ -227,18 +243,33 @@ class DashboardApp:
227
243
  str(item.rows_changed),
228
244
  str(item.rows_error),
229
245
  )
230
- self._pause_with_panel(Panel(table, title="执行结果", border_style="#219ebc", box=box.ROUNDED))
246
+ body: Group | Table = table
247
+ failures = [item for item in results if item.error_message]
248
+ if failures:
249
+ failure_table = Table(box=box.SIMPLE_HEAVY, expand=True)
250
+ failure_table.add_column("任务", style="bold red")
251
+ failure_table.add_column("失败原因", style="white")
252
+ for item in failures:
253
+ failure_table.add_row(item.task_name.value, item.error_message or "")
254
+ body = Group(table, Panel(failure_table, title="失败详情", border_style="red", box=box.ROUNDED))
255
+ self._pause_with_panel(Panel(body, title="执行结果", border_style="#219ebc", box=box.ROUNDED))
231
256
 
232
257
  def _daemon_action(self, action: str) -> None:
233
- if action == "start" and not self._ensure_config():
258
+ if action in {"start", "autostart-enable"} and not self._ensure_config():
234
259
  return
235
260
 
236
261
  if action == "start":
237
262
  status = self._daemon.start()
238
263
  elif action == "stop":
239
264
  status = self._daemon.stop(force=True)
240
- else:
265
+ elif action == "restart":
241
266
  status = self._daemon.restart()
267
+ elif action == "autostart-enable":
268
+ status = self._daemon.enable_autostart()
269
+ elif action == "autostart-disable":
270
+ status = self._daemon.disable_autostart()
271
+ else:
272
+ status = self._daemon.autostart_status()
242
273
 
243
274
  self._pause_with_panel(Panel(status.message, title="守护结果", border_style="#90be6d", box=box.ROUNDED))
244
275
 
@@ -282,7 +313,7 @@ class DashboardApp:
282
313
  def _is_config_ready(self) -> bool:
283
314
  fields = [
284
315
  self._settings.tencent_client_id,
285
- self._settings.tencent_client_secret,
316
+ self._settings.tencent_open_id,
286
317
  self._settings.tencent_access_token,
287
318
  self._settings.tencent_a_file_id,
288
319
  self._settings.tencent_a_sheet_id,
package/cli/setup.py CHANGED
@@ -15,6 +15,7 @@ import webbrowser
15
15
  from datetime import datetime
16
16
  from pathlib import Path
17
17
  from typing import Any, Callable, Optional, Sequence
18
+ from urllib.parse import parse_qs, urlparse
18
19
 
19
20
  try:
20
21
  from rich.console import Console
@@ -47,6 +48,7 @@ ENV_EXAMPLE_PATH = PROJECT_ROOT / ".env.example"
47
48
 
48
49
  # Column letter validator
49
50
  _COL_RE = re.compile(r"^[A-Z]{1,3}$")
51
+ _TENCENT_FILE_RE = re.compile(r"/(?:sheet|doc|slide|mind|form|pdf)/([^/?#]+)")
50
52
 
51
53
  TENCENT_DOCS_GUIDE_URL = "https://docs.qq.com/open/document/app/"
52
54
  TENCENT_DEVELOPER_CONSOLE_URL = "https://docs.qq.com/open/developers/"
@@ -98,6 +100,28 @@ def _mask_secret(value: str) -> str:
98
100
  return value[:4] + "****"
99
101
 
100
102
 
103
+ def parse_tencent_sheet_reference(raw: str) -> tuple[str, str]:
104
+ """Parse a Tencent Docs sheet URL or raw file id.
105
+
106
+ Returns `(file_id, sheet_id)` where `sheet_id` may be empty.
107
+ """
108
+ value = raw.strip()
109
+ if not value:
110
+ return "", ""
111
+
112
+ if not value.startswith(("http://", "https://")):
113
+ return value, ""
114
+
115
+ parsed = urlparse(value)
116
+ match = _TENCENT_FILE_RE.search(parsed.path)
117
+ if not match:
118
+ return "", ""
119
+
120
+ file_id = match.group(1).strip()
121
+ sheet_id = parse_qs(parsed.query).get("tab", [""])[0].strip()
122
+ return file_id, sheet_id
123
+
124
+
101
125
  # ── Setup Wizard ───────────────────────────────────────────────────────────
102
126
  class SetupWizard:
103
127
  def __init__(self, console: Optional[Console] = None) -> None:
@@ -265,8 +289,7 @@ class SetupWizard:
265
289
  error_msg="Client ID 不能为空",
266
290
  )
267
291
  self.values["TENCENT_CLIENT_SECRET"] = self._prompt(
268
- "Client Secret", "TENCENT_CLIENT_SECRET", secret=True, validator=_not_empty,
269
- error_msg="Client Secret 不能为空",
292
+ "Client Secret(当前运行可留空)", "TENCENT_CLIENT_SECRET", secret=True,
270
293
  )
271
294
  self.values["TENCENT_OPEN_ID"] = self._prompt(
272
295
  "Open ID(可选,部分接口需要)", "TENCENT_OPEN_ID", secret=True,
@@ -278,29 +301,33 @@ class SetupWizard:
278
301
 
279
302
  def _step_sheet_ids(self) -> None:
280
303
  self.console.print(f"\n[bold cyan]📊 {STEP_SHEETS}[/bold cyan]")
281
- self.console.print(" 从腾讯文档链接中提取 File ID Sheet ID\n")
304
+ self.console.print(" 可直接粘贴腾讯文档完整链接,系统会自动拆出 File ID / Sheet ID\n")
282
305
  self._show_sheet_id_guide()
283
306
  self.console.print("")
284
307
 
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
- )
308
+ def prompt_sheet_target(name: str, file_key: str, sheet_key: str) -> None:
309
+ while True:
310
+ self.console.print(f" [bold]{name}[/bold]")
311
+ ref = self._prompt(
312
+ f"{name}链接或 File ID", file_key, validator=_not_empty,
313
+ error_msg="请输入腾讯文档链接或 File ID",
314
+ )
315
+ file_id, sheet_id = parse_tencent_sheet_reference(ref)
316
+ if not file_id:
317
+ self.console.print(" [red]无法从链接中解析 File ID,请重新输入完整链接或直接填 File ID[/red]\n")
318
+ continue
319
+ self.values[file_key] = file_id
320
+ if sheet_id:
321
+ self.console.print(f" [green]已自动解析 {name} Sheet ID: {sheet_id}[/green]")
322
+ self.values[sheet_key] = self._prompt(
323
+ f"{name} Sheet ID", sheet_key, default=sheet_id, validator=_not_empty,
324
+ error_msg="Sheet ID 不能为空",
325
+ )
326
+ break
327
+
328
+ prompt_sheet_target("A 表(订单表)", "TENCENT_A_FILE_ID", "TENCENT_A_SHEET_ID")
329
+ self.console.print("")
330
+ prompt_sheet_target("B 表(退款表)", "TENCENT_B_FILE_ID", "TENCENT_B_SHEET_ID")
304
331
 
305
332
  def _step_feishu_creds(self) -> None:
306
333
  self.console.print(f"\n[bold cyan]🐦 {STEP_FEISHU}[/bold cyan]")
@@ -372,7 +399,7 @@ class SetupWizard:
372
399
  validator=_is_bool_str, error_msg="请输入 true 或 false",
373
400
  )
374
401
  self.values["ENABLE_STYLE_UPDATE"] = self._prompt(
375
- "是否启用行样式更新 (true / false)", "ENABLE_STYLE_UPDATE", default="false",
402
+ "是否启用行样式更新 (true / false)", "ENABLE_STYLE_UPDATE", default="true",
376
403
  validator=_is_bool_str, error_msg="请输入 true 或 false",
377
404
  )
378
405
 
@@ -414,7 +441,7 @@ class SetupWizard:
414
441
 
415
442
  # Business text
416
443
  self.values["REFUND_STATUS_TEXT"] = self._prompt(
417
- "退款状态文案", "REFUND_STATUS_TEXT", default="进入退款流程",
444
+ "退款状态文案", "REFUND_STATUS_TEXT", default="已退款",
418
445
  )
419
446
  self.values["DATA_ERROR_TEXT"] = self._prompt(
420
447
  "数据异常文案", "DATA_ERROR_TEXT", default="数据异常",
@@ -520,9 +547,20 @@ class SetupWizard:
520
547
  # ── Connection test ────────────────────────────────────────────────────
521
548
 
522
549
  def _test_connection(self) -> bool:
523
- """Try reading 1 row from A table to verify credentials."""
550
+ """Try reading both A/B sheets to verify credentials and document access."""
524
551
  self.console.print(f"\n[bold cyan]🔌 {STEP_TEST}[/bold cyan]")
525
- self.console.print(" 正在尝试读取 A 表第一行...\n")
552
+ self.console.print(" 正在执行启动自检:状态目录 + 腾讯文档 A/B 表读取...\n")
553
+
554
+ state_dir = Path(self.values.get("STATE_DIR", "state"))
555
+ try:
556
+ state_dir.mkdir(parents=True, exist_ok=True)
557
+ probe = state_dir / ".write_test"
558
+ probe.write_text("ok", encoding="utf-8")
559
+ probe.unlink(missing_ok=True)
560
+ self.console.print(f" [bold green]✅ 状态目录可写: {state_dir}[/bold green]")
561
+ except Exception as exc:
562
+ self.console.print(f" [bold red]❌ 状态目录不可写: {state_dir} ({exc})[/bold red]")
563
+ return False
526
564
 
527
565
  try:
528
566
  from connectors.tencent_docs import TencentDocsConnector
@@ -533,19 +571,32 @@ class SetupWizard:
533
571
  access_token=self.values.get("TENCENT_ACCESS_TOKEN", ""),
534
572
  open_id=self.values.get("TENCENT_OPEN_ID", ""),
535
573
  )
536
- rows = conn.read_rows(
574
+ a_rows = conn.read_rows(
537
575
  self.values.get("TENCENT_A_FILE_ID", ""),
538
576
  self.values.get("TENCENT_A_SHEET_ID", ""),
539
577
  start_row=0,
540
578
  end_row=2,
541
579
  )
542
- self.console.print(f" [bold green]✅ 连接成功!读取到 {len(rows)} 行数据[/bold green]")
543
- if rows:
544
- self.console.print(f" 表头: {rows[0][:5]}...")
580
+ b_rows = conn.read_rows(
581
+ self.values.get("TENCENT_B_FILE_ID", ""),
582
+ self.values.get("TENCENT_B_SHEET_ID", ""),
583
+ start_row=0,
584
+ end_row=2,
585
+ )
586
+ self.console.print(f" [bold green]✅ A 表可读:{len(a_rows)} 行[/bold green]")
587
+ self.console.print(f" [bold green]✅ B 表可读:{len(b_rows)} 行[/bold green]")
588
+ if a_rows:
589
+ self.console.print(f" A 表表头: {a_rows[0][:5]}...")
590
+ if b_rows:
591
+ self.console.print(f" B 表表头: {b_rows[0][:5]}...")
592
+ self.console.print(" [bold green]✅ 启动自检通过,可直接运行任务[/bold green]")
545
593
  return True
546
594
  except Exception as exc:
547
595
  self.console.print(f" [bold red]❌ 连接失败: {exc}[/bold red]")
548
- self.console.print(" [dim]请检查凭证和网络,稍后可重新运行 setup[/dim]")
596
+ if "400007" in str(exc) or "Requests Over Limit" in str(exc):
597
+ self.console.print(" [dim]当前更像是腾讯文档接口限流,请稍等后重新运行 tb check。[/dim]")
598
+ else:
599
+ self.console.print(" [dim]请检查 Access Token、Open ID、表格 ID、文档权限和网络,稍后可重新运行 setup/check[/dim]")
549
600
  return False
550
601
 
551
602
  # ── Check mode ─────────────────────────────────────────────────────────
@@ -567,8 +618,8 @@ class SetupWizard:
567
618
 
568
619
  required = {
569
620
  "TENCENT_CLIENT_ID": "腾讯文档 Client ID",
570
- "TENCENT_CLIENT_SECRET": "腾讯文档 Client Secret",
571
621
  "TENCENT_ACCESS_TOKEN": "腾讯文档 Access Token",
622
+ "TENCENT_OPEN_ID": "腾讯文档 Open ID",
572
623
  "TENCENT_A_FILE_ID": "A 表 File ID",
573
624
  "TENCENT_A_SHEET_ID": "A 表 Sheet ID",
574
625
  "TENCENT_B_FILE_ID": "B 表 File ID",
@@ -576,7 +627,7 @@ class SetupWizard:
576
627
  }
577
628
 
578
629
  optional = {
579
- "TENCENT_OPEN_ID": "腾讯文档 Open ID",
630
+ "TENCENT_CLIENT_SECRET": "腾讯文档 Client Secret",
580
631
  "FEISHU_APP_ID": "飞书 App ID",
581
632
  "FEISHU_APP_SECRET": "飞书 App Secret",
582
633
  "FEISHU_C_FILE_TOKEN": "飞书 C 表 Token",
@@ -628,7 +679,8 @@ class SetupWizard:
628
679
 
629
680
  if all_ok:
630
681
  self.console.print("\n[bold green]✅ 必填配置项均已设置[/bold green]")
631
- if self._prompt_bool("是否测试连接?", default=True):
682
+ self.console.print("[dim]接下来会做一次启动自检:状态目录写入 + 腾讯文档 A/B 表读取[/dim]")
683
+ if self._prompt_bool("是否执行启动自检?", default=True):
632
684
  self.values = dict(values) # type: ignore
633
685
  self._test_connection()
634
686
  else:
@@ -655,7 +707,7 @@ class SetupWizard:
655
707
  self._step_column_mapping()
656
708
 
657
709
  # Ensure business text defaults
658
- self.values.setdefault("REFUND_STATUS_TEXT", "进入退款流程")
710
+ self.values.setdefault("REFUND_STATUS_TEXT", "已退款")
659
711
  self.values.setdefault("DATA_ERROR_TEXT", "数据异常")
660
712
 
661
713
  self._show_summary()
@@ -73,7 +73,7 @@ class Settings(BaseSettings):
73
73
  write_batch_size: int = 100
74
74
  retry_times: int = 3
75
75
  dry_run: bool = False
76
- enable_style_update: bool = False
76
+ enable_style_update: bool = True
77
77
 
78
78
  # ── 列映射(可覆盖) ─────────────────────────────────
79
79
  a_col_product_price: str = "C"
@@ -86,7 +86,7 @@ class Settings(BaseSettings):
86
86
  b_col_order_no: str = "A"
87
87
 
88
88
  # ── 业务文案 ──────────────────────────────────────────
89
- refund_status_text: str = "进入退款流程"
89
+ refund_status_text: str = "已退款"
90
90
  data_error_text: str = "数据异常"
91
91
 
92
92