shopping-cli 3.2.0__tar.gz → 3.2.3__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 (110) hide show
  1. {shopping_cli-3.2.0/shopping_cli.egg-info → shopping_cli-3.2.3}/PKG-INFO +15 -1
  2. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/README.md +14 -0
  3. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/pyproject.toml +1 -1
  4. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/__init__.py +1 -1
  5. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/negotiation.py +13 -8
  6. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli.py +2 -1
  7. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_adapter_commands.py +7 -2
  8. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_common.py +10 -1
  9. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/kiwi-contracts.lock.json +1 -1
  10. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/channels.py +15 -1
  11. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/csv_excel_source.py +121 -14
  12. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/migrations.py +25 -1
  13. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/models.py +11 -0
  14. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/listings/projection.py +7 -20
  15. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation.py +57 -2
  16. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/tokens.py +1 -1
  17. {shopping_cli-3.2.0 → shopping_cli-3.2.3/shopping_cli.egg-info}/PKG-INFO +15 -1
  18. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/LICENSE +0 -0
  19. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/MANIFEST.in +0 -0
  20. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/setup.cfg +0 -0
  21. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/__init__.py +0 -0
  22. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/agent_cli.py +0 -0
  23. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/buyer_cli.py +0 -0
  24. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/merchant_agent.py +0 -0
  25. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/merchant_daemon.py +0 -0
  26. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/merchant_state_helpers.py +0 -0
  27. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/agents/tools.py +0 -0
  28. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/__init__.py +0 -0
  29. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/app.py +0 -0
  30. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/auth.py +0 -0
  31. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/error_handlers.py +0 -0
  32. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/error_response.py +0 -0
  33. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/fallback_asgi.py +0 -0
  34. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/fastapi_routes.py +0 -0
  35. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/__init__.py +0 -0
  36. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/agents.py +0 -0
  37. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/audit.py +0 -0
  38. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/buyer.py +0 -0
  39. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/catalog.py +0 -0
  40. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/common.py +0 -0
  41. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/conversations.py +0 -0
  42. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/erp.py +0 -0
  43. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/human_review.py +0 -0
  44. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/handlers/listings_projection.py +0 -0
  45. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/idempotency.py +0 -0
  46. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/limits.py +0 -0
  47. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/request_dispatch.py +0 -0
  48. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/request_limits.py +0 -0
  49. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/route_matching.py +0 -0
  50. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/route_registry.py +0 -0
  51. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/route_table.py +0 -0
  52. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_agents.py +0 -0
  53. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_conversations.py +0 -0
  54. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_marketplace.py +0 -0
  55. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/routes_merchants.py +0 -0
  56. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/api/server.py +0 -0
  57. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_agent_commands.py +0 -0
  58. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_buyer_commands.py +0 -0
  59. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_catalog_commands.py +0 -0
  60. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_conversation_commands.py +0 -0
  61. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_erp_commands.py +0 -0
  62. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/cli_listing_commands.py +0 -0
  63. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/commerce/__init__.py +0 -0
  64. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/commerce/adapters.py +0 -0
  65. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/commerce/data_source.py +0 -0
  66. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/config.py +0 -0
  67. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
  68. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
  69. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
  70. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
  71. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/__init__.py +0 -0
  72. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog.py +0 -0
  73. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog_scoring.py +0 -0
  74. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog_text.py +0 -0
  75. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/catalog_views.py +0 -0
  76. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/conversations.py +0 -0
  77. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/delivery.py +0 -0
  78. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/errors.py +0 -0
  79. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/harness.py +0 -0
  80. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/limits.py +0 -0
  81. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/negotiation.py +0 -0
  82. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/negotiation_contracts.py +0 -0
  83. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/policies.py +0 -0
  84. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/risk.py +0 -0
  85. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/core/tokens.py +0 -0
  86. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/__init__.py +0 -0
  87. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/adapter.py +0 -0
  88. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/data_sources/erp_source.py +0 -0
  89. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/__init__.py +0 -0
  90. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/provenance.py +0 -0
  91. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/db/session.py +0 -0
  92. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/http_client.py +0 -0
  93. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/listings/__init__.py +0 -0
  94. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/__init__.py +0 -0
  95. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/agents.py +0 -0
  96. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/audit.py +0 -0
  97. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/buyer_bootstrap.py +0 -0
  98. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/conversations.py +0 -0
  99. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/human_review.py +0 -0
  100. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_gates.py +0 -0
  101. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_message.py +0 -0
  102. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
  103. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_policy_result.py +0 -0
  104. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_snapshot.py +0 -0
  105. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
  106. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/SOURCES.txt +0 -0
  107. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/dependency_links.txt +0 -0
  108. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/entry_points.txt +0 -0
  109. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/shopping_cli.egg-info/requires.txt +0 -0
  110. {shopping_cli-3.2.0 → shopping_cli-3.2.3}/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.2.0
3
+ Version: 3.2.3
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -310,6 +310,20 @@ printf 'longjing gift delivery today\n/summary\n/quit\n' | \
310
310
 
311
311
  Default database path: `~/.local/share/shopping-cli/shopping-cli.sqlite`.
312
312
 
