feat(cps): 美团/京东领券前微信头像授权插页
落地页对齐淘宝:微信内已拿 openid 但未授权头像时出单按钮插页,点按钮触发 snsapi_userinfo 授权(交互手势避免快照页),授权回来经 ?authed=1 自动跳券;取消授权由前端 pageshow/visibilitychange 转跳目标券页 + 回调空 code 兜底,绝不阻断领券。受 wx_oauth_active 开关控制。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
"""把字符串安全嵌进 <script> 的 JS 字面量:json.dumps 不转义 '<',值里含 '</script>'
|
||||
会逃逸标签 → 把 '<' 转成 \\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,''
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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 = """<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<title>领取优惠券</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.card{width:100%;max-width:340px;background:#fff;border-radius:18px;padding:34px 24px;text-align:center;box-shadow:0 8px 24px rgba(255,59,59,.12)}
|
||||
.title{font-size:22px;font-weight:800;color:#ff3b3b;margin-bottom:10px}
|
||||
.sub{font-size:14px;color:#999;line-height:1.6;margin-bottom:28px}
|
||||
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:18px;font-weight:800;font-family:inherit;text-align:center;padding:15px;border:none;border-radius:28px;cursor:pointer;box-shadow:0 6px 16px rgba(255,59,59,.4)}
|
||||
.btn:active{transform:scale(.98)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="title">🎁 优惠券待领取</div>
|
||||
<div class="sub">点击下方按钮,领取你的专属优惠券</div>
|
||||
<button class="btn" onclick="claim()">立即领取优惠券</button>
|
||||
</div>
|
||||
<script>
|
||||
var AUTH=__AUTH_URL__,TARGET=__TARGET_URL__;
|
||||
function claim(){
|
||||
try{sessionStorage.setItem('mt_auth_ts',String(Date.now()))}catch(e){}
|
||||
location.href=AUTH;
|
||||
}
|
||||
// 从授权页返回(点了「取消」/未完成授权):若刚点过领取(3 分钟内)→ 直接进目标券页,
|
||||
// 保证拒绝也能拿到券(绝不阻断领券)。pageshow 覆盖 BFCache 与整页重载,visibilitychange
|
||||
// 兜底"页面保活只切可见性"的情况;靠 sessionStorage 时间戳防陈旧/误触发。
|
||||
function maybeForward(){
|
||||
var ts=0;try{ts=parseInt(sessionStorage.getItem('mt_auth_ts')||'0',10)}catch(e){}
|
||||
if(ts&&Date.now()-ts<180000){
|
||||
try{sessionStorage.removeItem('mt_auth_ts')}catch(e){}
|
||||
location.href=TARGET;
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow',maybeForward);
|
||||
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')maybeForward()});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user