feat: add FastAPI manager API compatibility baseline

This commit is contained in:
Tyke Chen
2026-07-20 17:00:13 +08:00
parent 7c58fa37b2
commit 804ddb51f2
140 changed files with 47169 additions and 1 deletions
@@ -0,0 +1 @@
"""Shared compatibility infrastructure."""
@@ -0,0 +1,72 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _default_java_resources() -> Path:
return Path(__file__).resolve().parents[3] / "manager-api" / "src" / "main" / "resources"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="APP_",
case_sensitive=False,
extra="ignore",
)
environment: Literal["development", "test", "production"] = "development"
host: str = "0.0.0.0" # noqa: S104 - container bind is intentional
port: int = 8002
context_path: str = "/xiaozhi"
timezone: str = "Asia/Shanghai"
database_url: str = "mysql+asyncmy://root:change-me@127.0.0.1:3306/xiaozhi_esp32_server?charset=utf8mb4"
redis_url: str = "redis://127.0.0.1:6379/0"
upload_dir: Path = Path("uploadfile")
java_resources_dir: Path = Field(default_factory=_default_java_resources)
external_request_timeout_seconds: float = 10.0
database_pool_size: int = 20
database_max_overflow: int = 20
trusted_proxy_count: int = 1
log_level: str = "INFO"
server_secret_override: str | None = None
allow_start_without_dependencies: bool = False
job_lock_ttl_seconds: int = 120
graceful_shutdown_seconds: float = 30.0
@field_validator("context_path")
@classmethod
def normalize_context_path(cls, value: str) -> str:
normalized = "/" + value.strip("/")
return "" if normalized == "/" else normalized
@field_validator("database_url")
@classmethod
def require_async_driver(cls, value: str) -> str:
if value.startswith("mysql://"):
return value.replace("mysql://", "mysql+asyncmy://", 1)
if value.startswith("sqlite:///"):
return value.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
return value
@property
def i18n_dir(self) -> Path:
return self.java_resources_dir / "i18n"
@property
def changelog_path(self) -> Path:
return self.java_resources_dir / "db" / "changelog" / "db.changelog-master.yaml"
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
def clear_settings_cache() -> None:
get_settings.cache_clear()
@@ -0,0 +1,63 @@
from __future__ import annotations
import hashlib
import secrets
import uuid
import bcrypt
from gmssl import func, sm2 # type: ignore[import-untyped]
def generate_database_token(value: str | None = None) -> str:
source = value if value is not None else str(uuid.uuid4())
return hashlib.md5(source.encode("utf-8"), usedforsecurity=False).hexdigest()
def bcrypt_hash(password: str, rounds: int = 10) -> str:
encoded = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=rounds))
# The bundled Java BCryptPasswordEncoder only accepts $2a$ hashes.
return encoded.decode("ascii").replace("$2b$", "$2a$", 1)
def bcrypt_matches(password: str, encoded: str | None) -> bool:
if not encoded or not encoded.startswith(("$2a$", "$2$")):
return False
normalized = encoded.replace("$2$", "$2a$", 1)
try:
return bcrypt.checkpw(password.encode("utf-8"), normalized.encode("ascii"))
except (ValueError, UnicodeEncodeError):
return False
def sm2_generate_keypair() -> tuple[str, str]:
private_key = func.random_hex(64)
helper = sm2.CryptSM2(private_key=private_key, public_key="", mode=1)
public_point = str(helper._kg(int(private_key, 16), sm2.default_ecc_table["g"])) # noqa: SLF001
public_key = "04" + public_point
return public_key, private_key
def sm2_encrypt_c1c3c2(public_key: str, plaintext: str) -> str:
helper = sm2.CryptSM2(private_key="", public_key=public_key, mode=1)
encrypted = helper.encrypt(plaintext.encode("utf-8"))
if encrypted is None:
raise ValueError("SM2 KDF returned an all-zero key")
# BouncyCastle's SM2Engine emits the uncompressed-point marker.
return "04" + bytes(encrypted).hex()
def sm2_decrypt_c1c3c2(private_key: str, ciphertext: str) -> str:
normalized = ciphertext.strip().lower()
if normalized.startswith("04"):
normalized = normalized[2:]
if len(normalized) < 128 + 64 or len(normalized) % 2:
raise ValueError("invalid SM2 C1C3C2 ciphertext")
helper = sm2.CryptSM2(private_key=private_key, public_key="", mode=1)
decrypted = helper.decrypt(bytes.fromhex(normalized))
if decrypted is None:
raise ValueError("SM2 decryption failed")
return bytes(decrypted).decode("utf-8")
def random_hex(length: int) -> str:
return secrets.token_hex((length + 1) // 2)[:length]
@@ -0,0 +1,96 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import Any
from sqlalchemy import Result, TextClause, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import Settings, get_settings
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def configure_database(settings: Settings | None = None) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
global _engine, _session_factory
selected = settings or get_settings()
engine_options: dict[str, Any] = {"pool_pre_ping": True}
if not selected.database_url.startswith("sqlite"):
engine_options.update(pool_size=selected.database_pool_size, max_overflow=selected.database_max_overflow)
_engine = create_async_engine(selected.database_url, **engine_options)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False, autoflush=False)
return _engine, _session_factory
def get_engine() -> AsyncEngine:
if _engine is None:
return configure_database()[0]
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
if _session_factory is None:
return configure_database()[1]
return _session_factory
async def get_db() -> AsyncIterator[AsyncSession]:
async with get_session_factory()() as session:
yield session
@asynccontextmanager
async def transaction() -> AsyncIterator[AsyncSession]:
async with get_session_factory()() as session, session.begin():
yield session
async def dispose_database() -> None:
global _engine, _session_factory
if _engine is not None:
await _engine.dispose()
_engine = None
_session_factory = None
class Repository:
def __init__(self, session: AsyncSession):
self.session = session
@staticmethod
def statement(sql: str | TextClause) -> TextClause:
return text(sql) if isinstance(sql, str) else sql
async def fetch_one(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> dict[str, Any] | None:
result = await self.session.execute(self.statement(sql), dict(params or {}))
row = result.mappings().first()
return dict(row) if row is not None else None
async def fetch_all(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
result = await self.session.execute(self.statement(sql), dict(params or {}))
return [dict(row) for row in result.mappings().all()]
async def scalar(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> Any:
result = await self.session.execute(self.statement(sql), dict(params or {}))
return result.scalar_one_or_none()
async def execute(self, sql: str | TextClause, params: Mapping[str, Any] | None = None) -> int:
result: Result[Any] = await self.session.execute(self.statement(sql), dict(params or {}))
return int(getattr(result, "rowcount", 0) or 0)
async def execute_many(self, sql: str | TextClause, params: Sequence[Mapping[str, Any]]) -> int:
if not params:
return 0
result: Result[Any] = await self.session.execute(self.statement(sql), [dict(item) for item in params])
return int(getattr(result, "rowcount", 0) or 0)
async def database_ping() -> bool:
try:
async with get_session_factory()() as session:
await session.execute(text("SELECT 1"))
return True
except Exception:
return False
@@ -0,0 +1,44 @@
from __future__ import annotations
from dataclasses import dataclass
class ErrorCode:
INTERNAL_SERVER_ERROR = 500
UNAUTHORIZED = 401
FORBIDDEN = 403
DB_RECORD_EXISTS = 10002
PARAMS_GET_ERROR = 10003
ACCOUNT_PASSWORD_ERROR = 10004
ACCOUNT_DISABLE = 10005
CAPTCHA_ERROR = 10007
PASSWORD_ERROR = 10009
UPLOAD_FILE_EMPTY = 10019
TOKEN_INVALID = 10021
ACCOUNT_LOCK = 10022
INVALID_SYMBOL = 10029
PASSWORD_LENGTH_ERROR = 10030
PASSWORD_WEAK_ERROR = 10031
DEL_MYSELF_ERROR = 10032
DEVICE_CAPTCHA_ERROR = 10033
PARAM_VALUE_NULL = 10034
PARAM_TYPE_NULL = 10035
PARAM_TYPE_INVALID = 10036
PARAM_NUMBER_INVALID = 10037
PARAM_BOOLEAN_INVALID = 10038
PARAM_ARRAY_INVALID = 10039
PARAM_JSON_INVALID = 10040
RESOURCE_NOT_FOUND = 10051
ADD_DATA_FAILED = 10065
UPDATE_DATA_FAILED = 10066
MODEL_TYPE_PROVIDE_CODE_NOT_NULL = 10131
@dataclass(slots=True)
class AppError(Exception):
code: int
message: str | None = None
params: tuple[object, ...] = ()
def __str__(self) -> str:
return self.message or str(self.code)
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from app.core.config import get_settings
LANGUAGE_FILES: dict[str, str] = {
"zh-CN": "messages_zh_CN.properties",
"zh-TW": "messages_zh_TW.properties",
"en-US": "messages_en_US.properties",
"de-DE": "messages_de_DE.properties",
"vi-VN": "messages_vi_VN.properties",
"pt-BR": "messages_pt_BR.properties",
}
_UNICODE_ESCAPE = re.compile(r"\\u([0-9a-fA-F]{4})")
def resolve_language(accept_language: str | None) -> str:
if not accept_language:
return "zh-CN"
primary = accept_language.split(",", 1)[0].split(";", 1)[0].strip().replace("_", "-")
exact = {key.lower(): key for key in LANGUAGE_FILES}
if primary.lower() in exact:
return exact[primary.lower()]
prefix = primary.lower().split("-", 1)[0]
return {
"zh": "zh-CN",
"en": "en-US",
"de": "de-DE",
"vi": "vi-VN",
"pt": "pt-BR",
}.get(prefix, "zh-CN")
def _unescape(value: str) -> str:
decoded = _UNICODE_ESCAPE.sub(lambda match: chr(int(match.group(1), 16)), value)
return (
decoded.replace("\\t", "\t")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\f", "\f")
.replace("\\=", "=")
.replace("\\:", ":")
.replace("\\ ", " ")
.replace("\\\\", "\\")
)
def _load_properties(path: Path) -> dict[str, str]:
messages: dict[str, str] = {}
if not path.exists():
return messages
continuation = ""
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = continuation + raw_line
if line.endswith("\\") and not line.endswith("\\\\"):
continuation = line[:-1]
continue
continuation = ""
stripped = line.strip()
if not stripped or stripped.startswith(("#", "!")):
continue
delimiter = "=" if "=" in line else ":"
if delimiter not in line:
continue
key, value = line.split(delimiter, 1)
messages[key.strip()] = _unescape(value.strip())
return messages
@lru_cache(maxsize=16)
def messages_for(language: str, i18n_dir: str | None = None) -> dict[str, str]:
directory = Path(i18n_dir) if i18n_dir else get_settings().i18n_dir
default_messages = _load_properties(directory / "messages.properties")
default_messages.update(_load_properties(directory / LANGUAGE_FILES.get(language, LANGUAGE_FILES["zh-CN"])))
return default_messages
def message_for(code: int, accept_language: str | None, *params: object) -> str:
language = resolve_language(accept_language)
template = messages_for(language).get(str(code), str(code))
for index, param in enumerate(params):
template = template.replace("{" + str(index) + "}", str(param))
return template
def clear_i18n_cache() -> None:
messages_for.cache_clear()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import os
import socket
import threading
import time
class SnowflakeIdGenerator:
"""MyBatis-Plus compatible 41/5/5/12-bit Snowflake identifier generator."""
EPOCH = 1288834974657
SEQUENCE_BITS = 12
WORKER_BITS = 5
DATACENTER_BITS = 5
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1
WORKER_SHIFT = SEQUENCE_BITS
DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_BITS
TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS
def __init__(self, worker_id: int | None = None, datacenter_id: int | None = None):
host_hash = sum(socket.gethostname().encode("utf-8"))
self.worker_id = worker_id if worker_id is not None else (host_hash ^ os.getpid()) & 31
self.datacenter_id = datacenter_id if datacenter_id is not None else host_hash & 31
if not 0 <= self.worker_id <= 31 or not 0 <= self.datacenter_id <= 31:
raise ValueError("worker_id and datacenter_id must be in [0, 31]")
self._sequence = 0
self._last_timestamp = -1
self._lock = threading.Lock()
@staticmethod
def _milliseconds() -> int:
return time.time_ns() // 1_000_000
def next_id(self) -> int:
with self._lock:
timestamp = self._milliseconds()
if timestamp < self._last_timestamp:
raise RuntimeError("clock moved backwards; refusing to generate a duplicate Snowflake ID")
if timestamp == self._last_timestamp:
self._sequence = (self._sequence + 1) & self.MAX_SEQUENCE
if self._sequence == 0:
while timestamp <= self._last_timestamp:
timestamp = self._milliseconds()
else:
self._sequence = 0
self._last_timestamp = timestamp
return (
((timestamp - self.EPOCH) << self.TIMESTAMP_SHIFT)
| (self.datacenter_id << self.DATACENTER_SHIFT)
| (self.worker_id << self.WORKER_SHIFT)
| self._sequence
)
snowflake = SnowflakeIdGenerator()
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from datetime import datetime
from typing import Any, cast
from zoneinfo import ZoneInfo
from redis.asyncio import Redis
from app.core.config import get_settings
_client: Redis | None = None
logger = logging.getLogger(__name__)
class JavaRedisCodec:
"""Wire-compatible subset of Spring Data's ``RedisSerializer.json()``.
Spring enables Jackson default typing for non-final values. Consequently a
plain JSON map/list cannot be read by the retained Java rollback service. A
map carries ``@class`` and a collection uses Jackson's wrapper-array form.
``java_type`` and ``item_java_type`` cover the few caches whose Java readers
cast values to concrete DTO/entity classes.
"""
@staticmethod
def encode(
value: Any,
*,
java_type: str | None = None,
item_java_type: str | None = None,
field_java_types: dict[str, str] | None = None,
) -> bytes:
wire = JavaRedisCodec._encode_value(
value,
java_type=java_type,
item_java_type=item_java_type,
field_java_types=field_java_types,
nested=False,
)
return json.dumps(wire, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
@staticmethod
def decode(value: bytes | str | None) -> Any:
if value is None:
return None
raw = value.decode("utf-8") if isinstance(value, bytes) else value
try:
return JavaRedisCodec._decode_value(json.loads(raw))
except json.JSONDecodeError:
return raw
@staticmethod
def _encode_value(
value: Any,
*,
java_type: str | None = None,
item_java_type: str | None = None,
field_java_types: dict[str, str] | None = None,
nested: bool = True,
) -> Any:
if value is None or isinstance(value, str | bool | float):
return value
if isinstance(value, int):
# Jackson's default typing only adds the Long wrapper when the
# runtime value sits behind an Object-typed container slot. A
# top-level Long, or a field with a declared Long type, is emitted
# as an ordinary JSON number.
if not nested or java_type == "java.lang.Long" or -(2**31) <= value < 2**31:
return value
return ["java.lang.Long", value]
if isinstance(value, datetime):
timezone = ZoneInfo(get_settings().timezone)
localized = value.replace(tzinfo=timezone) if value.tzinfo is None else value.astimezone(timezone)
return ["java.util.Date", int(localized.timestamp() * 1000)]
if isinstance(value, dict):
selected_type = java_type or "java.util.HashMap"
result: dict[str, Any] = {"@class": selected_type}
pojo = selected_type not in {
"java.util.HashMap",
"java.util.LinkedHashMap",
"java.util.TreeMap",
"cn.hutool.json.JSONObject",
}
for raw_key, item in value.items():
if raw_key == "@class":
continue
key = _snake_to_camel(str(raw_key)) if pojo else str(raw_key)
child_type = (field_java_types or {}).get(key) or (field_java_types or {}).get(str(raw_key))
result[key] = JavaRedisCodec._encode_value(item, java_type=child_type, nested=True)
return result
if isinstance(value, set | frozenset):
return [
"java.util.HashSet",
[
JavaRedisCodec._encode_value(item, java_type=item_java_type, nested=True)
for item in value
],
]
if isinstance(value, list | tuple):
return [
"java.util.ArrayList",
[
JavaRedisCodec._encode_value(item, java_type=item_java_type, nested=True)
for item in value
],
]
return value
@staticmethod
def _decode_value(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): JavaRedisCodec._decode_value(item)
for key, item in value.items()
if key != "@class"
}
if isinstance(value, list):
if len(value) == 2 and isinstance(value[0], str) and value[0].startswith("java."):
type_name, payload = value
if type_name == "java.util.Date":
timezone = ZoneInfo(get_settings().timezone)
return datetime.fromtimestamp(float(payload) / 1000, timezone).replace(tzinfo=None)
if type_name in {
"java.util.ArrayList",
"java.util.LinkedList",
"java.util.HashSet",
"java.util.LinkedHashSet",
} and isinstance(payload, list):
return [JavaRedisCodec._decode_value(item) for item in payload]
return JavaRedisCodec._decode_value(payload)
return [JavaRedisCodec._decode_value(item) for item in value]
return value
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part[:1].upper() + part[1:] for part in tail)
def get_redis() -> Redis:
global _client
if _client is None:
_client = Redis.from_url(get_settings().redis_url, decode_responses=False)
return _client
async def close_redis() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
async def redis_ping() -> bool:
try:
return bool(await get_redis().ping())
except Exception:
return False
async def java_get(key: str) -> Any:
return JavaRedisCodec.decode(await cast(Any, get_redis().get(key)))
async def java_set(
key: str,
value: Any,
ttl_seconds: int | None = None,
*,
java_type: str | None = None,
item_java_type: str | None = None,
) -> None:
await cast(Any, get_redis().set)(
key,
JavaRedisCodec.encode(value, java_type=java_type, item_java_type=item_java_type),
ex=ttl_seconds,
)
async def java_hget(key: str, field: str) -> Any:
return JavaRedisCodec.decode(await cast(Any, get_redis().hget(key, field)))
async def java_hset(key: str, field: str, value: Any, ttl_seconds: int = 86400) -> None:
redis = get_redis()
await cast(Any, redis.hset)(key, field, JavaRedisCodec.encode(value))
await cast(Any, redis.expire)(key, ttl_seconds)
@asynccontextmanager
async def distributed_lock(name: str, ttl_seconds: int) -> AsyncIterator[bool]:
lock = get_redis().lock(name, timeout=ttl_seconds, blocking_timeout=0)
acquired = bool(await lock.acquire(blocking=False))
renewal: asyncio.Task[None] | None = None
if acquired:
renewal = asyncio.create_task(_renew_lock(lock, ttl_seconds))
try:
yield acquired
finally:
if renewal is not None:
renewal.cancel()
with suppress(asyncio.CancelledError):
await renewal
if acquired:
try:
await lock.release()
except Exception:
logger.warning("Lost ownership of distributed lock %s before release", name, exc_info=True)
async def _renew_lock(lock: Any, ttl_seconds: int) -> None:
"""Keep a held job lock alive until its owner leaves the context."""
interval = max(float(ttl_seconds) / 3, 0.25)
while True:
await asyncio.sleep(interval)
try:
await lock.extend(ttl_seconds, replace_ttl=True)
except Exception:
logger.exception("Unable to renew distributed lock; duplicate execution protection is at risk")
return
@@ -0,0 +1,66 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from starlette.responses import JSONResponse, Response
from app.core.i18n import message_for
from app.core.serialization import java_compatible
class JavaJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return json.dumps(
java_compatible(content),
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
).encode("utf-8")
def envelope(data: Any = None, *, code: int = 0, msg: str = "success") -> dict[str, Any]:
return {"code": code, "msg": msg, "data": data}
def ok(data: Any = None) -> JavaJSONResponse:
return JavaJSONResponse(envelope(data))
def error_response(
request: Request,
code: int,
message: str | None = None,
*,
status_code: int = 200,
params: tuple[object, ...] = (),
media_type: str = "application/json",
) -> JavaJSONResponse:
translated = message or message_for(code, request.headers.get("Accept-Language"), *params)
return JavaJSONResponse(
envelope(None, code=code, msg=translated),
status_code=status_code,
media_type=media_type,
)
def raw_json(content: Any, *, exclude_none: bool = False, status_code: int = 200) -> Response:
normalized = java_compatible(content)
if exclude_none:
normalized = _drop_none(normalized)
body = json.dumps(normalized, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
return Response(
body,
status_code=status_code,
media_type="application/json",
headers={"Content-Length": str(len(body))},
)
def _drop_none(value: Any) -> Any:
if isinstance(value, dict):
return {key: _drop_none(item) for key, item in value.items() if item is not None}
if isinstance(value, list):
return [_drop_none(item) for item in value]
return value
@@ -0,0 +1,181 @@
from __future__ import annotations
import fnmatch
import hmac
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from fastapi import Request
from sqlalchemy import text
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from app.core.config import get_settings
from app.core.database import get_session_factory
from app.core.errors import AppError, ErrorCode
from app.core.responses import error_response
from app.services.system_params import SystemParamService
PUBLIC_PATTERNS = (
"/ota/*",
"/ota",
"/otaMag/download/*",
"/webjars/*",
"/druid/*",
"/v3/api-docs*",
"/doc.html*",
"/favicon.ico",
"/user/captcha",
"/user/smsVerification",
"/user/login",
"/user/pub-config",
"/user/register",
"/user/retrieve-password",
"/api/ping",
"/agent/chat-history/download/*",
"/agent/play/*",
"/voiceClone/play/*",
"/health",
"/health/live",
"/health/ready",
)
SERVER_PATTERNS = (
"/config/*",
"/device/address-book/call",
"/device/address-book/lookup",
"/agent/chat-history/report",
"/agent/chat-summary/*",
"/agent/chat-title/*",
)
@dataclass(slots=True, frozen=True)
class AuthUser:
id: int
username: str
super_admin: int
status: int
token: str
row: dict[str, Any]
@property
def is_super_admin(self) -> bool:
return self.super_admin == 1
def _matches(path: str, patterns: tuple[str, ...]) -> bool:
return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
def _bearer_token(request: Request) -> str | None:
authorization = request.headers.get("Authorization")
if not authorization or not authorization.startswith("Bearer "):
return None
value = authorization[len("Bearer ") :]
return value if value.strip() else None
class AuthenticationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.method == "OPTIONS":
return await call_next(request)
settings = get_settings()
path = request.url.path
if settings.context_path and path.startswith(settings.context_path):
path = path[len(settings.context_path) :] or "/"
if _matches(path, PUBLIC_PATTERNS):
request.state.auth_mode = "anonymous"
return await call_next(request)
if _matches(path, SERVER_PATTERNS):
return await self._server_auth(request, call_next)
return await self._user_auth(request, call_next)
async def _server_auth(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
provided = _bearer_token(request)
if provided is None:
return error_response(
request,
ErrorCode.UNAUTHORIZED,
"服务器密钥不能为空",
media_type="application/json;charset=utf-8",
)
expected = get_settings().server_secret_override
if expected is None:
try:
async with get_session_factory()() as session:
expected = await SystemParamService(session).get_value("server.secret", from_cache=True)
except Exception:
expected = None
if not expected or not hmac.compare_digest(provided, expected):
return error_response(
request,
ErrorCode.UNAUTHORIZED,
"无效的服务器密钥",
media_type="application/json;charset=utf-8",
)
request.state.auth_mode = "server"
return await call_next(request)
async def _user_auth(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
token = _bearer_token(request)
if token is None:
return error_response(
request,
ErrorCode.UNAUTHORIZED,
media_type="application/json;charset=utf-8",
)
try:
async with get_session_factory()() as session:
result = await session.execute(
text(
"SELECT u.* FROM sys_user_token t "
"JOIN sys_user u ON u.id = t.user_id "
"WHERE t.token = :token AND t.expire_date >= CURRENT_TIMESTAMP LIMIT 1"
),
{"token": token},
)
mapping = result.mappings().first()
except Exception:
mapping = None
if mapping is None or mapping.get("status") is None or int(mapping["status"]) != 1:
return error_response(
request,
ErrorCode.UNAUTHORIZED,
media_type="application/json;charset=utf-8",
)
row = dict(mapping)
request.state.user = AuthUser(
id=int(row["id"]),
username=str(row.get("username") or ""),
super_admin=int(row.get("super_admin") or 0),
status=int(row["status"]),
token=token,
row=row,
)
request.state.auth_mode = "user"
return await call_next(request)
def current_user(request: Request) -> AuthUser:
user = getattr(request.state, "user", None)
if not isinstance(user, AuthUser):
raise AppError(ErrorCode.UNAUTHORIZED)
return user
def require_normal(request: Request) -> AuthUser:
return current_user(request)
def require_super_admin(request: Request) -> AuthUser:
user = current_user(request)
if not user.is_super_admin:
raise AppError(ErrorCode.FORBIDDEN)
return user
def shanghai_now_naive() -> datetime:
from zoneinfo import ZoneInfo
return datetime.now(tz=ZoneInfo(get_settings().timezone)).replace(tzinfo=None)
@@ -0,0 +1,105 @@
from __future__ import annotations
import dataclasses
import re
from collections.abc import Mapping, Sequence
from datetime import date, datetime, time
from decimal import Decimal
from enum import Enum
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from pydantic import BaseModel
from app.core.config import get_settings
_SNAKE_PART = re.compile(r"_([a-zA-Z0-9])")
_LONG_FIELD_NAMES = {
"id",
"userId",
"creator",
"updater",
"createUserId",
"updateUserId",
"createDateTimestamp",
"createTime",
"createTimeFrom",
"createTimeTo",
"fileSize",
"lastConnectedAtTimestamp",
"pid",
"reportTime",
"size",
"timestamp",
"tokenCount",
"tokenNum",
"totalDocCount",
"totalTokenCount",
"updateTime",
}
class JavaMap(dict[str, Any]):
"""Marker for Java ``Map`` payloads whose keys Jackson leaves untouched."""
def preserve_java_map_keys(value: Any) -> Any:
"""Recursively mark a dynamic Java Map/List graph as key-preserving."""
if isinstance(value, Mapping):
return JavaMap({str(key): preserve_java_map_keys(item) for key, item in value.items()})
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
return [preserve_java_map_keys(item) for item in value]
return value
def snake_to_camel(value: str) -> str:
return _SNAKE_PART.sub(lambda match: match.group(1).upper(), value)
def _is_long_field(name: str | None) -> bool:
if not name:
return False
return name in _LONG_FIELD_NAMES or name.endswith("Id") or name.endswith("Ids")
def java_compatible(value: Any, *, field_name: str | None = None) -> Any:
if value is None or isinstance(value, str | bool | float):
return value
if isinstance(value, BaseModel):
return java_compatible(value.model_dump(by_alias=True, exclude_unset=False), field_name=field_name)
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return java_compatible(dataclasses.asdict(value), field_name=field_name)
if isinstance(value, Enum):
return java_compatible(value.value, field_name=field_name)
if isinstance(value, datetime):
timezone = ZoneInfo(get_settings().timezone)
localized = value.astimezone(timezone) if value.tzinfo else value
return localized.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
if isinstance(value, time):
return value.strftime("%H:%M:%S")
if isinstance(value, Decimal):
return float(value)
if isinstance(value, int):
return str(value) if _is_long_field(field_name) or not -(2**31) <= value < 2**31 else value
if isinstance(value, bytes):
return value
if isinstance(value, Path):
return str(value)
if isinstance(value, JavaMap):
return {
str(raw_key): java_compatible(item, field_name=snake_to_camel(str(raw_key)))
for raw_key, item in value.items()
}
if isinstance(value, Mapping):
result: dict[str, Any] = {}
for raw_key, item in value.items():
key = snake_to_camel(str(raw_key))
result[key] = java_compatible(item, field_name=key)
return result
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
return [java_compatible(item, field_name=field_name) for item in value]
return value