Files
huihuiSquare/digital-avatar-app/backend/routers/huihui_auth.py
2026-07-24 14:04:21 +08:00

337 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
会会短信验证码登录代理(真实开放平台对接)
──────────────────────────────────────────────
严格按会会开放平台 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/tokenloginType=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 偏移(不依赖 tzdataslim 镜像缺 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 一致yyyyMMddHHmmss24 小时制大写 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/codemobile 与公共字段均走 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})