From 37619d4ac5d16a9168ec327efc5943900b40f320 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 25 Jun 2026 16:21:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(cps):=20=E7=BE=8E=E5=9B=A2/=E4=BA=AC?= =?UTF-8?q?=E4=B8=9C=E9=A2=86=E5=88=B8=E5=89=8D=E5=BE=AE=E4=BF=A1=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E6=8E=88=E6=9D=83=E6=8F=92=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落地页对齐淘宝:微信内已拿 openid 但未授权头像时出单按钮插页,点按钮触发 snsapi_userinfo 授权(交互手势避免快照页),授权回来经 ?authed=1 自动跳券;取消授权由前端 pageshow/visibilitychange 转跳目标券页 + 回调空 code 兜底,绝不阻断领券。受 wx_oauth_active 开关控制。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/cps_redirect.py | 98 ++++++++++++++++++++++++++++++++++++-- tests/test_cps_redirect.py | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 tests/test_cps_redirect.py diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 57b5387..09f953e 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -94,16 +94,35 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): return HTMLResponse( _taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo) ) - # 美团短链 / 京东链接:直接 302 跳 + # 美团短链 / 京东链接:微信内 + 已拿 openid + 还没授权过头像 → 先出「领券前授权」插页, + # 点按钮(交互手势,避免微信快照页)走 snsapi_userinfo 拿昵称头像,授权回来经 + # /c/{code}?authed=1 自动 302 跳券。其余(非微信 / 未配授权 / 无 openid / 已授权)直接 302。 + # 产品决定:每次未授权都提示(不做冷却、不种"已提示"cookie)。取消授权由插页前端自动 + # 转跳目标券页(无单独"暂不授权"出口),拒绝也能拿到券(对齐「绝不阻断领券」)。 + if ( + settings.wx_oauth_active + and _is_wechat(request) + and openid is not None + and request.cookies.get(_WX_UINFO_COOKIE) != "1" + ): + return HTMLResponse(_coupon_auth_landing_html(link.target_url, code)) return RedirectResponse(link.target_url, status_code=302) @router.get("/wx/oauth/cb", include_in_schema=False) -def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)): +def wx_oauth_cb( + code: str | None = None, state: str | None = None, db: Session = Depends(get_db) +): """微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。 换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页, 绝不阻断用户领券。""" - kind, _, orig_code = state.partition(":") + kind, _, orig_code = (state or "").partition(":") + # 授权弹窗点「取消」时,部分微信版本会带空 code 回调本端点(而非退回落地页)。无 code 换 + # 不到 openid,直接兜底跳回落地页(美团/京东落地页前端会再转跳目标券页),不报 422。 + if not code: + return RedirectResponse( + f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302 + ) try: token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...} openid = token["openid"] @@ -139,6 +158,12 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict return {"ok": True} +def _js_str(s: str) -> str: + """把字符串安全嵌进 ' + 会逃逸标签 → 把 '<' 转成 \\u003c(JS 仍解析为 '<')。auth/target/淘口令等都走它。""" + return json.dumps(s).replace("<", "\\u003c") + + def _taobao_landing_html( token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool ) -> str: @@ -156,8 +181,8 @@ def _taobao_landing_html( return ( _TAOBAO_HTML .replace("__IMAGE_URL__", safe_img) - .replace("__UINFO_URL__", json.dumps(uinfo_url)) - .replace("__TOKEN_JS__", json.dumps(token)) + .replace("__UINFO_URL__", _js_str(uinfo_url)) + .replace("__TOKEN_JS__", _js_str(token)) ) @@ -210,3 +235,66 @@ if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,'' """ + + +def _coupon_auth_landing_html(target_url: str, code: str) -> str: + """美团/京东「领券前先授权头像」插页(单按钮)。 + + 唯一按钮「立即领取优惠券」→ snsapi_userinfo 授权(用户点击=交互手势,避免微信快照页)。 + - 点「允许」→ 回调拿昵称头像 + 种 wx_uinfo cookie → 经 /c/{code}?authed=1 自动 302 跳券。 + - 点「取消」→ 微信退回本插页,前端 pageshow/visibilitychange 检测到"刚点过领取"即直接 + 转跳 target(不再设单独的"暂不授权"出口),保证拒绝也能拿到券(对齐「绝不阻断领券」)。 + AUTH/TARGET 经 json.dumps 安全嵌入 JS 字符串(URL 里的 & 不会被 HTML 转义,直跳更稳)。 + """ + redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" + auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}") + return ( + _COUPON_AUTH_HTML + .replace("__AUTH_URL__", _js_str(auth_url)) + .replace("__TARGET_URL__", _js_str(target_url)) + ) + + +_COUPON_AUTH_HTML = """ + + + + +领取优惠券 + + + +
+
🎁 优惠券待领取
+
点击下方按钮,领取你的专属优惠券
+ +
+ + +""" diff --git a/tests/test_cps_redirect.py b/tests/test_cps_redirect.py new file mode 100644 index 0000000..a5cffd6 --- /dev/null +++ b/tests/test_cps_redirect.py @@ -0,0 +1,87 @@ +"""美团/京东 CPS 落地页「领券前授权头像」插页(/c/{code})。 + +验证:微信内未授权头像 → 出插页(snsapi_userinfo 按钮 + 「直接领券」出口); +已授权(wx_uinfo cookie) / 非微信 → 直接 302 跳 target。 +插页只渲染 HTML、不真调微信,故无需 mock 微信网络。 +""" +from __future__ import annotations + +import pytest + +from app.core.config import settings +from app.db.session import SessionLocal +from app.repositories import cps_link as cps_link_repo + +_WECHAT_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) MicroMessenger/8.0.40" +_OTHER_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Safari/605.1" + + +@pytest.fixture() +def _wx_oauth_on(monkeypatch): + """打开微信网页授权(插页只在 settings.wx_oauth_active 时出)。""" + monkeypatch.setattr(settings, "WX_MP_APPID", "wxtestappid") + monkeypatch.setattr(settings, "WX_MP_SECRET", "wxtestsecret") + monkeypatch.setattr(settings, "WX_MP_OAUTH_ENABLED", True) + monkeypatch.setattr(settings, "CPS_REDIRECT_BASE", "https://coupon.example.com") + + +def _make_link(platform: str, target: str) -> tuple[str, str]: + with SessionLocal() as db: + link = cps_link_repo.create_link( + db, group_id=1, activity_id=1, + sid="g1" if platform == "meituan" else None, + target_url=target, platform=platform, + ) + return link.code, link.target_url + + +@pytest.mark.parametrize("platform,target", [ + ("meituan", "https://dpurl.cn/mt-abc"), + ("jd", "https://u.jd.com/jd-xyz"), +]) +def test_wechat_unauthorized_shows_interstitial(client, _wx_oauth_on, platform, target): + """微信内 + 有 openid + 无 wx_uinfo → 200 插页:userinfo 授权按钮 + 直接领券出口。""" + code, tgt = _make_link(platform, target) + r = client.get( + f"/c/{code}", + headers={"user-agent": _WECHAT_UA, "cookie": "wx_openid=o_test"}, + follow_redirects=False, + ) + assert r.status_code == 200, r.text + body = r.text + assert "snsapi_userinfo" in body # 唯一按钮走 userinfo 授权(交互手势触发) + assert code in body # 授权 state=uinfo:{code} + assert tgt in body # 取消授权时前端转跳的目标券页(绝不阻断领券) + assert "暂不授权" not in body # 已去掉单独的"暂不授权"出口,改为取消即自动转跳 + + +def test_authorized_redirects_to_target(client, _wx_oauth_on): + """已授权头像(wx_uinfo=1)→ 不再出插页,直接 302 跳 target。""" + code, tgt = _make_link("meituan", "https://dpurl.cn/mt-ok") + r = client.get( + f"/c/{code}", + headers={"user-agent": _WECHAT_UA, "cookie": "wx_openid=o_test; wx_uinfo=1"}, + follow_redirects=False, + ) + assert r.status_code == 302 + assert r.headers["location"] == tgt + + +def test_non_wechat_redirects_to_target(client, _wx_oauth_on): + """非微信浏览器(拿不了授权)→ 直接 302 跳 target,不出插页。""" + code, tgt = _make_link("meituan", "https://dpurl.cn/mt-other") + r = client.get( + f"/c/{code}", + headers={"user-agent": _OTHER_UA, "cookie": "wx_openid=o_test"}, + follow_redirects=False, + ) + assert r.status_code == 302 + assert r.headers["location"] == tgt + + +def test_oauth_cb_cancel_no_code_redirects_back(client): + """授权弹窗点「取消」、微信带空 code 回调 → 兜底 302 回落地页(不报 422)。 + 落地页再由前端转跳目标券页,保证拒绝也能拿到券。""" + r = client.get("/wx/oauth/cb?state=uinfo:ABC123", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"] == "/c/ABC123"