313
+ For a single-merchant deployment, import commands can read the database and
314
+ merchant identity from the environment instead of repeating tenant flags:
315
+
316
+ ```bash
317
+ export SHOPPING_DB_PATH="$HOME/.local/share/shopping-cli/shopping-cli.sqlite"
318
+ export SHOPPING_MERCHANT_ID="your-merchant-id"
319
+
320
+ shopping-cli import-csv-excel --file products.csv
321
+ ```
322
+
323
+ `KIWI_MERCHANT_ID` is accepted as a compatibility alias for
324
+ `SHOPPING_MERCHANT_ID`. Explicit `--db` and `--merchant` arguments take
325
+ precedence over environment variables.
326
+
313
327
  ## Channel Ingress
314
328
 
315
329
  External channel adapters can ingest buyer messages through a stable local entry point before real WhatsApp, Telegram, Slack, or OpenClaw/Hermes gateway bridges are attached:
@@ -87,6 +87,20 @@ printf 'longjing gift delivery today\n/summary\n/quit\n' | \
87
87
 
88
88
  Default database path: `~/.local/share/shopping-cli/shopping-cli.sqlite`.
89
89
 
90
+ For a single-merchant deployment, import commands can read the database and
91
+ merchant identity from the environment instead of repeating tenant flags:
92
+
93
+ ```bash
94
+ export SHOPPING_DB_PATH="$HOME/.local/share/shopping-cli/shopping-cli.sqlite"
95
+ export SHOPPING_MERCHANT_ID="your-merchant-id"
96
+
97
+ shopping-cli import-csv-excel --file products.csv
98
+ ```
99
+
100
+ `KIWI_MERCHANT_ID` is accepted as a compatibility alias for
101
+ `SHOPPING_MERCHANT_ID`. Explicit `--db` and `--merchant` arguments take
102
+ precedence over environment variables.
103
+
90
104
  ## Channel Ingress
91
105
 
92
106
  External channel adapters can ingest buyer messages through a stable local entry point before real WhatsApp, Telegram, Slack, or OpenClaw/Hermes gateway bridges are attached:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shopping-cli"
7
- version = "3.2.0"
7
+ version = "3.2.3"
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.2.0"
3
+ VERSION = "3.2.3"
@@ -20,9 +20,13 @@ from shopping_cli.db.session import db_session
20
20
  from shopping_cli.services import negotiation as negotiation_service
21
21
 
22
22
  # 审查 S-M2:merchant/agent/negotiation/human-review 端点此前均无限流——已泄露/
23
- # 低熵 token 的滥用无节流。对磋商决策提交(写路径,追加磋商消息)加 per-owner
24
- # 固定窗口限流:进程内(单进程部署前提,与 ERP sync 限流同款)。env 可覆盖
25
- # (调用时读取,便于测试注入)。
23
+ # 低熵 token 的滥用无节流。对磋商决策提交(写路径,追加磋商消息)加固定窗口
24
+ # 限流:进程内(单进程部署前提,与 ERP sync 限流同款)。env 可覆盖(调用时
25
+ # 读取,便于测试注入)。
26
+ # 审查 S-M2 补强:限流键从 per-owner(buyer 的 owner_id 客户端声明,轮换
27
+ # buyer_id 即绕过)改为 per-token(token_hash,服务端派生)——同凭证无论声明
28
+ # 什么 buyer_id 都共享一个桶;轮换新 token 受 S-M1 bootstrap 全局桶(铸造速率)
29
+ # 封顶。merchant/agent 单 token 部署下语义不变。
26
30
  _NEGOTIATION_DECISION_WINDOW_SECONDS = 60
27
31
  _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE = 600
28
32
  _decision_buckets: dict[str, tuple[float, int]] = {}
@@ -38,18 +42,18 @@ def _decision_rate_limit_per_minute() -> int:
38
42
  return value if value > 0 else _DECISION_RATE_LIMIT_DEFAULT_PER_MINUTE
39
43
 
40
44
 
41
- def _enforce_decision_rate_limit(owner_id: str) -> None:
45
+ def _enforce_decision_rate_limit(credential_key: str) -> None:
42
46
  limit = _decision_rate_limit_per_minute()
43
47
  now = time.monotonic()
44
48
  with _decision_buckets_lock:
45
- window_start, count = _decision_buckets.get(owner_id, (0.0, 0))
49
+ window_start, count = _decision_buckets.get(credential_key, (0.0, 0))
46
50
  if now - window_start >= _NEGOTIATION_DECISION_WINDOW_SECONDS:
47
51
  window_start, count = now, 0
48
52
  if count >= limit:
49
53
  raise RateLimitError(
50
54
  f"negotiation decision rate limit exceeded ({limit}/minute)"
51
55
  )
52
- _decision_buckets[owner_id] = (window_start, count + 1)
56
+ _decision_buckets[credential_key] = (window_start, count + 1)
53
57
 
54
58
 
55
59
  def _actor(conn: Any, payload: dict[str, Any]) -> negotiation_service.NegotiationActor:
