""" 会会短信验证码登录代理(真实开放平台对接) ────────────────────────────────────────────── 严格按会会开放平台 sign.js 签名范式: - 公共字段 appId/accessId/timestamp(12小时制hh)/signType/signVersion/accessSecret/nonce 合并业务参数 - 过滤空值 → 字典序排序 → k=v& 拼接 → 末尾追加 accessSecret=secretKey → MD5/SHA256 大写 - 认证服务基址 / appId / accessId / accessSecret / clientCode 走环境变量 真实端点(来自 fat-open 网关 usercenter 的 Swagger): - 发送验证码:POST {BASE}{HUIHUI_SMS_SEND_PATH} 默认 /open/mobile/sms/code (query 参数) - 短信登录: POST {BASE}{HUIHUI_SMS_LOGIN_PATH} 默认 /open/login/token,loginType=code 登录成功后建/链本地 users 表(按会会 userId 唯一),签发本系统 app_token 作为会话。 无真实凭证时仍可走 DEV_MOCK 兜底联调。 """ import os import uuid import random import string import hashlib import httpx from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Body, Depends, Header from sqlalchemy.orm import Session # 会会网关按北京时间(Asia/Shanghai, UTC+8)校验时间戳,容器默认 UTC 会导致签名被拒。 # 用固定 +8 偏移(不依赖 tzdata,slim 镜像缺 IANA 库时会抛 ZoneInfoNotFoundError)。 _CN_TZ = timezone(timedelta(hours=8)) from database import get_db from models import User from responses import ok, fail router = APIRouter(tags=["会会账号"]) # ── 会会开放平台配置(环境变量)── AUTH_BASE_URL = os.getenv("HUIHUI_AUTH_BASE_URL", "https://fat-open.99hui.com/api/usercenter") APP_ID = os.getenv("HUIHUI_APP_ID", "") ACCESS_ID = os.getenv("HUIHUI_ACCESS_ID", "") ACCESS_SECRET = os.getenv("HUIHUI_ACCESS_SECRET", "") CLIENT_CODE = os.getenv("HUIHUI_CLIENT_CODE", "") SMS_SEND_PATH = os.getenv("HUIHUI_SMS_SEND_PATH", "/open/mobile/sms/code") SMS_LOGIN_PATH = os.getenv("HUIHUI_SMS_LOGIN_PATH", "/open/login/token") # 临时开发态:无真实会会凭证时,本地模拟短信收发,便于端到端联调。 DEV_MOCK = os.getenv("HUIHUI_DEV_MOCK", "false").lower() in ("1", "true", "yes") _mock_codes: dict[str, tuple[str, float]] = {} _MOCK_TTL = 300 # ── 签名体系(完全对应 sign.js)── def _get_nonce() -> str: # 与会会 sign.js 一致:base36 随机串 return "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) def _get_timestamp() -> str: # 与会会 sign.js 一致:yyyyMMddHHmmss(24 小时制大写 HH)。 # 实测:会会 common.format 走 24 小时制,用 12 小时制(%I)会被网关判"签名验证失败"。 # 必须用北京时间,否则容器(UTC)生成的时间戳与会会网关校验窗口偏差 8h 被拒。 return datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S") def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str: SIGN_KEY = "signature" SECRET_KEY = "accessSecret" keys = sorted(params.keys()) parts = [] for k in keys: if k in (SIGN_KEY, SECRET_KEY): continue v = params.get(k) if v is None or v == "" or v == []: continue if isinstance(v, list): continue parts.append(f"{k}={v}") sign_str = "&".join(parts) + f"&{SECRET_KEY}={secret_key}" if sign_type.upper() == "SHA256": return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper() return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper() def _build_form(extra: dict) -> dict: sign_type = "MD5" sign_version = "1.0" secret = ACCESS_SECRET base = { "appId": APP_ID, "accessId": ACCESS_ID, "timestamp": _get_timestamp(), "signType": sign_type, "signVersion": sign_version, "accessSecret": secret, "nonce": _get_nonce(), } base.update(extra) signature = _make_sign(base, secret, sign_type) if secret else "" base["signature"] = signature base.pop("accessSecret", None) # 不发送密钥 return base def _pick(d: dict, *keys, default=""): for k in keys: if d.get(k) not in (None, ""): return d[k] return default def _cfg_ready() -> bool: return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET) def _call_huihui(path: str, params: dict, as_query: bool = False): """调用会会接口,返回 (ok: bool, payload: dict, http_status: int)""" url = f"{AUTH_BASE_URL}{path}" with httpx.Client(timeout=30, follow_redirects=True) as c: if as_query: resp = c.post(url, params=params) else: resp = c.post(url, data=params) try: data = resp.json() except Exception: return False, {"message": f"会会返回非JSON: {resp.text[:200]}"}, resp.status_code # 会会统一包装 {code, message, data} code = data.get("code") if resp.status_code == 200 and code in (0, 200, "0", "200"): return True, data, resp.status_code return False, data, resp.status_code @router.post("/huihui/sms/send") def sms_send(body: dict = Body(...)): """请求会会发送短信验证码(真实开放平台 /open/mobile/sms/code)""" phone = (body.get("phone") or "").strip() if not phone or not phone.isdigit() or len(phone) != 11: return fail("请输入正确的 11 位手机号", 400) # 临时开发态:本地模拟发码 if DEV_MOCK and not _cfg_ready(): code = str(random.randint(100000, 999999)) _mock_codes[phone] = (code, datetime.now().timestamp() + _MOCK_TTL) return ok({"sent": True, "devCode": code, "dev": True}) if not _cfg_ready(): return fail("会会短信服务未配置(缺少 HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET)", 500) # /open/mobile/sms/code:mobile 与公共字段均走 query form = _build_form({"mobile": phone}) ok_flag, data, status = _call_huihui(SMS_SEND_PATH, form, as_query=True) if not ok_flag: return fail(data.get("message") or f"发送失败(HTTP {status})", 502) return ok({"sent": True}) @router.post("/huihui/sms/login") def sms_login(body: dict = Body(...), db: Session = Depends(get_db)): """短信验证码登录:调会会 /open/login/token(loginType=code) 换取 access_token + userId,落库并签发本系统会话""" phone = (body.get("phone") or "").strip() code = (body.get("code") or "").strip() if not phone or not code: return fail("手机号或验证码缺失", 400) # 临时开发态:本地校验模拟码 if DEV_MOCK and not _cfg_ready(): rec = _mock_codes.get(phone) if not rec or rec[1] < datetime.now().timestamp(): return fail("验证码已失效,请重新获取", 401) if rec[0] != code: return fail("验证码错误", 401) _mock_codes.pop(phone, None) return _issue_session(db, phone, { "userId": f"dev_{phone}", "nickname": f"会会用户{phone[-4:]}", "avatarUrl": "", "token": f"dev_token_{phone}", }) if not _cfg_ready(): return fail("会会登录服务未配置", 500) extra = { "username": phone, "password": code, "loginType": "code", "grantType": "password", "isRegister": "true", } if CLIENT_CODE: extra["clientCode"] = CLIENT_CODE form = _build_form(extra) ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False) if not ok_flag: return fail(data.get("message") or f"登录失败(HTTP {status})", 401) raw = data.get("data") or {} user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {} huihui_token = ( _pick(raw, "accessToken", "access_token", "token") or _pick(user_info, "accessToken", "access_token", "token") ) huihui_user_id = ( _pick(raw, "openid", "userId", "uid", "openId", "id") or _pick(user_info, "openid", "userId", "uid", "openId", "id") ) nickname = ( _pick(raw, "nickName", "nickname", "name", "userName") or _pick(user_info, "nickName", "nickname", "name", "userName") ) avatar_url = ( _pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl") or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl") ) if not huihui_user_id: return fail("会会未返回用户标识", 502) return _issue_session(db, phone, { "userId": huihui_user_id, "nickname": nickname, "avatarUrl": avatar_url, "token": huihui_token, }) @router.post("/huihui/pwd/login") def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)): """账号密码登录:调会会 /open/login/token(loginType=password) 换取 access_token + userId,落库并签发本系统会话。 与 news_service 既有对接完全一致:username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false。 """ account = (body.get("account") or "").strip() password = (body.get("password") or "").strip() if not account or not password: return fail("账号或密码缺失", 400) if not _cfg_ready(): return fail("会会登录服务未配置", 500) extra = { "username": account, "password": password, "loginType": "password", "grantType": "password", "isRegister": "false", } if CLIENT_CODE: extra["clientCode"] = CLIENT_CODE form = _build_form(extra) ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False) if not ok_flag: return fail(data.get("message") or f"登录失败(HTTP {status})", 401) raw = data.get("data") or {} user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {} huihui_token = ( _pick(raw, "accessToken", "access_token", "token") or _pick(user_info, "accessToken", "access_token", "token") ) huihui_user_id = ( _pick(raw, "openid", "userId", "uid", "openId", "id") or _pick(user_info, "openid", "userId", "uid", "openId", "id") ) nickname = ( _pick(raw, "nickName", "nickname", "name", "userName") or _pick(user_info, "nickName", "nickname", "name", "userName") ) avatar_url = ( _pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl") or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl") ) if not huihui_user_id: return fail("会会未返回用户标识", 502) # 账号即手机号时记录,便于资料展示;非手机号(用户名)则不覆盖已有 phone phone = account if (account.isdigit() and len(account) == 11) else "" return _issue_session(db, phone, { "userId": huihui_user_id, "nickname": nickname, "avatarUrl": avatar_url, "token": huihui_token, }) def _issue_session(db: Session, phone: str, info: dict): """建/链本地用户并签发本系统会话 token""" huihui_user_id = info.get("userId", "") user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first() if not user: user = User(huihui_user_id=huihui_user_id) if phone: user.phone = phone if info.get("nickname"): user.nickname = info["nickname"] if info.get("avatarUrl"): user.avatar_url = info["avatarUrl"] user.huihui_token = info.get("token", "") user.app_token = uuid.uuid4().hex user.last_login_at = datetime.now() db.add(user) db.commit() db.refresh(user) return ok({ "token": user.app_token, "user": user.to_dict(), "huihui": { "userId": huihui_user_id, "nickname": info.get("nickname", ""), "avatarUrl": info.get("avatarUrl", ""), "token": info.get("token", ""), }, }) @router.get("/huihui/me") def me(authorization: str = Header(None), db: Session = Depends(get_db)): """当前登录用户信息(Bearer app_token)""" if not authorization: return fail("未登录", 401) token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip() user = db.query(User).filter(User.app_token == token).first() if not user: return fail("会话无效或已过期", 401) return ok(user.to_dict()) @router.post("/huihui/logout") def logout(authorization: str = Header(None), db: Session = Depends(get_db)): """退出登录(作废 app_token)""" if authorization: token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip() user = db.query(User).filter(User.app_token == token).first() if user: user.app_token = "" db.commit() return ok({"success": True})