dudutt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dudutt/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """dudutt —— 快团团网店管理 MCP 服务。
4
+
5
+ 官方 POP API 为主,浏览器自动化兜底,双后端可插拔。
6
+ """
7
+
8
+ __version__ = "0.1.0"
dudutt/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """命令行入口:python -m dudutt"""
4
+
5
+ from dudutt.server import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
dudutt/actions.py ADDED
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 业务动作层:把"后端能力"包装成"带护栏的业务动作"。
5
+
6
+ 三层职责分离:
7
+ - backends/ 只管"怎么调用"(API / 浏览器 / mock)
8
+ - actions.py 管"能不能做 + 该不该做"(能力协商 / dry-run / 幂等 / 审计)
9
+ - server.py 只管"暴露成 MCP 工具"(参数校验 / 描述)
10
+
11
+ 为什么写操作默认 dry_run:
12
+ 快团团的写操作直接影响真实经营(下架=停售、改价=改收入)。
13
+ Agent 的意图经常是对的,但参数可能错。默认先演一遍,
14
+ 让人/Agent 看到"将要发生什么"再确认,是唯一稳妥的默认值。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import time
21
+ from typing import Any, Optional
22
+
23
+ from dudutt import audit
24
+ from dudutt.backends.base import (
25
+ SUPPORT_NATIVE,
26
+ SUPPORT_WORKAROUND,
27
+ Action,
28
+ BackendError,
29
+ BaseBackend,
30
+ blocked_payload,
31
+ )
32
+ from dudutt.pop_client import PopError
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # 幂等:同一 key 在窗口期内只真正执行一次
36
+ # ---------------------------------------------------------------------------
37
+
38
+ ENV_IDEMPOTENCY_TTL = "DUDUTT_IDEMPOTENCY_TTL"
39
+ DEFAULT_IDEMPOTENCY_TTL = 600 # 秒
40
+
41
+ # 进程内幂等缓存:{key: (timestamp, result)}
42
+ _IDEMPOTENCY_CACHE: dict[str, tuple[float, dict]] = {}
43
+
44
+
45
+ def _idem_ttl() -> int:
46
+ raw = (os.environ.get(ENV_IDEMPOTENCY_TTL) or "").strip()
47
+ if raw:
48
+ try:
49
+ return int(raw)
50
+ except ValueError:
51
+ pass
52
+ return DEFAULT_IDEMPOTENCY_TTL
53
+
54
+
55
+ def _idem_get(key: str) -> Optional[dict]:
56
+ item = _IDEMPOTENCY_CACHE.get(key)
57
+ if not item:
58
+ return None
59
+ ts, result = item
60
+ if time.time() - ts > _idem_ttl():
61
+ _IDEMPOTENCY_CACHE.pop(key, None)
62
+ return None
63
+ return result
64
+
65
+
66
+ def _idem_put(key: str, result: dict) -> None:
67
+ _IDEMPOTENCY_CACHE[key] = (time.time(), result)
68
+
69
+
70
+ def reset_idempotency_cache() -> None:
71
+ """清空幂等缓存(测试用)。"""
72
+ _IDEMPOTENCY_CACHE.clear()
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # 统一返回包装
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def _wrap_error(e: Exception, action: str, backend: str) -> dict:
81
+ """把异常统一成结构化错误。"""
82
+ if isinstance(e, PopError):
83
+ d = e.to_dict()
84
+ d["action"] = action
85
+ d["backend"] = backend
86
+ return d
87
+ return {
88
+ "ok": False,
89
+ "action": action,
90
+ "backend": backend,
91
+ "error": str(e),
92
+ }
93
+
94
+
95
+ def _guard_write(
96
+ backend: BaseBackend,
97
+ action: str,
98
+ params: dict,
99
+ dry_run: bool,
100
+ idempotency_key: Optional[str],
101
+ preview: dict,
102
+ ) -> Optional[dict]:
103
+ """写操作的通用护栏。返回非 None 表示应提前返回。
104
+
105
+ 依次检查:能力 → 幂等 → dry_run。
106
+ """
107
+ support = backend.support_for(action)
108
+
109
+ # 1) 能力检查:后端不支持就直接说清楚,绝不假装成功
110
+ # 替代方案统一从 Support.alternative 取,保证能力声明处的信息不会丢
111
+ if not support.usable:
112
+ result = blocked_payload(
113
+ action,
114
+ backend.name,
115
+ support,
116
+ hint=(
117
+ "可尝试:DUDUTT_BACKEND=browser 使用浏览器兜底;"
118
+ "或用 capabilities 工具查看各后端能力对比。"
119
+ ),
120
+ )
121
+ audit.record(action, backend.name, params, result, dry_run=dry_run,
122
+ idempotency_key=idempotency_key)
123
+ return result
124
+
125
+ # 2) 幂等:同一 key 重复调用直接返回上次结果(防重复执行)
126
+ if idempotency_key:
127
+ cached = _idem_get(idempotency_key)
128
+ if cached is not None:
129
+ return {
130
+ **cached,
131
+ "idempotent_replay": True,
132
+ "note": "检测到相同 idempotency_key 的近期调用,未重复执行。",
133
+ }
134
+
135
+ # 3) dry_run:只演不执行(默认行为)
136
+ if dry_run:
137
+ result = {
138
+ "ok": True,
139
+ "dry_run": True,
140
+ "action": action,
141
+ "backend": backend.name,
142
+ "support_level": support.level,
143
+ "via": support.via,
144
+ "caveat": support.caveat,
145
+ "will_do": preview,
146
+ "next_step": (
147
+ "确认无误后,用相同参数并设置 dry_run=false 真正执行。"
148
+ "写操作会记入审计日志。"
149
+ ),
150
+ }
151
+ audit.record(action, backend.name, params, result, dry_run=True,
152
+ idempotency_key=idempotency_key)
153
+ return result
154
+
155
+ return None
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # 业务动作
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def list_groups(
164
+ backend: BaseBackend,
165
+ start_update_time: Optional[int] = None,
166
+ end_update_time: Optional[int] = None,
167
+ page: int = 1,
168
+ size: int = 20,
169
+ ) -> dict:
170
+ """查询团列表(只读)。"""
171
+ try:
172
+ return backend.list_groups(
173
+ start_update_time=start_update_time,
174
+ end_update_time=end_update_time,
175
+ page=page,
176
+ size=size,
177
+ )
178
+ except (PopError, BackendError, NotImplementedError) as e:
179
+ return _wrap_error(e, Action.LIST_GROUPS, backend.name)
180
+
181
+
182
+ def list_goods(
183
+ backend: BaseBackend,
184
+ activity_no: Optional[str] = None,
185
+ page: int = 1,
186
+ size: int = 20,
187
+ ) -> dict:
188
+ """查询商品列表(只读)。"""
189
+ try:
190
+ return backend.list_goods(activity_no=activity_no, page=page, size=size)
191
+ except (PopError, BackendError, NotImplementedError) as e:
192
+ return _wrap_error(e, Action.LIST_GOODS, backend.name)
193
+
194
+
195
+ def get_goods(backend: BaseBackend, goods_id: int) -> dict:
196
+ """查询单个商品(只读)。"""
197
+ try:
198
+ return backend.get_goods(int(goods_id))
199
+ except (PopError, BackendError, NotImplementedError) as e:
200
+ return _wrap_error(e, Action.GET_GOODS, backend.name)
201
+
202
+
203
+ def publish_goods(
204
+ backend: BaseBackend,
205
+ spec: dict,
206
+ dry_run: bool = True,
207
+ idempotency_key: Optional[str] = None,
208
+ ) -> dict:
209
+ """发布商品。
210
+
211
+ spec 必填:title, start_time, end_time, goods_list
212
+ goods_list 每项必填:category_name, goods_desc, goods_name, sku_list
213
+ sku_list 每项必填:price_in_fen, quantity_type, spec_id_list, total_quantity
214
+ """
215
+ action = Action.PUBLISH_GOODS
216
+ support = backend.support_for(action)
217
+
218
+ # 先查能力、再校验参数:若动作根本不可能,讨论参数细节会误导调用方
219
+ # (他能改好参数,但依然做不到)
220
+ if not support.usable:
221
+ result = blocked_payload(
222
+ action, backend.name, support,
223
+ hint="可用 capabilities 工具查看各后端能力对比。",
224
+ )
225
+ audit.record(action, backend.name, {"spec": spec}, result, dry_run=dry_run,
226
+ idempotency_key=idempotency_key)
227
+ return result
228
+
229
+ problems = validate_publish_spec(spec)
230
+ if problems:
231
+ result = {
232
+ "ok": False,
233
+ "action": action,
234
+ "backend": backend.name,
235
+ "error": "发布参数校验未通过。",
236
+ "problems": problems,
237
+ }
238
+ audit.record(action, backend.name, {"spec": spec}, result, dry_run=dry_run,
239
+ idempotency_key=idempotency_key)
240
+ return result
241
+
242
+ preview = {
243
+ "title": spec.get("title"),
244
+ "goods_count": len(spec.get("goods_list") or []),
245
+ "goods_names": [
246
+ g.get("goods_name") for g in (spec.get("goods_list") or [])
247
+ ],
248
+ "start_time": spec.get("start_time"),
249
+ "end_time": spec.get("end_time"),
250
+ "is_preview_only": spec.get("is_save_preview") == 1,
251
+ "note": (
252
+ "将调用官方创建团购接口。⚠️ 创建后价格锁死,无法再改。"
253
+ if support.level == SUPPORT_NATIVE and backend.name == "api"
254
+ else "将创建团购。"
255
+ ),
256
+ }
257
+
258
+ early = _guard_write(backend, action, {"spec": spec}, dry_run,
259
+ idempotency_key, preview)
260
+ if early is not None:
261
+ return early
262
+
263
+ try:
264
+ result = backend.publish_goods(spec)
265
+ except (PopError, BackendError, NotImplementedError) as e:
266
+ result = _wrap_error(e, action, backend.name)
267
+
268
+ if idempotency_key and result.get("ok"):
269
+ _idem_put(idempotency_key, result)
270
+ audit.record(action, backend.name, {"spec": spec}, result, dry_run=False,
271
+ idempotency_key=idempotency_key)
272
+ return result
273
+
274
+
275
+ def set_stock(
276
+ backend: BaseBackend,
277
+ goods_id: int,
278
+ sku_id: int,
279
+ quantity: int,
280
+ modify_quantity_type: int = 2,
281
+ dry_run: bool = True,
282
+ idempotency_key: Optional[str] = None,
283
+ ) -> dict:
284
+ """改库存。
285
+
286
+ modify_quantity_type:1=增量修改(quantity 为增减值),2=全量设置(默认)
287
+ """
288
+ action = Action.SET_STOCK
289
+ if quantity < 0 and modify_quantity_type == 2:
290
+ return {
291
+ "ok": False,
292
+ "action": action,
293
+ "error": "全量设置模式下库存不能为负。若想减少库存请用增量模式(-1)。",
294
+ }
295
+ params = {
296
+ "goods_id": goods_id,
297
+ "sku_id": sku_id,
298
+ "quantity": quantity,
299
+ "modify_quantity_type": modify_quantity_type,
300
+ }
301
+ preview = {
302
+ "goods_id": goods_id,
303
+ "sku_id": sku_id,
304
+ "mode": "增量" if modify_quantity_type == 1 else "全量设置",
305
+ "quantity": quantity,
306
+ }
307
+ early = _guard_write(backend, action, params, dry_run, idempotency_key, preview)
308
+ if early is not None:
309
+ return early
310
+
311
+ try:
312
+ result = backend.set_stock(
313
+ goods_id=int(goods_id),
314
+ sku_id=int(sku_id),
315
+ quantity_delta=int(quantity),
316
+ modify_quantity_type=int(modify_quantity_type),
317
+ )
318
+ except (PopError, BackendError, NotImplementedError) as e:
319
+ result = _wrap_error(e, action, backend.name)
320
+
321
+ if idempotency_key and result.get("ok"):
322
+ _idem_put(idempotency_key, result)
323
+ audit.record(action, backend.name, params, result, dry_run=False,
324
+ idempotency_key=idempotency_key)
325
+ return result
326
+
327
+
328
+ def delist_goods(
329
+ backend: BaseBackend,
330
+ goods_id: int,
331
+ sku_ids: Optional[list[int]] = None,
332
+ dry_run: bool = True,
333
+ idempotency_key: Optional[str] = None,
334
+ ) -> dict:
335
+ """下架商品。
336
+
337
+ 官方 API 只能变通(库存归零),浏览器后端可真正下架。
338
+ 返回体里的 method 字段说明实际用了哪种。
339
+ """
340
+ action = Action.DELIST_GOODS
341
+ support = backend.support_for(action)
342
+ params = {"goods_id": goods_id, "sku_ids": sku_ids}
343
+ preview = {
344
+ "goods_id": goods_id,
345
+ "method": (
346
+ "库存全量置 0(变通)"
347
+ if support.level == SUPPORT_WORKAROUND
348
+ else "真实下架"
349
+ ),
350
+ "caveat": support.caveat,
351
+ "skus_to_zero": sku_ids or "自动查询该商品全部 SKU",
352
+ }
353
+ early = _guard_write(backend, action, params, dry_run, idempotency_key, preview)
354
+ if early is not None:
355
+ return early
356
+
357
+ try:
358
+ result = backend.delist_goods(goods_id=int(goods_id), sku_ids=sku_ids)
359
+ except (PopError, BackendError, NotImplementedError) as e:
360
+ result = _wrap_error(e, action, backend.name)
361
+
362
+ if idempotency_key and result.get("ok"):
363
+ _idem_put(idempotency_key, result)
364
+ audit.record(action, backend.name, params, result, dry_run=False,
365
+ idempotency_key=idempotency_key)
366
+ return result
367
+
368
+
369
+ def update_price(
370
+ backend: BaseBackend,
371
+ goods_id: int,
372
+ sku_id: int,
373
+ price_in_fen: int,
374
+ dry_run: bool = True,
375
+ idempotency_key: Optional[str] = None,
376
+ ) -> dict:
377
+ """改价。
378
+
379
+ 官方 API 不支持,会返回 unsupported(并给出替代方案);
380
+ 浏览器/mock 后端可执行。
381
+ """
382
+ action = Action.UPDATE_PRICE
383
+ support = backend.support_for(action)
384
+
385
+ if price_in_fen < 0:
386
+ return {"ok": False, "action": action, "error": "价格不能为负。"}
387
+
388
+ params = {
389
+ "goods_id": goods_id,
390
+ "sku_id": sku_id,
391
+ "price_in_fen": price_in_fen,
392
+ }
393
+ preview = {
394
+ "goods_id": goods_id,
395
+ "sku_id": sku_id,
396
+ "new_price_in_fen": price_in_fen,
397
+ "new_price_yuan": round(price_in_fen / 100, 2),
398
+ "support_level": support.level,
399
+ }
400
+ early = _guard_write(backend, action, params, dry_run, idempotency_key, preview)
401
+ if early is not None:
402
+ return early
403
+
404
+ try:
405
+ result = backend.update_price(
406
+ goods_id=int(goods_id), sku_id=int(sku_id), price_in_fen=int(price_in_fen)
407
+ )
408
+ except (PopError, BackendError, NotImplementedError) as e:
409
+ result = _wrap_error(e, action, backend.name)
410
+
411
+ if idempotency_key and result.get("ok"):
412
+ _idem_put(idempotency_key, result)
413
+ audit.record(action, backend.name, params, result, dry_run=False,
414
+ idempotency_key=idempotency_key)
415
+ return result
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # 参数校验
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ def validate_publish_spec(spec: dict) -> list[str]:
424
+ """校验发布商品参数,返回问题列表(空列表=通过)。
425
+
426
+ 校验依据官方 pdd.ktt.group.create 文档:
427
+ - goods_list 每项必填 category_name / goods_desc / goods_name / sku_list
428
+ - sku_list 每项必填 price_in_fen / quantity_type / spec_id_list / total_quantity
429
+ - total_quantity 最大 100w
430
+ - pic_url_list 不超过 20 张
431
+ """
432
+ problems: list[str] = []
433
+ if not isinstance(spec, dict):
434
+ return ["spec 必须是对象"]
435
+
436
+ for key in ("title", "start_time", "end_time", "goods_list"):
437
+ if not spec.get(key):
438
+ problems.append(f"缺少必填字段:{key}")
439
+
440
+ goods_list = spec.get("goods_list")
441
+ if goods_list is not None:
442
+ if not isinstance(goods_list, list) or not goods_list:
443
+ problems.append("goods_list 必须是非空数组")
444
+ else:
445
+ for i, g in enumerate(goods_list):
446
+ if not isinstance(g, dict):
447
+ problems.append(f"goods_list[{i}] 必须是对象")
448
+ continue
449
+ for key in ("category_name", "goods_desc", "goods_name", "sku_list"):
450
+ if not g.get(key):
451
+ problems.append(f"goods_list[{i}] 缺少必填字段:{key}")
452
+ if len(g.get("pic_url_list") or []) > 20:
453
+ problems.append(f"goods_list[{i}].pic_url_list 不能超过 20 张")
454
+ skus = g.get("sku_list")
455
+ if isinstance(skus, list):
456
+ for j, s in enumerate(skus):
457
+ if not isinstance(s, dict):
458
+ problems.append(f"goods_list[{i}].sku_list[{j}] 必须是对象")
459
+ continue
460
+ for key in ("price_in_fen", "quantity_type",
461
+ "spec_id_list", "total_quantity"):
462
+ if s.get(key) is None:
463
+ problems.append(
464
+ f"goods_list[{i}].sku_list[{j}] 缺少必填字段:{key}"
465
+ )
466
+ tq = s.get("total_quantity")
467
+ if isinstance(tq, int) and tq > 1_000_000:
468
+ problems.append(
469
+ f"goods_list[{i}].sku_list[{j}].total_quantity "
470
+ f"超过上限 100 万"
471
+ )
472
+
473
+ # 时间合理性
474
+ start, end = spec.get("start_time"), spec.get("end_time")
475
+ if isinstance(start, int) and isinstance(end, int) and end <= start:
476
+ problems.append("end_time 必须晚于 start_time")
477
+
478
+ return problems