@@ -96,8 +100,9 @@ def get_snapshot(db_path: str | Path, payload: dict[str, Any], query: dict[str,
96
100
  def submit_decision(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
97
101
  with db_session(db_path) as conn:
98
102
  actor = _actor(conn, payload)
99
- # 审查 S-M2:per-owner 固定窗口限流(决策提交=磋商消息追加,滥用最直接)。
100
- _enforce_decision_rate_limit(actor.owner_id)
103
+ # 审查 S-M2:固定窗口限流(决策提交=磋商消息追加,滥用最直接)。键用
104
+ # 服务端派生的 token_hash(per-token),空值兜底 owner_id。
105
+ _enforce_decision_rate_limit(actor.token_hash or actor.owner_id)
101
106
  policy_result = negotiation_service.submit_decision(
102
107
  conn,
103
108
  actor,
@@ -867,7 +867,8 @@ def build_parser() -> argparse.ArgumentParser:
867
867
  "import-csv-excel", help="Import product facts from CSV / Excel (.xlsx) into the local store"
868
868
  )
869
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)")
870
+ import_csv.add_argument("--merchant", default="", dest="merchant", help="Owning merchant id (or SHOPPING_MERCHANT_ID / KIWI_MERCHANT_ID)")
871
+ import_csv.add_argument("--default-merchant", default="", dest="default_merchant", help=argparse.SUPPRESS)
871
872
  import_csv.add_argument("--allowed-merchant", default="", dest="allowed_merchant", help="Tenant boundary: only rows of this merchant are written")
872
873
  import_csv.add_argument("--format", choices=["text", "json"], default="text")
873
874
  import_csv.set_defaults(func=cmd_import_csv_excel)
@@ -7,11 +7,11 @@
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import os
10
11
 
11
12
  from shopping_cli.cli_common import db_path_from_args, emit
12
13
  from shopping_cli.data_sources import csv_excel_source # noqa: F401 # 导入即注册适配器
13
14
  from shopping_cli.data_sources.adapter import (
14
- AdapterError,
15
15
  SyncContext,
16
16
  registered_adapters,
17
17
  run,
@@ -30,7 +30,12 @@ def cmd_adapters_list(args: argparse.Namespace) -> None:
30
30
  def cmd_import_csv_excel(args: argparse.Namespace) -> None:
31
31
  """运行 CSV/Excel 适配器导入本地 products 表。"""
32
32
  db_path = db_path_from_args(args)
33
- merchant_id = str(args.merchant or "").strip() or str(args.default_merchant or "").strip()
33
+ merchant_id = (
34
+ str(args.merchant or "").strip()
35
+ or str(getattr(args, "default_merchant", "")).strip()
36
+ or str(os.environ.get("SHOPPING_MERCHANT_ID") or "").strip()
37
+ or str(os.environ.get("KIWI_MERCHANT_ID") or "").strip()
38
+ )
34
39
  if not merchant_id:
35
40
  raise SystemExit("--merchant is required (FK 归属防护)")
36
41
  with db_session(db_path) as conn:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import math
8
+ import os
8
9
  from pathlib import Path
9
10
  from typing import Any
10
11
 
@@ -132,4 +133,12 @@ def tcp_port(value: str) -> int:
132
133
 
133
134
 
134
135
  def db_path_from_args(args: argparse.Namespace) -> Path:
135
- return Path(getattr(args, "agent_db", None) or args.db or args.data or DEFAULT_DB_PATH).expanduser()
136
+ configured = (
137
+ getattr(args, "agent_db", None)
138
+ or getattr(args, "db", None)
139
+ or getattr(args, "data", None)
140
+ or os.environ.get("SHOPPING_DB_PATH")
141
+ or os.environ.get("SHOPPING_DB")
142
+ or DEFAULT_DB_PATH
143
+ )
144
+ return Path(configured).expanduser()
@@ -2,6 +2,6 @@
2
2
  "lock_version": 1,
3
3
  "source_repository": "harrylabsj/kiwi",
4
4
  "source_commit": "009bc25cdae2668c8fd19ccedcc4e0c64a34f6be",
5
- "bundle_sha256": "2dbbc52a1113dc5c05dc544802571ee188b44e1567d6bfb0cd8ccd87ea7f4998",
5
+ "bundle_sha256": "2350ecaacebb791cd980cc2886511359e356378a6f18cdecc1ac6331c31b0e70",
6
6
  "contracts_manifest": "contracts/manifest.json"
7
7
  }
@@ -168,7 +168,7 @@ def _begin_channel_ingress(
168
168
  return _existing_ingress_response(conn, row, buyer_id, channel)
169
169
  # stale_at 非空 → 落 INSERT ON CONFLICT 覆盖旧行(重处理幂等)
170
170
  try:
171
- conn.execute(
171
+ cursor = conn.execute(
172
172
  """
173
173
  insert into channel_message_ingresses(
174
174
  channel, external_user_id, external_message_id, status,
@@ -181,6 +181,7 @@ def _begin_channel_ingress(
181
181
  message_id = excluded.message_id,
182
182
  stale_at = excluded.stale_at,
183
183
  updated_at = excluded.updated_at
184
+ where channel_message_ingresses.stale_at != ''
184
185
  """,
185
186
  (channel, external_user_id, external_message_id, PROCESSING_STATUS, now, now),
186
187
  )
@@ -195,6 +196,19 @@ def _begin_channel_ingress(
195
196
  if row is not None:
196
197
  return _existing_ingress_response(conn, row, buyer_id, channel)
197
198
  raise
199
+ if cursor.rowcount == 0:
200
+ # 并发重复:另一请求已先落 fresh processing 行(stale_at 为空),DO UPDATE
201
+ # 被 WHERE 跳过、rowcount=0 → 我们未抢到写权,回查走冲突/幂等分支。
202
+ row = conn.execute(
203
+ """
204
+ select * from channel_message_ingresses
205
+ where channel = ? and external_user_id = ? and external_message_id = ?
206
+ """,
207
+ (channel, external_user_id, external_message_id),
208
+ ).fetchone()
209
+ if row is not None:
210
+ return _existing_ingress_response(conn, row, buyer_id, channel)
211
+ raise
198
212
  return None
199
213
 
200
214
 
@@ -23,7 +23,7 @@ import math
23
23
  import sqlite3
24
24
  import zipfile
25
25
  import xml.etree.ElementTree as ET
26
- from dataclasses import dataclass, field
26
+ from dataclasses import dataclass
27
27
  from datetime import datetime, timedelta, timezone
28
28
  from decimal import Decimal, InvalidOperation
29
29
  from pathlib import Path
@@ -45,6 +45,17 @@ SOURCE_CSV_EXCEL = "csv_excel"
45
45
 
46
46
  _XLSX_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
47
47
 
48
+ # Import limits are deliberately conservative: CSV/XLSX are operator-supplied
49
+ # files, so parsing must not turn an accidental or malicious workbook into an
50
+ # unbounded memory/CPU allocation (zip bombs, giant shared strings, or millions
51
+ # of rows). These are wire/data-source limits, not database limits.
52
+ MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024
53
+ MAX_IMPORT_ROWS = 100_000
54
+ MAX_IMPORT_COLUMNS = 256
55
+ MAX_IMPORT_CELL_CHARS = 16_384
56
+ MAX_XLSX_ENTRIES = 256
57
+ MAX_XLSX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
58
+
48
59
 
49
60
  @dataclass(frozen=True)
50
61
  class CsvExcelSyncConfig:
@@ -75,6 +86,12 @@ def read_rows(path: str) -> list[dict[str, str]]:
75
86
  p = Path(path)
76
87
  if not p.is_file():
77
88
  raise AdapterError(f"file not found: {path}")
89
+ try:
90
+ size = p.stat().st_size
91
+ except OSError as exc:
92
+ raise AdapterError(f"cannot stat import file: {path}") from exc
93
+ if size > MAX_IMPORT_FILE_BYTES:
94
+ raise AdapterError(f"import file exceeds {MAX_IMPORT_FILE_BYTES} bytes")
78
95
  if p.suffix.lower() == ".xlsx":
79
96
  return _read_xlsx_rows(p)
80
97
  if p.suffix.lower() in {".csv", ".tsv", ""}:
@@ -85,33 +102,97 @@ def read_rows(path: str) -> list[dict[str, str]]:
85
102
  def _read_csv_rows(path: Path) -> list[dict[str, str]]:
86
103
  try:
87
104
  with path.open("r", encoding="utf-8-sig", newline="") as f:
105
+ # csv's parser otherwise accepts arbitrarily large individual fields.
106
+ csv.field_size_limit(MAX_IMPORT_CELL_CHARS)
88
107
  reader = csv.DictReader(f)
89
- rows = [dict(r) for r in reader]
108
+ header = reader.fieldnames
109
+ if header is None:
110
+ return []
111
+ if len(header) > MAX_IMPORT_COLUMNS:
112
+ raise AdapterError(f"CSV has more than {MAX_IMPORT_COLUMNS} columns")
113
+ if any(len(str(name or "")) > MAX_IMPORT_CELL_CHARS for name in header):
114
+ raise AdapterError(f"CSV header exceeds {MAX_IMPORT_CELL_CHARS} characters")
115
+ rows: list[dict[str, str]] = []
116
+ for index, row in enumerate(reader, start=1):
117
+ if index > MAX_IMPORT_ROWS:
118
+ raise AdapterError(f"CSV exceeds {MAX_IMPORT_ROWS} data rows")
119
+ if len(row) > MAX_IMPORT_COLUMNS:
120
+ raise AdapterError(f"CSV row {index} has more than {MAX_IMPORT_COLUMNS} columns")
121
+ normalized: dict[str, str] = {}
122
+ for key, value in row.items():
123
+ if key is None:
124
+ raise AdapterError(f"CSV row {index} has too many columns")
125
+ text = str(value or "")
126
+ if len(text) > MAX_IMPORT_CELL_CHARS:
127
+ raise AdapterError(f"CSV row {index} contains a cell over {MAX_IMPORT_CELL_CHARS} characters")
128
+ normalized[str(key)] = text
129
+ rows.append(normalized)
90
130
  except (UnicodeDecodeError, csv.Error) as exc:
91
131
  raise AdapterError(f"invalid CSV: {exc}") from exc
92
132
  return rows
93
133
 
94
134
 
135
+ def _xlsx_cell_column(ref: str | None, row_index: int, cell_pos: int) -> int:
136
+ """解析单元格 ``r`` 属性(如 ``C2``)→ 0 基列号。
137
+
138
+ Excel 常省略空单元格(留白/样式单元格/合并后空列),按文档顺序对齐会让
139
+ 后续有值单元格左移、静默写坏 price/stock(审查 S-M5)。因此以 ``r`` 属性
140
+ 的列字母定位(A=0 … ZZ=701),缺列由调用方补 ``""``。无 ``r`` 属性
141
+ (少见)回退文档顺序位置。
142
+ """
143
+ if ref:
144
+ letters = "".join(ch for ch in ref if ch.isalpha()).upper()
145
+ if letters:
146
+ col = 0
147
+ for ch in letters:
148
+ col = col * 26 + (ord(ch) - ord("A") + 1)
149
+ if col >= 1:
150
+ return col - 1
151
+ # 无 r / 纯数字引用:回退文档顺序位置(保持既有行为)。
152
+ return cell_pos - 1
153
+
154
+
95
155
  def _xlsx_read_sheet(zf: zipfile.ZipFile, sheet_path: str, shared: list[str]) -> list[list[str]]:
96
156
  root = ET.fromstring(zf.read(sheet_path))
97
157
  ns = _XLSX_NS
98
158
  rows: list[list[str]] = []
99
- for row in root.iter(f"{{{ns}}}row"):
159
+ for row_index, row in enumerate(root.iter(f"{{{ns}}}row"), start=1):
160
+ if row_index > MAX_IMPORT_ROWS:
161
+ raise AdapterError(f"xlsx exceeds {MAX_IMPORT_ROWS} data rows")
100
162
  cells: list[str] = []
101
- for cell in row:
163
+ for cell_pos, cell in enumerate(row, start=1):
164
+ if cell_pos > MAX_IMPORT_COLUMNS:
165
+ raise AdapterError(f"xlsx row {row_index} has more than {MAX_IMPORT_COLUMNS} columns")
166
+ col = _xlsx_cell_column(cell.get("r"), row_index, cell_pos)
167
+ if col >= MAX_IMPORT_COLUMNS:
168
+ raise AdapterError(f"xlsx row {row_index} column {col + 1} exceeds {MAX_IMPORT_COLUMNS} columns")
169
+ # 按 r 属性定位列,跳过的空列补 ""(S-M5:空单元格不得左移错位)。
170
+ if col >= len(cells):
171
+ cells.extend("" for _ in range(col + 1 - len(cells)))
102
172
  t = cell.get("t", "n")
103
173
  v = cell.find(f"{{{ns}}}v")
174
+ value = ""
104
175
  if t == "s" and v is not None:
105
- idx = int(v.text or "0")
106
- cells.append(shared[idx] if idx < len(shared) else "")
176
+ try:
177
+ idx = int(v.text or "0")
178
+ except ValueError as exc:
179
+ raise AdapterError(
180
+ f"xlsx row {row_index} has invalid shared string index {v.text!r}"
181
+ ) from exc
182
+ if idx < 0 or idx >= len(shared):
183
+ raise AdapterError(
184
+ f"xlsx row {row_index} shared string index {idx} out of range"
185
+ )
186
+ value = shared[idx]
107
187
  elif t == "inlineStr":
108
188
  is_el = cell.find(f"{{{ns}}}is")
109
- text = ""
110
189
  if is_el is not None:
111
- text = "".join(tt.text or "" for tt in is_el.iter(f"{{{ns}}}t"))
112
- cells.append(text)
190
+ value = "".join(tt.text or "" for tt in is_el.iter(f"{{{ns}}}t"))
113
191
  elif v is not None:
114
- cells.append(str(v.text or ""))
192
+ value = str(v.text or "")
193
+ if len(value) > MAX_IMPORT_CELL_CHARS:
194
+ raise AdapterError(f"xlsx row {row_index} contains a cell over {MAX_IMPORT_CELL_CHARS} characters")
195
+ cells[col] = value
115
196
  rows.append(cells)
116
197
  return rows
117
198
 
@@ -119,11 +200,22 @@ def _xlsx_read_sheet(zf: zipfile.ZipFile, sheet_path: str, shared: list[str]) ->
119
200
  def _read_xlsx_rows(path: Path) -> list[dict[str, str]]:
120
201
  try:
121
202
  with zipfile.ZipFile(path) as zf:
203
+ infos = zf.infolist()
204
+ if len(infos) > MAX_XLSX_ENTRIES:
205
+ raise AdapterError(f"xlsx has more than {MAX_XLSX_ENTRIES} zip entries")
206
+ uncompressed = sum(info.file_size for info in infos)
207
+ if uncompressed > MAX_XLSX_UNCOMPRESSED_BYTES:
208
+ raise AdapterError(f"xlsx uncompressed content exceeds {MAX_XLSX_UNCOMPRESSED_BYTES} bytes")
122
209
  shared: list[str] = []
123
210
  if "xl/sharedStrings.xml" in zf.namelist():
124
211
  root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
125
212
  for si in root.iter(f"{{{_XLSX_NS}}}si"):
126
- shared.append("".join(t.text or "" for t in si.iter(f"{{{_XLSX_NS}}}t")))
213
+ if len(shared) >= MAX_IMPORT_ROWS * MAX_IMPORT_COLUMNS:
214
+ raise AdapterError("xlsx shared strings exceed the supported cell count")
215
+ value = "".join(t.text or "" for t in si.iter(f"{{{_XLSX_NS}}}t"))
216
+ if len(value) > MAX_IMPORT_CELL_CHARS:
217
+ raise AdapterError(f"xlsx shared string exceeds {MAX_IMPORT_CELL_CHARS} characters")
218
+ shared.append(value)
127
219
  sheet_path = None
128
220
  for name in ("xl/worksheets/sheet1.xml", "xl/worksheets/sheet.xml"):
129
221
  if name in zf.namelist():
@@ -150,6 +242,21 @@ def _read_xlsx_rows(path: Path) -> list[dict[str, str]]:
150
242
  # ---------------------------------------------------------------------------
151
243
 
152
244
 
245
+ _FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
246
+
247
+
248
+ def _neutralize_formula(value: str) -> str:
249
+ """防 CSV/Excel 公式注入:以 = + - @ \\t \\r 开头的文本前缀单引号。
250
+
251
+ 审查 L1:导入的文本字段若被下游重新导出为 CSV/Excel 并打开,公式前缀会被
252
+ 执行(=cmd|... / @SUM(...) 等)。前缀 ' 让 Excel 把单元格视为文本,不破坏
253
+ 合法数据(如 "-5% off")。
254
+ """
255
+ if value.startswith(_FORMULA_INJECTION_PREFIXES):
256
+ return "'" + value
257
+ return value
258
+
259
+
153
260
  def _parse_product(raw: dict[str, str], index: int) -> dict[str, Any]:
154
261
  """CSV/Excel 行 → 本地 products 行(sku/title/price/stock 校验,fail-closed)。"""
155
262
  sku = (raw.get("sku") or "").strip()
@@ -180,12 +287,12 @@ def _parse_product(raw: dict[str, str], index: int) -> dict[str, Any]:
180
287
  raise AdapterError(f"row {index}: invalid stock {stock_raw!r}")
181
288
  return {
182
289
  "sku": sku,
183
- "title": title,
290
+ "title": _neutralize_formula(title),
184
291
  "price": price,
185
292
  "stock": stock,
186
293
  "currency": (raw.get("currency") or "").strip() or "CNY",
187
- "category": (raw.get("category") or "").strip(),
188
- "description": (raw.get("description") or "").strip(),
294
+ "category": _neutralize_formula((raw.get("category") or "").strip()),
295
+ "description": _neutralize_formula((raw.get("description") or "").strip()),
189
296
  "merchant_id": (raw.get("merchant_id") or "").strip(),
190
297
  }
191
298
 
@@ -10,7 +10,7 @@ from typing import Callable
10
10
 
11
11
  from shopping_cli.core.tokens import is_sha256_digest, token_digest, token_prefix, token_suffix
12
12
 
13
- CURRENT_SCHEMA_VERSION = 24
13
+ CURRENT_SCHEMA_VERSION = 25
14
14
 
15
15
 
16
16
  @dataclass(frozen=True)
@@ -506,6 +506,29 @@ def migration_023_remove_scheme_a_stub_merchants(conn: sqlite3.Connection) -> No
506
506
  conn.execute("delete from merchants where id = ?", (merchant_id,))
507
507
 
508
508
 
509
+ def migration_025_negotiation_decision_idempotency(conn: sqlite3.Connection) -> None:
510
+ """协商决策幂等账本(审查 H2 2026-08-16)。
511
+
512
+ ``submit_decision`` 此前靠"先扫描 messages 再 append"判断幂等,并发相同
513
+ idempotency_key 的两个请求都能通过预读并各自写入决策消息(违反冻结协议
514
+ "同 key 重放绝不重复")。新增 (conversation_id, agent_id, idempotency_key)
515
+ 唯一表,claim 时由唯一约束兜底并发——与 buyer_request_idempotency 同模式。
516
+ """
517
+ conn.execute(
518
+ """
519
+ create table if not exists negotiation_decision_idempotency (
520
+ conversation_id text not null,
521
+ agent_id text not null,
522
+ idempotency_key text not null,
523
+ message_id integer not null default 0,
524
+ decision_json text not null default '{}',
525
+ created_at text not null,
526
+ primary key (conversation_id, agent_id, idempotency_key)
527
+ )
528
+ """
529
+ )
530
+
531
+
509
532
  MIGRATIONS: tuple[Migration, ...] = (
510
533
  Migration(1, "conversation_next_actor", migration_001_conversation_next_actor),
511
534
  Migration(2, "agent_runtime_columns", migration_002_agent_runtime_columns),
@@ -524,6 +547,7 @@ MIGRATIONS: tuple[Migration, ...] = (
524
547
  Migration(22, "product_handoff_destination", migration_022_product_handoff_destination),
525
548
  Migration(23, "remove_scheme_a_stub_merchants", migration_023_remove_scheme_a_stub_merchants),
526
549
  Migration(24, "product_source_csv_excel", migration_024_product_source_csv_excel),
550
+ Migration(25, "negotiation_decision_idempotency", migration_025_negotiation_decision_idempotency),
527
551
  )
528
552
 
529
553
 
@@ -233,6 +233,17 @@ SCHEMA = [
233
233
  primary key (admin_token_hash, idempotency_key)
234
234
  )
235
235
  """,
236
+ """
237
+ create table if not exists negotiation_decision_idempotency (
238
+ conversation_id text not null,
239
+ agent_id text not null,
240
+ idempotency_key text not null,
241
+ message_id integer not null default 0,
242
+ decision_json text not null default '{}',
243
+ created_at text not null,
244
+ primary key (conversation_id, agent_id, idempotency_key)
245
+ )
246
+ """,
236
247
 
237
248
  ]
238
249
 
@@ -1,12 +1,10 @@
1
1
  """PublicListingProjection(shopping-cli v0.3 §14/§16;DoD #1-#3)。
2
2
 
3
3
  public-only 白名单投影:把 products 行压缩为可公开的 discovery projection。
4
- **绝不输出** cost / floor price / 私密库存 / credentials / 客户特定条款
5
- (v0.3 §16 不得输出清单;DoD #2 有回归测试锁定)。
6
-
7
- availability_hint / price_range_hint 必须携带 provenance 并注明只是
8
- discovery hint(v0.3 §14)——权威值以本地 products 行为准(v3.0 起
9
- 发布面已随 kiwi-catalog 子系统迁移至独立服务)。
4
+ **绝不输出** cost / floor price / 私密库存 / credentials / 客户特定条款 / 价格 /
5
+ 促销 / 底价(v0.3 §16 不得输出清单;DoD #2 有回归测试锁定)。商品 listing 只携带
6
+ 名称 + 分类/标签/区域等发现元数据,价格与供应事实以本地 products 行为准
7
+ (v3.0 起发布面已随 kiwi-catalog 子系统迁移至独立服务)。
10
8
  """
11
9
 
12
10
  from __future__ import annotations
@@ -27,11 +25,6 @@ def _now_iso() -> str:
27
25
  return datetime.now(timezone.utc).isoformat()
28
26
 
29
27
 
30
- def _hint_price_range(price: float, currency: str) -> str:
31
- """price_range_hint:粗粒度公开价格提示(不是报价)。"""
32
- return f"{currency} {price:.0f}"
33
-
34
-
35
28
  def _product_row(conn: sqlite3.Connection, sku: str) -> dict[str, Any]:
36
29
  row = conn.execute(
37
30
  "select * from products where sku = ?", (str(sku).strip(),)
@@ -74,12 +67,6 @@ def project_product_listing(
74
67
  "category": str(row.get("category") or ""),
75
68
  "regions": [],
76
69
  "tags": json.loads(row.get("tags_json") or "[]"),
77
- "commercial_hints": {
78
- "price_range_hint": _hint_price_range(float(row["price"]), str(row.get("currency") or "CNY")),
79
- "availability_hint": "in_stock" if int(row.get("stock", 0)) > 0 else "out_of_stock",
80
- "moq": 1,
81
- "supports_bulk_quote": True,
82
- },
83
70
  }
84
71
  # 每商品成交入口(KTH destination_ref):商家自行维护,publish 时同步进
85
72
  # catalog listing 的 handoff_destination_ref。审查 S-M3:私有字段,缺省
@@ -90,14 +77,14 @@ def project_product_listing(
90
77
  if description:
91
78
  projection["summary"] = description
92
79
 
93
- # provenance 标注:availability/price 是 discovery hint,不是权威事实(v0.3 §14)。
94
- # 只存在于 projection(Merchant Kiwi 可见)(wire commercial_hints 七键白名单,v0.4 §4.1)。
80
+ # provenance 标注:只存在于 projection(Merchant Kiwi 可见),发布前由
81
+ # strip_provenance 剥离;商品不再携带价格/库存 hint(名称-only,v0.3 §16)。
95
82
  projection["_provenance"] = {
96
83
  "authority": authority,
97
84
  "source_revision": projection["source_revision"],
98
85
  "observed_at": str(row.get("observed_at") or now),
99
86
  "fresh_until": str(row.get("fresh_until") or ""),
100
- "note": "discovery hint only; authoritative value in the local product record",
87
+ "note": "name-only discovery projection; authoritative facts in the local product record",
101
88
  }
102
89
  return projection
103
90
 
@@ -92,10 +92,14 @@ class NegotiationActor:
92
92
  owner_id: str # buyer_id or merchant_id bound to the token
93
93
  agent_id: str # claim identity used in agent_message_processes
94
94
  conversation_id: str = "" # buyer tokens are bound to one conversation
95
+ token_hash: str = "" # 审查 S-M2 补强:token 摘要(服务端派生)——决策限流按
96
+ # 凭证(token)键控而非客户端声明的 owner_id,buyer 轮换 buyer_id 无法用
97
+ # 同一 token 重置限流桶。
95
98
 
96
99
 
97
100
  def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> NegotiationActor:
98
101
  row = token_service.require_api_token(conn, token, "negotiation token required")
102
+ token_hash = str(row["token_hash"] or "")
99
103
  role = str(row["role"] or "")
100
104
  if role == "buyer":
101
105
  buyer_id = str(row["buyer_id"] or "")
@@ -107,6 +111,7 @@ def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> Negotiati
107
111
  owner_id=buyer_id,
108
112
  agent_id=protocol.buyer_agent_identity(buyer_id),
109
113
  conversation_id=conversation_id,
114
+ token_hash=token_hash,
110
115
  )
111
116
  if role == "merchant":
112
117
  merchant_id = str(row["merchant_id"] or "")
@@ -116,13 +121,14 @@ def require_negotiation_actor(conn: sqlite3.Connection, token: Any) -> Negotiati
116
121
  role="merchant",
117
122
  owner_id=merchant_id,
118
123
  agent_id=token_service.default_merchant_agent_id(merchant_id),
124
+ token_hash=token_hash,
119
125
  )
120
126
  if role == "agent":
121
127
  merchant_id = str(row["merchant_id"] or "")
122
128
  agent_id = str(row["agent_id"] or "")
123
129
  if not merchant_id or agent_id != token_service.default_merchant_agent_id(merchant_id):
124
130
  raise AuthError("agent token cannot act as a negotiation merchant agent")
125
- return NegotiationActor(role="merchant", owner_id=merchant_id, agent_id=agent_id)
131
+ return NegotiationActor(role="merchant", owner_id=merchant_id, agent_id=agent_id, token_hash=token_hash)
126
132
  raise AuthError("token role cannot negotiate")
127
133
 
128
134
 
@@ -504,7 +510,10 @@ def _merchant_gate(
504
510
  proposal = decision.get("proposal")
505
511
  if decision["action"] in {"propose", "counter"} and proposal is None:
506
512
  return _reject("missing_proposal", "propose/counter 必须携带结构化 proposal。")
507
- product = product_summary(conn, str(conversation["sku"]))
513
+ # 审查 M7:此前直接 product_summary(sku) 取地板价,未校验商品归属本会话商家——
514
+ # SKU 复用/重建时会对错误商家的 automation_boundaries 做 floor/折扣判断。
515
+ # 换用 _load_conversation_product(带归属校验,fail-closed)。
516
+ product = _load_conversation_product(conn, conversation)
508
517
  floor_str = merchant_agent._authorized_bargain_amount(product)
509
518
  # 泄漏守卫对每个 merchant 决策运行——ask/decline 等无 proposal 的决策
510
519
  # 也可能携带泄露底价的 public_message(此前该分支直接放行)。
@@ -697,6 +706,38 @@ def submit_decision(
697
706
  sender = _decision_sender(actor.role)
698
707
  status = _decision_status(actor.role, decision["action"])
699
708
  structured_payload = _decision_structured_payload(actor.agent_id, actor.role, decision, idempotency_key)
709
+ # 幂等 claim:DB 唯一约束兜底并发(审查 H2)。两个并发相同 key 的请求只
710
+ # 有一个能插入成功;另一个在此拿到 IntegrityError,此时对方已提交,回查
711
+ # messages 走重放/冲突。与顶部 _find_decision_replay(服务"丢响应后回合已
712
+ # 推进再重试"的串行幂等)互补,共同覆盖并发 + 串行两条重试路径。
713
+ try:
714
+ conn.execute(
715
+ """
716
+ insert into negotiation_decision_idempotency(
717
+ conversation_id, agent_id, idempotency_key, created_at
718
+ )
719
+ values (?, ?, ?, ?)
720
+ """,
721
+ (conversation_id, actor.agent_id, idempotency_key, now_iso()),
722
+ )
723
+ except sqlite3.IntegrityError:
724
+ replay = _find_decision_replay(conn, actor, conversation_id, idempotency_key)
725
+ if replay is not None:
726
+ if protocol.canonical_json(replay["decision"]) != protocol.canonical_json(decision):
727
+ raise IdempotencyConflict(
728
+ f"idempotency key {idempotency_key} was already used with a different decision"
729
+ )
730
+ refreshed = require_conversation(conn, conversation_id)
731
+ return _policy_result(
732
+ conversation_id,
733
+ "accepted",
734
+ protocol.snapshot_next_actor(str(refreshed["next_actor"] or "")),
735
+ list(decision["reason_codes"]),
736
+ "决策已接受并写入会话(幂等重放,未重复写入)。",
737
+ 0,
738
+ message_id=int(replay["message_id"]),
739
+ )
740
+ raise IdempotencyConflict(f"idempotency key {idempotency_key} was already used")
700
741
  message = append_message(
701
742
  conn,
702
743
  conversation_id,
@@ -706,6 +747,20 @@ def submit_decision(
706
747
  structured_payload=structured_payload,
707
748
  status=status,
708
749
  )
750
+ conn.execute(
751
+ """
752
+ update negotiation_decision_idempotency
753
+ set message_id = ?, decision_json = ?
754
+ where conversation_id = ? and agent_id = ? and idempotency_key = ?
755
+ """,
756
+ (
757
+ int(message["id"]),
758
+ protocol.canonical_json(decision),
759
+ conversation_id,
760
+ actor.agent_id,
761
+ idempotency_key,
762
+ ),
763
+ )
709
764
  if status == "closed":
710
765
  append_conversation_closed_audit(conn, conversation_id, sender, "")
711
766
  append_audit_event(
@@ -112,7 +112,7 @@ def require_api_token(conn: Any, token: Any, missing_error: str = "authorization
112
112
  digest = token_digest(raw_token)
113
113
  row = conn.execute(
114
114
  """
115
- select role, merchant_id, buyer_id, agent_id, conversation_id, revoked_at, expires_at
115
+ select role, merchant_id, buyer_id, agent_id, conversation_id, revoked_at, expires_at, token_hash
116
116
  from api_tokens
117
117
  where token_hash = ?
118
118
  """,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.2.0
3
+ Version: 3.2.3
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -310,6 +310,20 @@ printf 'longjing gift delivery today\n/summary\n/quit\n' | \
310
310
 
311
311
  Default database path: `~/.local/share/shopping-cli/shopping-cli.sqlite`.
312
312
 
313
+ For a single-merchant deployment, import commands can read the database and
314
+ merchant identity from the environment instead of repeating tenant flags:
315
+
316
+ ```bash
317
+ export SHOPPING_DB_PATH="$HOME/.local/share/shopping-cli/shopping-cli.sqlite"
318
+ export SHOPPING_MERCHANT_ID="your-merchant-id"
319
+
320
+ shopping-cli import-csv-excel --file products.csv
321
+ ```
322
+
323
+ `KIWI_MERCHANT_ID` is accepted as a compatibility alias for
324
+ `SHOPPING_MERCHANT_ID`. Explicit `--db` and `--merchant` arguments take
325
+ precedence over environment variables.
326
+
313
327
  ## Channel Ingress
314
328
 
315
329
  External channel adapters can ingest buyer messages through a stable local entry point before real WhatsApp, Telegram, Slack, or OpenClaw/Hermes gateway bridges are attached:
File without changes
File without changes
File without changes