Merge origin/main into feat/a11y-liveness-heartbeat

冲突全在领券「弹窗频控按 App 独立」这一功能——本分支夹带的旧 WIP 草稿(2026-06-12,
app_package 可空+NULL 兜底行)与 main 上已合并的最终版 #52(2026-06-14,package NOT NULL
+"" 占位)是同一功能的两套不兼容实现。

解决:四个 coupon 文件(api/models/repositories/schemas)一律取 main(#52)——它是
权威已合版,且文件未冲突区已自动并入 #52 的签名,只有取 theirs 才自洽。

Alembic 收敛为单 head:
- 删本分支 WIP 迁移 coupon_engagement_app_package(否则与 #52 重复建 per-App 列)
  及其仅用于挂接它的两个空 merge 节点 3a9941e76909 / f3d0a16bb4c2
- device_liveness_table 的 down_revision 由 f3d0a16bb4c2 改挂 main 头 store_mapping_tb_dl_invalid

验证:alembic heads 单头;全链在临时 sqlite upgrade head 通过;
coupon_prompt_engagement 只有 package、无 app_package;app 全量 import 通过。

注:本分支不再保留他人领券 WIP——无损,#52 即该功能的最终版,已在 main。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈世睿
2026-06-16 10:12:49 +08:00
37 changed files with 658 additions and 328 deletions
+29 -1
View File
@@ -17,7 +17,12 @@ from fastapi import APIRouter, Header
from app.api.deps import DbSession
from app.api.internal.price import _check_secret
from app.repositories import store_mapping as repo
from app.schemas.store_mapping import StoreMappingIn, StoreMappingOut
from app.schemas.store_mapping import (
StoreMappingIn,
StoreMappingInvalidateIn,
StoreMappingInvalidateOut,
StoreMappingOut,
)
logger = logging.getLogger("shagua.internal.store")
@@ -77,3 +82,26 @@ def report_store_mapping(
payload.source_device_id, payload.source_user_id,
)
return StoreMappingOut(inserted=created, row_id=row_id)
@router.post(
"/store-mapping/invalidate",
response_model=StoreMappingInvalidateOut,
summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)",
)
def invalidate_store_mapping(
payload: StoreMappingInvalidateIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingInvalidateOut:
_check_secret(x_internal_secret)
if payload.platform != "taobao":
# 当前只接淘宝; 其它平台先 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,
)
return StoreMappingInvalidateOut(ok=True, affected=affected)
+35 -35
View File
@@ -67,11 +67,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]:
def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str
device_id: str, package: str, user_id: int | None, engage_type: str
) -> None:
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
with SessionLocal() as db:
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
def _record_claims_blocking(
@@ -112,16 +112,17 @@ async def coupon_step(
device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id")
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
pkg = meta.get("package") or ""
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started)
# 透传链路拿不到发起 App 的包名 → 写 app_package=NULL 的全局兜底行:只对旧式不带
# package 的 should-show 查询生效;按 App 频控(2026-06-12)由客户端 /prompt/shown
# 负责,本行不堵其他 App 的弹窗(领一半终止,其他平台还要弹)。写库失败绝不能
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0:
try:
await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started"
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
)
except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e)
@@ -191,30 +192,28 @@ async def coupon_step(
return resp_json
@router.post("/prompt/shown", summary="领券引导窗已对某 App 弹出(按 App 频控主判据)")
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹窗一亮即调用 → 记一条今日该 App 的 engagement(shown)。
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹
2026-06-12 频控按 App 为单位(方案文档「一app 一天只能弹一次,时机=每天第一次
进入」):以"弹出"为频控锚点,用户领/拒/无视都只算这一次;dismiss/claim_started
后续只升级同一行的 engage_type。MVP 不鉴权,按 device_id 记。
频控主判据(管跨重装):弹出即占用今天这App "一次"。用户领/拒/无视都算用掉。
后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。
"""
coupon_repo.mark_engagement(
db, payload.device_id, payload.user_id, "shown", payload.package
db, payload.device_id, payload.package, payload.user_id, "shown"
)
return {"ok": True}
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天 App 不再弹。
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
频控以 /prompt/shown 为主判据,本端点把同一行升级成 dismissed(记录用);
旧客户端不带 package → 写 NULL 全局兜底行(旧语义)。MVP 不鉴权,按 device_id 记。
频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
"""
coupon_repo.mark_engagement(
db, payload.device_id, payload.user_id, "dismissed", payload.package
db, payload.device_id, payload.package, payload.user_id, "dismissed"
)
return {"ok": True}
@@ -222,35 +221,23 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict
@router.get(
"/prompt/should-show",
response_model=CouponPromptShouldShowOut,
summary="切到外卖 App 时是否还应弹领券引导窗(按 App 频控)",
summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
device_id: str, db: DbSession, package: str | None = None
device_id: str, db: DbSession, package: str = ""
) -> CouponPromptShouldShowOut:
"""should_show = 今天没跑完整轮领券 AND 该 App 今天没弹过。
2026-06-12 按 App 为单位(产品确认):
- 该 App 今天弹过(shown/dismissed/claim_started 任一)→ false;
- **其他 App** 弹过/领了一半不影响本 App → 仍 true(没领完进其他平台要再弹);
- 今天已跑完整轮(coupon_daily_completion,"彻底领完")→ 全局 false。
package 不传 = 旧客户端,退回旧全局语义(任意一行算 engage)+ 同样吃 completion 闸。
客户端据此决定弹不弹(纯后台判据,客户端不做前台 SP 缓存判断)。"""
if coupon_repo.has_completed_today(db, device_id):
return CouponPromptShouldShowOut(should_show=False)
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
)
@router.post("/prompt/reset", summary="重置今日领券状态:弹窗 engagement + 完成记录(开发测用)")
@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""删这台设备今天的 engagement + coupon_daily_completion:弹窗又能弹,且首页
「去领取」卡/弹窗「一键自动领取」CTA 恢复可点(只删 engagement 的话, 重置后 CTA
仍被"今日已跑完整轮"置灰, 2026-06-12)。配合客户端本地重置 = 等效重装。
领券记录(coupon_claim_record)是资产沉淀、不参与任何门控判断, 不删。
"""删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
coupon_repo.reset_today_engagement(db, payload.device_id)
coupon_repo.reset_today_completion(db, payload.device_id)
return {"ok": True}
@@ -268,3 +255,16 @@ def coupon_completed_today(
return CouponCompletedTodayOut(
completed=coupon_repo.has_completed_today(db, device_id)
)
@router.post(
"/completed-today/reset",
summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
)
def coupon_completed_today_reset(
payload: CouponPromptDismissIn, db: DbSession
) -> dict[str, bool]:
"""删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
coupon_repo.reset_today_completion(db, payload.device_id)
return {"ok": True}
+4 -5
View File
@@ -1,7 +1,7 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
截图复用 [app.core.media] 落盘到 /media/feedback/。
"""
@@ -20,7 +20,7 @@ logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_MAX_IMAGES = 6
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
@@ -30,7 +30,8 @@ async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -39,8 +40,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")