feat(gateway): 接入42和72并为42/52/72启用可刷新令牌

This commit is contained in:
xsl
2026-11-07 20:50:22 +08:00
parent dc53a0ecfe
commit 51baf96d78
24 changed files with 1312 additions and 126 deletions
+88 -24
View File
@@ -6,17 +6,17 @@
import asyncio
import base64
import hmac
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, Header, Request, UploadFile
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.client_auth import client_auth_error, router as client_auth_router
from gateway.config import load_config
from gateway.logging_middleware import (
get_available_dates,
@@ -91,6 +91,8 @@ app = FastAPI(
lifespan=lifespan,
)
app.include_router(client_auth_router)
# 静态文件托管(阶段四完善)
static_root = Path(__file__).resolve().parent.parent / "static"
static_root.mkdir(parents=True, exist_ok=True)
@@ -250,6 +252,8 @@ async def index():
"if2_hair_grow": "/static/test_interface2.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if42_features": "/static/test_interface42.html",
"if72_b_end_points": "/static/test_interface72.html",
"if5_hairline": "/static/test_interface5.html",
"if52_hairline": "/static/test_interface52.html",
"if6_measure_v2": "/static/test_interface6.html",
@@ -596,6 +600,24 @@ async def face_features(
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口4:用户特征分析 — 豆包 6 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
return await _face_features_response(request, image_file, image_url, image_base64)
@app.post("/api/v1/face/features-42", summary="42号接口:用户特征与两项判定", tags=["人脸分析"])
async def face_features_42(
request: Request,
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 Base64"),
auth_error=Depends(client_auth_error),
):
"""质检、扶正裁切后执行接口4分析,再加入颧弓外扩与太阳穴凹陷。"""
if auth_error is not None:
return auth_error
return await _face_features_response(request, image_file, image_url, image_base64, include_ratios=True)
async def _face_features_response(request, image_file, image_url, image_base64, *, include_ratios=False):
import uuid as _uuid
# 三选一校验
@@ -644,6 +666,25 @@ async def face_features(
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
ratio_features = {}
if include_ratios:
from face_analysis.face_ratios import PortraitQualityError
try:
img_bytes, ratio_features = await _prepare_features_42(img_bytes, image_url)
except PortraitQualityError as ex:
return JSONResponse(status_code=200, content={
"code": ex.code, "message": str(ex),
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
except Exception:
logger.exception("接口42 图片预处理失败")
return JSONResponse(status_code=200, content={
"code": 1007, "message": "图片预处理失败",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
image_url = None
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
@@ -663,10 +704,42 @@ async def face_features(
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(feats, ensure_ascii=False)},
"data": {"features": json.dumps({**feats, **ratio_features}, ensure_ascii=False)},
})
async def _prepare_features_42(image_bytes, image_url):
from fastapi.concurrency import run_in_threadpool
if image_url:
import httpx
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
response = await client.get(image_url)
response.raise_for_status()
image_bytes = response.content
except httpx.HTTPError as exc:
from face_analysis.face_ratios import PortraitQualityError
raise PortraitQualityError(1008, "图片下载失败(URL 不可达)") from exc
async with _if99_lock:
return await run_in_threadpool(_preprocess_and_measure_42, image_bytes)
def _preprocess_and_measure_42(image_bytes):
import cv2
from face_analysis.face_ratios import analyze_ratio_features, prepare_feature_portrait
image, landmarks = prepare_feature_portrait(image_bytes)
success, encoded = cv2.imencode(".png", image)
if not success:
raise RuntimeError("处理后图片编码失败")
ratios = analyze_ratio_features(image, landmarks)
return encoded.tobytes(), ratios
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
@@ -675,30 +748,21 @@ async def hairline_generate(request: Request):
@app.post("/api/v1/hairline/generate-52", tags=["人脸分析"],
summary="接口52:发际线新规则与人像裁切")
async def hairline_generate_52(
request: Request,
api_token: Optional[str] = Header(default=None, alias="X-API-Token",
description="52号接口客户端Token,默认yonghe1999"),
):
"""透传图片三选一、gender、hair_style及normalize_image等参数至GPU worker。
worker负责中下庭均值定位、扶正裁切和生发;网关沿用原有派发、鉴权与图片URL改写。
空图片保持原有 *_base64: null 字段。接口5保持原样。
客户端须传X-API-Token;缺少或不匹配时返回HTTP401/code1009,不进入派发队列。
"""
from uuid import uuid4
from gateway.config import DEFAULTS, get_config
expected = get_config().get("interface52_token", DEFAULTS["interface52_token"])
if (not isinstance(expected, str) or not expected or not api_token
or not hmac.compare_digest(api_token.encode("utf-8"), expected.encode("utf-8"))):
return JSONResponse(status_code=401, content={
"code": 1009, "message": "未授权:缺少或错误的 X-API-Token",
"request_id": f"gw-{uuid4().hex[:8]}", "data": None,
})
async def hairline_generate_52(request: Request, auth_error=Depends(client_auth_error)):
"""鉴权后透传图片、gender、hair_style 与 normalize_image 至 GPU worker。"""
if auth_error is not None:
return auth_error
return await _proxy(request, "/api/v1/hairline/generate-52")
@app.post("/api/v1/face/b-end-points", summary="72号接口:B 端五个关键点", tags=["人脸分析"])
async def face_b_end_points(request: Request, auth_error=Depends(client_auth_error)):
"""鉴权后转发到 worker,图片按网关规则返回 URL。"""
if auth_error is not None:
return auth_error
return await _proxy(request, "/api/v1/face/b-end-points")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7:C端生发 v2(add_hair2 工作流)"""
+147
View File
@@ -0,0 +1,147 @@
"""Expiring client credentials for the protected gateway interfaces."""
import base64
import binascii
import hashlib
import hmac
import json
import os
import secrets
import time
from uuid import uuid4
from fastapi import APIRouter, Security
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from gateway.config import get_config
ACCESS_TTL = 15 * 60
REFRESH_TTL = 7 * 24 * 60 * 60
_USERNAME = "yonghe"
_PASSWORD = "yonghe1999"
router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"])
_bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer")
class LoginRequest(BaseModel):
username: str = Field(max_length=128)
password: str = Field(max_length=256)
class RefreshRequest(BaseModel):
refresh_token: str = Field(max_length=1024)
def _response(code, message, *, status=200, data=None, challenge=False):
headers = {"Cache-Control": "no-store"}
if challenge:
headers["WWW-Authenticate"] = "Bearer"
return JSONResponse(status_code=status, headers=headers, content={
"code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data,
})
def _secret():
value = os.getenv("HAIR_API_TOKEN_SECRET")
if value is None:
try:
value = get_config().get("api_token_secret")
except FileNotFoundError:
return None
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
return None
return value.encode("utf-8")
def _encode(raw):
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _decode(value):
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
def _issue(secret, kind, ttl):
payload = _encode(json.dumps({
"sub": _USERNAME,
"kind": kind,
"exp": int(time.time()) + ttl,
"nonce": secrets.token_urlsafe(16),
}, separators=(",", ":")).encode())
signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)
return payload + "." + _encode(signature)
def _valid(token, secret, kind):
if not token or len(token) > 1024:
return False
try:
payload, signature = token.split(".")
expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256))
if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")):
return False
claims = json.loads(_decode(payload))
return (
isinstance(claims, dict)
and claims.get("sub") == _USERNAME
and claims.get("kind") == kind
and type(claims.get("exp")) is int
and time.time() < claims["exp"]
)
except (ValueError, UnicodeError, binascii.Error):
return False
def _unconfigured():
return _response(1007, "客户端令牌服务未配置", status=503)
def _unauthorized(message):
return _response(1009, message, status=401, challenge=True)
def _access_data(secret):
return {
"access_token": _issue(secret, "access", ACCESS_TTL),
"token_type": "Bearer",
"expires_in": ACCESS_TTL,
}
@router.post("/login", summary="用户名和密码换取访问及刷新令牌")
async def login(body: LoginRequest):
username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8"))
password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8"))
if not (username_ok and password_ok):
return _unauthorized("用户名或密码错误")
secret = _secret()
if secret is None:
return _unconfigured()
data = _access_data(secret)
data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL)
return _response(0, "success", data=data)
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshRequest):
secret = _secret()
if secret is None:
return _unconfigured()
if not _valid(body.refresh_token, secret, "refresh"):
return _unauthorized("刷新令牌无效或已过期,请重新登录")
return _response(0, "success", data=_access_data(secret))
async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)):
"""Return an error response for protected handlers before local work or dispatch."""
if credentials is None:
return _unauthorized("请提供 Authorization: Bearer 访问令牌")
secret = _secret()
if secret is None:
return _unconfigured()
if not _valid(credentials.credentials, secret, "access"):
return _unauthorized("访问令牌无效或已过期")
return None
+1 -1
View File
@@ -1,5 +1,5 @@
{
"interface52_token": "yonghe1999",
"api_token_secret": "",
"workers": [
"http://127.0.0.1:28187",
"http://127.0.0.1:28188"
+1 -5
View File
@@ -13,7 +13,7 @@ logger = logging.getLogger("gateway.config")
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
DEFAULTS = {
"interface52_token": "yonghe1999",
"api_token_secret": "",
"public_base_url": "https://hair.xiangsilian.com",
"static_dir": "static/annotations",
"primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker
@@ -77,10 +77,6 @@ def load_config() -> dict:
cfg = _deep_merge(DEFAULTS, raw)
# --- 校验 ---
client_token = cfg["interface52_token"]
if not isinstance(client_token, str) or not client_token:
raise ValueError("配置错误: interface52_token 必须是非空字符串")
workers: List[str] = cfg.get("workers", [])
if not workers:
raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址")
+1 -1
View File
@@ -223,7 +223,7 @@ def is_initialized() -> bool:
def _should_log(path: str) -> bool:
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
return path.startswith("/api/")
return path.startswith("/api/") and not path.startswith("/api/v1/auth/")
async def request_logging_middleware(request, call_next):