shopping-cli 3.0.2__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.
Files changed (111) hide show
  1. {shopping_cli-3.0.2/shopping_cli.egg-info → shopping_cli-3.2.0}/PKG-INFO +1 -1
  2. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/pyproject.toml +1 -1
  3. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/__init__.py +1 -1
  4. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/merchant_daemon.py +5 -1
  5. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/fastapi_routes.py +13 -4
  6. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/catalog.py +4 -1
  7. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/conversations.py +3 -1
  8. shopping_cli-3.2.0/shopping_cli/api/handlers/listings_projection.py +68 -0
  9. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/negotiation.py +38 -0
  10. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/idempotency.py +58 -12
  11. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/route_table.py +8 -6
  12. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli.py +18 -0
  13. shopping_cli-3.2.0/shopping_cli/cli_adapter_commands.py +49 -0
  14. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/catalog.py +25 -0
  15. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/catalog_views.py +5 -0
  16. shopping_cli-3.2.0/shopping_cli/data_sources/adapter.py +175 -0
  17. shopping_cli-3.2.0/shopping_cli/data_sources/csv_excel_source.py +292 -0
  18. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/db/migrations.py +123 -1
  19. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/db/models.py +1 -1
  20. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/listings/projection.py +16 -5
  21. {shopping_cli-3.0.2 → shopping_cli-3.2.0/shopping_cli.egg-info}/PKG-INFO +1 -1
  22. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli.egg-info/SOURCES.txt +3 -0
  23. shopping_cli-3.0.2/shopping_cli/api/handlers/listings_projection.py +0 -42
  24. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/LICENSE +0 -0
  25. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/MANIFEST.in +0 -0
  26. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/README.md +0 -0
  27. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/setup.cfg +0 -0
  28. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/__init__.py +0 -0
  29. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/agent_cli.py +0 -0
  30. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/buyer_cli.py +0 -0
  31. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/merchant_agent.py +0 -0
  32. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/merchant_state_helpers.py +0 -0
  33. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/agents/tools.py +0 -0
  34. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/__init__.py +0 -0
  35. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/app.py +0 -0
  36. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/auth.py +0 -0
  37. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/error_handlers.py +0 -0
  38. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/error_response.py +0 -0
  39. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/fallback_asgi.py +0 -0
  40. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/__init__.py +0 -0
  41. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/agents.py +0 -0
  42. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/audit.py +0 -0
  43. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/buyer.py +0 -0
  44. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/common.py +0 -0
  45. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/erp.py +0 -0
  46. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/handlers/human_review.py +0 -0
  47. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/limits.py +0 -0
  48. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/request_dispatch.py +0 -0
  49. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/request_limits.py +0 -0
  50. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/route_matching.py +0 -0
  51. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/route_registry.py +0 -0
  52. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/routes_agents.py +0 -0
  53. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/routes_conversations.py +0 -0
  54. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/routes_marketplace.py +0 -0
  55. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/routes_merchants.py +0 -0
  56. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/api/server.py +0 -0
  57. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_agent_commands.py +0 -0
  58. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_buyer_commands.py +0 -0
  59. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_catalog_commands.py +0 -0
  60. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_common.py +0 -0
  61. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_conversation_commands.py +0 -0
  62. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_erp_commands.py +0 -0
  63. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/cli_listing_commands.py +0 -0
  64. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/commerce/__init__.py +0 -0
  65. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/commerce/adapters.py +0 -0
  66. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/commerce/data_source.py +0 -0
  67. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/config.py +0 -0
  68. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/contracts/kiwi-contracts.lock.json +0 -0
  69. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
  70. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
  71. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
  72. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
  73. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/__init__.py +0 -0
  74. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/catalog_scoring.py +0 -0
  75. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/catalog_text.py +0 -0
  76. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/channels.py +0 -0
  77. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/conversations.py +0 -0
  78. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/delivery.py +0 -0
  79. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/errors.py +0 -0
  80. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/harness.py +0 -0
  81. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/limits.py +0 -0
  82. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/negotiation.py +0 -0
  83. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/negotiation_contracts.py +0 -0
  84. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/policies.py +0 -0
  85. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/risk.py +0 -0
  86. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/core/tokens.py +0 -0
  87. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/data_sources/__init__.py +0 -0
  88. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/data_sources/erp_source.py +0 -0
  89. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/db/__init__.py +0 -0
  90. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/db/provenance.py +0 -0
  91. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/db/session.py +0 -0
  92. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/http_client.py +0 -0
  93. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/listings/__init__.py +0 -0
  94. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/__init__.py +0 -0
  95. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/agents.py +0 -0
  96. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/audit.py +0 -0
  97. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/buyer_bootstrap.py +0 -0
  98. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/conversations.py +0 -0
  99. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/human_review.py +0 -0
  100. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation.py +0 -0
  101. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_gates.py +0 -0
  102. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_message.py +0 -0
  103. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
  104. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_policy_result.py +0 -0
  105. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_snapshot.py +0 -0
  106. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
  107. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli/services/tokens.py +1 -1
  108. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli.egg-info/dependency_links.txt +0 -0
  109. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli.egg-info/entry_points.txt +0 -0
  110. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli.egg-info/requires.txt +0 -0
  111. {shopping_cli-3.0.2 → shopping_cli-3.2.0}/shopping_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.0.2
