Files
shanxia2.0/backend/app/api/wechat_notices.py
T
wuzilongandCursor e7c1762113 fix: 小程序服务号绑定先打开门户域名再跳转微信授权。
web-view 不能直接打开 open.weixin.qq.com,移动端改为打开已配置的业务域名中转页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 01:44:40 +08:00

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}'"
),
},
)