feat(store-mapping): JD 缓存 deeplink 失效标记(对标淘宝) (#56)
Reviewed-on: #56
This commit was merged in pull request #56.
This commit is contained in:
@@ -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')
|
||||||
@@ -95,11 +95,14 @@ def invalidate_store_mapping(
|
|||||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||||
) -> StoreMappingInvalidateOut:
|
) -> StoreMappingInvalidateOut:
|
||||||
_check_secret(x_internal_secret)
|
_check_secret(x_internal_secret)
|
||||||
if payload.platform != "taobao":
|
if payload.platform == "taobao":
|
||||||
# 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
|
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)
|
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
|
||||||
return StoreMappingInvalidateOut(ok=True, affected=0)
|
return StoreMappingInvalidateOut(ok=True, affected=0)
|
||||||
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行",
|
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行",
|
||||||
payload.platform, payload.shop_id, affected,
|
payload.platform, payload.shop_id, affected,
|
||||||
|
|||||||
+14
-1
@@ -18,7 +18,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from fastapi.concurrency import run_in_threadpool
|
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.config import settings
|
||||||
from app.core.pricebot_router import pick_pricebot
|
from app.core.pricebot_router import pick_pricebot
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
@@ -28,6 +28,7 @@ from app.schemas.coupon_state import (
|
|||||||
CouponPromptDismissIn,
|
CouponPromptDismissIn,
|
||||||
CouponPromptShouldShowOut,
|
CouponPromptShouldShowOut,
|
||||||
CouponPromptShownIn,
|
CouponPromptShownIn,
|
||||||
|
CouponStatsOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.coupon")
|
logger = logging.getLogger("shagua.coupon")
|
||||||
@@ -268,3 +269,15 @@ def coupon_completed_today_reset(
|
|||||||
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
|
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
|
||||||
coupon_repo.reset_today_completion(db, payload.device_id)
|
coupon_repo.reset_today_completion(db, payload.device_id)
|
||||||
return {"ok": True}
|
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))
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ class StoreMapping(Base):
|
|||||||
jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链
|
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_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
|
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)
|
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import logging
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -206,3 +206,29 @@ def record_claims(
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
return written
|
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)
|
||||||
|
|||||||
@@ -124,6 +124,23 @@ def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
|
|||||||
return result.rowcount or 0
|
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 直接
|
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
|
||||||
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
|
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
|
||||||
@@ -181,6 +198,9 @@ def lookup_nearest(
|
|||||||
if tgt == "taobao":
|
if tgt == "taobao":
|
||||||
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
|
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
|
||||||
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
|
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:
|
if not cands:
|
||||||
continue
|
continue
|
||||||
best = _pick_best(cands, lat, lng)
|
best = _pick_best(cands, lat, lng)
|
||||||
|
|||||||
@@ -39,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel):
|
|||||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
|
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
|
||||||
|
|
||||||
completed: bool
|
completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CouponStatsOut(BaseModel):
|
||||||
|
"""「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。
|
||||||
|
|
||||||
|
口径(2026-06-15 用户定):SUM(claimed_count) —— 各成功领券记录的 pricebot 展示张数之和,
|
||||||
|
与领券完成时给用户看的「本次领了 N 张」同源。详见 repositories.coupon_state.sum_claimed_count。
|
||||||
|
"""
|
||||||
|
|
||||||
|
coupon_count: int
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ _SECRET = "test-internal-secret-only-for-pytest"
|
|||||||
|
|
||||||
|
|
||||||
def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None,
|
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(
|
row = StoreMapping(
|
||||||
trace_id=trace_id, business_type="food",
|
trace_id=trace_id, business_type="food",
|
||||||
name_meituan=name_meituan, name_taobao=name_taobao,
|
name_meituan=name_meituan, name_taobao=name_taobao,
|
||||||
id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng,
|
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.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -110,9 +112,45 @@ def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch):
|
|||||||
assert "taobao" not in lk.json()
|
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)
|
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
|
||||||
r = client.post("/internal/store-mapping/invalidate",
|
r = client.post("/internal/store-mapping/invalidate",
|
||||||
json={"platform": "jd", "shop_id": "X"},
|
json={"platform": "meituan", "shop_id": "X"},
|
||||||
headers={"X-Internal-Secret": _SECRET})
|
headers={"X-Internal-Secret": _SECRET})
|
||||||
assert r.status_code == 200 and r.json()["affected"] == 0
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user