| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- """服务号身份绑定:扫码候选 + 原登录端确认,两步均在短时会话内完成。"""
- 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")
|