shopping-cli 3.1.0__tar.gz → 3.2.0__tar.gz
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.
- {shopping_cli-3.1.0/shopping_cli.egg-info → shopping_cli-3.2.0}/PKG-INFO +1 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/pyproject.toml +1 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/__init__.py +1 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/conversations.py +3 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/listings_projection.py +13 -9
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/negotiation.py +38 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/idempotency.py +58 -12
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli.py +18 -0
- shopping_cli-3.2.0/shopping_cli/cli_adapter_commands.py +49 -0
- shopping_cli-3.2.0/shopping_cli/data_sources/adapter.py +175 -0
- shopping_cli-3.2.0/shopping_cli/data_sources/csv_excel_source.py +292 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/db/migrations.py +66 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/db/models.py +1 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/listings/projection.py +16 -5
- {shopping_cli-3.1.0 → shopping_cli-3.2.0/shopping_cli.egg-info}/PKG-INFO +1 -1
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli.egg-info/SOURCES.txt +3 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/LICENSE +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/MANIFEST.in +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/README.md +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/setup.cfg +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/agent_cli.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/buyer_cli.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/merchant_agent.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/merchant_daemon.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/merchant_state_helpers.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/agents/tools.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/app.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/auth.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/error_handlers.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/error_response.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/fallback_asgi.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/fastapi_routes.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/agents.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/audit.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/buyer.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/catalog.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/common.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/erp.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/handlers/human_review.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/limits.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/request_dispatch.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/request_limits.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/route_matching.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/route_registry.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/route_table.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/routes_agents.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/routes_conversations.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/routes_marketplace.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/routes_merchants.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/api/server.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_agent_commands.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_buyer_commands.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_catalog_commands.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_common.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_conversation_commands.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_erp_commands.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/cli_listing_commands.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/commerce/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/commerce/adapters.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/commerce/data_source.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/config.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/contracts/kiwi-contracts.lock.json +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/catalog.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/catalog_scoring.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/catalog_text.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/catalog_views.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/channels.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/conversations.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/delivery.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/errors.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/harness.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/limits.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/negotiation.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/negotiation_contracts.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/policies.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/risk.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/core/tokens.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/data_sources/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/data_sources/erp_source.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/db/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/db/provenance.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/db/session.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/http_client.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/listings/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/__init__.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/agents.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/audit.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/buyer_bootstrap.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/conversations.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/human_review.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_gates.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_message.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_policy_result.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_snapshot.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli/services/tokens.py +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli.egg-info/dependency_links.txt +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli.egg-info/entry_points.txt +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli.egg-info/requires.txt +0 -0
- {shopping_cli-3.1.0 → shopping_cli-3.2.0}/shopping_cli.egg-info/top_level.txt +0 -0
|
@@ -74,7 +74,9 @@ def append_conversation_message(db_path: str | Path, conversation_id: str, paylo
|
|
|
74
74
|
def close_conversation(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
75
75
|
with db_session(db_path) as conn:
|
|
76
76
|
conversation = conversation_summary(conn, conversation_id)
|
|
77
|
-
|
|
77
|
+
# 审查 S-H1:sender 缺省不得是 "operator"——商家用自己 token 不带
|
|
78
|
+
# sender 关会话会被误记为 operator。缺省回落到最诚实的 "merchant"。
|
|
79
|
+
sender = str(payload.get("sender") or "merchant")
|
|
78
80
|
if sender in {"buyer", "buyer_cli"}:
|
|
79
81
|
token_service.require_buyer_conversation_token(conn, conversation, _payload_token(payload))
|
|
80
82
|
elif sender == "merchant":
|
|
@@ -40,10 +40,12 @@ def list_listing_projections(
|
|
|
40
40
|
raise ValidationError("merchant_id is required")
|
|
41
41
|
with db_session(db_path) as conn:
|
|
42
42
|
owner = _owner_merchant_from_payload(conn, payload)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
is_owner = bool(owner and owner == merchant_id)
|
|
44
|
+
# 审查 S-M3:私有剥离收进投影层(include_private)——此前调用方手动
|
|
45
|
+
# pop,CLI --format json 路径漏剥。
|
|
46
|
+
projections = list_publishable_listings(
|
|
47
|
+
conn, merchant_id=merchant_id, include_private=is_owner
|
|
48
|
+
)
|
|
47
49
|
return {"ok": True, "results": projections, "count": len(projections)}
|
|
48
50
|
|
|
49
51
|
|
|
@@ -53,12 +55,14 @@ def get_listing_projection(db_path: str | Path, sku: str, payload: dict[str, Any
|
|
|
53
55
|
if not sku:
|
|
54
56
|
raise ValidationError("sku is required")
|
|
55
57
|
with db_session(db_path) as conn:
|
|
58
|
+
row = conn.execute("select merchant_id from products where sku = ?", (sku,)).fetchone()
|
|
59
|
+
owner = _owner_merchant_from_payload(conn, payload)
|
|
60
|
+
is_owner = bool(
|
|
61
|
+
owner and row is not None and owner == str(row["merchant_id"] or "")
|
|
62
|
+
)
|
|
56
63
|
try:
|
|
57
|
-
|
|
64
|
+
# 审查 S-M3:私有剥离收进投影层(include_private)。
|
|
65
|
+
projection = project_product_listing(conn, sku, include_private=is_owner)
|
|
58
66
|
except ProjectionError as exc:
|
|
59
67
|
raise NotFoundError(str(exc)) from exc
|
|
60
|
-
owner = _owner_merchant_from_payload(conn, payload)
|
|
61
|
-
row = conn.execute("select merchant_id from products where sku = ?", (sku,)).fetchone()
|
|
62
|
-
if not (owner and row is not None and owner == str(row["merchant_id"] or "")):
|
|
63
|
-
projection.pop("handoff_destination", None)
|
|
64
68
|
return {"ok": True, "projection": projection}
|
|
@@ -6,15 +6,51 @@ fallback ASGI router dispatch here, so no business logic is duplicated.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
from typing import Any
|
|
11
14
|
|
|
12
15
|
from shopping_cli.api import auth as api_auth
|
|
13
16
|
from shopping_cli.api.handlers.common import positive_whole_int, require_field
|
|
14
17
|
from shopping_cli.core import negotiation as protocol
|
|
18
|
+
from shopping_cli.core.errors import RateLimitError
|
|
15
19
|
from shopping_cli.db.session import db_session
|
|
16
20
|
from shopping_cli.services import negotiation as negotiation_service
|
|
17
21
|
|
|
22
|
+
# 审查 S-M2:merchant/agent/negotiation/human-review 端点此前均无限流——已泄露/
|
|
23
|
+
# 低熵 token 的滥用无节流。对磋商决策提交(写路径,追加磋商消息)加 per-owner
|
|
24
|
+
# 固定窗口限流:进程内(单进程部署前提,与 ERP sync 限流同款)。env 可覆盖
|
|
25
|
+
# (调用时读取,便于测试注入)。
|
|
26
|
+
_NEGOTIATION_DECISION_WINDOW_SECONDS = 60
|
|
27
|
+
_DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE = 600
|
|
28
|
+
_decision_buckets: dict[str, tuple[float, int]] = {}
|
|
29
|
+
_decision_buckets_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _decision_rate_limit_per_minute() -> int:
|
|
33
|
+
raw = os.environ.get("SHOPPING_NEGOTIATION_DECISION_RATE_LIMIT_PER_MINUTE", "")
|
|
34
|
+
try:
|
|
35
|
+
value = int(raw)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE
|
|
38
|
+
return value if value > 0 else _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _enforce_decision_rate_limit(owner_id: str) -> None:
|
|
42
|
+
limit = _decision_rate_limit_per_minute()
|
|
43
|
+
now = time.monotonic()
|
|
44
|
+
with _decision_buckets_lock:
|
|
45
|
+
window_start, count = _decision_buckets.get(owner_id, (0.0, 0))
|
|
46
|
+
if now - window_start >= _NEGOTIATION_DECISION_WINDOW_SECONDS:
|
|
47
|
+
window_start, count = now, 0
|
|
48
|
+
if count >= limit:
|
|
49
|
+
raise RateLimitError(
|
|
50
|
+
f"negotiation decision rate limit exceeded ({limit}/minute)"
|
|
51
|
+
)
|
|
52
|
+
_decision_buckets[owner_id] = (window_start, count + 1)
|
|
53
|
+
|
|
18
54
|
|
|
19
55
|
def _actor(conn: Any, payload: dict[str, Any]) -> negotiation_service.NegotiationActor:
|
|
20
56
|
return negotiation_service.require_negotiation_actor(conn, api_auth.payload_token(payload))
|
|
@@ -60,6 +96,8 @@ def get_snapshot(db_path: str | Path, payload: dict[str, Any], query: dict[str,
|
|
|
60
96
|
def submit_decision(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
61
97
|
with db_session(db_path) as conn:
|
|
62
98
|
actor = _actor(conn, payload)
|
|
99
|
+
# 审查 S-M2:per-owner 固定窗口限流(决策提交=磋商消息追加,滥用最直接)。
|
|
100
|
+
_enforce_decision_rate_limit(actor.owner_id)
|
|
63
101
|
policy_result = negotiation_service.submit_decision(
|
|
64
102
|
conn,
|
|
65
103
|
actor,
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import hmac
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
8
9
|
import sqlite3
|
|
9
10
|
from datetime import datetime
|
|
10
11
|
from typing import Any
|
|
@@ -15,6 +16,11 @@ from shopping_cli.db.session import decode_json, encode_json, now_iso
|
|
|
15
16
|
|
|
16
17
|
DEFAULT_BUYER_BOOTSTRAP_RATE_LIMIT_PER_MINUTE = 60
|
|
17
18
|
BUYER_BOOTSTRAP_RATE_LIMIT_WINDOW_SECONDS = 60
|
|
19
|
+
# 审查 S-M1:保留桶(sentinel buyer_id)承载共享 token 的全局硬上限——
|
|
20
|
+
# buyer_id 完全客户端声明,轮换即可绕过单买家限流;全局桶任何请求都计数。
|
|
21
|
+
BUYER_BOOTSTRAP_GLOBAL_BUCKET = "__global__"
|
|
22
|
+
# 全局硬上限 = 单买家上限 × 该倍数(轮换 buyer_id 无法越过的兜底)。
|
|
23
|
+
DEFAULT_BUYER_BOOTSTRAP_GLOBAL_LIMIT_MULTIPLIER = 100
|
|
18
24
|
MAX_IDEMPOTENCY_KEY_LENGTH = 160
|
|
19
25
|
MAX_SQLITE_INTEGER = 2**63 - 1
|
|
20
26
|
|
|
@@ -87,6 +93,41 @@ def rate_limit_window_start(current: datetime) -> str:
|
|
|
87
93
|
return datetime.fromtimestamp(window_epoch).replace(microsecond=0).isoformat()
|
|
88
94
|
|
|
89
95
|
|
|
96
|
+
def _buyer_bootstrap_global_limit_multiplier() -> int:
|
|
97
|
+
"""全局硬上限倍数(env 可覆盖,测试注入用;非法/非正回退缺省)。"""
|
|
98
|
+
raw = os.environ.get("SHOPPING_BUYER_BOOTSTRAP_GLOBAL_LIMIT_MULTIPLIER", "")
|
|
99
|
+
try:
|
|
100
|
+
value = int(raw)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return DEFAULT_BUYER_BOOTSTRAP_GLOBAL_LIMIT_MULTIPLIER
|
|
103
|
+
return value if value > 0 else DEFAULT_BUYER_BOOTSTRAP_GLOBAL_LIMIT_MULTIPLIER
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _bump_window(
|
|
107
|
+
conn: Any,
|
|
108
|
+
token_hash: str,
|
|
109
|
+
bucket: str,
|
|
110
|
+
window_start: str,
|
|
111
|
+
limit: int,
|
|
112
|
+
current: datetime,
|
|
113
|
+
label: str,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""给 (token_hash, bucket, window) 计数 +1;超过 limit 抛 RateLimitError。"""
|
|
116
|
+
cursor = conn.execute(
|
|
117
|
+
"""
|
|
118
|
+
insert into buyer_bootstrap_rate_limits(token_hash, buyer_id, window_start, request_count, updated_at)
|
|
119
|
+
values (?, ?, ?, 1, ?)
|
|
120
|
+
on conflict(token_hash, buyer_id, window_start) do update set
|
|
121
|
+
request_count = buyer_bootstrap_rate_limits.request_count + 1,
|
|
122
|
+
updated_at = excluded.updated_at
|
|
123
|
+
where buyer_bootstrap_rate_limits.request_count < ?
|
|
124
|
+
""",
|
|
125
|
+
(token_hash, bucket, window_start, current.isoformat(), limit),
|
|
126
|
+
)
|
|
127
|
+
if cursor.rowcount != 1:
|
|
128
|
+
raise RateLimitError(f"{label} rate limit exceeded ({limit}/minute)")
|
|
129
|
+
|
|
130
|
+
|
|
90
131
|
def enforce_buyer_bootstrap_rate_limit(
|
|
91
132
|
conn: Any,
|
|
92
133
|
bootstrap_token_hash: str,
|
|
@@ -124,19 +165,24 @@ def enforce_buyer_bootstrap_rate_limit(
|
|
|
124
165
|
except sqlite3.OperationalError:
|
|
125
166
|
# 清理失败不影响限流本身(fail-safe 方向;下次再试)
|
|
126
167
|
pass
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
168
|
+
window_start = rate_limit_window_start(current)
|
|
169
|
+
# 单买家窗口(买家维度预算隔离)
|
|
170
|
+
_bump_window(
|
|
171
|
+
conn, bootstrap_token_hash, buyer_id, window_start, limit, current, "buyer bootstrap"
|
|
172
|
+
)
|
|
173
|
+
# 全局硬上限(审查 S-M1):同一 token 的全部请求(无论 buyer_id)都计入
|
|
174
|
+
# 保留桶——buyer_id 完全客户端声明、轮换即可绕过单买家限流,全局桶轮换
|
|
175
|
+
# 无法绕过。
|
|
176
|
+
global_limit = limit * _buyer_bootstrap_global_limit_multiplier()
|
|
177
|
+
_bump_window(
|
|
178
|
+
conn,
|
|
179
|
+
bootstrap_token_hash,
|
|
180
|
+
BUYER_BOOTSTRAP_GLOBAL_BUCKET,
|
|
181
|
+
window_start,
|
|
182
|
+
global_limit,
|
|
183
|
+
current,
|
|
184
|
+
"buyer bootstrap global",
|
|
137
185
|
)
|
|
138
|
-
if cursor.rowcount != 1:
|
|
139
|
-
raise RateLimitError(f"buyer bootstrap rate limit exceeded ({limit}/minute)")
|
|
140
186
|
|
|
141
187
|
|
|
142
188
|
def idempotency_row(
|
|
@@ -65,6 +65,7 @@ from shopping_cli.cli_conversation_commands import (
|
|
|
65
65
|
)
|
|
66
66
|
from shopping_cli.cli_erp_commands import cmd_erp_sync
|
|
67
67
|
from shopping_cli.cli_listing_commands import cmd_listing_projections_list
|
|
68
|
+
from shopping_cli.cli_adapter_commands import cmd_adapters_list, cmd_import_csv_excel
|
|
68
69
|
from shopping_cli.config import ConfigError, DEFAULT_DB_PATH, validate_production_config
|
|
69
70
|
from shopping_cli.core.catalog import require_merchant
|
|
70
71
|
from shopping_cli.core.conversations import merchant_conversations
|
|
@@ -854,6 +855,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
854
855
|
erp_sync.add_argument("--format", choices=["text", "json"], default="text")
|
|
855
856
|
erp_sync.set_defaults(func=cmd_erp_sync)
|
|
856
857
|
|
|
858
|
+
# ── adapters(Issue 14 / §6.3:Adapter SDK 注册表)────────────────────
|
|
859
|
+
adapters = subparsers.add_parser("adapters", help="Registered data source adapters (Adapter SDK)")
|
|
860
|
+
adapters_sub = adapters.add_subparsers(dest="adapters_command", required=True)
|
|
861
|
+
adapters_list = adapters_sub.add_parser("list", help="List registered adapters")
|
|
862
|
+
adapters_list.add_argument("--format", choices=["text", "json"], default="text")
|
|
863
|
+
adapters_list.set_defaults(func=cmd_adapters_list)
|
|
864
|
+
|
|
865
|
+
# ── import-csv-excel(Issue 14 / §6.3:首条接入路径)─────────────────
|
|
866
|
+
import_csv = subparsers.add_parser(
|
|
867
|
+
"import-csv-excel", help="Import product facts from CSV / Excel (.xlsx) into the local store"
|
|
868
|
+
)
|
|
869
|
+
import_csv.add_argument("--file", required=True, help="Path to .csv / .tsv / .xlsx file")
|
|
870
|
+
import_csv.add_argument("--merchant", default="", dest="merchant", help="Owning merchant id (required, FK guard)")
|
|
871
|
+
import_csv.add_argument("--allowed-merchant", default="", dest="allowed_merchant", help="Tenant boundary: only rows of this merchant are written")
|
|
872
|
+
import_csv.add_argument("--format", choices=["text", "json"], default="text")
|
|
873
|
+
import_csv.set_defaults(func=cmd_import_csv_excel)
|
|
874
|
+
|
|
857
875
|
# ── listings(shopping-cli v0.3 §14:PublicListingProjection 只读预览)───
|
|
858
876
|
listings = subparsers.add_parser("listings", help="Product-first listing projections (read-only preview)")
|
|
859
877
|
listings_sub = listings.add_subparsers(dest="listings_command", required=True)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Adapter SDK CLI(Issue 14 / §6.3)。
|
|
2
|
+
|
|
3
|
+
``shopping adapters list`` —— 列出已注册数据源适配器
|
|
4
|
+
``shopping import-csv-excel ...`` —— 运行 CSV/Excel 适配器(首条接入路径)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
|
|
11
|
+
from shopping_cli.cli_common import db_path_from_args, emit
|
|
12
|
+
from shopping_cli.data_sources import csv_excel_source # noqa: F401 # 导入即注册适配器
|
|
13
|
+
from shopping_cli.data_sources.adapter import (
|
|
14
|
+
AdapterError,
|
|
15
|
+
SyncContext,
|
|
16
|
+
registered_adapters,
|
|
17
|
+
run,
|
|
18
|
+
)
|
|
19
|
+
from shopping_cli.db.session import db_session
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_adapters_list(args: argparse.Namespace) -> None:
|
|
23
|
+
"""列出已注册适配器(Adapter SDK 注册表)。"""
|
|
24
|
+
emit(
|
|
25
|
+
[{"name": a.name, "description": a.description} for a in registered_adapters().values()],
|
|
26
|
+
args.format,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_import_csv_excel(args: argparse.Namespace) -> None:
|
|
31
|
+
"""运行 CSV/Excel 适配器导入本地 products 表。"""
|
|
32
|
+
db_path = db_path_from_args(args)
|
|
33
|
+
merchant_id = str(args.merchant or "").strip() or str(args.default_merchant or "").strip()
|
|
34
|
+
if not merchant_id:
|
|
35
|
+
raise SystemExit("--merchant is required (FK 归属防护)")
|
|
36
|
+
with db_session(db_path) as conn:
|
|
37
|
+
ctx = SyncContext(
|
|
38
|
+
conn=conn,
|
|
39
|
+
default_merchant_id=merchant_id,
|
|
40
|
+
allowed_merchant_id=str(args.allowed_merchant or "").strip(),
|
|
41
|
+
config={"path": args.file},
|
|
42
|
+
)
|
|
43
|
+
report = run("csv_excel", ctx)
|
|
44
|
+
emit(report.as_dict(), args.format)
|
|
45
|
+
if report.errors or report.conflicts:
|
|
46
|
+
raise SystemExit(
|
|
47
|
+
f"csv_excel import completed with {len(report.errors)} errors and "
|
|
48
|
+
f"{len(report.conflicts)} conflicts (see report above)"
|
|
49
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Adapter SDK(Issue 14 / §6.3):稳定 REST/ERP adapter 接口 + 注册表。
|
|
2
|
+
|
|
3
|
+
shopping-cli 是 Merchant Commerce Data & Operations Hub——外部商品事实(ERP、
|
|
4
|
+
CSV/Excel、未来 REST 连接器)经**数据源适配器**接入本地 ``products`` 表,Kiwi
|
|
5
|
+
merchant 只消费 shopping-cli 的开放层。
|
|
6
|
+
|
|
7
|
+
本模块定义稳定的适配器契约(不按品牌堆连接器代码):
|
|
8
|
+
|
|
9
|
+
* ``DataSourceAdapter`` Protocol——任何适配器实现 ``sync(ctx) -> SyncReport``;
|
|
10
|
+
* ``SyncContext``——连接、授权边界(跨租户)、时钟、适配器配置;
|
|
11
|
+
* ``SyncReport``——可审计结果(fetched/upserted/skipped/conflicts/errors);
|
|
12
|
+
* 注册表 + ``run(name, ctx)``——CLI / API 统一按名字执行适配器。
|
|
13
|
+
|
|
14
|
+
权威语义(data hub v0.2.1 §5):source='local' 本地录入 = LOCAL_AUTHORITATIVE;
|
|
15
|
+
外部源(erp / csv_excel)= UPSTREAM_PROXY(本地是缓存)。同 SKU 冲突绝不静默
|
|
16
|
+
合并冲突权威源。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sqlite3
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any, Callable, Protocol
|
|
25
|
+
|
|
26
|
+
SOURCE_LOCAL = "local"
|
|
27
|
+
AUTHORITY_LOCAL = "LOCAL_AUTHORITATIVE"
|
|
28
|
+
AUTHORITY_UPSTREAM = "UPSTREAM_PROXY"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _now_iso() -> str:
|
|
32
|
+
return datetime.now(timezone.utc).isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SyncContext:
|
|
37
|
+
"""适配器执行上下文。"""
|
|
38
|
+
|
|
39
|
+
conn: sqlite3.Connection
|
|
40
|
+
default_merchant_id: str = ""
|
|
41
|
+
allowed_merchant_id: str = ""
|
|
42
|
+
now: Callable[[], str] = _now_iso
|
|
43
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SyncReport:
|
|
48
|
+
"""一次适配器同步的可审计结果。"""
|
|
49
|
+
|
|
50
|
+
source: str
|
|
51
|
+
authority: str = AUTHORITY_UPSTREAM
|
|
52
|
+
fetched: int = 0
|
|
53
|
+
upserted: int = 0
|
|
54
|
+
skipped: int = 0
|
|
55
|
+
conflicts: list[dict[str, Any]] = field(default_factory=list)
|
|
56
|
+
errors: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def as_dict(self) -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"fetched": self.fetched,
|
|
61
|
+
"upserted": self.upserted,
|
|
62
|
+
"skipped": self.skipped,
|
|
63
|
+
"conflicts": self.conflicts,
|
|
64
|
+
"errors": self.errors,
|
|
65
|
+
"source": self.source,
|
|
66
|
+
"authority": self.authority,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AdapterError(Exception):
|
|
71
|
+
"""适配器执行失败(fail-closed:任何校验/结构/IO 错误抛本异常)。"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DataSourceAdapter(Protocol):
|
|
75
|
+
"""适配器契约:``name`` + ``sync(ctx)``。"""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
description: str
|
|
79
|
+
|
|
80
|
+
def sync(self, ctx: SyncContext) -> SyncReport: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# 注册表
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
_ADAPTERS: dict[str, DataSourceAdapter] = {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def register(adapter: DataSourceAdapter) -> DataSourceAdapter:
|
|
91
|
+
"""注册适配器(同名字符串参数不覆盖——重复注册即显式错误)。"""
|
|
92
|
+
if adapter.name in _ADAPTERS:
|
|
93
|
+
raise AdapterError(f"adapter {adapter.name!r} already registered")
|
|
94
|
+
_ADAPTERS[adapter.name] = adapter
|
|
95
|
+
return adapter
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def registered_adapters() -> dict[str, DataSourceAdapter]:
|
|
99
|
+
"""按名字返回已注册适配器(拷贝,避免外部篡改)。"""
|
|
100
|
+
return dict(_ADAPTERS)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run(name: str, ctx: SyncContext) -> SyncReport:
|
|
104
|
+
"""按名字执行适配器。未知名字 → AdapterError(fail-closed)。"""
|
|
105
|
+
adapter = _ADAPTERS.get(name)
|
|
106
|
+
if adapter is None:
|
|
107
|
+
raise AdapterError(
|
|
108
|
+
f"unknown adapter {name!r} (registered: {', '.join(sorted(_ADAPTERS)) or 'none'})"
|
|
109
|
+
)
|
|
110
|
+
return adapter.sync(ctx)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_merchant_or_raise(conn: sqlite3.Connection, merchant_id: str) -> None:
|
|
114
|
+
"""FK 防护:归属 merchant 必须真实存在(否则裸 IntegrityError 中止同步)。"""
|
|
115
|
+
exists = conn.execute("select 1 from merchants where id = ?", (merchant_id,)).fetchone()
|
|
116
|
+
if exists is None:
|
|
117
|
+
raise AdapterError(f"unknown merchant {merchant_id!r}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def upsert_product_row(
|
|
121
|
+
conn: sqlite3.Connection,
|
|
122
|
+
*,
|
|
123
|
+
sku: str,
|
|
124
|
+
merchant_id: str,
|
|
125
|
+
title: str,
|
|
126
|
+
description: str,
|
|
127
|
+
category: str,
|
|
128
|
+
price: float,
|
|
129
|
+
currency: str,
|
|
130
|
+
stock: int,
|
|
131
|
+
source: str,
|
|
132
|
+
revision: str,
|
|
133
|
+
now_ts: str,
|
|
134
|
+
fresh_until: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""共享 upsert(ERP / CSV-Excel 同款语义:source 标注 + provenance 回填)。"""
|
|
137
|
+
conn.execute(
|
|
138
|
+
"""
|
|
139
|
+
insert into products(
|
|
140
|
+
sku, merchant_id, title, description, category, tags_json,
|
|
141
|
+
price, currency, stock, delivery_attributes_json, active,
|
|
142
|
+
source, source_revision, observed_at, fresh_until,
|
|
143
|
+
created_at, updated_at
|
|
144
|
+
) values (?, ?, ?, ?, ?, '[]', ?, ?, ?, '[]', 1, ?, ?, ?, ?, ?, ?)
|
|
145
|
+
on conflict(sku) do update set
|
|
146
|
+
merchant_id=excluded.merchant_id,
|
|
147
|
+
title=excluded.title,
|
|
148
|
+
description=excluded.description,
|
|
149
|
+
category=excluded.category,
|
|
150
|
+
price=excluded.price,
|
|
151
|
+
currency=excluded.currency,
|
|
152
|
+
stock=excluded.stock,
|
|
153
|
+
source=excluded.source,
|
|
154
|
+
source_revision=excluded.source_revision,
|
|
155
|
+
observed_at=excluded.observed_at,
|
|
156
|
+
fresh_until=excluded.fresh_until,
|
|
157
|
+
updated_at=excluded.updated_at
|
|
158
|
+
""",
|
|
159
|
+
(
|
|
160
|
+
sku,
|
|
161
|
+
merchant_id,
|
|
162
|
+
title,
|
|
163
|
+
description,
|
|
164
|
+
category,
|
|
165
|
+
price,
|
|
166
|
+
currency,
|
|
167
|
+
stock,
|
|
168
|
+
source,
|
|
169
|
+
revision,
|
|
170
|
+
now_ts,
|
|
171
|
+
fresh_until,
|
|
172
|
+
now_ts,
|
|
173
|
+
now_ts,
|
|
174
|
+
),
|
|
175
|
+
)
|