3
+ Version: 3.2.0
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shopping-cli"
7
- version = "3.0.2"
7
+ version = "3.2.0"
8
8
  description = "Standalone local-commerce AI consultation marketplace runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Standalone shopping-cli consultation runtime."""
2
2
 
3
- VERSION = "3.0.2"
3
+ VERSION = "3.2.0"
@@ -180,11 +180,15 @@ class _PidFileLock:
180
180
 
181
181
  def __enter__(self) -> "_PidFileLock":
182
182
  self.lock_path.parent.mkdir(parents=True, exist_ok=True)
183
+ # 审查 P3-03:与日志/stop 文件对齐 0600(此前 0644);os.open 的
184
+ # mode 只在新建时生效,已存在的遗留文件也显式 chmod(同
185
+ # _open_log_append 的 P2-07 模式)。
183
186
  self._fd = os.open(
184
187
  str(self.lock_path),
185
188
  os.O_RDWR | os.O_CREAT,
186
- 0o644,
189
+ 0o600,
187
190
  )
191
+ os.chmod(self.lock_path, 0o600)
188
192
  if fcntl is not None:
189
193
  try:
190
194
  fcntl.flock(self._fd, fcntl.LOCK_EX)
@@ -226,12 +226,21 @@ def register_fastapi_routes(app: Any, db_path: str | Path) -> None:
226
226
  return _update_product(db_path, sku, api_auth.payload_with_auth(payload, authorization))
227
227
 
228
228
  @app.get("/v1/merchant/listings/projections")
229
- def merchant_listing_projections(merchant_id: str = "") -> dict[str, Any]:
230
- return _list_listing_projections(db_path, {"merchant_id": merchant_id})
229
+ def merchant_listing_projections(
230
+ merchant_id: str = "",
231
+ authorization: str = AUTHORIZATION_HEADER,
232
+ ) -> dict[str, Any]:
233
+ # 审查 P2-B:handoff_destination 仅向所属商户本人开放(owner 判定在 handler)
234
+ return _list_listing_projections(
235
+ db_path, {"merchant_id": merchant_id}, api_auth.payload_with_auth({}, authorization)
236
+ )
231
237
 
232
238
  @app.get("/v1/merchant/listings/{sku}/projection")
233
- def merchant_listing_projection(sku: str) -> dict[str, Any]:
234
- return _get_listing_projection(db_path, sku)
239
+ def merchant_listing_projection(
240
+ sku: str,
241
+ authorization: str = AUTHORIZATION_HEADER,
242
+ ) -> dict[str, Any]:
243
+ return _get_listing_projection(db_path, sku, api_auth.payload_with_auth({}, authorization))
235
244
 
236
245
  @app.post("/v1/merchant/erp/sync")
