bocai-cli-core 0.2.0__py3-none-any.whl

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.
Files changed (42) hide show
  1. bocai_cli/__init__.py +3 -0
  2. bocai_cli/api/__init__.py +29 -0
  3. bocai_cli/api/auth_client.py +57 -0
  4. bocai_cli/api/card_client.py +222 -0
  5. bocai_cli/api/center_client.py +55 -0
  6. bocai_cli/api/checkin_client.py +29 -0
  7. bocai_cli/api/course_client.py +85 -0
  8. bocai_cli/api/facade.py +40 -0
  9. bocai_cli/api/guanyuan_client.py +50 -0
  10. bocai_cli/api/member_client.py +97 -0
  11. bocai_cli/api/month_dashboard_client.py +130 -0
  12. bocai_cli/api/qywx_client.py +28 -0
  13. bocai_cli/api/report_client.py +41 -0
  14. bocai_cli/api/reservation_client.py +29 -0
  15. bocai_cli/api/transport.py +166 -0
  16. bocai_cli/app.py +12 -0
  17. bocai_cli/client.py +69 -0
  18. bocai_cli/command_utils.py +28 -0
  19. bocai_cli/commands/__init__.py +8 -0
  20. bocai_cli/commands/auth.py +211 -0
  21. bocai_cli/commands/card.py +653 -0
  22. bocai_cli/commands/center.py +138 -0
  23. bocai_cli/commands/checkin.py +71 -0
  24. bocai_cli/commands/course.py +260 -0
  25. bocai_cli/commands/customreport.py +77 -0
  26. bocai_cli/commands/guanyuan.py +94 -0
  27. bocai_cli/commands/member.py +288 -0
  28. bocai_cli/commands/month_dashboard.py +235 -0
  29. bocai_cli/commands/qywx.py +112 -0
  30. bocai_cli/commands/registry.py +70 -0
  31. bocai_cli/commands/report.py +93 -0
  32. bocai_cli/commands/reservation.py +71 -0
  33. bocai_cli/config.py +121 -0
  34. bocai_cli/date_range_validation.py +65 -0
  35. bocai_cli/errors.py +22 -0
  36. bocai_cli/main.py +8 -0
  37. bocai_cli/output.py +28 -0
  38. bocai_cli/product.py +10 -0
  39. bocai_cli_core-0.2.0.dist-info/METADATA +138 -0
  40. bocai_cli_core-0.2.0.dist-info/RECORD +42 -0
  41. bocai_cli_core-0.2.0.dist-info/WHEEL +4 -0
  42. bocai_cli_core-0.2.0.dist-info/licenses/LICENSE +19 -0
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api.transport import ApiTransport
6
+
7
+
8
+ class MonthDashboardClient:
9
+ def __init__(self, transport: ApiTransport) -> None:
10
+ self._transport = transport
11
+
12
+ def _request_date_range(
13
+ self,
14
+ target: str,
15
+ *,
16
+ center_id: str,
17
+ start_date: str,
18
+ end_date: str,
19
+ ) -> Any:
20
+ return self._transport.request(
21
+ "GET",
22
+ target,
23
+ params={
24
+ "centerId": center_id,
25
+ "startDate": start_date,
26
+ "endDate": end_date,
27
+ },
28
+ )
29
+
30
+ def statistics(
31
+ self,
32
+ *,
33
+ center_id: str,
34
+ start_date: str,
35
+ end_date: str,
36
+ ) -> Any:
37
+ return self._request_date_range(
38
+ "/api/bocai/month/dashboard/month_payment",
39
+ center_id=center_id,
40
+ start_date=start_date,
41
+ end_date=end_date,
42
+ )
43
+
44
+ def recognize_income(
45
+ self,
46
+ *,
47
+ center_id: str,
48
+ start_date: str,
49
+ end_date: str,
50
+ ) -> Any:
51
+ return self._transport.request(
52
+ "GET",
53
+ "/api/bocai/month/dashboard/recognize/income",
54
+ params={
55
+ "centerId": center_id,
56
+ "type": 1,
57
+ "startDate": start_date,
58
+ "endDate": end_date,
59
+ },
60
+ )
61
+
62
+ def clue_conversion(
63
+ self,
64
+ *,
65
+ center_id: str,
66
+ start_date: str,
67
+ end_date: str,
68
+ ) -> Any:
69
+ return self._request_date_range(
70
+ "/api/bocai/month/dashboard/clue_conversion/count",
71
+ center_id=center_id,
72
+ start_date=start_date,
73
+ end_date=end_date,
74
+ )
75
+
76
+ def service_delivery(
77
+ self,
78
+ *,
79
+ center_id: str,
80
+ start_date: str,
81
+ end_date: str,
82
+ ) -> Any:
83
+ return self._request_date_range(
84
+ "/api/bocai/month/dashboard/service_delivery",
85
+ center_id=center_id,
86
+ start_date=start_date,
87
+ end_date=end_date,
88
+ )
89
+
90
+ def member_count(
91
+ self,
92
+ *,
93
+ center_id: str,
94
+ start_date: str,
95
+ end_date: str,
96
+ ) -> Any:
97
+ return self._request_date_range(
98
+ "/api/bocai/month/dashboard/member/count",
99
+ center_id=center_id,
100
+ start_date=start_date,
101
+ end_date=end_date,
102
+ )
103
+
104
+ def attendance_count(
105
+ self,
106
+ *,
107
+ center_id: str,
108
+ start_date: str,
109
+ end_date: str,
110
+ ) -> Any:
111
+ return self._request_date_range(
112
+ "/api/bocai/month/dashboard/attendance/count",
113
+ center_id=center_id,
114
+ start_date=start_date,
115
+ end_date=end_date,
116
+ )
117
+
118
+ def revenue_info(
119
+ self,
120
+ *,
121
+ center_id: str,
122
+ start_date: str,
123
+ end_date: str,
124
+ ) -> Any:
125
+ return self._request_date_range(
126
+ "/api/bocai/month/dashboard/revenue_info",
127
+ center_id=center_id,
128
+ start_date=start_date,
129
+ end_date=end_date,
130
+ )
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from bocai_cli.api.transport import ApiTransport
7
+
8
+
9
+ class QywxClient:
10
+ def __init__(self, transport: ApiTransport) -> None:
11
+ self._transport = transport
12
+
13
+ def send_message(self, *, center_id: str, payload: dict[str, Any]) -> Any:
14
+ return self._transport.request(
15
+ "POST",
16
+ "/api/bocai/qywx/message/send",
17
+ params={"centerId": center_id},
18
+ json_body=payload,
19
+ )
20
+
21
+ def upload(self, *, center_id: str, file_path: Path) -> Any:
22
+ with file_path.open("rb") as file_handle:
23
+ return self._transport.request(
24
+ "POST",
25
+ "/api/bocai/qywx/media/upload",
26
+ params={"centerId": center_id},
27
+ files={"file": (file_path.name, file_handle)},
28
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api.transport import ApiTransport
6
+
7
+
8
+ class ReportClient:
9
+ def __init__(self, transport: ApiTransport) -> None:
10
+ self._transport = transport
11
+
12
+ def get_revenue(
13
+ self,
14
+ *,
15
+ start_date: str | None = None,
16
+ end_date: str | None = None,
17
+ center_id: str | None = None,
18
+ ) -> Any:
19
+ params: dict[str, Any] | None = None
20
+ if start_date is not None and end_date is not None:
21
+ params = {"startDate": start_date, "endDate": end_date}
22
+ if center_id is not None:
23
+ if params is None:
24
+ params = {}
25
+ params["centerId"] = center_id
26
+ return self._transport.request("GET", "/api/bocai/revenue", params=params)
27
+
28
+ def get_revenue_detail(
29
+ self,
30
+ *,
31
+ type_: str,
32
+ start_date: str | None = None,
33
+ end_date: str | None = None,
34
+ center_id: str | None = None,
35
+ ) -> Any:
36
+ params: dict[str, Any] = {"type": type_}
37
+ if start_date is not None and end_date is not None:
38
+ params.update({"startDate": start_date, "endDate": end_date})
39
+ if center_id is not None:
40
+ params["centerId"] = center_id
41
+ return self._transport.request("GET", "/api/bocai/revenue/detail", params=params)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api.transport import ApiTransport
6
+
7
+
8
+ class ReservationClient:
9
+ def __init__(self, transport: ApiTransport) -> None:
10
+ self._transport = transport
11
+
12
+ def list(
13
+ self,
14
+ *,
15
+ start_date: str,
16
+ end_date: str,
17
+ page_num: int,
18
+ page_size: int,
19
+ center_id: str | None = None,
20
+ ) -> Any:
21
+ params: dict[str, Any] = {
22
+ "startDate": start_date,
23
+ "endDate": end_date,
24
+ "pageNum": page_num,
25
+ "pageSize": page_size,
26
+ }
27
+ if center_id is not None:
28
+ params["centerId"] = center_id
29
+ return self._transport.request("GET", "/api/bocai/reservation", params=params)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
7
+
8
+ from bocai_cli.errors import ApiRequestError, ConfigValidationError
9
+
10
+
11
+ def _should_retry(exc: BaseException) -> bool:
12
+ if isinstance(exc, ApiRequestError):
13
+ return exc.status_code is None or exc.status_code >= 500
14
+ return False
15
+
16
+
17
+ class ApiTransport:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ base_url: str | None,
22
+ token: str | None,
23
+ timeout_seconds: float = 30.0,
24
+ ) -> None:
25
+ self.base_url = _upgrade_openapi_url((base_url or "").strip()).rstrip("/")
26
+ self.token = token
27
+ self.timeout_seconds = timeout_seconds
28
+
29
+ def request(
30
+ self,
31
+ method: str,
32
+ target: str,
33
+ *,
34
+ params: dict[str, Any] | None = None,
35
+ json_body: dict[str, Any] | None = None,
36
+ files: dict[str, Any] | None = None,
37
+ absolute_url: bool = False,
38
+ auth_required: bool = True,
39
+ ) -> Any:
40
+ url = self._build_url(target, absolute_url=absolute_url)
41
+ headers = self._build_headers(auth_required=auth_required)
42
+ if json_body is not None and files is None:
43
+ headers["Content-Type"] = "application/json"
44
+
45
+ @retry(
46
+ stop=stop_after_attempt(3),
47
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=2),
48
+ retry=retry_if_exception(_should_retry),
49
+ reraise=True,
50
+ )
51
+ def _do_request() -> httpx.Response:
52
+ try:
53
+ with httpx.Client(timeout=self.timeout_seconds) as http_client:
54
+ request_kwargs: dict[str, Any] = {
55
+ "headers": headers,
56
+ "params": params,
57
+ "json": json_body,
58
+ }
59
+ if files is not None:
60
+ request_kwargs["files"] = files
61
+
62
+ response = http_client.request(method, url, **request_kwargs)
63
+ except httpx.TimeoutException as exc:
64
+ raise ApiRequestError("请求超时。") from exc
65
+ except httpx.RequestError as exc:
66
+ raise ApiRequestError("网络请求失败。") from exc
67
+
68
+ if response.status_code >= 500:
69
+ raise ApiRequestError(
70
+ "服务暂时不可用,请稍后重试。",
71
+ status_code=response.status_code,
72
+ )
73
+
74
+ if response.status_code >= 400:
75
+ raise ApiRequestError(
76
+ _extract_error_message(response),
77
+ status_code=response.status_code,
78
+ )
79
+ return response
80
+
81
+ response = _do_request()
82
+ if response.status_code == 204 or not response.text.strip():
83
+ return {}
84
+
85
+ try:
86
+ payload = response.json()
87
+ except ValueError as exc:
88
+ raise ApiRequestError("响应不是合法 JSON。", status_code=response.status_code) from exc
89
+
90
+ return self._unwrap_payload(payload, status_code=response.status_code)
91
+
92
+ def _unwrap_payload(self, payload: Any, *, status_code: int) -> Any:
93
+ if not isinstance(payload, dict) or "code" not in payload:
94
+ return payload
95
+
96
+ code = payload.get("code")
97
+ if code != 200:
98
+ message = payload.get("message")
99
+ if not isinstance(message, str) or not message.strip():
100
+ message = f"请求失败(code={code})。"
101
+ raise ApiRequestError(
102
+ message.strip(),
103
+ status_code=status_code,
104
+ details={"code": code},
105
+ )
106
+
107
+ return payload.get("data")
108
+
109
+ def _build_url(self, target: str, *, absolute_url: bool) -> str:
110
+ if absolute_url:
111
+ if target.startswith("http://") or target.startswith("https://"):
112
+ return _upgrade_openapi_url(target)
113
+ if target.startswith("/"):
114
+ if not self.base_url:
115
+ raise ConfigValidationError(
116
+ "token_url 为相对路径时,配置中必须包含 base_url。",
117
+ details={"missing": ["base_url"]},
118
+ )
119
+ return _upgrade_openapi_url(f"{self.base_url}{target}")
120
+ raise ConfigValidationError(
121
+ "token_url 必须是完整 URL,或以 / 开头的相对路径。",
122
+ details={"invalid": ["token_url"]},
123
+ )
124
+
125
+ if not self.base_url:
126
+ raise ConfigValidationError(
127
+ "配置缺少 base_url。",
128
+ details={"missing": ["base_url"]},
129
+ )
130
+ return _upgrade_openapi_url(f"{self.base_url}{target}")
131
+
132
+ def _build_headers(self, *, auth_required: bool) -> dict[str, str]:
133
+ headers = {"Accept": "application/json"}
134
+ if auth_required:
135
+ if not self.token:
136
+ raise ConfigValidationError(
137
+ "配置缺少 token。",
138
+ details={"missing": ["token"]},
139
+ )
140
+ headers["Authorization"] = f"Bearer {self.token}"
141
+ return headers
142
+
143
+
144
+ def _upgrade_openapi_url(url: str) -> str:
145
+ http_origin = "http://openapi.forzadata.cn"
146
+ https_origin = "https://openapi.forzadata.cn"
147
+ if url == http_origin:
148
+ return https_origin
149
+ if url.startswith(f"{http_origin}/"):
150
+ return f"{https_origin}/{url[len(http_origin) + 1 :]}"
151
+ return url
152
+
153
+
154
+ def _extract_error_message(response: httpx.Response) -> str:
155
+ try:
156
+ payload = response.json()
157
+ except ValueError:
158
+ return f"请求失败(HTTP {response.status_code})。"
159
+
160
+ if isinstance(payload, dict):
161
+ for field in ("message", "error", "detail"):
162
+ value = payload.get(field)
163
+ if isinstance(value, str) and value.strip():
164
+ return value.strip()
165
+
166
+ return f"请求失败(HTTP {response.status_code})。"
bocai_cli/app.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from bocai_cli.commands.registry import register_commands
6
+ from bocai_cli.product import PRODUCT_HELP, Product
7
+
8
+
9
+ def create_app(product: Product) -> typer.Typer:
10
+ root_app = typer.Typer(no_args_is_help=True, help=PRODUCT_HELP[product])
11
+ register_commands(root_app, product=product)
12
+ return root_app
bocai_cli/client.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api import ApiFacade
6
+
7
+
8
+ class SaasClient:
9
+ """Deprecated: 请优先使用 bocai_cli.api.ApiFacade。"""
10
+
11
+ def __init__(
12
+ self,
13
+ *,
14
+ base_url: str | None,
15
+ token: str | None,
16
+ timeout_seconds: float = 30.0,
17
+ ) -> None:
18
+ self._api = ApiFacade(
19
+ base_url=base_url,
20
+ token=token,
21
+ timeout_seconds=timeout_seconds,
22
+ )
23
+
24
+ def get_revenue(
25
+ self,
26
+ *,
27
+ start_date: str | None = None,
28
+ end_date: str | None = None,
29
+ ) -> Any:
30
+ return self._api.report.get_revenue(start_date=start_date, end_date=end_date)
31
+
32
+ def get_revenue_detail(
33
+ self,
34
+ *,
35
+ type_: str,
36
+ start_date: str | None = None,
37
+ end_date: str | None = None,
38
+ ) -> Any:
39
+ return self._api.report.get_revenue_detail(
40
+ type_=type_,
41
+ start_date=start_date,
42
+ end_date=end_date,
43
+ )
44
+
45
+ def fetch_access_token(self, *, token_url: str, app_id: str, secret: str) -> str:
46
+ return self._api.auth.fetch_access_token(
47
+ token_url=token_url,
48
+ app_id=app_id,
49
+ secret=secret,
50
+ )
51
+
52
+ def _request(
53
+ self,
54
+ method: str,
55
+ target: str,
56
+ *,
57
+ params: dict[str, Any] | None = None,
58
+ json_body: dict[str, Any] | None = None,
59
+ absolute_url: bool = False,
60
+ auth_required: bool = True,
61
+ ) -> Any:
62
+ return self._api.transport.request(
63
+ method,
64
+ target,
65
+ params=params,
66
+ json_body=json_body,
67
+ absolute_url=absolute_url,
68
+ auth_required=auth_required,
69
+ )
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from functools import wraps
5
+ from typing import Any, ParamSpec, TypeVar
6
+
7
+ import typer
8
+
9
+ from bocai_cli.errors import BocaiCliError
10
+ from bocai_cli.output import print_error
11
+
12
+ P = ParamSpec("P")
13
+ R = TypeVar("R")
14
+
15
+
16
+ def command_handler(func: Callable[P, R]) -> Callable[P, R]:
17
+ @wraps(func)
18
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
19
+ try:
20
+ return func(*args, **kwargs)
21
+ except BocaiCliError as exc:
22
+ print_error(exc.message, status_code=exc.status_code, details=exc.details)
23
+ raise typer.Exit(code=1)
24
+ except Exception:
25
+ print_error("发生未知错误。")
26
+ raise typer.Exit(code=1)
27
+
28
+ return wrapper
@@ -0,0 +1,8 @@
1
+ from bocai_cli.commands.auth import app as auth_app
2
+ from bocai_cli.commands.center import app as center_app
3
+ from bocai_cli.commands.checkin import app as checkin_app
4
+ from bocai_cli.commands.course import app as course_app
5
+ from bocai_cli.commands.reservation import app as reservation_app
6
+ from bocai_cli.commands.report import app as report_app
7
+
8
+ __all__ = ["auth_app", "center_app", "checkin_app", "course_app", "reservation_app", "report_app"]