| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136 |
- """本人服务号绑定;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}'"
- ),
- },
- )
|