From 55c01ae4f52b628f42e0a18cd88320790043089a Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Jul 2026 17:18:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=20/api/cart/*=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=9B=E5=8A=A0=E8=B4=AD=E9=93=BE=E8=B7=AF=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E5=BD=92=20trading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trading 新增 4 条购物车接口(POST /api/cart/{add,status,clear,remove}), 全部 Bearer 鉴权、走 SiteInteractor(Playwright + storage_state)。同步把 SiteInteractor 从 gateway URL 解耦——lifespan 总是构造与启停,container 字段 worker_site → site,加 asyncio.Lock 让 HTTP 与 worker 共用同一把锁 (同账号串行硬约束)。clear/remove 用 UI 点击 button[aria-label="削除"], 探针回报这是稳定 selector;真账号实测前先用此路径。 抽 ichiba 加购字段解析到 app/shared/purchase_contract.py(常量 + inventory_flag_for + basket_domain_of + base_form_fields),原本 scraping 与 trading 重复实现同一段 __INITIAL_STATE__.purchase 解析。进一步发现 README 写的「purchase 块是两服务契约」实际未落地——trading 必须 Playwright 开页(httpx 被 TLS 指纹拦死),本地抽比再调 /api/item_detail 更快更新鲜。 删除 scraping 端 PurchaseInfo/PurchaseOption/PurchaseOptionValue 模型、 各站 _purchase_info 函数、tests/test_purchase.py。ItemDetailData 保留 purchase_condition / is_sold_out / purchase_unit / sku 等商品状态字段。 README「加购与下单」段重写。 328 测试全绿(含架构测试守住三方互不 import)。 Co-Authored-By: Claude Opus 4.6 --- README.md | 66 ++- app/scraping/models/scrape.py | 50 +-- app/scraping/parsers/item.py | 84 +--- app/scraping/parsers/subsites/biccamera.py | 18 - app/scraping/parsers/subsites/books.py | 32 +- app/scraping/parsers/subsites/brandavenue.py | 40 -- app/shared/purchase_contract.py | 78 ++++ app/trading/api/routes/cart.py | 148 +++++++ app/trading/container.py | 13 +- app/trading/main.py | 40 +- app/trading/models.py | 62 +++ app/trading/worker/site_interact.py | 439 +++++++++++++++---- tests/test_purchase.py | 187 -------- tests/test_trading_api.py | 213 ++++++++- 14 files changed, 934 insertions(+), 536 deletions(-) create mode 100644 app/shared/purchase_contract.py create mode 100644 app/trading/api/routes/cart.py delete mode 100644 tests/test_purchase.py diff --git a/README.md b/README.md index 9b87748..d023233 100644 --- a/README.md +++ b/README.md @@ -518,50 +518,46 @@ cookie 落在 `.auth/`(已 gitignore,内含可直接冒充账号的凭据, | 规格 | `sku.axis` + `sku.variants` | 无(单件商品,仅一个 `size` 字段) | | 每页条数 | 45(搜索) | 40(搜索)/ 36(店铺页) | | 翻页上限 | `reachable_count`,随查询变化(405~6750) | 固定 100 页 | -| 加购标识 | `purchase` 块 | 无(未实现) | +| 加购 | 由 trading 服务完成(见下) | 无(未实现) | ## 加购与下单(仅乐天市场) -详情响应里的 `purchase` 块给出构造「加入购物车」请求所需的标识。 -**抓取服务只提供数据,不执行加购**——加购需要账号登录态,属于交易服务 -(`app/trading/`,尚在建设中)。这个 `purchase` 块正是两个服务之间的契约: -交易服务调抓取服务的 `/api/item_detail` 取它,而不是直接 import 解析器。 +加购与下单**完全在交易服务(`app/trading/`,:31108)内部完成**——交易服务用 Playwright +打开商品页(带账号 cookie),从 `__INITIAL_STATE__.purchase` 抽 `basketDomain` 与字段, +构造表单 POST 到加购端点。抓取服务**不参与**这条链路,`/api/item_detail` +不再返回 `purchase` 块。 -```jsonc -"purchase": { - "cart_url": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set", - "cart_method": "POST", - "form_fields": { // 原样提交的固定字段 - "shop_bid": "231431", - "item_id": "10008065", - "inventory_flag": "2", // 1=单一库存,2=多规格 - "__event": "ES01_003_001" - }, - "quantity_field": "units", // 购买件数填这里 - "variant_field": "variant_id", // 选中的 SKU 填这里 - "options_field": "choice", // 商品选项填这里 - "has_required_options": true, // 有必填选项,缺失会被站点拒绝 - "options": [ - { "id": 1, "name": "名入れ", "type": "select", "is_required": true, - "values": [{ "name": "希望する【次の項目で入力】" }] }, - { "id": 2, "name": "【お名前】", "type": "text", "is_required": false, "values": [] } - ] -} -``` +理由:httpx 在带账号的写操作上被 Rakuten TLS 指纹拦死(探针实测),必须用 Playwright; +既然 Playwright 已经打开商品页(为了发加购 POST 拿到 cookie 上下文),本地抽 +`__INITIAL_STATE__.purchase` 比再发 HTTP 到抓取服务更快、数据更新鲜。 -拼装规则: +抓取服务只暴露**商品状态字段**,不暴露加购指令: -- `form_fields` 原样带上,再按 `quantity_field` 填件数。 -- `inventory_flag=1` 时 `variant_id` 已在 `form_fields` 里预填好;`inventory_flag=2` 时**必须**由调用方从 `sku.variants[].variant_id` 选一个,填到 `variant_field`。 -- 字段名为空字符串表示该站不支持该项(如 `books` 不能指定件数与规格)。 -- `options` 里 `type=select` 从 `values` 中选,`type=text` 由买家填写;`is_required=true` 的项不能省。 +| 字段 | 含义 | +| --- | --- | +| `purchase_condition` | 站点原值,`enabled` 表示可购买 | +| `is_sold_out` | `purchase_condition != "enabled"` 即售罄 | +| `purchase_unit` | 起订单位 | +| `sku.inventory_type` | `multiple` 表示多规格,要看 `sku.variants[]` | +| `sku.variants[].variant_id` | 多规格商品的规格 ID(trading 加购多规格时按此选择) | -几点差异值得留意: +交易服务对外接口(:31108,全部需要 Bearer token): -- **`cart_url` 逐商品不同**,不要写死。不同店铺落在不同 basket 集群(实测有 `sp.basket…` 与 `ts.sp.basket…`),`brandavenue` 的端点还要再经一层编号映射。 -- **`books` 的 `item_id` 与 URL 上的商品编号不是同一个值**(如 URL `17065211` 对应 `item_id` `20600328`)。下单请用 `purchase.form_fields.item_id`,`item_code` 只是市场侧编号。 -- **`biccamera` 没有 `shop_bid`**,走自己的 JSON 接口;它的选项取值结构未取到样本验证,因此只用 `has_required_options` 如实回报「有无选项」而不给出选项定义,这类商品需另行处理。 +- `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}` +- `POST /api/cart/status` — 调 cart count API,返回购物车商品件数 +- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`) +- `POST /api/cart/remove` — 删除指定 `item_id` + +底层共用 `app/shared/purchase_contract.py` 的常量与字段构造(与 scraping 模型解耦)。 +trading 加购时的字段选择策略:多规格挑第一个非售罄的 variant;必填选项拼「名:值」。 +站点端点 `basketDomain` 逐商品不同(实测有 `sp.basket…` 与 `ts.sp.basket…`),不能写死。 + +几点子站差异(仅信息,trading 不覆盖子站加购): + +- **`books` 的 `item_id` 与 URL 上的商品编号不是同一个值**(如 URL `17065211` 对应 `item_id` `20600328`)。`/api/item_detail` 已经把表单里的 `item_id` 抽到顶层,调用方直接用即可。 +- **`biccamera` 没有 `shop_bid`**,走自己的 JSON 接口;选项取值结构未取到样本验证。 +- **`brandavenue` 的 cart 端点由 `cart_url_type` 编号映射得到**。 ## 错误码 diff --git a/app/scraping/models/scrape.py b/app/scraping/models/scrape.py index 16944fe..9bfd553 100644 --- a/app/scraping/models/scrape.py +++ b/app/scraping/models/scrape.py @@ -347,56 +347,15 @@ class Breadcrumb(BaseModel): url: str = "" -class PurchaseOptionValue(BaseModel): - """商品选项的一个可选值""" - - value_id: str = "" - name: str = "" - - -class PurchaseOption(BaseModel): - """商品选项(選択肢),如「名入れ」「ラッピング」 - - 加购时需要按 `名称:取值` 的形式拼进 options_field 指定的字段。 - """ - - option_id: str = "" - name: str = "" - type: str = "" # select 单选 / check 多选 / text 自由文本 - is_required: bool = False - values: list[PurchaseOptionValue] = Field(default_factory=list) # type=text 时为空 - - -class PurchaseInfo(BaseModel): - """构造加购请求所需的信息 - - 本服务只提供数据、不执行加购——加购需要已登录的乐天账号会话,由持有登录态的 - 下游负责。四个来源的加购端点与字段名各不相同,因此这里不写死字段,而是把 - 「提交到哪里、固定字段是什么、数量/规格/选项各该用哪个字段名」显式描述出来: - - payload = {**form_fields} - payload[quantity_field] = 数量 # quantity_field 为空表示不支持指定数量 - payload[variant_field] = 选中的 sku.variants[].variant_id # variant_field 为空表示无规格 - payload[options_field] = ["选项名:取值", ...] # options_field 为空表示无选项 - - 然后以 cart_method 提交到 cart_url。 - """ - - cart_url: str = "" - cart_method: str = "POST" - form_fields: dict[str, str] = Field(default_factory=dict) - quantity_field: str = "" - variant_field: str = "" - options_field: str = "" - options: list[PurchaseOption] = Field(default_factory=list) - has_required_options: bool = False - - class ItemDetailData(BaseModel): """商品详情数据 部分乐天官方店的商品页会跳转到独立子站,各子站页面结构不同、可提供的字段也 不同。source 标明这条数据由哪个站点解析而来,字段覆盖差异见 README。 + + 加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话, + 归 trading 服务(app.trading)。本响应只描述「商品状态」:能不能买 + (purchase_condition / is_sold_out)、规格(sku.variants)、起订单位等。 """ source: str = "ichiba" # ichiba / books / brandavenue / biccamera @@ -421,7 +380,6 @@ class ItemDetailData(BaseModel): breadcrumbs: list[Breadcrumb] = Field(default_factory=list) shipping: ShippingInfo = Field(default_factory=ShippingInfo) sku: SkuInfo = Field(default_factory=SkuInfo) - purchase: PurchaseInfo = Field(default_factory=PurchaseInfo) class HealthData(BaseModel): diff --git a/app/scraping/parsers/item.py b/app/scraping/parsers/item.py index 497a636..cafc121 100644 --- a/app/scraping/parsers/item.py +++ b/app/scraping/parsers/item.py @@ -14,9 +14,6 @@ from app.shared.errors import ScrapeParseError from app.scraping.models.scrape import ( Breadcrumb, ItemDetailData, - PurchaseInfo, - PurchaseOption, - PurchaseOptionValue, ReviewSummary, ShippingInfo, ShopSummary, @@ -31,12 +28,7 @@ from app.scraping.utils.coerce import as_dict, as_float, as_int, as_list, as_str # purchase.sellType 下表示「可正常购买」的状态值 _PURCHASABLE_CONDITION = "enabled" -# 普通购买的事件标识,站点前端构造加购表单时固定带上 -_NORMAL_PURCHASE_EVENT = "ES01_003_001" - -# 库存类型 → 加购表单里的 inventory_flag -_INVENTORY_FLAG = {"multiple": 2} -_DEFAULT_INVENTORY_FLAG = 1 +# 库存类型 → 加购表单里的 inventory_flag(常量与基础字段构造在 app.shared.purchase_contract) def _parse_attributes(raw: Any) -> list[SkuAttribute]: @@ -91,72 +83,6 @@ def _parse_variants(raw: Any) -> list[SkuVariant]: return variants -def parse_purchase_options(raw: Any) -> list[PurchaseOption]: - """解析商品选项(選択肢) - - 结构为 {id, name, type: select|check|text, isRequired, values:[{id, name}]}; - type=text 的选项没有候选值,由买家自由填写(如刻字内容)。 - """ - options: list[PurchaseOption] = [] - for option in as_list(raw): - if not isinstance(option, dict): - continue - options.append( - PurchaseOption( - option_id=str(as_int(option.get("id"))) if option.get("id") is not None else "", - name=as_str(option.get("name")), - type=as_str(option.get("type")), - is_required=bool(option.get("isRequired")), - values=[ - PurchaseOptionValue( - value_id=str(as_int(value.get("id"))) if value.get("id") is not None else "", - name=as_str(value.get("name")), - ) - for value in as_list(option.get("values")) - if isinstance(value, dict) - ], - ) - ) - return options - - -def _purchase_info( - *, - sell_type: dict[str, Any], - raw_sku: dict[str, Any], - information: dict[str, Any], - shop_id: int, - item_id: str, - item_variant_id: str, -) -> PurchaseInfo: - """组装加购所需的端点与字段 - - 字段名与取值来自站点前端构造加购表单的逻辑(getPurchaseFormData)。 - basketDomain 逐商品不同(不同店铺落在不同的 basket 集群),不能写死。 - """ - inventory_flag = _INVENTORY_FLAG.get(as_str(raw_sku.get("inventoryType")), _DEFAULT_INVENTORY_FLAG) - form_fields = { - "shop_bid": str(shop_id), - "item_id": item_id, - "inventory_flag": str(inventory_flag), - "__event": _NORMAL_PURCHASE_EVENT, - } - # 单一库存商品的规格是固定的,直接填好,调用方无需再选 - if inventory_flag == _DEFAULT_INVENTORY_FLAG and item_variant_id: - form_fields["variant_id"] = item_variant_id - - options = parse_purchase_options(information.get("options")) - return PurchaseInfo( - cart_url=as_str(sell_type.get("basketDomain")), - form_fields=form_fields, - quantity_field="units", - variant_field="variant_id", - options_field="choice" if options else "", - options=options, - has_required_options=any(option.is_required for option in options), - ) - - def _pick_sell_type(sell_type: dict[str, Any]) -> dict[str, Any]: """取售卖方式信息,优先普通购买,其次任意一种带价格的方式(如定期购)""" normal = as_dict(sell_type.get("normalPurchase")) @@ -270,12 +196,4 @@ def parse_item_detail( breadcrumbs=breadcrumbs, shipping=shipping, sku=sku, - purchase=_purchase_info( - sell_type=sell_type, - raw_sku=raw_sku, - information=as_dict(purchase.get("information")), - shop_id=as_int(shop_information.get("shopId")), - item_id=item_id, - item_variant_id=as_str(item.get("variantId")), - ), ) diff --git a/app/scraping/parsers/subsites/biccamera.py b/app/scraping/parsers/subsites/biccamera.py index 2a7a6b0..993fa22 100644 --- a/app/scraping/parsers/subsites/biccamera.py +++ b/app/scraping/parsers/subsites/biccamera.py @@ -15,7 +15,6 @@ from app.shared.errors import ScrapeParseError from app.scraping.models.scrape import ( Breadcrumb, ItemDetailData, - PurchaseInfo, ShippingInfo, ShopSummary, SkuInfo, @@ -50,22 +49,6 @@ def _extract_nuxt(html: str) -> dict: return state -def _purchase_info(state: dict, item: dict) -> PurchaseInfo: - """该站加购走自己的 JSON 接口,字段与市场完全不同(没有 shop_bid) - - 选项(choices)的取值结构未取到样本验证,因此只如实回报「有没有选项」, - 不给出可能不准确的选项定义——带选项的商品需要调用方另行处理。 - """ - choices = as_list(state.get("choices")) - return PurchaseInfo( - cart_url=as_str(item.get("add_cart_api_url")), - form_fields={"item_id": str(as_int(item.get("item_id")))}, - quantity_field="units", - options_field="choice" if choices else "", - has_required_options=bool(choices), - ) - - def parse(page: SubsitePage) -> ItemDetailData: """解析ビックカメラ商品页""" nuxt = _extract_nuxt(page.html) @@ -128,5 +111,4 @@ def parse(page: SubsitePage) -> ItemDetailData: show_inventory=inventory > 0, delivery_message=delivery, ), - purchase=_purchase_info(as_dict(nuxt.get("state")), item), ) diff --git a/app/scraping/parsers/subsites/books.py b/app/scraping/parsers/subsites/books.py index 392baa0..77ab1bd 100644 --- a/app/scraping/parsers/subsites/books.py +++ b/app/scraping/parsers/subsites/books.py @@ -17,7 +17,6 @@ from app.shared.errors import ScrapeParseError from app.scraping.models.scrape import ( Breadcrumb, ItemDetailData, - PurchaseInfo, ReviewSummary, ShippingInfo, ShopSummary, @@ -120,27 +119,21 @@ def _parse_genres(html: str) -> tuple[str, list[Breadcrumb]]: return genre_id, crumbs -def _parse_purchase(tree: HTMLParser) -> PurchaseInfo: - """加购信息直接取页面上的购物车表单 +def _extract_cart_form_item_id(tree: HTMLParser) -> str: + """从购物车表单里抽 item_id - 注意表单里的 item_id 与商品 URL 上的编号不是一回事,加购必须用表单里的值。 - 该表单没有数量字段,无法在加购时指定件数。 + 楽天ブックス 的站内 item_id 与 URL 上的商品编号不是一回事(URL 17065211 → + item_id 20600328)。下单要用表单里的 item_id,所以这里专门抽它。 + 加购表单本身不再解析成 PurchaseInfo——加购由 trading 内部完成。 """ for form in tree.css("form"): action = form.attributes.get("action") or "" if "/bs/Cart" not in action: continue - fields = { - name: inp.attributes.get("value") or "" - for inp in form.css("input") - if (name := inp.attributes.get("name")) - } - return PurchaseInfo( - cart_url=action, - cart_method=(form.attributes.get("method") or "POST").upper(), - form_fields=fields, - ) - return PurchaseInfo() + for inp in form.css("input"): + if inp.attributes.get("name") == "item_id": + return inp.attributes.get("value") or "" + return "" def parse(page: SubsitePage) -> ItemDetailData: @@ -158,13 +151,13 @@ def parse(page: SubsitePage) -> ItemDetailData: status = _text(tree, ".status") genre_id, breadcrumbs = _parse_genres(page.html) review_count = _text(tree, '[itemprop="reviewCount"]') - purchase = _parse_purchase(tree) + # 站内 item_id 与 URL 编号不同,下单要用表单里的——保留抽取,但不构 PurchaseInfo + item_id = _extract_cart_form_item_id(tree) or page.item_code return ItemDetailData( source=SOURCE, source_url=page.final_url, - # 站内商品 ID 与 URL 上的编号不同,以购物车表单里的为准(下单要用它) - item_id=purchase.form_fields.get("item_id", "") or page.item_code, + item_id=item_id, item_code=page.item_code, item_name=name, description=_parse_description(tree), @@ -183,5 +176,4 @@ def parse(page: SubsitePage) -> ItemDetailData: # 图书统一由楽天ブックス发货,页面只给库存措辞,不给运费明细 shipping=ShippingInfo(delivery_message=status), sku=SkuInfo(inventory_type="single", attributes=_parse_spec(tree), delivery_message=status), - purchase=purchase, ) diff --git a/app/scraping/parsers/subsites/brandavenue.py b/app/scraping/parsers/subsites/brandavenue.py index 7aecc30..cf38627 100644 --- a/app/scraping/parsers/subsites/brandavenue.py +++ b/app/scraping/parsers/subsites/brandavenue.py @@ -18,7 +18,6 @@ from app.shared.errors import ScrapeParseError from app.scraping.models.scrape import ( Breadcrumb, ItemDetailData, - PurchaseInfo, ShopSummary, SkuAttribute, SkuAxis, @@ -37,44 +36,6 @@ SHOP_NAME = "Rakuten Fashion" _COLOR_AXIS = "カラー" _SIZE_AXIS = "サイズ" -# cart_info.cart_url_type → 加购端点。站点前端用同名映射表(resolveCartUrl)解析; -# 若该字段本身已经是一个 URL,则直接使用。 -_CART_URL_BY_TYPE = { - "1": "https://ts.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set", - "2": "https://ts.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set", - "3": "https://t2.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set", - "4": "https://t2.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set", - "5": "https://basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set", - "6": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set", -} -_DEFAULT_PURCHASE_EVENT = "ES01_003_001" - - -def _resolve_cart_url(cart_url_type: str) -> str: - if cart_url_type.startswith("http"): - return cart_url_type - return _CART_URL_BY_TYPE.get(cart_url_type, "") - - -def _purchase_info(product: dict) -> PurchaseInfo: - """加购字段与市场是同一套契约,差别只在端点由 cart_url_type 映射得到""" - cart_info = as_dict(as_dict(product.get("rms_info")).get("cart_info")) - if not cart_info: - return PurchaseInfo() - return PurchaseInfo( - cart_url=_resolve_cart_url(as_str(cart_info.get("cart_url_type"))), - form_fields={ - "shop_bid": as_str(cart_info.get("shop_bid")), - "item_id": as_str(cart_info.get("item_id")), - "inventory_flag": as_str(cart_info.get("inventory_type")), - "__event": as_str(cart_info.get("event")) or _DEFAULT_PURCHASE_EVENT, - "encode": "utf8", - }, - quantity_field="units", - variant_field="variant_id", - ) - - def validate(html: str) -> str | None: if "window.__INITIAL_STATE__" in html: return None @@ -206,5 +167,4 @@ def parse(page: SubsitePage) -> ItemDetailData: genre_id=as_str(rms.get("genre_id")), breadcrumbs=_breadcrumbs(product), sku=_sku(product, include_variants=page.include_sku_variants), - purchase=_purchase_info(product), ) diff --git a/app/shared/purchase_contract.py b/app/shared/purchase_contract.py new file mode 100644 index 0000000..990d6f5 --- /dev/null +++ b/app/shared/purchase_contract.py @@ -0,0 +1,78 @@ +"""乐天市场(ichiba)加购字段抽取的纯函数与常量 + +`__INITIAL_STATE__.purchase` 的字段映射在两处使用: +- `app/scraping/parsers/item.py::_purchase_info` — `/api/item_detail` 返回的 PurchaseInfo +- `app/trading/worker/site_interact.py::_extract_purchase_fields` — trading 加购链路 + +两者都解析同一份站点 JSON,本模块抽出**双方都要用**的核心逻辑: +- 常量:普通购买事件标识、库存类型 → inventory_flag 映射 +- `inventory_flag_for(inventory_type)` — 多规格判定 +- `basket_domain_of(sell_type)` — 抽 basketDomain + 反转义 `\\u002F` +- `base_form_fields(shop_id, item_id, inventory_flag)` — 加购表单四件套 + +**不**放这里: +- `PurchaseInfo` pydantic 模型 — 是 scraping 的对外契约,4 个子站共用,留在 scraping/models +- variant_id / choice 自动选择策略 — trading 独有(scraping 把决策权留给上游) +- options 结构化解析 — scraping 独有(trading 用原始 dict) +- 子站(books/biccamera/brandavenue)的加购契约 — 各自独立,不走这里 + +依赖约束:仅 `typing.Any`(标准库),不 import scraping / trading / gateway, +符合架构测试 tests/test_architecture.py 的依赖方向规则。 +""" +from __future__ import annotations + +from typing import Any + +# 站点前端构造加购表单时固定带的事件标识(普通购买 normalPurchase) +NORMAL_PURCHASE_EVENT: str = "ES01_003_001" + +# 库存类型 → inventory_flag +# 多规格商品(sku.variants[] 非空)inventoryType="multiple" → flag "2" +# 单一库存商品 → flag "1" +# 注意:scraping 早期版本写过 int 2/1,但表单最终要 str,统一在这里就转成 str +INVENTORY_FLAG_MULTIPLE: str = "2" +INVENTORY_FLAG_DEFAULT: str = "1" +_INVENTORY_FLAG_MAP: dict[str, str] = {"multiple": INVENTORY_FLAG_MULTIPLE} + + +def inventory_flag_for(inventory_type: Any) -> str: + """从 __INITIAL_STATE__.purchase.sku.inventoryType 取 inventory_flag + + inventory_type="multiple" → "2"(多规格,调用方需选 variant_id) + 其他(含 None / 未知值)→ "1"(单一库存) + """ + key = inventory_type if isinstance(inventory_type, str) else "" + return _INVENTORY_FLAG_MAP.get(key, INVENTORY_FLAG_DEFAULT) + + +def basket_domain_of(sell_type: dict[str, Any]) -> str: + """从 purchase.sellType.normalPurchase 抽 basketDomain 并反转义 + + 站点 JSON 里 `/` 被编码为 `\\u002F`,POST 前要还原。 + sell_type 不是 dict 或 basketDomain 缺失时返回空串。 + """ + if not isinstance(sell_type, dict): + return "" + raw = sell_type.get("basketDomain") + if not isinstance(raw, str): + return "" + return raw.replace("\\u002F", "/") + + +def base_form_fields( + *, + shop_id: Any, + item_id: Any, + inventory_flag: str, +) -> dict[str, str]: + """构造加购表单的基础四件套 + + shop_bid / item_id / inventory_flag / __event 是 rakuten 主站加购的固定字段。 + variant_id / choice / units 由调用方按业务策略再加(trading 自动选,scraping 留给上游)。 + """ + return { + "shop_bid": str(shop_id) if shop_id else "", + "item_id": str(item_id) if item_id else "", + "inventory_flag": inventory_flag, + "__event": NORMAL_PURCHASE_EVENT, + } diff --git a/app/trading/api/routes/cart.py b/app/trading/api/routes/cart.py new file mode 100644 index 0000000..bbbc786 --- /dev/null +++ b/app/trading/api/routes/cart.py @@ -0,0 +1,148 @@ +"""购物车路由:加购 / 查询状态 / 清空 / 删除指定商品 + +这些接口的目的是**测试加购与购物车操作是否稳定**,以及在下单流程跑测前 +清空购物车避免脏数据。不是给上游业务系统调用的——上游走 order-gateway +拉单,由 worker 异步处理。 + +底层全走 SiteInteractor(Playwright + 账号 cookie);httpx 在带账号的写操作 +上被 Rakuten TLS 指纹拦死,详见 project://jp-rakuten/checkout-flow-probe-findings。 + +所有路由共用一把 asyncio.Lock(持有在 SiteInteractor),同账号写操作串行 +(per project://jp-rakuten/trading-split「全局并发度 1」硬约束)。 +""" +from fastapi import APIRouter, Depends + +from app.shared.api import ApiResponse, get_container, require_bearer_token +from app.shared.errors import UpstreamRequestError +from app.trading.container import TradingContainer +from app.trading.models import ( + CartAddData, + CartAddRequest, + CartClearData, + CartClearRequest, + CartRemoveData, + CartRemoveRequest, + CartStatusData, + CartStatusRequest, +) + +router = APIRouter(prefix="/api/cart", tags=["cart"]) + + +def _require_site(container: TradingContainer): + """取 container.site,未就绪时报错 + + 生产环境 lifespan 一定构造了 site;只有测试或异常启动路径才会 None。 + """ + if container.site is None: + raise UpstreamRequestError( + "SiteInteractor 未就绪:服务可能未正确启动(Playwright 未初始化)" + ) + return container.site + + +@router.post( + "/add", + response_model=ApiResponse[CartAddData], + dependencies=[Depends(require_bearer_token)], +) +async def cart_add( + payload: CartAddRequest, + container: TradingContainer = Depends(get_container), +) -> ApiResponse[CartAddData]: + """加购一件商品 + + 底层:打开 item_url → 抽 __INITIAL_STATE__.purchase → POST basketDomain。 + variant_id / choice 不传时按商品页定义自动选第一个候选值。 + 成功后顺带查一次 cart count API 一并返回。 + """ + site = _require_site(container) + result = await site.add_to_cart_payload( # type: ignore[union-attr] + item_url=payload.item_url, + quantity=payload.quantity, + variant_id=payload.variant_id, + choice=payload.choice, + ) + return ApiResponse[CartAddData]( + success=True, + msg="success", + data=CartAddData( + added=True, + item_id=result["item_id"], + shop_bid=result["shop_bid"], + cart_count=result["cart_count"], + ), + code=0, + ) + + +@router.post( + "/status", + response_model=ApiResponse[CartStatusData], + dependencies=[Depends(require_bearer_token)], +) +async def cart_status( + payload: CartStatusRequest, + container: TradingContainer = Depends(get_container), +) -> ApiResponse[CartStatusData]: + """查询购物车状态(轻量) + + 只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。 + """ + site = _require_site(container) + result = await site.cart_status() # type: ignore[union-attr] + return ApiResponse[CartStatusData]( + success=True, + msg="success", + data=CartStatusData(**result), + code=0, + ) + + +@router.post( + "/clear", + response_model=ApiResponse[CartClearData], + dependencies=[Depends(require_bearer_token)], +) +async def cart_clear( + payload: CartClearRequest, + container: TradingContainer = Depends(get_container), +) -> ApiResponse[CartClearData]: + """清空购物车 + + 策略:渲染 cart SPA → 反复点 button[aria-label="削除"] 直到没有该按钮 → + cart count API 校验。返回实际点击的按钮数与末尾 cart count。 + """ + site = _require_site(container) + result = await site.clear_cart() # type: ignore[union-attr] + return ApiResponse[CartClearData]( + success=True, + msg="success", + data=CartClearData(**result), + code=0, + ) + + +@router.post( + "/remove", + response_model=ApiResponse[CartRemoveData], + dependencies=[Depends(require_bearer_token)], +) +async def cart_remove( + payload: CartRemoveRequest, + container: TradingContainer = Depends(get_container), +) -> ApiResponse[CartRemoveData]: + """删除购物车里指定 item_id 的商品 + + 策略:渲染 cart SPA → 在 DOM 里找 button[aria-label="削除"],向上 walk + parentElement 找 innerText 包含 item_id 的祖先,click 那个按钮。 + Rakuten cart item 卡片无稳定 data-* 属性,靠文本回溯定位。 + """ + site = _require_site(container) + result = await site.remove_item(payload.item_id) # type: ignore[union-attr] + return ApiResponse[CartRemoveData]( + success=True, + msg="success", + data=CartRemoveData(**result), + code=0, + ) diff --git a/app/trading/container.py b/app/trading/container.py index 8692e66..dea92e3 100644 --- a/app/trading/container.py +++ b/app/trading/container.py @@ -1,7 +1,9 @@ """交易服务容器:集中管理交易侧服务实例,用于依赖注入 -持有两类东西: +持有三类东西: - 登录态会话(auth_session)——加购与下单必须用同一份 cookie,全程共享 +- 站点交互器(site)——持有 Playwright BrowserContext,加购/校验/清空/删除共用。 + 不依赖 gateway URL:HTTP 接口与 worker 都要用它,故总是构造。 - 本地下单 worker(可选)——RAKUTEN_ORDER_GATEWAY_URL 留空时整套不构造 worker 的子组件(gateway_client / local_db / evidence_store / runner)都在 @@ -24,15 +26,18 @@ class TradingContainer: 订单、页面证据都属于某个具体账号,因此交易服务只能单实例运行 (或按账号分片),不能像抓取服务那样随意横向扩容。 + site 字段始终构造(HTTP /api/cart/* 接口与 worker 都依赖它)。 worker_* 字段在 RAKUTEN_ORDER_GATEWAY_URL 留空时为 None——此时交易服务 - 只跑登录态接口,不起后台 worker。 + 只跑登录态与购物车接口,不起后台 worker。 """ settings: Settings auth_session: AuthSession - # 以下五项仅在 order_gateway_url 配置时构造;否则为 None,worker 不启动 + # 站点交互器:持有 Playwright + 账号 cookie,加购/校验/清空/删除共用一份。 + # 不依赖 gateway URL——HTTP 接口与 worker 都要用它,故总是构造。 + site: object | None = None # app.trading.worker.site_interact.SiteInteractor + # 以下四项仅在 order_gateway_url 配置时构造;否则为 None,worker 不启动 worker_client: object | None = None # app.trading.worker.client.GatewayClient worker_local_db: object | None = None # app.trading.worker.local_db.LocalDB worker_evidence: object | None = None # app.trading.worker.evidence.EvidenceStore - worker_site: object | None = None # app.trading.worker.site_interact.SiteInteractor worker_runner: object | None = None # app.trading.worker.runner.WorkerRunner diff --git a/app/trading/main.py b/app/trading/main.py index c2e7807..43b47b9 100644 --- a/app/trading/main.py +++ b/app/trading/main.py @@ -10,8 +10,10 @@ 因此本服务只能单实例运行(或按账号分片),扩容靠抓取服务那一侧。 -worker 启动条件:RAKUTEN_ORDER_GATEWAY_URL 非空。留空时本服务只跑登录态接口, -不构造任何 worker 子组件(本地 DB / 证据目录 / 出站客户端都不创建)。 +SiteInteractor 始终启动:HTTP /api/auth/* 与 /api/cart/* 都要靠它持账号 cookie +做站点交互(加购、校验、清空、删除)。worker 启动条件:RAKUTEN_ORDER_GATEWAY_URL +非空。留空时不构造 worker 子组件(本地 DB / 证据目录 / 出站客户端都不创建), +但 SiteInteractor 与 Playwright 仍会启动。 """ from __future__ import annotations @@ -26,6 +28,7 @@ from app.shared.config import get_settings from app.shared.logging_setup import configure_logging from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry from app.trading.api.routes.auth import router as auth_router +from app.trading.api.routes.cart import router as cart_router from app.trading.api.routes.health import router as health_router from app.trading.container import TradingContainer from app.trading.services.auth_session import AuthSession @@ -34,17 +37,26 @@ logger = logging.getLogger(__name__) def build_container() -> TradingContainer: - """构建交易服务容器:登录态会话始终构造,worker 仅在配置了网关 URL 时构造""" + """构建交易服务容器 + + - 登录态会话与站点交互器始终构造(HTTP /api/cart/* 与 /api/auth/* 都依赖) + - worker 仅在配置了网关 URL 时构造(依赖 httpx 客户端、本地 DB、证据目录) + """ settings = get_settings() container = TradingContainer(settings=settings, auth_session=AuthSession(settings)) + # SiteInteractor 总是构造:HTTP 接口需要它,worker(如果配置了)也需要。 + # 延迟 import 让没装 Playwright 的环境能起服务做基本配置检查。 + from app.trading.worker.site_interact import SiteInteractor + + container.site = SiteInteractor(auth_session=container.auth_session, settings=settings) + if settings.order_gateway_url: # 延迟 import:未配置网关 URL 时不加载 worker 模块(也就不会拉起 aiosqlite 等) from app.trading.worker.client import GatewayClient from app.trading.worker.evidence import EvidenceStore from app.trading.worker.local_db import LocalDB from app.trading.worker.runner import WorkerRunner - from app.trading.worker.site_interact import SiteInteractor client = GatewayClient( settings.order_gateway_url, @@ -53,18 +65,16 @@ def build_container() -> TradingContainer: ) local_db = LocalDB(settings.trading_db_path_resolved) evidence = EvidenceStore(settings.evidence_path) - site = SiteInteractor(auth_session=container.auth_session, settings=settings) runner = WorkerRunner( settings=settings, gateway_client=client, local_db=local_db, evidence=evidence, - site=site, + site=container.site, ) container.worker_client = client container.worker_local_db = local_db container.worker_evidence = evidence - container.worker_site = site container.worker_runner = runner return container @@ -72,7 +82,7 @@ def build_container() -> TradingContainer: @asynccontextmanager async def lifespan(app: FastAPI): - """应用生命周期管理:登录态会话 + 可选 worker""" + """应用生命周期管理:登录态会话 + 站点交互器(必起)+ 可选 worker""" container = build_container() app.state.container = container @@ -87,15 +97,16 @@ async def lifespan(app: FastAPI): logger.info("当前环境:%s", container.settings.app_env) await container.auth_session.start() + # SiteInteractor 总是启动:HTTP /api/cart/* 接口要靠它发请求 + assert container.site is not None + await container.site.start() # type: ignore[union-attr] worker_task: asyncio.Task | None = None if container.worker_runner is not None: - # 顺序:先开本地 DB 与 SiteInteractor(worker 写证据/发请求前要先就绪),再起后台任务 + # 顺序:本地 DB 在 worker 写证据前先就绪;SiteInteractor 已在外层启动 assert container.worker_local_db is not None assert container.worker_client is not None - assert container.worker_site is not None await container.worker_local_db.start() - await container.worker_site.start() # type: ignore[union-attr] worker_task = asyncio.create_task( container.worker_runner.run(), name="trading-worker" ) @@ -106,7 +117,7 @@ async def lifespan(app: FastAPI): ) else: logger.info( - "未配置 RAKUTEN_ORDER_GATEWAY_URL,下单 worker 不启动(仅登录态接口)" + "未配置 RAKUTEN_ORDER_GATEWAY_URL,下单 worker 不启动(仅登录态与购物车接口)" ) try: @@ -119,8 +130,8 @@ async def lifespan(app: FastAPI): await worker_task except asyncio.CancelledError: pass - if container.worker_site is not None: - await container.worker_site.close() # type: ignore[union-attr] + if container.site is not None: + await container.site.close() # type: ignore[union-attr] if container.worker_local_db is not None: await container.worker_local_db.close() if container.worker_client is not None: @@ -134,6 +145,7 @@ def create_app() -> FastAPI: app = FastAPI(title="Rakuten Trading Service", lifespan=lifespan) app.include_router(health_router) app.include_router(auth_router) + app.include_router(cart_router) register_exception_handlers(app) instrument_app(app) return app diff --git a/app/trading/models.py b/app/trading/models.py index aea549b..3ea6141 100644 --- a/app/trading/models.py +++ b/app/trading/models.py @@ -68,3 +68,65 @@ class TradingHealthData(BaseModel): status: str auth: dict[str, Any] = Field(default_factory=dict) + + +# ---- 购物车接口(/api/cart/*)---- + + +class CartAddRequest(BaseModel): + """加购请求 + + item_url 必填;variant_id / choice 不传时由 SiteInteractor 按商品页 + __INITIAL_STATE__.purchase 自动选(多规格选第一个非售罄,必填选项选第一个候选值)。 + choice 接受字符串("颜色:赤")或字符串列表(["颜色:赤", "サイズ:M"])。 + """ + + item_url: str + quantity: int = 1 + variant_id: str | None = None + choice: str | list[str] | None = None + + +class CartStatusRequest(BaseModel): + """购物车状态查询请求(无入参,保留结构对称)""" + + +class CartClearRequest(BaseModel): + """清空购物车请求(无入参)""" + + +class CartRemoveRequest(BaseModel): + """删除指定商品请求""" + + item_id: str + + +class CartAddData(BaseModel): + """加购响应数据""" + + added: bool = True + item_id: str + shop_bid: str + cart_count: int # -1 表示加购成功但末尾 count 查询失败 + + +class CartStatusData(BaseModel): + """购物车状态响应数据""" + + logged_in: bool + count: int + raw_status: str # 站点状态码字符串,"100" 表示正常 + + +class CartClearData(BaseModel): + """清空购物车响应数据""" + + removed_count: int # 实际点击「削除」按钮的次数 + cart_count: int # -1 表示末尾 count 查询失败 + + +class CartRemoveData(BaseModel): + """删除指定商品响应数据""" + + removed: bool # 末尾 cart HTML 已不含 item_id 时为 true + item_id: str diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index d18038f..6c1f507 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -1,7 +1,9 @@ -"""站点交互:加购 / 校验购物车 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控 +"""站点交互:加购 / 校验 / 清空 / 删除 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控 实测进度(详见 project://jp-rakuten/checkout-flow-probe-findings): - add_to_cart、verify_cart 已实测可用(Playwright + storage_state 走 SP 通道) +- clear_cart、remove_item 用 UI 点击 `button[aria-label="削除"]` 路径实现, + 未做真账号实测;首次跑通后回填实测 selector 与 modal 行为到记忆节点 - enter_checkout 及之后**未实现**:Rakuten 对 checkout 这类敏感操作要求 session upgrade(重输密码),即使当前 SSO 已登录。这是自动 checkout 的硬墙, 比 3DS 还前置。本层留接口缝,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。 @@ -9,9 +11,14 @@ **httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验, 同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright 的 BrowserContext + APIRequestContext(共享 cookie,绕过 CORS)。 + +**并发约束**:所有公开方法入口 acquire `self._lock`,保证 HTTP 路由与 worker +主循环不会同时操作同一个 Playwright context——同一账号必须串行 +(per project://jp-rakuten/trading-split「全局并发度 1」)。 """ from __future__ import annotations +import asyncio import json import logging import re @@ -23,6 +30,13 @@ from app.shared.errors import ( InvalidRequestError, NotLoggedInError, ) +from app.shared.purchase_contract import ( + INVENTORY_FLAG_DEFAULT, + INVENTORY_FLAG_MULTIPLE, + base_form_fields, + basket_domain_of, + inventory_flag_for, +) from app.trading.core import auth_site from app.trading.worker.models import LeaseTask @@ -32,14 +46,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# 普通购买的事件标识,站点前端构造加购表单时固定带上(同 scraping/parsers/item.py) -_NORMAL_PURCHASE_EVENT = "ES01_003_001" - -# 库存类型 → 加购表单里的 inventory_flag(与 scraping/parsers/item.py 一致) -_INVENTORY_FLAG = {"multiple": "2"} -_DEFAULT_INVENTORY_FLAG = "1" - -# 加购端点路径片段(探针实测:basketDomain 逐商品不同,但路径固定) +# 普通购买事件标识、inventory_flag 映射、basketDomain 反转义、form_fields 基础构造 +# 均在 app.shared.purchase_contract,与 scraping/parsers/item.py::_purchase_info 共用同一份契约 _BASKET_PATH = "/rms/mall/bss/cartadd/set" # 购物车页(SP)与 cart 数量 JSONP API(探针实测) @@ -49,6 +57,16 @@ _CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/j # 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据) _LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER +# 删除按钮稳定 selector(探针回报:.probe/checkout/06-cart-with-items.html 4 处命中) +# aria-label 是 a11y 属性,比 CSS modules hash class 稳定;Rakuten 不用 data-testid +_DELETE_BUTTON_SELECTOR = 'button[aria-label="削除"]' + +# clear_cart 安全上限:防止 SPA 异常时死循环 +_CLEAR_CART_MAX_ITER = 50 + +# 确认 modal 候选 selector:点击「削除」后站点可能弹「本当に削除しますか?」 +_CONFIRM_BUTTON_SELECTORS = ('button:has-text("はい")', 'button:has-text("OK")') + _NOT_IMPLEMENTED_MSG = ( "站点交互未实现:见 docs/order-gateway.md §10 与 " "project://jp-rakuten/checkout-flow-probe-findings。" @@ -72,18 +90,27 @@ class SiteInteractor: """Rakuten 站点交互器:持有 Playwright 浏览器 context,复用账号 cookie 生命周期: - - start() 在 worker 启动时调用一次:启动 Playwright + 创建带 cookie 的 context - - add_to_cart / verify_cart 在每个任务里调用 - - close() 在 worker 关闭时调用 + - start() 在 trading 服务 lifespan 启动时调用一次:启动 Playwright + 创建带 cookie 的 context + - add_to_cart / verify_cart / cart_status / clear_cart / remove_item 在任务或 HTTP 请求里调用 + - close() 在服务关闭时调用 - 浏览器 context 复用同一份登录态,所有任务串行(worker 主循环本就串行), + 浏览器 context 复用同一份登录态,所有任务串行(self._lock + worker 主循环本就串行), 不需要为每个任务开新 context——开销大且 cookie 状态会乱。 + + 调用方: + - worker runner:传入 LeaseTask,调 add_to_cart(task) / verify_cart(task) + - HTTP 路由 /api/cart/*:调 add_to_cart_payload(...) / cart_status() / + clear_cart() / remove_item(item_id) """ # 每任务保留的临时状态:task_id → {"item_id": str, "shop_bid": str} # 用于 add_to_cart 把抓出来的 item_id / shop_bid 喂给 verify_cart _per_task_state: dict[str, dict[str, str]] + # 所有公开方法共用一把锁:HTTP 接口与 worker 主循环都走它,串行化所有 Playwright 操作。 + # 同一账号被并发操作 = 风控触发风险 + cart 状态错乱。 + _lock: asyncio.Lock + def __init__(self, *, auth_session: "AuthSession", settings: "Settings"): self._auth_session = auth_session self._settings = settings @@ -91,6 +118,7 @@ class SiteInteractor: self._browser = None self._context = None # playwright BrowserContext self._per_task_state = {} + self._lock = asyncio.Lock() # 上次 build context 时读到的 storage_state 文件 mtime。 # 用于在任务间检测 AuthSession 重登后产生的新 storage_state,触发 context 重建。 self._state_mtime: float | None = None @@ -185,10 +213,10 @@ class SiteInteractor: self._browser = None self._playwright = None - # ---- 已实现:add_to_cart / verify_cart ---- + # ---- 已实现:add_to_cart / verify_cart / cart_status ---- async def add_to_cart(self, task: LeaseTask) -> None: - """加购:打开商品页 → 抽 __INITIAL_STATE__.purchase → POST basketDomain + """加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields 调用方需在 task.intent 提供: - item_url: 商品详情页 URL(必填) @@ -209,11 +237,68 @@ class SiteInteractor: if quantity <= 0: raise InvalidRequestError(f"intent.quantity 必须为正整数,收到 {quantity}") - await self._auth_session.require_logged_in("rakuten") + async with self._lock: + result = await self._add_to_cart_with_fields( + item_url=item_url, + quantity=quantity, + variant_id=intent.get("variant_id"), + choice=intent.get("choice"), + ) + self._per_task_state[task.task_id] = { + "item_id": result["item_id"], + "shop_bid": result["shop_bid"], + "basket_domain": result["basket_domain"], + } - # 任务开始前刷新 context:AuthSession 可能刚自动重登过,storage_state 变了 + async def add_to_cart_payload( + self, + *, + item_url: str, + quantity: int = 1, + variant_id: str | None = None, + choice: str | list[str] | None = None, + ) -> dict: + """加购(HTTP 入口):返回加购结果与最新 cart count,不写 _per_task_state + + 与 add_to_cart(task) 共享 _add_to_cart_with_fields,差异仅在: + - 入参形态(关键字 vs intent dict) + - 返回值(dict vs None,结果记在 _per_task_state) + - 不带 task_id(HTTP 调用方自己持有结果) + """ + if not item_url: + raise InvalidRequestError("item_url 必填") + if quantity <= 0: + raise InvalidRequestError(f"quantity 必须为正整数,收到 {quantity}") + async with self._lock: + return await self._add_to_cart_with_fields( + item_url=item_url, + quantity=quantity, + variant_id=variant_id, + choice=choice, + ) + + async def _add_to_cart_with_fields( + self, + *, + item_url: str, + quantity: int, + variant_id: str | None, + choice: str | list[str] | None, + ) -> dict: + """加购核心逻辑(不持锁,由调用方包裹 self._lock) + + 返回 dict:{item_id, shop_bid, basket_domain, cart_count} + cart_count 在加购成功后顺带查一次 cart count API,方便 HTTP 调用方一次性返回。 + """ + await self._auth_session.require_logged_in("rakuten") await self._refresh_context_if_stale() + intent_for_extract: dict = {} + if variant_id is not None: + intent_for_extract["variant_id"] = variant_id + if choice is not None: + intent_for_extract["choice"] = choice + page = await self._context.new_page() try: try: @@ -232,12 +317,7 @@ class SiteInteractor: if not state: raise CartOperationError("无法从商品页抽 __INITIAL_STATE__(可能 PC 模板或反爬)") - fields = _extract_purchase_fields(state, intent_override=intent) - self._per_task_state[task.task_id] = { - "item_id": fields["form_fields"].get("item_id", ""), - "shop_bid": fields["form_fields"].get("shop_bid", ""), - "basket_domain": fields["basket_domain"], - } + fields = _extract_purchase_fields(state, intent_override=intent_for_extract) if fields["purchase_condition"] != "enabled": raise CartOperationError( @@ -246,7 +326,7 @@ class SiteInteractor: if not fields["basket_domain"]: raise CartOperationError("basketDomain 为空(商品可能下架)") # 多规格商品要求选了 variant_id - if fields["inventory_flag"] == _INVENTORY_FLAG["multiple"] and not fields["form_fields"].get("variant_id"): + if fields["inventory_flag"] == INVENTORY_FLAG_MULTIPLE and not fields["form_fields"].get("variant_id"): raise CartOperationError( "多规格商品未选 variant,且 sku.variants 全部售罄或为空" ) @@ -260,8 +340,7 @@ class SiteInteractor: payload[fields["quantity_field"]] = str(quantity) logger.info( - "加购请求:task_id=%s basket=%s payload=%s", - task.task_id, fields["basket_domain"], payload, + "加购请求:basket=%s payload=%s", fields["basket_domain"], payload, ) resp = await self._context.request.post( @@ -287,12 +366,26 @@ class SiteInteractor: ) logger.info( - "加购成功:task_id=%s item_id=%s final=%s", - task.task_id, fields["form_fields"].get("item_id"), final_url, + "加购成功:item_id=%s final=%s", + fields["form_fields"].get("item_id"), final_url, ) finally: await page.close() + # 加购成功后顺带查 cart count(best-effort:失败时返回 -1,不掩盖加购成功) + try: + _, cart_count = await self._query_cart_count() + except CartOperationError as exc: + logger.warning("加购后查 cart count 失败(不影响加购结果):%s", exc.message) + cart_count = -1 + + return { + "item_id": fields["form_fields"].get("item_id", ""), + "shop_bid": fields["form_fields"].get("shop_bid", ""), + "basket_domain": fields["basket_domain"], + "cart_count": cart_count, + } + async def verify_cart(self, task: LeaseTask) -> None: """校验购物车里有没有刚加的商品 @@ -302,57 +395,208 @@ class SiteInteractor: 登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。 """ - await self._auth_session.require_logged_in("rakuten") + async with self._lock: + await self._auth_session.require_logged_in("rakuten") + await self._refresh_context_if_stale() - # 同 add_to_cart:刷新 context 以反映可能的自动重登结果 - await self._refresh_context_if_stale() + per_task = self._per_task_state.get(task.task_id, {}) + item_id = (task.intent or {}).get("item_id") or per_task.get("item_id") + if not item_id: + raise CartOperationError( + "无法确定 item_id:intent 未提供且 add_to_cart 未记录" + ) - per_task = self._per_task_state.get(task.task_id, {}) - item_id = (task.intent or {}).get("item_id") or per_task.get("item_id") + # 1. cart count API + _, count = await self._query_cart_count() + if count == 0: + raise CartOperationError("购物车为空,加购可能未生效") + logger.info("cart count=%s task_id=%s", count, task.task_id) + + # 2. 渲染 cart 页确认 item_id 在里面 + await self._verify_item_in_cart_html(item_id, label=f"task_id={task.task_id}") + + async def cart_status(self) -> dict: + """轻量查询购物车状态:调 cart count JSONP API,不渲染整页 + + 返回 {logged_in, count, raw_status}。 + count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。 + raw_status 是站点的状态码字符串,"100" 表示正常。 + """ + async with self._lock: + await self._auth_session.require_logged_in("rakuten") + await self._refresh_context_if_stale() + raw_status, count = await self._query_cart_count() + return { + "logged_in": True, + "count": count, + "raw_status": raw_status, + } + + # ---- 已实现:clear_cart / remove_item(Playwright UI 点击)---- + + async def clear_cart(self) -> dict: + """清空购物车:渲染 cart SPA → 反复点第一个「削除」按钮 → count API 校验 + + 策略:每次循环都重新查 `button[aria-label="削除"]`,点完一个等 SPA 重渲染 + 再点下一个,避免索引漂移。最多 _CLEAR_CART_MAX_ITER 次防死循环。 + + 未实测前已知边界: + - 确认 modal 不确定是否存在,按「先 try 找再 click,找不到就继续」处理 + - 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整 + - Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位 + + 返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败。 + """ + async with self._lock: + await self._auth_session.require_logged_in("rakuten") + await self._refresh_context_if_stale() + + page = await self._context.new_page() + removed = 0 + try: + await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000) + await self._wait_cart_rendered(page, label="clear_cart") + + for i in range(_CLEAR_CART_MAX_ITER): + btn = page.locator(_DELETE_BUTTON_SELECTOR).first + try: + await btn.wait_for(state="visible", timeout=2_000) + except Exception: + logger.info("clear_cart:第 %s 次循环未找到删除按钮,结束", i + 1) + break + try: + await btn.click() + except Exception as exc: + logger.warning("clear_cart:点击删除按钮失败:%s", exc) + break + removed += 1 + await self._handle_confirm_modal(page) + # 等 SPA 重新渲染:domcontentloaded 或 1s 兜底 + try: + await page.wait_for_load_state("domcontentloaded", timeout=5_000) + except Exception: + await page.wait_for_timeout(1_000) + else: + logger.warning( + "clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常", + _CLEAR_CART_MAX_ITER, + ) + finally: + await page.close() + + # 末尾用 count API 校验 + try: + _, cart_count = await self._query_cart_count() + except CartOperationError as exc: + logger.warning("clear_cart 后查 cart count 失败:%s", exc.message) + cart_count = -1 + + logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count) + return {"removed_count": removed, "cart_count": cart_count} + + async def remove_item(self, item_id: str) -> dict: + """删除购物车里指定 item_id 的商品 + + 策略:渲染 cart SPA → 在 DOM 里找 button[aria-label="削除"],向上 walk + parentElement 找 innerText 包含 item_id 的祖先 → click 那个按钮。 + + Rakuten cart item 卡片无稳定 data-* 属性,CSS modules hash class 易变, + 只能靠「按钮祖先节点的 innerText 包含目标 item_id」做文本回溯定位。 + item_id 在 Rakuten 是 8 位数字,正常页面其他位置误匹配概率低。 + + Raises: + InvalidRequestError: item_id 为空 + CartOperationError: 购物车里找不到 item_id + NotLoggedInError: 登录态失效 + + 返回 {removed, item_id}。removed=false 表示点击了但 SPA 没在末尾 HTML + 里移除该 item_id(可能删除被站点静默拒绝)。 + """ if not item_id: - raise CartOperationError( - "无法确定 item_id:intent 未提供且 add_to_cart 未记录" - ) + raise InvalidRequestError("item_id 必填") + async with self._lock: + await self._auth_session.require_logged_in("rakuten") + await self._refresh_context_if_stale() - # 1. cart count API + page = await self._context.new_page() + try: + await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000) + await self._wait_cart_rendered(page, label=f"remove_item {item_id}") + + # JS 在 DOM 里定位包含 item_id 的祖先节点的删除按钮,click 它 + clicked = await page.evaluate( + """(itemId) => { + const buttons = document.querySelectorAll('button[aria-label="削除"]'); + for (const btn of buttons) { + let node = btn.parentElement; + for (let i = 0; i < 12 && node; i++) { + const text = node.innerText || ""; + if (text.includes(itemId)) { + btn.click(); + return true; + } + node = node.parentElement; + } + } + return false; + }""", + str(item_id), + ) + if not clicked: + raise CartOperationError( + f"购物车里没有 item_id={item_id}(或 SPA 未渲染出来)" + ) + + await self._handle_confirm_modal(page) + try: + await page.wait_for_load_state("domcontentloaded", timeout=5_000) + except Exception: + await page.wait_for_timeout(1_000) + + # 校验:item_id 不再出现在 cart HTML + html = await page.content() + if str(item_id) in html: + # SPA 可能还没刷新完,再等 2s 兜底 + await page.wait_for_timeout(2_000) + html = await page.content() + removed = str(item_id) not in html + finally: + await page.close() + + logger.info("remove_item 完成:item_id=%s removed=%s", item_id, removed) + return {"removed": removed, "item_id": str(item_id)} + + # ---- 内部辅助:cart count API 与 cart 页渲染 ---- + + async def _query_cart_count(self) -> tuple[str, int]: + """调 cart count JSONP API,返回 (raw_status, count) + + 解析失败、status 非 100 都抛 CartOperationError——这是站点在告诉我们 + 「请求被拒了」,常见原因是 Referer 错或 cookie 失效。 + """ resp = await self._context.request.get( _CART_COUNT_API + "?sid=1010", headers={"Referer": _CART_PAGE}, ) - count_body = await resp.text() - status_match = re.search(r'"status"\s*:\s*"(\d+)"', count_body) + body = await resp.text() + status_match = re.search(r'"status"\s*:\s*"(\d+)"', body) if not status_match: + raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}") + raw_status = status_match.group(1) + if raw_status != "100": raise CartOperationError( - f"cart count 响应无法解析:{count_body[:200]!r}" + f"cart count API 异常:status={raw_status} body={body[:200]!r}" ) - if status_match.group(1) != "100": - raise CartOperationError( - f"cart count API 异常:status={status_match.group(1)} body={count_body[:200]!r}" - ) - count_match = re.search(r'"count"\s*:\s*"(\d+)"', count_body) + count_match = re.search(r'"count"\s*:\s*"(\d+)"', body) count = int(count_match.group(1)) if count_match and count_match.group(1) else 0 - if count == 0: - raise CartOperationError("购物车为空,加购可能未生效") - logger.info("cart count=%s task_id=%s", count, task.task_id) + return raw_status, count - # 2. 渲染 cart 页确认 item_id 在里面 + async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> None: + """渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车""" page = await self._context.new_page() try: await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000) - # 等 SPA 把 shopUrlList / cartItem 渲染出来 - try: - await page.wait_for_function( - """() => { - const s = window.__INITIAL_STATE__; - return s && s.cart && Array.isArray(s.cart.shopUrlList) && s.cart.shopUrlList.length > 0; - }""", - timeout=15_000, - ) - except Exception: - logger.warning( - "cart SPA 15s 内未渲染出 shopUrlList,继续按静态 HTML 校验:task_id=%s", - task.task_id, - ) + await self._wait_cart_rendered(page, label=label) html = await page.content() # 旧 marker 出现一定是登录失效;新 SPA 不渲染 marker,所以这判据是单边的 @@ -365,13 +609,45 @@ class SiteInteractor: raise CartOperationError( f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)" ) - logger.info( - "cart 校验通过:task_id=%s item_id=%s in cart HTML", - task.task_id, item_id, - ) + logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id) finally: await page.close() + async def _wait_cart_rendered(self, page, *, label: str) -> None: + """等 cart SPA 把商品列表渲染出来(shopUrlList 非空),最多 15s + + 失败时只记 warning 不抛——空购物车时 shopUrlList 本就为空,调用方根据 + 后续业务逻辑(看 HTML、看 count API)自行判断。 + """ + try: + await page.wait_for_function( + """() => { + const s = window.__INITIAL_STATE__; + return s && s.cart && Array.isArray(s.cart.shopUrlList) && s.cart.shopUrlList.length > 0; + }""", + timeout=15_000, + ) + except Exception: + logger.warning( + "cart SPA 15s 内未渲染出 shopUrlList(可能购物车为空):%s", label, + ) + + async def _handle_confirm_modal(self, page) -> None: + """点击「削除」后若弹出确认 modal,尝试找「はい」/「OK」按钮点击 + + 站点是否弹 modal 未实测确认,按「先 try 找再 click,找不到就跳过」处理。 + 每个候选 selector 给 1.5s 等待,命中后立即返回。 + """ + for sel in _CONFIRM_BUTTON_SELECTORS: + try: + btn = page.locator(sel).first + await btn.wait_for(state="visible", timeout=1_500) + await btn.click() + logger.info("点击确认 modal 按钮:%s", sel) + return + except Exception: + continue + # ---- 未实现:保留 NotImplementedError ---- async def enter_checkout(self, task: LeaseTask) -> str: @@ -414,9 +690,14 @@ def _parse_initial_state(html: str) -> dict | None: def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> dict: - """从 __INITIAL_STATE__ 抽加购所需字段(与 scraping/parsers/item.py 同源逻辑) + """从 __INITIAL_STATE__ 抽加购所需字段 - intent_override 允许调用方提供 variant_id / choice 覆盖自动选择。 + 与 scraping/parsers/item.py::_purchase_info 共用基础字段构造 + (app.shared.purchase_contract);本函数额外做: + - variant_id 自动选(多规格挑第一个非售罄;调用方覆盖优先) + - choice 自动填(必填选项拼「名:值」;调用方覆盖优先) + - 返回 basket_domain / min_price / purchase_condition / shop_name / item_name + 等业务字段,便于日志与错误信息使用 """ intent_override = intent_override or {} purchase = state.get("purchase") or {} @@ -426,24 +707,22 @@ def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> di shop = (state.get("shop") or {}).get("information") or {} information = purchase.get("information") or {} - basket_domain = (sell_type.get("basketDomain") or "").replace("\\u002F", "/") - inventory_type = raw_sku.get("inventoryType") - inventory_flag = _INVENTORY_FLAG.get(inventory_type, _DEFAULT_INVENTORY_FLAG) + basket_domain = basket_domain_of(sell_type) + inventory_flag = inventory_flag_for(raw_sku.get("inventoryType")) shop_id = shop.get("shopId") item_id = item.get("itemId") - form_fields: dict[str, str] = { - "shop_bid": str(shop_id) if shop_id else "", - "item_id": str(item_id) if item_id else "", - "inventory_flag": inventory_flag, - "__event": _NORMAL_PURCHASE_EVENT, - } + form_fields = base_form_fields( + shop_id=shop_id, + item_id=item_id, + inventory_flag=inventory_flag, + ) # variant_id 选择:调用方覆盖 > 多规格自动选第一个非售罄 > 单规格用 item.variantId chosen_variant = None if intent_override.get("variant_id"): form_fields["variant_id"] = str(intent_override["variant_id"]) - elif inventory_flag == _INVENTORY_FLAG["multiple"]: + elif inventory_flag == INVENTORY_FLAG_MULTIPLE: for v in raw_sku.get("variants") or []: if not v.get("isSoldOut"): chosen_variant = v @@ -454,7 +733,7 @@ def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> di form_fields["variant_id"] = str( chosen_variant.get("variantId") or chosen_variant.get("id") or "" ) - elif inventory_flag == _DEFAULT_INVENTORY_FLAG and item.get("variantId"): + elif inventory_flag == INVENTORY_FLAG_DEFAULT and item.get("variantId"): form_fields["variant_id"] = str(item.get("variantId")) # 必填选项:调用方覆盖 > 自动填第一个候选值 diff --git a/tests/test_purchase.py b/tests/test_purchase.py deleted file mode 100644 index 4497f08..0000000 --- a/tests/test_purchase.py +++ /dev/null @@ -1,187 +0,0 @@ -"""加购信息(purchase 块)解析测试 - -契约取自各站点前端构造加购请求的逻辑,四个来源的端点与字段名互不相同, -因此这里逐来源验证「提交到哪里、带哪些固定字段、数量/规格/选项各用哪个字段名」。 -本服务只提供这些数据,不执行加购。 -""" -from pathlib import Path - -import pytest - -from app.scraping.parsers.item import parse_item_detail, parse_purchase_options -from app.scraping.parsers.subsites import parse_subsite_item -from app.scraping.parsers.subsites.base import SubsitePage -from app.scraping.parsers.subsites.brandavenue import _resolve_cart_url - -FIXTURES = Path(__file__).parent / "fixtures" - -ICHIBA_URL = "https://item.rakuten.co.jp/fafachai/tx-300/" - - -def subsite(filename: str, host: str, shop_code: str, item_code: str): - return parse_subsite_item( - SubsitePage( - html=(FIXTURES / filename).read_text(encoding="utf-8"), - requested_url=f"https://item.rakuten.co.jp/{shop_code}/{item_code}/", - final_url=f"https://{host}/item/{item_code}/", - shop_code=shop_code, - item_code=item_code, - include_sku_variants=True, - ) - ) - - -# ---- 市场 ---- - -def test_ichiba_cart_url_comes_from_item_not_hardcoded(item_state): - """basketDomain 逐商品不同(不同店铺落在不同 basket 集群),不能写死""" - d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True) - assert d.purchase.cart_url == "https://ts.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set" - - -def test_ichiba_form_fields_match_site_contract(item_state): - d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True) - fields = d.purchase.form_fields - assert fields["shop_bid"] == str(d.shop.shop_id) - assert fields["item_id"] == d.item_id - assert fields["__event"] == "ES01_003_001" - assert d.purchase.quantity_field == "units" - assert d.purchase.variant_field == "variant_id" - - -def test_ichiba_multi_sku_reports_inventory_flag_2_without_prefilled_variant(item_state): - """多规格商品必须由调用方选定 SKU,不能预填""" - d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True) - assert d.sku.inventory_type == "multiple" - assert d.purchase.form_fields["inventory_flag"] == "2" - assert "variant_id" not in d.purchase.form_fields - - -def test_ichiba_single_sku_prefills_its_only_variant(item_state): - """单一库存商品规格固定,直接填好,调用方无需再选""" - item_state["purchase"]["sku"]["inventoryType"] = "single" - item_state["item"]["variantId"] = "4902370549263" - d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True) - assert d.purchase.form_fields["inventory_flag"] == "1" - assert d.purchase.form_fields["variant_id"] == "4902370549263" - - -def test_ichiba_selected_variant_id_is_available_from_sku(item_state): - """调用方要往 variant_field 里填的值,来自 sku.variants[].variant_id""" - d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True) - assert d.sku.variants[0].variant_id - - -def test_item_without_options_has_no_options_field(item_state): - d = parse_item_detail(item_state, item_url=ICHIBA_URL, shop_code="fafachai", include_sku_variants=True) - assert d.purchase.options == [] - assert d.purchase.options_field == "" - assert d.purchase.has_required_options is False - - -# ---- 商品选项 ---- - -def test_options_are_parsed_with_types_and_required_flag(item_with_options_state): - d = parse_item_detail( - item_with_options_state, - item_url="https://item.rakuten.co.jp/kizamu/set-beermug-031/", - shop_code="kizamu", - include_sku_variants=True, - ) - assert d.purchase.options_field == "choice" - assert d.purchase.has_required_options is True - types = {o.type for o in d.purchase.options} - assert {"select", "text"} <= types - required = [o for o in d.purchase.options if o.is_required] - assert required and required[0].name - - -def test_select_option_carries_candidate_values(item_with_options_state): - d = parse_item_detail( - item_with_options_state, item_url="https://x", shop_code="kizamu", include_sku_variants=True - ) - select = next(o for o in d.purchase.options if o.type == "select") - assert select.values - assert all(v.name for v in select.values) - - -def test_text_option_has_no_candidate_values(item_with_options_state): - """自由文本选项由买家填写,没有候选值""" - d = parse_item_detail( - item_with_options_state, item_url="https://x", shop_code="kizamu", include_sku_variants=True - ) - text = next(o for o in d.purchase.options if o.type == "text") - assert text.values == [] - - -def test_parse_purchase_options_tolerates_malformed_input(): - assert parse_purchase_options(None) == [] - assert parse_purchase_options([None, "x", 1]) == [] - assert parse_purchase_options([{"id": 1, "name": "a", "type": "select", "values": [None]}])[0].values == [] - - -# ---- 楽天ブックス ---- - -def test_books_item_id_comes_from_cart_form_not_url(): - """加购要用表单里的 item_id;URL 上的编号是市场侧商品编号,拿去下单会失败""" - d = subsite("books_item.html", "books.rakuten.co.jp", "book", "17065211") - assert d.item_code == "17065211" - assert d.item_id == "20600328" - assert d.purchase.form_fields["item_id"] == "20600328" - - -def test_books_cart_endpoint_and_shop_bid(): - d = subsite("books_item.html", "books.rakuten.co.jp", "book", "17065211") - assert d.purchase.cart_url == "https://sp.books.step.rakuten.co.jp/rms/mall/book/bs/Cart" - assert d.purchase.form_fields["shop_bid"] == "213310" - - -def test_books_cart_form_has_no_quantity_or_variant_field(): - """该站表单不支持指定件数与规格,字段名留空以示不可用""" - d = subsite("books_item.html", "books.rakuten.co.jp", "book", "17065211") - assert d.purchase.quantity_field == "" - assert d.purchase.variant_field == "" - - -# ---- Rakuten Fashion ---- - -@pytest.mark.parametrize( - ("cart_url_type", "expected"), - [ - ("6", "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set"), - ("5", "https://basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set"), - ("1", "https://ts.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set"), - ("https://custom.example.com/cart", "https://custom.example.com/cart"), - ("99", ""), - ("", ""), - ], -) -def test_brandavenue_cart_url_type_mapping(cart_url_type, expected): - """端点由 cart_url_type 编号映射得到;已经是 URL 时原样使用""" - assert _resolve_cart_url(cart_url_type) == expected - - -def test_brandavenue_uses_same_contract_as_ichiba(): - d = subsite("brandavenue_item.html", "brandavenue.rakuten.co.jp", "stylife", "md7606") - assert d.purchase.cart_url == "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set" - assert d.purchase.form_fields["shop_bid"] == "279405" - assert d.purchase.form_fields["item_id"] == "14674748" - assert d.purchase.form_fields["inventory_flag"] == "2" - assert d.purchase.quantity_field == "units" - assert d.purchase.variant_field == "variant_id" - - -def test_brandavenue_variant_ids_are_usable_for_cart(): - """variant_field 要填的值就是 sku.variants[].variant_id(来自 inventory_list)""" - d = subsite("brandavenue_item.html", "brandavenue.rakuten.co.jp", "stylife", "md7606") - assert all(v.variant_id for v in d.sku.variants if v.quantity) - - -# ---- ビックカメラ ---- - -def test_biccamera_uses_its_own_json_endpoint_without_shop_bid(): - d = subsite("biccamera_item.html", "biccamera.rakuten.co.jp", "biccamera", "4548736147041") - assert d.purchase.cart_url == "https://biccamera.step.rakuten.co.jp/cart/item" - assert d.purchase.form_fields == {"item_id": "13873289"} - assert d.purchase.quantity_field == "units" - assert "shop_bid" not in d.purchase.form_fields diff --git a/tests/test_trading_api.py b/tests/test_trading_api.py index 1456aa3..689b79d 100644 --- a/tests/test_trading_api.py +++ b/tests/test_trading_api.py @@ -1,7 +1,8 @@ -"""交易服务 API 测试:健康检查、登录态查询与重载、鉴权 +"""交易服务 API 测试:健康检查、登录态查询与重载、购物车接口、鉴权 -登录态会话被替换为桩,不触达真实站点、不需要真实账号。 +登录态会话与站点交互器都被替换为桩,不触达真实站点、不需要真实账号、不起 Playwright。 AuthSession 自身的行为(cookie 加载、探测判据)在 tests/test_auth_session.py。 +SiteInteractor 的 DOM 解析纯函数在 tests/test_site_interact.py。 """ from __future__ import annotations @@ -9,7 +10,7 @@ import pytest from fastapi.testclient import TestClient from app.shared.config import get_settings -from app.shared.errors import NotLoggedInError +from app.shared.errors import CartOperationError, NotLoggedInError from app.trading.main import create_app from app.trading.services.auth_session import AuthStatus @@ -60,23 +61,91 @@ class StubAuthSession: """lifespan 收尾会调用;桩没有真实客户端要关""" +class StubSiteInteractor: + """记录调用并返回固定结果的 SiteInteractor 桩 + + 真实 SiteInteractor 起 Playwright,本桩不打外网、不开浏览器。 + 通过 fail_with 注入异常可测错误路径(5001/5002)。 + """ + + def __init__(self) -> None: + self.add_calls: list[dict] = [] + self.status_calls = 0 + self.clear_calls = 0 + self.remove_calls: list[str] = [] + self.fail_with: Exception | None = None + + async def add_to_cart_payload( + self, + *, + item_url: str, + quantity: int = 1, + variant_id: str | None = None, + choice: str | list[str] | None = None, + ) -> dict: + self.add_calls.append( + { + "item_url": item_url, + "quantity": quantity, + "variant_id": variant_id, + "choice": choice, + } + ) + if self.fail_with: + raise self.fail_with + return { + "item_id": "10000382", + "shop_bid": "284609", + "basket_domain": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set", + "cart_count": 1, + } + + async def cart_status(self) -> dict: + self.status_calls += 1 + if self.fail_with: + raise self.fail_with + return {"logged_in": True, "count": 1, "raw_status": "100"} + + async def clear_cart(self) -> dict: + self.clear_calls += 1 + if self.fail_with: + raise self.fail_with + return {"removed_count": 1, "cart_count": 0} + + async def remove_item(self, item_id: str) -> dict: + self.remove_calls.append(item_id) + if self.fail_with: + raise self.fail_with + return {"removed": True, "item_id": item_id} + + async def close(self) -> None: + """lifespan 收尾会调用;桩没有真实浏览器要关""" + + @pytest.fixture -def client_and_stub(): +def client_and_stubs(): app = create_app() with TestClient(app) as client: stub = StubAuthSession() + stub_site = StubSiteInteractor() app.state.container.auth_session = stub - yield client, stub + app.state.container.site = stub_site + yield client, stub, stub_site @pytest.fixture -def client(client_and_stub): - return client_and_stub[0] +def client(client_and_stubs): + return client_and_stubs[0] @pytest.fixture -def stub(client_and_stub): - return client_and_stub[1] +def stub(client_and_stubs): + return client_and_stubs[1] + + +@pytest.fixture +def stub_site(client_and_stubs): + return client_and_stubs[2] # ---- 健康检查 ---- @@ -155,3 +224,129 @@ def test_reload_reloads_then_probes(client, stub): # 重载后必须立刻探测一次,否则调用方拿不到「这次登录到底成没成」 assert stub.reloaded == ["rakuten"] assert stub.checked == ["rakuten"] + + +# ---- 购物车接口 ---- + + +@pytest.mark.parametrize( + "path", + ["/api/cart/add", "/api/cart/status", "/api/cart/clear", "/api/cart/remove"], +) +def test_cart_endpoints_reject_missing_token(client, path): + """所有 cart 接口都要 Bearer token""" + response = client.post(path, json={}) + assert response.status_code == 401 + assert response.json()["code"] == 1001 + + +def test_cart_add_happy_path(client, stub_site): + """加购成功:返回 added=true 与 cart_count""" + response = client.post( + "/api/cart/add", + json={"item_url": "https://item.rakuten.co.jp/shop/x/", "quantity": 2}, + headers=AUTH, + ) + assert response.status_code == 200 + body = response.json() + assert body["data"]["added"] is True + assert body["data"]["item_id"] == "10000382" + assert body["data"]["shop_bid"] == "284609" + assert body["data"]["cart_count"] == 1 + # 桩记下了入参 + assert stub_site.add_calls == [ + { + "item_url": "https://item.rakuten.co.jp/shop/x/", + "quantity": 2, + "variant_id": None, + "choice": None, + } + ] + + +def test_cart_add_missing_item_url(client): + """item_url 必填,缺失时 Pydantic 在校验层挡下(422)""" + response = client.post("/api/cart/add", json={"quantity": 1}, headers=AUTH) + assert response.status_code == 422 + assert response.json()["code"] == 1002 + + +def test_cart_add_propagates_not_logged_in(client, stub_site): + """SiteInteractor 抛 NotLoggedInError(5001)→ HTTP 401""" + stub_site.fail_with = NotLoggedInError(site="rakuten", detail="stub") + response = client.post( + "/api/cart/add", + json={"item_url": "https://item.rakuten.co.jp/shop/x/"}, + headers=AUTH, + ) + assert response.status_code == 401 + assert response.json()["code"] == 5001 + + +def test_cart_add_propagates_cart_error(client, stub_site): + """SiteInteractor 抛 CartOperationError(5002)→ HTTP 400""" + stub_site.fail_with = CartOperationError("商品不可购买:purchaseCondition=disabled") + response = client.post( + "/api/cart/add", + json={"item_url": "https://item.rakuten.co.jp/shop/x/"}, + headers=AUTH, + ) + assert response.status_code == 400 + assert response.json()["code"] == 5002 + + +def test_cart_status_happy_path(client, stub_site): + response = client.post("/api/cart/status", json={}, headers=AUTH) + assert response.status_code == 200 + body = response.json() + assert body["data"]["logged_in"] is True + assert body["data"]["count"] == 1 + assert body["data"]["raw_status"] == "100" + assert stub_site.status_calls == 1 + + +def test_cart_clear_happy_path(client, stub_site): + response = client.post("/api/cart/clear", json={}, headers=AUTH) + assert response.status_code == 200 + body = response.json() + assert body["data"]["removed_count"] == 1 + assert body["data"]["cart_count"] == 0 + assert stub_site.clear_calls == 1 + + +def test_cart_remove_happy_path(client, stub_site): + response = client.post( + "/api/cart/remove", json={"item_id": "10000382"}, headers=AUTH + ) + assert response.status_code == 200 + body = response.json() + assert body["data"]["removed"] is True + assert body["data"]["item_id"] == "10000382" + assert stub_site.remove_calls == ["10000382"] + + +def test_cart_remove_missing_item_id(client): + response = client.post("/api/cart/remove", json={}, headers=AUTH) + assert response.status_code == 422 + assert response.json()["code"] == 1002 + + +def test_cart_remove_propagates_cart_error(client, stub_site): + """指定 item_id 不在购物车里 → 5002""" + stub_site.fail_with = CartOperationError("购物车里没有 item_id=99999") + response = client.post( + "/api/cart/remove", json={"item_id": "99999"}, headers=AUTH + ) + assert response.status_code == 400 + assert response.json()["code"] == 5002 + + +def test_cart_endpoints_unavailable_when_site_is_none(client): + """SiteInteractor 未就绪(container.site 为 None)→ UpstreamRequestError 3001 + + 生产环境 lifespan 一定构造了 site;本测试模拟异常启动路径。 + """ + client.app.state.container.site = None + response = client.post("/api/cart/status", json={}, headers=AUTH) + assert response.status_code == 400 + assert response.json()["code"] == 3001