237
246
  def merchant_erp_sync(
@@ -29,7 +29,6 @@ from .common import (
29
29
  )
30
30
  from shopping_cli.core import catalog_views
31
31
 
32
-
33
32
  def health(db_path: str | Path) -> dict[str, Any]:
34
33
  profile = deployment_profile_from()
35
34
  checks: dict[str, Any] = production_config_checks()
@@ -320,6 +319,7 @@ def create_product(
320
319
  tags=payload.get("tags") or [],
321
320
  description=str(payload.get("description") or ""),
322
321
  delivery_attributes=payload.get("delivery_attributes") or [],
322
+ handoff_destination=str(payload.get("handoff_destination") or ""),
323
323
  )
324
324
  return {"ok": True, "product": product}
325
325
 
@@ -346,6 +346,9 @@ def update_product(
346
346
  tags=payload.get("tags") if "tags" in payload else None,
347
347
  description=payload.get("description"),
348
348
  delivery_attributes=payload.get("delivery_attributes") if "delivery_attributes" in payload else None,
349
+ handoff_destination=payload.get("handoff_destination")
350
+ if "handoff_destination" in payload
351
+ else None,
349
352
  )
350
353
  return {"ok": True, "product": product}
351
354
 
@@ -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
- sender = str(payload.get("sender") or "operator")
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":
@@ -0,0 +1,68 @@
1
+ """Listing 投影只读 API(shopping-cli v0.3 §14;Merchant Kiwi-compatible)。
2
+
3
+ - GET /v1/merchant/listings/projections —— 可发布投影列表(public-only);
4
+ - GET /v1/merchant/listings/{sku}/projection —— 单条投影。
5
+
6
+ 公开读免鉴权(沿用 products 现状);发布动作只经 CLI 不进 API(评审:
7
+ 写面保持 push-first 单向,API 不暴露发布副作用)。
8
+
9
+ handoff_destination 是商家私有成交入口(审查 P2-B):匿名/非本人读一律
10
+ 剥离;仅持商品所属商户本人有效 token 的调用保留(kiwi merchant agent
11
+ 发布/成交取数路径)。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from shopping_cli.api.handlers.catalog import _owner_merchant_from_payload
20
+ from shopping_cli.core.errors import NotFoundError, ValidationError
21
+ from shopping_cli.db.session import db_session
22
+ from shopping_cli.listings.projection import (
23
+ ProjectionError,
24
+ list_publishable_listings,
25
+ project_product_listing,
26
+ )
27
+
28
+
29
+ def list_listing_projections(
30
+ db_path: str | Path, query: dict[str, Any], payload: dict[str, Any] | None = None
31
+ ) -> dict[str, Any]:
32
+ """GET /v1/merchant/listings/projections —— 可发布投影(public-only)。
33
+
34
+ 必须带 ``merchant_id`` 过滤(审查 P3-01:无过滤的匿名枚举会暴露全部
35
+ 商家的可发布投影);匿名或 token 不属于该商户本人时剥离
36
+ handoff_destination(审查 P2-B)。
37
+ """
38
+ merchant_id = str(query.get("merchant_id") or "").strip()
39
+ if not merchant_id:
40
+ raise ValidationError("merchant_id is required")
41
+ with db_session(db_path) as conn:
42
+ owner = _owner_merchant_from_payload(conn, payload)
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
+ )
49
+ return {"ok": True, "results": projections, "count": len(projections)}
50
+
51
+
52
+ def get_listing_projection(db_path: str | Path, sku: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
53
+ """GET /v1/merchant/listings/{sku}/projection —— 单条投影。"""
54
+ sku = str(sku).strip()
55
+ if not sku:
56
+ raise ValidationError("sku is required")
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
+ )
63
+ try:
64
+ # 审查 S-M3:私有剥离收进投影层(include_private)。
65
+ projection = project_product_listing(conn, sku, include_private=is_owner)
66
+ except ProjectionError as exc:
67
+ raise NotFoundError(str(exc)) from exc
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
- cursor = conn.execute(
128
- """
129
- insert into buyer_bootstrap_rate_limits(token_hash, buyer_id, window_start, request_count, updated_at)
130
- values (?, ?, ?, 1, ?)
131
- on conflict(token_hash, buyer_id, window_start) do update set
132
- request_count = buyer_bootstrap_rate_limits.request_count + 1,
133
- updated_at = excluded.updated_at
134
- where buyer_bootstrap_rate_limits.request_count < ?
135
- """,
136
- (bootstrap_token_hash, buyer_id, rate_limit_window_start(current), current.isoformat(), limit),
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(
@@ -88,16 +88,18 @@ def _get_product(db_path: str | Path, sku: str, payload: dict[str, Any] | None =
88
88
  return catalog_handlers.get_product(db_path, sku, payload)
89
89
 
90
90
 
91
- def _list_listing_projections(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
92
- return listings_projection_handlers.list_listing_projections(db_path, query)
91
+ def _list_listing_projections(
92
+ db_path: str | Path, query: dict[str, Any], payload: dict[str, Any] | None = None
93
+ ) -> dict[str, Any]:
94
+ return listings_projection_handlers.list_listing_projections(db_path, query, payload)
93
95
 
94
96
 
95
97
  def _sync_erp(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
96
98
  return erp_handlers.sync_erp(db_path, payload)
97
99
 
98
100
 
99
- def _get_listing_projection(db_path: str | Path, sku: str) -> dict[str, Any]:
100
- return listings_projection_handlers.get_listing_projection(db_path, sku)
101
+ def _get_listing_projection(db_path: str | Path, sku: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
102
+ return listings_projection_handlers.get_listing_projection(db_path, sku, payload)
101
103
 
102
104
 
103
105
  def _search_products(db_path: str | Path, query: dict[str, Any], payload: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -402,12 +404,12 @@ _ROUTE_TABLE: tuple[RouteEntry, ...] = (
402
404
  RouteEntry(
403
405
  {"GET"},
404
406
  "/v1/merchant/listings/projections",
405
- lambda db_path, payload, query, **kw: _list_listing_projections(db_path, query or {}),
407
+ lambda db_path, payload, query, **kw: _list_listing_projections(db_path, query or {}, payload),
406
408
  ),
407
409
  RouteEntry(
408
410
  {"GET"},
409
411
  "/v1/merchant/listings/{sku}/projection",
410
- lambda db_path, payload, query, sku: _get_listing_projection(db_path, sku),
412
+ lambda db_path, payload, query, sku: _get_listing_projection(db_path, sku, payload),
411
413
  ),
412
414
  RouteEntry(
413
415
  {"POST"},
@@ -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
+ )
@@ -6,6 +6,7 @@ import math
6
6
  import re
7
7
  import sqlite3
8
8
  from typing import Any, Mapping
9
+ from urllib.parse import urlparse
9
10
 
10
11
  from shopping_cli.core.errors import ConflictError, NotFoundError, ValidationError
11
12
  from shopping_cli.core.harness import append_audit_event
@@ -114,6 +115,27 @@ def _whole_int(value: Any, message: str) -> int:
114
115
 
115
116
  # safe_non_negative_int / safe_non_negative_float are imported from core.limits
116
117
 
118
+ # 审查 P2-A:可无 ``://`` 出现的危险伪 scheme,一律拒绝(opaque 放行见下)。
119
+ _BLOCKED_HANDOFF_SCHEMES = frozenset({"javascript", "data", "file", "vbscript", "blob"})
120
+
121
+
122
+ def _validate_handoff_destination(value: str) -> None:
123
+ """handoff_destination 形态校验(审查 P2-A)。
124
+
125
+ 空值允许;值呈现 URL 形态(含 ``://``)必须是合法 http/https origin,
126
+ 拒绝其他一切 scheme;``javascript:``/``data:`` 等危险伪 scheme 即使
127
+ 无 ``://`` 也拒绝;纯 opaque 引用串(chat-id、文档引用等)原样允许。
128
+ """
129
+ if not value:
130
+ return
131
+ parsed = urlparse(value)
132
+ scheme = parsed.scheme.lower()
133
+ if "://" in value:
134
+ if scheme not in ("http", "https") or not parsed.hostname:
135
+ raise ValidationError("handoff destination URL must be a valid http(s) origin")
136
+ elif scheme in _BLOCKED_HANDOFF_SCHEMES:
137
+ raise ValidationError(f"handoff destination scheme is not allowed: {scheme}")
138
+
117
139
 
118
140
  def _audit_catalog(conn: sqlite3.Connection, merchant_id: str, event: str, details: dict) -> None:
119
141
  """catalog 写操作审计(conversation_id=''——非会话域事件)。
@@ -329,6 +351,7 @@ def create_product(
329
351
  category = bounded_text(category, "product category", MAX_SHORT_TEXT_CHARS)
330
352
  currency = bounded_text(currency, "currency", 16)
331
353
  handoff_destination = bounded_text(handoff_destination, "handoff destination", MAX_PERSISTED_TEXT_CHARS)
354
+ _validate_handoff_destination(handoff_destination)
332
355
  if not merchant_id:
333
356
  raise ValidationError("merchant id is required")
334
357
  if not sku:
@@ -405,6 +428,7 @@ def update_product(
405
428
  handoff_destination = bounded_text(
406
429
  handoff_destination, "handoff destination", MAX_PERSISTED_TEXT_CHARS
407
430
  )
431
+ _validate_handoff_destination(handoff_destination)
408
432
  if price is not None:
409
433
  price = _price_with_precision(price, "--price must be finite")
410
434
  if stock is not None:
@@ -606,6 +630,7 @@ def _product_summary_from_search_row(row: sqlite3.Row) -> dict[str, Any]:
606
630
  "currency": row["currency"],
607
631
  "stock": _safe_non_negative_int(row["stock"]),
608
632
  "delivery_attributes": decode_json(row["delivery_attributes_json"], []),
633
+ "handoff_destination": row["handoff_destination"] or "",
609
634
  "merchant": merchant,
610
635
  "delivery": merchant["delivery"],
611
636
  "warnings": product_warnings(row, merchant),
@@ -21,10 +21,15 @@ def public_product_summary(product: dict[str, Any]) -> dict[str, Any]:
21
21
  出网精确 stock,可逐 SKU 枚举全平台库存)。需要精确库存的商家走
22
22
  merchant_product_summary(handler 层 owner 鉴权门)。
23
23
 
24
+ handoff_destination 是商家私有成交入口(谈判达成后由商家 Agent 点对点
25
+ 交给买家),公开/匿名投影一律剥离(审查 P2-B),同款纪律参照
26
+ public_merchant_summary 剥离 contact;认证商家读走 merchant_product_summary。
27
+
24
28
  对已投影数据幂等:含 ``stock`` 时替换为 availability_hint;不含则保留
25
29
  已有 hint(审查 P1-02:幂等回放响应二次投影不丢失 in_stock)。
26
30
  """
27
31
  summary = dict(product)
32
+ summary.pop("handoff_destination", None)
28
33
  if "stock" in summary:
29
34
  stock = summary.pop("stock", None)
30
35
  summary["availability_hint"] = (