bocai-cli 0.1.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.
bocai_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,19 @@
1
+ from bocai_cli.api.auth_client import AuthClient
2
+ from bocai_cli.api.card_client import CardClient
3
+ from bocai_cli.api.checkin_client import CheckinClient
4
+ from bocai_cli.api.course_client import CourseClient
5
+ from bocai_cli.api.facade import ApiFacade
6
+ from bocai_cli.api.report_client import ReportClient
7
+ from bocai_cli.api.reservation_client import ReservationClient
8
+ from bocai_cli.api.transport import ApiTransport
9
+
10
+ __all__ = [
11
+ "ApiFacade",
12
+ "ApiTransport",
13
+ "AuthClient",
14
+ "CardClient",
15
+ "CheckinClient",
16
+ "CourseClient",
17
+ "ReservationClient",
18
+ "ReportClient",
19
+ ]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api.transport import ApiTransport
6
+ from bocai_cli.errors import ApiRequestError
7
+
8
+
9
+ class AuthClient:
10
+ def __init__(self, transport: ApiTransport) -> None:
11
+ self._transport = transport
12
+
13
+ def fetch_access_token(self, *, token_url: str, app_id: str, secret: str) -> str:
14
+ payload = self._transport.request(
15
+ "POST",
16
+ token_url,
17
+ json_body={"appId": app_id, "appSecret": secret},
18
+ absolute_url=True,
19
+ auth_required=False,
20
+ )
21
+ if isinstance(payload, dict):
22
+ value = payload.get("accessToken")
23
+ if isinstance(value, str) and value.strip():
24
+ return value.strip()
25
+
26
+ raise ApiRequestError("token 接口响应缺少 accessToken。")
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api.transport import ApiTransport
6
+
7
+
8
+ class CardClient:
9
+ def __init__(self, transport: ApiTransport) -> None:
10
+ self._transport = transport
11
+
12
+ def sale_performance(
13
+ self,
14
+ *,
15
+ start_date: str,
16
+ end_date: str,
17
+ page_num: int,
18
+ page_size: int,
19
+ type_: int,
20
+ center_id: str | None = None,
21
+ ) -> Any:
22
+ params: dict[str, Any] = {
23
+ "startDate": start_date,
24
+ "endDate": end_date,
25
+ "pageNum": page_num,
26
+ "pageSize": page_size,
27
+ "type": type_,
28
+ }
29
+ if center_id is not None:
30
+ params["centerId"] = center_id
31
+ return self._transport.request("GET", "/api/bocai/card/sale", params=params)
32
+
33
+ def performance_clear(
34
+ self,
35
+ *,
36
+ transaction_ids: list[str],
37
+ phone: str,
38
+ center_id: str | None = None,
39
+ ) -> Any:
40
+ params: dict[str, Any] | None = None
41
+ if center_id is not None:
42
+ params = {"centerId": center_id}
43
+
44
+ return self._transport.request(
45
+ "POST",
46
+ "/api/bocai/course/performance/clear",
47
+ params=params,
48
+ json_body={"transactionIds": transaction_ids, "phone": phone},
49
+ )
50
+
51
+ def performance_update(
52
+ self,
53
+ *,
54
+ phone: str,
55
+ transactions: list[dict[str, Any]],
56
+ center_id: str | None = None,
57
+ ) -> Any:
58
+ params: dict[str, Any] | None = None
59
+ if center_id is not None:
60
+ params = {"centerId": center_id}
61
+
62
+ return self._transport.request(
63
+ "POST",
64
+ "/api/bocai/course/performance/update",
65
+ params=params,
66
+ json_body={"phone": phone, "transactions": transactions},
67
+ )
68
+
69
+ def performance_new(
70
+ self,
71
+ *,
72
+ type_: int,
73
+ payload: dict[str, Any],
74
+ center_id: str | None = None,
75
+ ) -> Any:
76
+ params: dict[str, Any] = {"type": type_}
77
+ if center_id is not None:
78
+ params["centerId"] = center_id
79
+
80
+ return self._transport.request(
81
+ "POST",
82
+ "/api/bocai/card/performance/new",
83
+ params=params,
84
+ json_body=payload,
85
+ )
@@ -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 CheckinClient:
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/checkin", params=params)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from bocai_cli.api.transport import ApiTransport
6
+
7
+
8
+ class CourseClient:
9
+ def __init__(self, transport: ApiTransport) -> None:
10
+ self._transport = transport
11
+
12
+ def sale_performance(
13
+ self,
14
+ *,
15
+ start_date: str,
16
+ end_date: str,
17
+ page_num: int,
18
+ page_size: int,
19
+ type_: int,
20
+ center_id: str | None = None,
21
+ ) -> Any:
22
+ params: dict[str, Any] = {
23
+ "startDate": start_date,
24
+ "endDate": end_date,
25
+ "pageNum": page_num,
26
+ "pageSize": page_size,
27
+ "type": type_,
28
+ }
29
+ if center_id is not None:
30
+ params["centerId"] = center_id
31
+ return self._transport.request("GET", "/api/bocai/course/sale", params=params)
32
+
33
+ def performance_clear(
34
+ self,
35
+ *,
36
+ transaction_ids: list[str],
37
+ phone: str,
38
+ center_id: str | None = None,
39
+ ) -> Any:
40
+ params: dict[str, Any] | None = None
41
+ if center_id is not None:
42
+ params = {"centerId": center_id}
43
+
44
+ return self._transport.request(
45
+ "POST",
46
+ "/api/bocai/course/performance/clear",
47
+ params=params,
48
+ json_body={"transactionIds": transaction_ids, "phone": phone},
49
+ )
50
+
51
+ def performance_update(
52
+ self,
53
+ *,
54
+ phone: str,
55
+ transactions: list[dict[str, Any]],
56
+ center_id: str | None = None,
57
+ ) -> Any:
58
+ params: dict[str, Any] | None = None
59
+ if center_id is not None:
60
+ params = {"centerId": center_id}
61
+
62
+ return self._transport.request(
63
+ "POST",
64
+ "/api/bocai/course/performance/update",
65
+ params=params,
66
+ json_body={"phone": phone, "transactions": transactions},
67
+ )
68
+
69
+ def performance_new(
70
+ self,
71
+ *,
72
+ type_: int,
73
+ payload: dict[str, Any],
74
+ center_id: str | None = None,
75
+ ) -> Any:
76
+ params: dict[str, Any] = {"type": type_}
77
+ if center_id is not None:
78
+ params["centerId"] = center_id
79
+
80
+ return self._transport.request(
81
+ "POST",
82
+ "/api/bocai/course/performance/new",
83
+ params=params,
84
+ json_body=payload,
85
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from bocai_cli.api.auth_client import AuthClient
4
+ from bocai_cli.api.card_client import CardClient
5
+ from bocai_cli.api.checkin_client import CheckinClient
6
+ from bocai_cli.api.course_client import CourseClient
7
+ from bocai_cli.api.report_client import ReportClient
8
+ from bocai_cli.api.reservation_client import ReservationClient
9
+ from bocai_cli.api.transport import ApiTransport
10
+
11
+
12
+ class ApiFacade:
13
+ def __init__(
14
+ self,
15
+ *,
16
+ base_url: str | None,
17
+ token: str | None,
18
+ timeout_seconds: float = 30.0,
19
+ ) -> None:
20
+ self.transport = ApiTransport(
21
+ base_url=base_url,
22
+ token=token,
23
+ timeout_seconds=timeout_seconds,
24
+ )
25
+ self.auth = AuthClient(self.transport)
26
+ self.card = CardClient(self.transport)
27
+ self.checkin = CheckinClient(self.transport)
28
+ self.course = CourseClient(self.transport)
29
+ self.reservation = ReservationClient(self.transport)
30
+ self.report = ReportClient(self.transport)
@@ -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,153 @@
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 = (base_url or "").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
+ absolute_url: bool = False,
37
+ auth_required: bool = True,
38
+ ) -> Any:
39
+ url = self._build_url(target, absolute_url=absolute_url)
40
+ headers = self._build_headers(auth_required=auth_required)
41
+ if json_body is not None:
42
+ headers["Content-Type"] = "application/json"
43
+
44
+ @retry(
45
+ stop=stop_after_attempt(3),
46
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=2),
47
+ retry=retry_if_exception(_should_retry),
48
+ reraise=True,
49
+ )
50
+ def _do_request() -> httpx.Response:
51
+ try:
52
+ with httpx.Client(timeout=self.timeout_seconds) as http_client:
53
+ response = http_client.request(
54
+ method,
55
+ url,
56
+ headers=headers,
57
+ params=params,
58
+ json=json_body,
59
+ )
60
+ except httpx.TimeoutException as exc:
61
+ raise ApiRequestError("请求超时。") from exc
62
+ except httpx.RequestError as exc:
63
+ raise ApiRequestError("网络请求失败。") from exc
64
+
65
+ if response.status_code >= 500:
66
+ raise ApiRequestError(
67
+ "服务暂时不可用,请稍后重试。",
68
+ status_code=response.status_code,
69
+ )
70
+
71
+ if response.status_code >= 400:
72
+ raise ApiRequestError(
73
+ _extract_error_message(response),
74
+ status_code=response.status_code,
75
+ )
76
+ return response
77
+
78
+ response = _do_request()
79
+ if response.status_code == 204 or not response.text.strip():
80
+ return {}
81
+
82
+ try:
83
+ payload = response.json()
84
+ except ValueError as exc:
85
+ raise ApiRequestError("响应不是合法 JSON。", status_code=response.status_code) from exc
86
+
87
+ return self._unwrap_payload(payload, status_code=response.status_code)
88
+
89
+ def _unwrap_payload(self, payload: Any, *, status_code: int) -> Any:
90
+ if not isinstance(payload, dict) or "code" not in payload:
91
+ return payload
92
+
93
+ code = payload.get("code")
94
+ if code != 200:
95
+ message = payload.get("message")
96
+ if not isinstance(message, str) or not message.strip():
97
+ message = f"请求失败(code={code})。"
98
+ raise ApiRequestError(
99
+ message.strip(),
100
+ status_code=status_code,
101
+ details={"code": code},
102
+ )
103
+
104
+ return payload.get("data")
105
+
106
+ def _build_url(self, target: str, *, absolute_url: bool) -> str:
107
+ if absolute_url:
108
+ if target.startswith("http://") or target.startswith("https://"):
109
+ return target
110
+ if target.startswith("/"):
111
+ if not self.base_url:
112
+ raise ConfigValidationError(
113
+ "token_url 为相对路径时,配置中必须包含 base_url。",
114
+ details={"missing": ["base_url"]},
115
+ )
116
+ return f"{self.base_url}{target}"
117
+ raise ConfigValidationError(
118
+ "token_url 必须是完整 URL,或以 / 开头的相对路径。",
119
+ details={"invalid": ["token_url"]},
120
+ )
121
+
122
+ if not self.base_url:
123
+ raise ConfigValidationError(
124
+ "配置缺少 base_url。",
125
+ details={"missing": ["base_url"]},
126
+ )
127
+ return f"{self.base_url}{target}"
128
+
129
+ def _build_headers(self, *, auth_required: bool) -> dict[str, str]:
130
+ headers = {"Accept": "application/json"}
131
+ if auth_required:
132
+ if not self.token:
133
+ raise ConfigValidationError(
134
+ "配置缺少 token。",
135
+ details={"missing": ["token"]},
136
+ )
137
+ headers["Authorization"] = f"Bearer {self.token}"
138
+ return headers
139
+
140
+
141
+ def _extract_error_message(response: httpx.Response) -> str:
142
+ try:
143
+ payload = response.json()
144
+ except ValueError:
145
+ return f"请求失败(HTTP {response.status_code})。"
146
+
147
+ if isinstance(payload, dict):
148
+ for field in ("message", "error", "detail"):
149
+ value = payload.get(field)
150
+ if isinstance(value, str) and value.strip():
151
+ return value.strip()
152
+
153
+ return f"请求失败(HTTP {response.status_code})。"
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,7 @@
1
+ from bocai_cli.commands.auth import app as auth_app
2
+ from bocai_cli.commands.checkin import app as checkin_app
3
+ from bocai_cli.commands.course import app as course_app
4
+ from bocai_cli.commands.reservation import app as reservation_app
5
+ from bocai_cli.commands.report import app as report_app
6
+
7
+ __all__ = ["auth_app", "checkin_app", "course_app", "reservation_app", "report_app"]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import typer
6
+
7
+ from bocai_cli.api import ApiFacade
8
+ from bocai_cli.command_utils import command_handler
9
+ from bocai_cli.config import load_config, save_config
10
+ from bocai_cli.errors import ConfigValidationError
11
+ from bocai_cli.output import print_json
12
+
13
+ app = typer.Typer(help="鉴权相关命令。", invoke_without_command=True)
14
+
15
+
16
+ @app.callback()
17
+ def auth_root(ctx: typer.Context) -> None:
18
+ if ctx.invoked_subcommand is None:
19
+ typer.echo(ctx.get_help())
20
+ raise typer.Exit(code=0)
21
+
22
+
23
+ def _build_api() -> ApiFacade:
24
+ config = load_config()
25
+ return ApiFacade(
26
+ base_url=config.base_url,
27
+ token=config.token,
28
+ timeout_seconds=config.timeout_seconds,
29
+ )
30
+
31
+
32
+ @app.command("login", help="登录并获取accessToken")
33
+ @command_handler
34
+ def login() -> None:
35
+ config = load_config()
36
+ required_fields = ["base_url", "app_id", "secret"]
37
+ missing = [field for field in required_fields if not getattr(config, field)]
38
+ if missing:
39
+ raise ConfigValidationError(
40
+ "配置缺少获取 token 所需字段。",
41
+ details={"missing": missing},
42
+ )
43
+
44
+ api = _build_api()
45
+ new_token = api.auth.fetch_access_token(
46
+ token_url=config.token_url or "/api/auth/token",
47
+ app_id=config.app_id or "",
48
+ secret=config.secret or "",
49
+ )
50
+
51
+ previous_token = config.token
52
+ if previous_token and previous_token != new_token:
53
+ config.token_backup = previous_token
54
+
55
+ config.token = new_token
56
+ config.token_updated_at = datetime.now(timezone.utc).isoformat()
57
+ config_path = save_config(config)
58
+
59
+ print_json(
60
+ {
61
+ "status": "ok",
62
+ "message": "登录成功,token 已更新。",
63
+ "token_updated": True,
64
+ "config_path": str(config_path),
65
+ }
66
+ )