From f15ca74a220aa78afe5b2e375edd2c1dac02f4e5 Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 16 Jun 2026 01:25:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(store-mapping):=20JD=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=20deeplink=20=E5=A4=B1=E6=95=88=E6=A0=87=E8=AE=B0(=E5=AF=B9?= =?UTF-8?q?=E6=A0=87=E6=B7=98=E5=AE=9D)=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/56 --- .../versions/store_mapping_jd_dl_invalid.py | 32 ++++++++++++++ app/api/internal/store.py | 9 ++-- app/api/v1/coupon.py | 15 ++++++- app/models/store_mapping.py | 2 + app/repositories/coupon_state.py | 28 +++++++++++- app/repositories/store_mapping.py | 20 +++++++++ app/schemas/coupon_state.py | 10 +++++ tests/test_store_mapping_invalidate.py | 44 +++++++++++++++++-- 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/store_mapping_jd_dl_invalid.py diff --git a/alembic/versions/store_mapping_jd_dl_invalid.py b/alembic/versions/store_mapping_jd_dl_invalid.py new file mode 100644 index 0000000..09a1af2 --- /dev/null +++ b/alembic/versions/store_mapping_jd_dl_invalid.py @@ -0,0 +1,32 @@ +"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at + +Revision ID: store_mapping_jd_dl_invalid +Revises: store_mapping_tb_dl_invalid +Create Date: 2026-06-15 00:00:00.000000 + +京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到 +失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻 +(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。 +与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_jd_dl_invalid' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_column('jd_deeplink_invalid_at') diff --git a/app/api/internal/store.py b/app/api/internal/store.py index b7959e7..104d379 100644 --- a/app/api/internal/store.py +++ b/app/api/internal/store.py @@ -95,11 +95,14 @@ def invalidate_store_mapping( x_internal_secret: Annotated[str | None, Header()] = None, ) -> StoreMappingInvalidateOut: _check_secret(x_internal_secret) - if payload.platform != "taobao": - # 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 + if payload.platform == "taobao": + affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id) + elif payload.platform == "jd": + affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id) + else: + # 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform) return StoreMappingInvalidateOut(ok=True, affected=0) - affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id) logger.info( "store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行", payload.platform, payload.shop_id, affected, diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 6e07120..07c19af 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -18,7 +18,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from app.api.deps import DbSession +from app.api.deps import CurrentUser, DbSession from app.core.config import settings from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal @@ -28,6 +28,7 @@ from app.schemas.coupon_state import ( CouponPromptDismissIn, CouponPromptShouldShowOut, CouponPromptShownIn, + CouponStatsOut, ) logger = logging.getLogger("shagua.coupon") @@ -268,3 +269,15 @@ def coupon_completed_today_reset( 与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。""" coupon_repo.reset_today_completion(db, payload.device_id) return {"ok": True} + + +@router.get( + "/stats", + response_model=CouponStatsOut, + summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)", +) +def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut: + """该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。 + **鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id): + 个人战绩按 user_id 聚合,必须有登录态。""" + return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id)) diff --git a/app/models/store_mapping.py b/app/models/store_mapping.py index 35272d0..78850a2 100644 --- a/app/models/store_mapping.py +++ b/app/models/store_mapping.py @@ -105,6 +105,8 @@ class StoreMapping(Base): jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链 jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink + # 京东 deeplink 失效标记(同 taobao_deeplink_invalid_at):撞"当前门店超出配送范围"页时被置,NULL=有效。 + jd_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # 灵活字段兜底(免得加字段就迁移) attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 33f4781..e3e2765 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -9,7 +9,7 @@ import logging from datetime import date, datetime from zoneinfo import ZoneInfo -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -206,3 +206,29 @@ def record_claims( ) return 0 return written + + +# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)===== + +def sum_claimed_count(db: Session, user_id: int) -> int: + """该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) —— + 各成功领券记录的 pricebot 展示张数(claimed_count 列,存的是 display_count)之和, + 与领券完成时给用户看的「本次领了 N 张」同源。 + + - 只算 status ∈ {success, already_claimed}:already_claimed=今日已领过,协议里算「已领到」; + failed / skipped 不计。 + - claimed_count 为 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计; + 为 NULL 的兜底成 1(成功领到至少 1 张;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)。 + - 维度 user_id:登录态领的券才归入。登录前匿名领的(user_id 为空)不算(产品可接受)。 + """ + total = db.execute( + select( + func.coalesce( + func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0 + ) + ).where( + CouponClaimRecord.user_id == user_id, + CouponClaimRecord.status.in_(("success", "already_claimed")), + ) + ).scalar_one() + return int(total or 0) diff --git a/app/repositories/store_mapping.py b/app/repositories/store_mapping.py index 358aa7d..ed59cfc 100644 --- a/app/repositories/store_mapping.py +++ b/app/repositories/store_mapping.py @@ -124,6 +124,23 @@ def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int: return result.rowcount or 0 +def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int: + """把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。 + + 同 mark_taobao_deeplink_invalid:按 storeId 标记**所有**行(撞京东"当前门店超出配送范围"页), + 全标掉才能让后续 lookup 不再返回它。幂等:已标记的行(invalid_at 非 NULL)跳过。""" + result = db.execute( + update(StoreMapping) + .where( + StoreMapping.id_jd == store_id, + StoreMapping.jd_deeplink_invalid_at.is_(None), + ) + .values(jd_deeplink_invalid_at=func.now()) + ) + db.commit() + return result.rowcount or 0 + + # ============================================================ # 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接 # deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。 @@ -181,6 +198,9 @@ def lookup_nearest( if tgt == "taobao": # 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。 cands = [r for r in cands if r.taobao_deeplink_invalid_at is None] + elif tgt == "jd": + # 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。 + cands = [r for r in cands if r.jd_deeplink_invalid_at is None] if not cands: continue best = _pick_best(cands, lat, lng) diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 15e9c16..236d311 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -39,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel): """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" completed: bool + + +class CouponStatsOut(BaseModel): + """「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。 + + 口径(2026-06-15 用户定):SUM(claimed_count) —— 各成功领券记录的 pricebot 展示张数之和, + 与领券完成时给用户看的「本次领了 N 张」同源。详见 repositories.coupon_state.sum_claimed_count。 + """ + + coupon_count: int diff --git a/tests/test_store_mapping_invalidate.py b/tests/test_store_mapping_invalidate.py index e1930f9..2d3188f 100644 --- a/tests/test_store_mapping_invalidate.py +++ b/tests/test_store_mapping_invalidate.py @@ -16,11 +16,13 @@ _SECRET = "test-internal-secret-only-for-pytest" def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None, - id_taobao=None, deeplink=None, lat=None, lng=None): + id_taobao=None, deeplink=None, lat=None, lng=None, + name_jd=None, id_jd=None, jd_deeplink=None): row = StoreMapping( trace_id=trace_id, business_type="food", name_meituan=name_meituan, name_taobao=name_taobao, id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng, + name_jd=name_jd, id_jd=id_jd, jd_deeplink=jd_deeplink, ) db.add(row) db.commit() @@ -110,9 +112,45 @@ def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch): assert "taobao" not in lk.json() -def test_invalidate_endpoint_non_taobao_noop(client, monkeypatch): +def test_invalidate_endpoint_unsupported_platform_noop(client, monkeypatch): + # 当前支持 taobao/jd;其它平台(如 meituan)no-op 返 affected=0 monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) r = client.post("/internal/store-mapping/invalidate", - json={"platform": "jd", "shop_id": "X"}, + json={"platform": "meituan", "shop_id": "X"}, headers={"X-Internal-Secret": _SECRET}) assert r.status_code == 200 and r.json()["affected"] == 0 + + +# ---------- 京东(对称淘宝)---------- + +def test_mark_jd_invalid_marks_all_rows_with_store_id(db): + _mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_a") + _mk_row(db, trace_id="j2", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_b") + _mk_row(db, trace_id="j3", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JGOOD", jd_deeplink="dl_g") + assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 2 + assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 0 # 幂等 + rows = {r.trace_id: r for r in db.query(StoreMapping).all()} + assert rows["j1"].jd_deeplink_invalid_at is not None + assert rows["j2"].jd_deeplink_invalid_at is not None + assert rows["j3"].jd_deeplink_invalid_at is None # 不同 storeId 不动 + + +def test_lookup_filters_invalid_jd(db): + _mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl") + assert repo.lookup_nearest(db, "meituan", "兰州拉面")["jd"]["store_id"] == "JBAD" + repo.mark_jd_deeplink_invalid(db, "JBAD") + assert "jd" not in repo.lookup_nearest(db, "meituan", "兰州拉面") + + +def test_invalidate_endpoint_jd_marks_and_lookup_miss(client, db, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + _mk_row(db, trace_id="j1", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl") + _mk_row(db, trace_id="j2", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl2") + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "jd", "shop_id": "JBADX"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 2 + lk = client.get("/internal/store-mapping/lookup", + params={"source_platform": "meituan", "name": "店J"}, + headers={"X-Internal-Secret": _SECRET}) + assert "jd" not in lk.json()