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 +8 -0
- dudutt/__main__.py +8 -0
- dudutt/actions.py +478 -0
- dudutt/api_map.py +296 -0
- dudutt/audit.py +126 -0
- dudutt/backends/__init__.py +116 -0
- dudutt/backends/api_backend.py +327 -0
- dudutt/backends/base.py +218 -0
- dudutt/backends/browser_backend.py +259 -0
- dudutt/backends/mock_backend.py +308 -0
- dudutt/compat.py +39 -0
- dudutt/login.py +83 -0
- dudutt/pop_client.py +281 -0
- dudutt/py.typed +3 -0
- dudutt/server.py +425 -0
- dudutt-0.1.0.dist-info/METADATA +135 -0
- dudutt-0.1.0.dist-info/RECORD +21 -0
- dudutt-0.1.0.dist-info/WHEEL +5 -0
- dudutt-0.1.0.dist-info/entry_points.txt +2 -0
- dudutt-0.1.0.dist-info/licenses/LICENSE +21 -0
- dudutt-0.1.0.dist-info/top_level.txt +1 -0
dudutt/__init__.py
ADDED
dudutt/__main__.py
ADDED
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
|