web-view 不能直接打开 open.weixin.qq.com,移动端改为打开已配置的业务域名中转页。 Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
6.2 KiB
Python
137 lines
6.2 KiB
Python
"""本人服务号绑定;OAuth 回调仅暂存候选,不直接绑定、不授予门户登录。"""
|
|
from html import escape
|
|
import json
|
|
from pathlib import Path
|
|
import secrets
|
|
from typing import Literal, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.auth import get_current_user
|
|
from app.core.database import get_db
|
|
from app.core.response import success_response
|
|
from app.services import wechat_notice_binding as svc
|
|
|
|
router = APIRouter(tags=["服务号通知"])
|
|
|
|
|
|
class StartBinding(BaseModel):
|
|
mode: Literal["desktop", "mobile"] = "desktop"
|
|
|
|
|
|
class ConfirmBinding(BaseModel):
|
|
session_id: str = Field(alias="sessionId", min_length=20, max_length=100)
|
|
|
|
|
|
@router.get("/web/wechat-notices/binding")
|
|
def binding(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return success_response(svc.binding_status(db, user))
|
|
|
|
|
|
@router.post("/web/wechat-notices/binding/start")
|
|
def start(body: Optional[StartBinding] = None, user: dict = Depends(get_current_user)):
|
|
return success_response(svc.start_binding(user, mode=body.mode if body else "desktop"))
|
|
|
|
|
|
@router.get("/web/wechat-notices/binding/session")
|
|
def session(sessionId: str = Query(min_length=20, max_length=100), user: dict = Depends(get_current_user)):
|
|
return success_response(svc.binding_session(user, sessionId))
|
|
|
|
|
|
@router.post("/web/wechat-notices/binding/confirm")
|
|
def confirm(body: ConfirmBinding, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return success_response(svc.confirm_binding(db, user, body.session_id))
|
|
|
|
|
|
@router.delete("/web/wechat-notices/binding")
|
|
def remove(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return success_response(svc.unbind(db, user))
|
|
|
|
|
|
@router.get("/public/wechat-notices/oauth/launch", response_class=HTMLResponse)
|
|
def oauth_launch(state: str = Query(default="")):
|
|
"""小程序 web-view 入口。先由业务域名打开本页,再跳到微信授权。"""
|
|
try:
|
|
target = svc.resolve_mobile_authorize_target(state)
|
|
except HTTPException as exc:
|
|
message = exc.detail if isinstance(exc.detail, str) else "授权页面打开失败,请返回小程序重试"
|
|
return _binding_message_page(message)
|
|
return _oauth_redirect_page(target)
|
|
|
|
|
|
@router.get("/public/wechat-notices/oauth/callback", response_class=HTMLResponse)
|
|
def callback(state: str = Query(min_length=20, max_length=100), code: str = Query(min_length=1, max_length=512)):
|
|
try:
|
|
result = svc.oauth_callback(state, code)
|
|
if result.get("mode") == "mobile":
|
|
return mobile_callback_page(result["subscribed"])
|
|
text = ("微信授权已完成,尚未绑定。请记下验证码:" + result["verificationCode"] +
|
|
"。手机用户请点左上角返回小程序,在原绑定页面核对账号和验证码,点击确认绑定;电脑用户请回原门户窗口核对并确认绑定。"
|
|
if result["subscribed"] else "请先关注山峡化工服务号,再返回原绑定页面重新授权;手机用户可点左上角返回小程序。")
|
|
status = 200
|
|
except HTTPException as exc:
|
|
text, status = str(exc.detail), exc.status_code
|
|
return HTMLResponse(
|
|
'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
'<title>山峡化工服务号绑定</title><body><h2>微信通知绑定</h2><p>' + escape(text) + '</p></body></html>',
|
|
status_code=status, headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'"},
|
|
)
|
|
|
|
|
|
def _page_headers(nonce):
|
|
return {
|
|
"Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
|
"Content-Security-Policy": (
|
|
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; "
|
|
f"script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"
|
|
),
|
|
}
|
|
|
|
|
|
def _binding_message_page(message):
|
|
nonce = secrets.token_urlsafe(24)
|
|
return HTMLResponse(
|
|
'<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
'<meta name="referrer" content="no-referrer"><title>微信授权</title>'
|
|
f'<style nonce="{nonce}">body{{font:16px/1.6 sans-serif;color:#444;padding:24px}}</style>'
|
|
f'<body><p>{escape(message)}</p></body></html>',
|
|
status_code=200,
|
|
headers=_page_headers(nonce),
|
|
)
|
|
|
|
|
|
def _oauth_redirect_page(target):
|
|
nonce = secrets.token_urlsafe(24)
|
|
payload = json.dumps(target).replace("<", "\\u003c")
|
|
return HTMLResponse(
|
|
'<!doctype html><html lang="zh-CN"><meta charset="utf-8">'
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
'<meta name="referrer" content="no-referrer"><title>微信授权</title>'
|
|
f'<body><p>正在打开微信授权…</p>'
|
|
f'<style nonce="{nonce}">body{{font:16px/1.6 sans-serif;color:#444;padding:24px}}</style>'
|
|
f'<script nonce="{nonce}">location.replace({payload})</script></body></html>',
|
|
status_code=200,
|
|
headers=_page_headers(nonce),
|
|
)
|
|
|
|
|
|
def mobile_callback_page(subscribed: bool):
|
|
"""只返回原小程序;真实绑定仍由原登录客户端提交并显示最终结果。"""
|
|
nonce = secrets.token_urlsafe(24)
|
|
text = ("微信授权完成,正在返回小程序完成绑定。" if subscribed else
|
|
"请先关注山峡化工服务号,再返回小程序重新授权绑定。")
|
|
template = (Path(__file__).resolve().parent.parent / "templates" / "wechat-binding-mobile.html").read_text(encoding="utf-8")
|
|
return HTMLResponse(
|
|
template.replace("__NONCE__", nonce).replace("__MESSAGE__", escape(text)),
|
|
headers={
|
|
"Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
|
"Content-Security-Policy": (
|
|
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'; "
|
|
f"script-src 'nonce-{nonce}' https://res.wx.qq.com; style-src 'nonce-{nonce}'"
|
|
),
|
|
},
|
|
)
|