"""本人服务号绑定;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( '' '山峡化工服务号绑定

微信通知绑定

' + escape(text) + '

', 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( '' '' '微信授权' f'' f'

{escape(message)}

', 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( '' '' '微信授权' f'

正在打开微信授权…

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