mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-24 08:03:53 +08:00
feat: add FastAPI manager API compatibility baseline
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user