"""微信小程序后端服务。 提供以下能力: 1. code2Session — 用前端 uni.login 返回的 code 换取 openid + session_key 2. get_phone_number — 用 getPhoneNumber 回调返回的 code 换取手机号(2023+ 新 API,无需 AES 解密) 3. get_qrcode — 调用 getwxacodeunlimit 生成小程序码 4. ensure_wx_user — 通过 openid 查找或自动注册用户 5. wx_mini_login / wx_mini_phone_login — 登录编排(code → openid/手机号 → 用户 → JWT) 所有微信 API 调用统一走 httpx.AsyncClient,access_token 通过 Redis 缓存(TTL < 7200s)。 """ import json import secrets from typing import Any from urllib.parse import urlencode import httpx from fastapi import Request from redis.asyncio.client import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.common.enums import RedisInitKeyConfig from app.config.setting import settings from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException from app.core.logger import logger from app.core.redis_crud import RedisCURD from app.modules.system.user.crud import UserCRUD from app.modules.system.user.model import UserModel from .schema import LoginOutSchema from .service import LoginService, auto_register_login_user, sanitize_login_name # 微信 API 基础地址 _WX_API_BASE = "https://api.weixin.qq.com" # code2Session 接口 _CODE2SESSION_URL = f"{_WX_API_BASE}/sns/jscode2session" # 获取 access_token(稳定版,推荐) _ACCESS_TOKEN_URL = f"{_WX_API_BASE}/cgi-bin/stable_token" # 获取手机号(2023+ 新 API) _GET_PHONE_URL = f"{_WX_API_BASE}/wxa/business/getuserphonenumber" # 生成无限制小程序码 _GET_QRCODE_URL = f"{_WX_API_BASE}/wxa/getwxacodeunlimit" def _require_mini_credentials() -> tuple[str, str]: """校验小程序 AppID / AppSecret 是否已配置。""" app_id = settings.WX_MINI_APP_ID app_secret = settings.WX_MINI_APP_SECRET if not app_id or not app_secret: raise CustomException(msg="微信小程序未配置(AppID / AppSecret 为空)") return app_id, app_secret async def _http_json(method: str, url: str, **kwargs: Any) -> dict: """发起 HTTP 请求并解析 JSON 响应。""" timeout = settings.HTTPX_DEFAULT_TIMEOUT async with httpx.AsyncClient(timeout=timeout) as client: r = await client.request(method, url, **kwargs) r.raise_for_status() try: return r.json() except json.JSONDecodeError: logger.error(f"微信 API 非 JSON 响应: {r.text[:500]}") raise async def _get_access_token(redis: Redis) -> str: """获取微信小程序 access_token,优先从 Redis 缓存读取。 微信 access_token 有效期 7200 秒,多实例共享同一 token,必须通过中央存储(Redis)缓存。 使用 stable_token 接口(比 cgi-bin/token 更稳定,不会因并发请求导致 token 互相覆盖)。 """ rc = RedisCURD(redis) cache_key = f"{RedisInitKeyConfig.WX_MINI_ACCESS_TOKEN.key}" cached = await rc.get(cache_key) if cached: if isinstance(cached, bytes): cached = cached.decode("utf-8") return str(cached) app_id, app_secret = _require_mini_credentials() data = await _http_json( "POST", _ACCESS_TOKEN_URL, json={ "grant_type": "client_credential", "appid": app_id, "secret": app_secret, "force_refresh": False, }, ) token = data.get("access_token") if not token: errmsg = data.get("errmsg") or "未知错误" errcode = data.get("errcode") raise CustomException(msg=f"获取微信 access_token 失败: [{errcode}] {errmsg}") # 缓存到 Redis,TTL 比微信上限少 200 秒留余量 ttl = settings.WX_MINI_ACCESS_TOKEN_CACHE_TTL await rc.set(key=cache_key, value=str(token), expire=ttl) logger.info(f"微信小程序 access_token 已缓存,TTL={ttl}s") return str(token) def _check_wx_error(data: dict, action: str) -> None: """统一检查微信 API 返回的 errcode。""" errcode = data.get("errcode") if errcode and errcode != 0: errmsg = data.get("errmsg") or "未知错误" raise CustomException(msg=f"微信{action}失败: [{errcode}] {errmsg}") async def code2session(code: str) -> dict: """用 uni.login 返回的 code 换取 openid + session_key。 参数: - code (str): 前端 uni.login 返回的临时登录凭证 返回: - dict: {"openid": str, "session_key": str, "unionid": str | None} """ app_id, app_secret = _require_mini_credentials() qs = urlencode( { "appid": app_id, "secret": app_secret, "js_code": code, "grant_type": "authorization_code", } ) data = await _http_json("GET", f"{_CODE2SESSION_URL}?{qs}") _check_wx_error(data, "code2Session") openid = data.get("openid") session_key = data.get("session_key") if not openid or not session_key: raise CustomException(msg="微信 code2Session 返回数据不完整") return { "openid": str(openid), "session_key": str(session_key), "unionid": str(data["unionid"]) if data.get("unionid") else None, } async def get_phone_number(redis: Redis, code: str) -> str: """用 getPhoneNumber 回调返回的 code 换取手机号。 2023+ 微信推荐方案:前端