337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""
|
||
会会短信验证码登录代理(真实开放平台对接)
|
||
──────────────────────────────────────────────
|
||
严格按会会开放平台 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})
|