web-view 不能直接打开 open.weixin.qq.com,移动端改为打开已配置的业务域名中转页。 Co-authored-by: Cursor <cursoragent@cursor.com>
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""服务号身份绑定:扫码候选 + 原登录端确认,两步均在短时会话内完成。"""
|
||
import json
|
||
import re
|
||
import secrets
|
||
import time
|
||
from urllib.parse import urlencode, urlparse
|
||
|
||
from fastapi import HTTPException
|
||
from redis.exceptions import RedisError
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from app.core.config import settings
|
||
from app.core.redis_client import get_sync_redis_client
|
||
from app.core.utils import generate_uuid
|
||
from app.models.wechat_notice import WechatBinding
|
||
from app.services.wechat_oa_client import client, WechatAPIError
|
||
|
||
TTL = 300
|
||
_STATE_RE = re.compile(r"^[A-Za-z0-9_-]{20,100}$")
|
||
_GETDEL = "local v=redis.call('GET',KEYS[1]); redis.call('DEL',KEYS[1]); return v"
|
||
_UNLOCK = "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0"
|
||
|
||
|
||
def enabled():
|
||
base = urlparse(settings.WECHAT_OA_PUBLIC_BASE_URL)
|
||
return bool(settings.WECHAT_OA_ENABLED and settings.WECHAT_OA_APPID and settings.WECHAT_OA_SECRET
|
||
and base.scheme == "https" and base.netloc and not base.username
|
||
and not base.query and not base.fragment)
|
||
|
||
|
||
def _redis():
|
||
r = get_sync_redis_client()
|
||
if r is None:
|
||
raise HTTPException(503, "服务号绑定需要 Redis,请联系管理员")
|
||
return r
|
||
|
||
|
||
def _call(method, *args, **kwargs):
|
||
try:
|
||
return getattr(_redis(), method)(*args, **kwargs)
|
||
except RedisError:
|
||
raise HTTPException(503, "绑定服务暂不可用,请稍后重试") from None
|
||
|
||
|
||
def _key(session_id):
|
||
return "wechat:oa:binding:" + session_id
|
||
|
||
|
||
def _owner_key(user):
|
||
return "wechat:oa:current:" + settings.WECHAT_OA_APPID + ":" + user["tenant_no"] + ":" + user["user_id"]
|
||
|
||
|
||
def _decode(value):
|
||
return value.decode() if isinstance(value, bytes) else value
|
||
|
||
|
||
def _load(session_id):
|
||
raw = _call("get", _key(session_id))
|
||
if not raw:
|
||
return None
|
||
data = json.loads(raw)
|
||
if data["expiresAt"] <= time.time() or data["appid"] != settings.WECHAT_OA_APPID:
|
||
return None
|
||
return data
|
||
|
||
|
||
def _owned(user, session_id):
|
||
data = _load(session_id)
|
||
if not data:
|
||
return None
|
||
if (data["userId"], data["tenantNo"]) != (user["user_id"], user["tenant_no"]):
|
||
raise HTTPException(403, "不能操作其他用户的绑定会话")
|
||
if _decode(_call("get", _owner_key(user))) != session_id:
|
||
return None
|
||
return data
|
||
|
||
|
||
def _save(session_id, data):
|
||
remaining = int(data["expiresAt"] - time.time())
|
||
if remaining <= 0:
|
||
raise HTTPException(400, "绑定会话已过期,请重新扫码")
|
||
_call("set", _key(session_id), json.dumps(data), ex=remaining)
|
||
|
||
|
||
def binding_status(db, user, *, refresh=True):
|
||
if not enabled():
|
||
return {"enabled": False, "bound": False, "subscribed": False}
|
||
row = db.query(WechatBinding).filter_by(
|
||
user_id=user["user_id"], tenant_no=user["tenant_no"], appid=settings.WECHAT_OA_APPID, del_flag=0,
|
||
).first()
|
||
if row and row.openid and refresh:
|
||
previous_openid, binding_id = row.openid, row.id
|
||
try:
|
||
subscribed = client.subscribed(previous_openid)
|
||
except WechatAPIError:
|
||
raise HTTPException(502, "微信关注状态校验失败,请稍后刷新") from None
|
||
# 微信请求期间可能已重绑/解绑;旧身份的关注结果不能覆盖新绑定。
|
||
db.query(WechatBinding).filter_by(
|
||
id=binding_id, user_id=user["user_id"], tenant_no=user["tenant_no"],
|
||
appid=settings.WECHAT_OA_APPID, openid=previous_openid, del_flag=0,
|
||
).update({"subscribed": int(subscribed)}, synchronize_session=False)
|
||
db.commit()
|
||
db.expire_all()
|
||
row = db.query(WechatBinding).filter_by(
|
||
user_id=user["user_id"], tenant_no=user["tenant_no"], appid=settings.WECHAT_OA_APPID, del_flag=0,
|
||
).first()
|
||
return {"enabled": True, "bound": bool(row and row.openid), "subscribed": bool(row and row.subscribed)}
|
||
|
||
|
||
def start_binding(user, *, mode="desktop"):
|
||
if not enabled():
|
||
raise HTTPException(503, "服务号通知尚未配置启用")
|
||
lock_key, lock_token = _owner_key(user) + ":lock", secrets.token_urlsafe(16)
|
||
if not _call("set", lock_key, lock_token, ex=60, nx=True):
|
||
raise HTTPException(409, "绑定正在处理中,请稍后再试")
|
||
try:
|
||
return _start_binding(user, mode=mode)
|
||
finally:
|
||
_call("eval", _UNLOCK, 1, lock_key, lock_token)
|
||
|
||
|
||
def _start_binding(user, *, mode="desktop"):
|
||
session_id, state = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||
data = {"userId": user["user_id"], "tenantNo": user["tenant_no"], "appid": settings.WECHAT_OA_APPID,
|
||
"mode": mode, "status": "waiting", "expiresAt": time.time() + TTL, "subscribed": False,
|
||
"verificationCode": f"{secrets.randbelow(1000000):06d}"}
|
||
_save(session_id, data)
|
||
_call("set", "wechat:oa:state:" + state, session_id, ex=TTL)
|
||
_call("set", _owner_key(user), session_id, ex=TTL)
|
||
# 小程序 web-view 只能打开已配置的业务域名。open.weixin.qq.com 不能作为 src,
|
||
# 移动端先打开门户中转页,再由该页跳到微信授权;电脑扫码仍直接使用授权链接。
|
||
authorize_url = mobile_launch_url(state) if mode == "mobile" else oauth_authorize_url(state)
|
||
return {"sessionId": session_id, "authorizeUrl": authorize_url, "expiresIn": TTL}
|
||
|
||
|
||
def oauth_authorize_url(state):
|
||
callback = settings.WECHAT_OA_PUBLIC_BASE_URL.rstrip("/") + settings.API_V1_PREFIX + "/public/wechat-notices/oauth/callback"
|
||
params = {"appid": settings.WECHAT_OA_APPID, "redirect_uri": callback,
|
||
"response_type": "code", "scope": "snsapi_base", "state": state}
|
||
return "https://open.weixin.qq.com/connect/oauth2/authorize?" + urlencode(params) + "#wechat_redirect"
|
||
|
||
|
||
def mobile_launch_url(state):
|
||
base = settings.WECHAT_OA_PUBLIC_BASE_URL.rstrip("/") + settings.API_V1_PREFIX
|
||
return base + "/public/wechat-notices/oauth/launch?" + urlencode({"state": state})
|
||
|
||
|
||
def resolve_mobile_authorize_target(state):
|
||
"""校验尚未消费的 state,返回官方授权地址。不删除 state,授权回调才消费。"""
|
||
if not _STATE_RE.fullmatch(state or ""):
|
||
raise HTTPException(400, "授权链接无效,请返回小程序重新绑定")
|
||
if not enabled():
|
||
raise HTTPException(503, "服务号通知尚未配置启用")
|
||
raw = _call("get", "wechat:oa:state:" + state)
|
||
session_id = _decode(raw) if raw else ""
|
||
if not session_id or not _load(session_id):
|
||
raise HTTPException(400, "授权已过期,请返回小程序重新绑定")
|
||
return oauth_authorize_url(state)
|
||
|
||
|
||
def oauth_callback(state, code):
|
||
if not enabled():
|
||
raise HTTPException(503, "服务号通知尚未配置启用")
|
||
raw = _call("eval", _GETDEL, 1, "wechat:oa:state:" + state)
|
||
session_id = _decode(raw)
|
||
data = _load(session_id) if session_id else None
|
||
if not data:
|
||
raise HTTPException(400, "绑定二维码已过期或已使用,请重新生成")
|
||
user = {"user_id": data["userId"], "tenant_no": data["tenantNo"]}
|
||
if not _owned(user, session_id):
|
||
raise HTTPException(400, "绑定二维码已失效,请使用最新二维码")
|
||
try:
|
||
openid = client.oauth_openid(code)
|
||
subscribed = client.subscribed(openid)
|
||
except WechatAPIError:
|
||
raise HTTPException(502, "微信授权校验失败,请重新生成二维码") from None
|
||
data.update(status="scanned", openid=openid, subscribed=subscribed)
|
||
_save(session_id, data)
|
||
return {"status": "scanned", "subscribed": subscribed, "verificationCode": data["verificationCode"],
|
||
"mode": data.get("mode", "desktop")}
|
||
|
||
|
||
def binding_session(user, session_id):
|
||
data = _owned(user, session_id)
|
||
if not data:
|
||
return {"status": "expired", "subscribed": False}
|
||
result = {"status": data["status"], "subscribed": data["subscribed"]}
|
||
if data["status"] == "scanned":
|
||
result["verificationCode"] = data["verificationCode"]
|
||
return result
|
||
|
||
|
||
def confirm_binding(db, user, session_id):
|
||
if not enabled():
|
||
raise HTTPException(503, "服务号通知尚未启用")
|
||
data = _owned(user, session_id)
|
||
if not data or data["status"] not in ("scanned", "confirmed"):
|
||
raise HTTPException(400, "请先扫描有效二维码完成微信授权")
|
||
if data["status"] == "confirmed":
|
||
return binding_status(db, user, refresh=False)
|
||
# 所有确认/解绑使用同用户互斥锁,防止双确认或解绑后的旧会话复活。
|
||
lock_key = _owner_key(user) + ":lock"
|
||
if not _call("set", lock_key, session_id, ex=60, nx=True):
|
||
raise HTTPException(409, "绑定正在处理中,请稍后再试")
|
||
try:
|
||
data = _owned(user, session_id)
|
||
if not data or data["status"] != "scanned":
|
||
raise HTTPException(400, "绑定会话已失效")
|
||
try:
|
||
subscribed = client.subscribed(data["openid"])
|
||
except WechatAPIError:
|
||
raise HTTPException(502, "微信关注状态校验失败,请稍后重试") from None
|
||
if not subscribed:
|
||
raise HTTPException(400, "请先关注山峡化工服务号,再重新扫码绑定")
|
||
existing = db.query(WechatBinding).filter_by(
|
||
tenant_no=user["tenant_no"], appid=settings.WECHAT_OA_APPID, openid=data["openid"], del_flag=0,
|
||
).first()
|
||
if existing and existing.user_id != user["user_id"]:
|
||
raise HTTPException(409, "该微信已绑定本租户其他账号,请先解绑")
|
||
row = db.query(WechatBinding).filter_by(
|
||
tenant_no=user["tenant_no"], appid=settings.WECHAT_OA_APPID, user_id=user["user_id"],
|
||
).with_for_update().first()
|
||
if not row:
|
||
row = WechatBinding(id=generate_uuid(), tenant_no=user["tenant_no"], appid=settings.WECHAT_OA_APPID,
|
||
user_id=user["user_id"], create_by=user["user_id"], create_dept_id=user.get("region_id") or "")
|
||
db.add(row)
|
||
row.openid, row.subscribed, row.del_flag, row.update_by = data["openid"], 1, 0, user["user_id"]
|
||
try:
|
||
db.commit()
|
||
except IntegrityError:
|
||
db.rollback()
|
||
raise HTTPException(409, "该微信已被绑定,请刷新后重试") from None
|
||
data["status"] = "confirmed"
|
||
_save(session_id, data)
|
||
return binding_status(db, user, refresh=False)
|
||
finally:
|
||
_call("eval", _UNLOCK, 1, lock_key, session_id)
|
||
|
||
|
||
def unbind(db, user):
|
||
if not enabled():
|
||
raise HTTPException(503, "服务号通知尚未启用")
|
||
lock_key = _owner_key(user) + ":lock"
|
||
if not _call("set", lock_key, "unbind", ex=60, nx=True):
|
||
raise HTTPException(409, "绑定正在处理中,请稍后再试")
|
||
try:
|
||
_call("delete", _owner_key(user))
|
||
row = db.query(WechatBinding).filter_by(
|
||
tenant_no=user["tenant_no"], appid=settings.WECHAT_OA_APPID, user_id=user["user_id"], del_flag=0,
|
||
).with_for_update().first()
|
||
if row:
|
||
row.openid, row.subscribed, row.del_flag, row.update_by = None, 0, 1, user["user_id"]
|
||
db.commit()
|
||
return {"enabled": True, "bound": False, "subscribed": False}
|
||
finally:
|
||
_call("eval", _UNLOCK, 1, lock_key, "unbind")
|