feat(gateway): 接入42和72并为42/52/72启用可刷新令牌
This commit is contained in:
+88
-24
@@ -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 工作流)"""
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"interface52_token": "yonghe1999",
|
||||
"api_token_secret": "",
|
||||
"workers": [
|
||||
"http://127.0.0.1:28187",
|
||||
"http://127.0.0.1:28188"
|
||||
|
||||
+1
-5
@@ -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 地址")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user