Files
xiaozhi-esp32-server/main/manager-api-fastapi/app/services/sys.py
T

716 lines
28 KiB
Python

from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import re
import secrets
import string
import time
import uuid
from datetime import datetime
from typing import Any, cast
from zoneinfo import ZoneInfo
import httpx
from redis.asyncio import Redis
from websockets.asyncio.client import connect
from app.core.config import get_settings
from app.core.crypto import bcrypt_hash
from app.core.errors import AppError, ErrorCode
from app.core.ids import snowflake
from app.core.redis import JavaRedisCodec, get_redis
from app.core.security import AuthUser, shanghai_now_naive
from app.repositories.sys import SysRepository
from app.schemas.sys import DictDataPayload, DictTypePayload, EmitServerActionRequest, SysParamPayload
from app.services.java_validation import validation_message
logger = logging.getLogger(__name__)
WS_PATTERN = re.compile(r"^wss?://[\w.-]+(?:\.[\w.-]+)*(?::\d+)?(?:/[\w.-]*)*$")
class AdminService:
def __init__(self, repository: SysRepository):
self.repository = repository
async def page_users(self, *, mobile: str | None, page: int, limit: int) -> dict[str, Any]:
rows, total = await self.repository.page_users(
mobile=mobile,
page=max(1, page),
limit=max(0, limit),
)
values = [
{
"deviceCount": str(row.get("device_count") or 0),
"mobile": row.get("username"),
"status": row.get("status"),
"userid": str(row["id"]),
"createDate": row.get("create_date"),
}
for row in rows
]
return {"list": values, "total": total}
async def reset_password(self, user_id: int, user: AuthUser) -> str:
password = self._generate_password()
await self.repository.reset_user_password(user_id, bcrypt_hash(password), user.id, shanghai_now_naive())
await self.repository.session.commit()
return password
async def delete_user(self, user_id: int) -> None:
try:
await self.repository.delete_user_cascade(user_id)
await self.repository.session.commit()
except Exception:
await self.repository.session.rollback()
raise
async def change_status(self, status: int, user_ids: list[str], user: AuthUser) -> None:
# SysUserServiceImpl.changeStatus has an outer Spring transaction: a later
# parse/update failure rolls back every earlier item in the same request.
try:
for value in user_ids:
await self.repository.change_user_status(status, [int(value)], user.id, shanghai_now_naive())
await self.repository.session.commit()
except Exception:
await self.repository.session.rollback()
raise
async def page_devices(self, *, keywords: str | None, page: int, limit: int) -> dict[str, Any]:
rows, total = await self.repository.page_devices(
keywords=keywords,
page=max(1, page),
limit=max(0, limit),
)
result = []
for row in rows:
result.append(
{
"appVersion": row.get("app_version"),
"bindUserName": row.get("bind_user_name"),
"deviceType": row.get("board"),
"board": row.get("board"),
"id": row.get("id"),
"macAddress": row.get("mac_address"),
"alias": row.get("alias"),
"otaUpgrade": None,
"recentChatTime": self._short_time(cast(datetime | str | None, row.get("update_date"))),
"lastConnectedAtTimestamp": self._timestamp_ms(
cast(datetime | str | None, row.get("last_connected_at"))
),
"createDateTimestamp": self._timestamp_ms(
cast(datetime | str | None, row.get("create_date"))
),
"createDate": self._utc_datetime_string(
cast(datetime | str | None, row.get("create_date"))
),
}
)
return {"list": result, "total": total}
@staticmethod
def _generate_password() -> str:
characters = string.ascii_letters + string.digits + "!@#$%^&*()"
values = [
secrets.choice(string.digits),
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice("!@#$%^&*()"),
]
values.extend(secrets.choice(characters) for _ in range(8))
secrets.SystemRandom().shuffle(values)
return "".join(values)
@staticmethod
def _timestamp_ms(value: datetime | str | None) -> int | None:
value = AdminService._database_datetime(value)
if value is None:
return None
timezone = ZoneInfo(get_settings().timezone)
localized = value if value.tzinfo else value.replace(tzinfo=timezone)
return int(localized.timestamp() * 1000)
@staticmethod
def _short_time(value: datetime | str | None) -> str | None:
value = AdminService._database_datetime(value)
if value is None:
return None
now = shanghai_now_naive()
if value.tzinfo:
value = value.astimezone(ZoneInfo(get_settings().timezone)).replace(tzinfo=None)
seconds = int((now - value).total_seconds())
if seconds <= 10:
return "刚刚"
if seconds < 60:
return f"{seconds}秒前"
if seconds < 3600:
return f"{seconds // 60}分钟前"
if seconds < 86400:
return f"{seconds // 3600}小时前"
if seconds < 604800:
return f"{seconds // 86400}天前"
return value.strftime("%Y-%m-%d %H:%M:%S")
@staticmethod
def _utc_datetime_string(value: datetime | str | None) -> str | None:
value = AdminService._database_datetime(value)
if value is None:
return None
timezone = ZoneInfo(get_settings().timezone)
localized = value if value.tzinfo else value.replace(tzinfo=timezone)
return localized.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M:%S")
@staticmethod
def _database_datetime(value: datetime | str | None) -> datetime | None:
if isinstance(value, str):
return datetime.fromisoformat(value)
return value
class ParamExternalValidator:
def __init__(self, client: httpx.AsyncClient | None = None):
self.client = client
async def validate(self, code: str, value: str) -> None:
if code == "server.websocket":
await self._websockets(value)
elif code == "server.ota":
await self._http_endpoint(value, kind="ota")
elif code == "server.mcp_endpoint":
await self._http_endpoint(value, kind="mcp")
elif code == "server.voice_print":
await self._http_endpoint(value, kind="voiceprint")
elif code == "server.mqtt_signature_key":
self._mqtt_secret(value)
async def _websockets(self, value: str) -> None:
urls = value.split(";")
while urls and urls[-1] == "":
urls.pop()
if not urls:
raise AppError(10098)
for raw_url in urls:
if not raw_url.strip():
continue
if "localhost" in raw_url or "127.0.0.1" in raw_url:
raise AppError(10099)
if not WS_PATTERN.fullmatch(raw_url.strip()):
raise AppError(10100)
try:
async with connect(raw_url, open_timeout=5):
pass
except Exception as exc:
raise AppError(10101) from exc
async def _http_endpoint(self, value: str, *, kind: str) -> None:
if not value.strip() or value == "null":
return
if "localhost" in value or "127.0.0.1" in value:
raise AppError({"ota": 10103, "mcp": 10110, "voiceprint": 10116}[kind])
if kind == "ota":
if not value.lower().startswith("http"):
raise AppError(10104)
if not value.endswith("/ota/"):
raise AppError(10105)
elif kind == "mcp":
if "key" not in value.lower():
raise AppError(10111)
else:
if "key" not in value.lower():
raise AppError(10117)
if not value.lower().startswith("http"):
raise AppError(10118)
final_code = {"ota": 10108, "mcp": 10114, "voiceprint": 10121}[kind]
marker = {"ota": "OTA", "mcp": "success", "voiceprint": "healthy"}[kind]
try:
if self.client is not None:
response = await self.client.get(value)
else:
async with httpx.AsyncClient(timeout=get_settings().external_request_timeout_seconds) as client:
response = await client.get(value)
if response.status_code != 200 or marker not in response.text:
raise ValueError("external endpoint response did not match Java validation")
except Exception as exc:
raise AppError(final_code) from exc
@staticmethod
def _mqtt_secret(secret: str) -> None:
if not secret.strip() or secret == "null": # noqa: S105 - sentinel value from the Java parameter table
raise AppError(10122)
if len(secret) < 8:
raise AppError(10123)
if not re.search(r"[a-z]", secret) or not re.search(r"[A-Z]", secret):
raise AppError(10124)
lowered = secret.lower()
if any(weak in lowered for weak in ("test", "1234", "admin", "password", "qwerty", "xiaozhi")):
raise AppError(10125)
class SysParamService:
def __init__(
self,
repository: SysRepository,
*,
redis: Redis | None = None,
validator: ParamExternalValidator | None = None,
):
self.repository = repository
self.redis = redis or get_redis()
self.validator = validator or ParamExternalValidator()
async def page(
self,
*,
param_code: str | None,
page: int,
limit: int,
order_field: str | None,
order: str | None,
) -> dict[str, Any]:
rows, total = await self.repository.page_params(
param_code=param_code,
page=max(1, page),
limit=max(0, limit),
order_field=order_field,
order=order,
)
return {"list": [self._param_dto(row) for row in rows], "total": total}
async def get(self, param_id: int) -> dict[str, Any] | None:
row = await self.repository.get_param(param_id)
return None if row is None else self._param_dto(row)
async def save(
self,
dto: SysParamPayload,
user: AuthUser,
accept_language: str | None = None,
) -> None:
self._validate_group(dto, update=False, accept_language=accept_language)
self._validate_value(dto)
assert dto.param_code is not None
assert dto.param_value is not None
assert dto.value_type is not None
await self.repository.insert_param(
param_id=snowflake.next_id(),
param_code=dto.param_code,
param_value=dto.param_value,
value_type=dto.value_type,
remark=dto.remark,
user_id=user.id,
now=shanghai_now_naive(),
)
await self._cache_set(dto.param_code, dto.param_value)
await self.repository.session.commit()
async def update(
self,
dto: SysParamPayload,
user: AuthUser,
accept_language: str | None = None,
) -> None:
self._validate_group(dto, update=True, accept_language=accept_language)
assert dto.id is not None
assert dto.param_code is not None
assert dto.param_value is not None
assert dto.value_type is not None
# These checks live in the Java controller and therefore run before
# SysParamsService.update validates the declared value type.
await self.validator.validate(dto.param_code, dto.param_value)
if dto.param_code == "system-web.menu":
await self._update_system_web_menu(dto.param_value, user)
else:
self._validate_value(dto)
await self.repository.update_param(
param_id=dto.id,
param_code=dto.param_code,
param_value=dto.param_value,
value_type=dto.value_type,
remark=dto.remark,
user_id=user.id,
now=shanghai_now_naive(),
)
await self._cache_set(dto.param_code, dto.param_value)
await self.repository.session.commit()
async def delete(self, ids: list[str]) -> None:
if not ids:
raise AppError(10001, "id")
parsed_ids = [int(value) for value in ids]
codes = await self.repository.param_codes_for_ids(parsed_ids)
if codes:
await cast(Any, self.redis.hdel)("sys:params", *codes)
await self.repository.delete_params(parsed_ids)
await self.repository.session.commit()
async def get_value(self, code: str, *, from_cache: bool = True) -> str | None:
if from_cache:
cached = JavaRedisCodec.decode(await cast(Any, self.redis.hget)("sys:params", code))
if cached is not None:
return str(cached)
value = await self.repository.get_param_value(code)
if value is not None and from_cache:
await self._cache_set(code, value)
return value
async def config_rows(self) -> list[dict[str, Any]]:
return await self.repository.list_config_params()
async def _update_system_web_menu(self, config_json: str, user: AuthUser) -> None:
current_config = await self.repository.get_param_value("system-web.menu")
try:
current = json.loads(current_config) if current_config and current_config.strip() else None
updated = json.loads(config_json) if config_json.strip() else None
except json.JSONDecodeError as exc:
raise AppError(ErrorCode.PARAM_JSON_INVALID) from exc
if isinstance(current, dict) and isinstance(updated, dict):
current_features = current.get("features")
updated_features = updated.get("features")
# Java only evaluates addressBook when both feature maps are present.
if isinstance(current_features, dict) and isinstance(updated_features, dict):
current_address = current_features.get("addressBook")
updated_address = updated_features.get("addressBook")
current_enabled = self._java_enabled(current_address)
updated_enabled = self._java_enabled(updated_address)
if current_enabled and not updated_enabled:
await self.repository.delete_plugin_mapping_by_plugin_id("SYSTEM_PLUGIN_CALL_DEVICE")
await self.repository.update_param_value_by_code(
"system-web.menu", config_json, user.id, shanghai_now_naive()
)
await self._cache_set("system-web.menu", config_json)
async def _cache_set(self, code: str, value: str) -> None:
await cast(Any, self.redis.hset)("sys:params", code, JavaRedisCodec.encode(value))
await cast(Any, self.redis.expire)("sys:params", 24 * 60 * 60)
@staticmethod
def _java_enabled(address_book: Any) -> bool:
if not isinstance(address_book, dict):
return False
value = address_book.get("enabled")
if value is None:
return False
if not isinstance(value, bool):
# The Java implementation casts the JSON value to Boolean.
raise TypeError("addressBook.enabled must be a boolean")
return value
@staticmethod
def _validate_value(dto: SysParamPayload) -> None:
assert dto.param_value is not None
assert dto.value_type is not None
if not dto.param_value.strip():
raise AppError(ErrorCode.PARAM_VALUE_NULL)
if not dto.value_type.strip():
raise AppError(ErrorCode.PARAM_TYPE_NULL)
value_type = dto.value_type.lower()
if value_type in {"string", "array"}:
return
if value_type == "number":
try:
float(dto.param_value)
except ValueError as exc:
raise AppError(ErrorCode.PARAM_NUMBER_INVALID) from exc
return
if value_type == "boolean":
if dto.param_value.lower() not in {"true", "false"}:
raise AppError(ErrorCode.PARAM_BOOLEAN_INVALID)
return
if value_type == "json":
stripped = dto.param_value.strip()
if not stripped.startswith("{") or not stripped.endswith("}"):
raise AppError(ErrorCode.PARAM_JSON_INVALID)
try:
json.loads(dto.param_value)
except json.JSONDecodeError as exc:
raise AppError(ErrorCode.PARAM_JSON_INVALID) from exc
return
raise AppError(ErrorCode.PARAM_TYPE_INVALID)
@staticmethod
def _validate_group(
dto: SysParamPayload,
*,
update: bool,
accept_language: str | None,
) -> None:
def fail(key: str) -> None:
raise AppError(500, validation_message(key, accept_language))
if update and dto.id is None:
fail("id.require")
if not update and dto.id is not None:
fail("id.null")
if dto.param_code is None or not dto.param_code.strip():
fail("sysparams.paramcode.require")
if dto.param_value is None or not dto.param_value.strip():
fail("sysparams.paramvalue.require")
if dto.value_type is None or not dto.value_type.strip():
fail("sysparams.valuetype.require")
if dto.value_type not in {"string", "number", "boolean", "array", "json"}:
fail("sysparams.valuetype.pattern")
@staticmethod
def _param_dto(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row.get("id"),
"paramCode": row.get("param_code"),
"paramValue": row.get("param_value"),
"valueType": row.get("value_type"),
"remark": row.get("remark"),
"createDate": row.get("create_date"),
"updateDate": row.get("update_date"),
}
class DictService:
def __init__(self, repository: SysRepository, *, redis: Redis | None = None):
self.repository = repository
self.redis = redis or get_redis()
async def page_types(
self,
*,
dict_type: str | None,
dict_name: str | None,
page: int,
limit: int,
) -> dict[str, Any]:
rows, total = await self.repository.page_dict_types(
dict_type=dict_type,
dict_name=dict_name,
page=max(1, page),
limit=max(0, limit),
)
return {"list": [self._type_vo(row, include_names=True) for row in rows], "total": total}
async def get_type(self, type_id: int) -> dict[str, Any]:
row = await self.repository.get_dict_type(type_id)
if row is None:
raise AppError(10076)
return self._type_vo(row, include_names=False)
async def save_type(self, dto: DictTypePayload, user: AuthUser) -> None:
if await self.repository.dict_type_exists(dto.dict_type):
raise AppError(10077)
await self.repository.insert_dict_type(
type_id=dto.id if dto.id is not None else snowflake.next_id(),
dict_type=dto.dict_type,
dict_name=dto.dict_name,
remark=dto.remark,
sort=dto.sort,
user_id=user.id,
now=shanghai_now_naive(),
)
await self.repository.session.commit()
async def update_type(self, dto: DictTypePayload, user: AuthUser) -> None:
if await self.repository.dict_type_exists(dto.dict_type, exclude_id=dto.id):
raise AppError(10077)
await self.repository.update_dict_type(
type_id=dto.id,
dict_type=dto.dict_type,
dict_name=dto.dict_name,
remark=dto.remark,
sort=dto.sort,
user_id=user.id,
now=shanghai_now_naive(),
)
await self.repository.session.commit()
async def delete_types(self, ids: list[int]) -> None:
await self.repository.delete_dict_types(ids)
await self.repository.session.commit()
async def page_data(
self,
*,
dict_type_id: int,
dict_label: str | None,
dict_value: str | None,
page: int,
limit: int,
) -> dict[str, Any]:
rows, total = await self.repository.page_dict_data(
dict_type_id=dict_type_id,
dict_label=dict_label,
dict_value=dict_value,
page=max(1, page),
limit=max(0, limit),
)
return {"list": [self._data_vo(row, include_names=True) for row in rows], "total": total}
async def get_data(self, data_id: int) -> dict[str, Any] | None:
row = await self.repository.get_dict_data(data_id)
return None if row is None else self._data_vo(row, include_names=False)
async def save_data(self, dto: DictDataPayload, user: AuthUser) -> None:
# Java compares dict_label against dictValue here; retain that behavior for compatibility.
if await self.repository.dict_data_label_exists(dto.dict_type_id, dto.dict_value):
raise AppError(10128)
await self.repository.insert_dict_data(
data_id=dto.id if dto.id is not None else snowflake.next_id(),
dict_type_id=dto.dict_type_id,
dict_label=dto.dict_label,
dict_value=dto.dict_value,
remark=dto.remark,
sort=dto.sort,
user_id=user.id,
now=shanghai_now_naive(),
)
await self._clear_dict_cache(dto.dict_type_id)
await self.repository.session.commit()
async def update_data(self, dto: DictDataPayload, user: AuthUser) -> None:
if await self.repository.dict_data_label_exists(dto.dict_type_id, dto.dict_value, exclude_id=dto.id):
raise AppError(10128)
await self.repository.update_dict_data(
data_id=dto.id,
dict_type_id=dto.dict_type_id,
dict_label=dto.dict_label,
dict_value=dto.dict_value,
remark=dto.remark,
sort=dto.sort,
user_id=user.id,
now=shanghai_now_naive(),
)
await self._clear_dict_cache(dto.dict_type_id)
await self.repository.session.commit()
async def delete_data(self, ids: list[int]) -> None:
if ids:
codes = await self.repository.dict_type_codes_for_data_ids(ids)
if codes:
await cast(Any, self.redis.delete)(*[f"sys:dict:data:{code}" for code in codes])
await self.repository.delete_dict_data(ids)
await self.repository.session.commit()
async def items(self, dict_type: str) -> list[dict[str, Any]] | None:
if not dict_type.strip():
return None
key = f"sys:dict:data:{dict_type}"
cached = JavaRedisCodec.decode(await cast(Any, self.redis.get)(key))
if isinstance(cached, list):
return cast(list[dict[str, Any]], cached)
rows = await self.repository.dict_items(dict_type)
await cast(Any, self.redis.set)(
key,
JavaRedisCodec.encode(
rows,
item_java_type="xiaozhi.modules.sys.vo.SysDictDataItem",
),
ex=24 * 60 * 60,
)
return rows
async def _clear_dict_cache(self, type_id: int | None) -> None:
dict_type = await self.repository.dict_type_code(type_id)
if dict_type is not None:
await cast(Any, self.redis.delete)(f"sys:dict:data:{dict_type}")
@staticmethod
def _type_vo(row: dict[str, Any], *, include_names: bool) -> dict[str, Any]:
return {
"id": row.get("id"),
"dictType": row.get("dict_type"),
"dictName": row.get("dict_name"),
"remark": row.get("remark"),
"sort": row.get("sort"),
"creator": row.get("creator"),
"creatorName": row.get("creator_name") if include_names else None,
"createDate": row.get("create_date"),
"updater": row.get("updater"),
"updaterName": row.get("updater_name") if include_names else None,
"updateDate": row.get("update_date"),
}
@staticmethod
def _data_vo(row: dict[str, Any], *, include_names: bool) -> dict[str, Any]:
return {
"id": row.get("id"),
"dictTypeId": row.get("dict_type_id"),
"dictLabel": row.get("dict_label"),
"dictValue": row.get("dict_value"),
"remark": row.get("remark"),
"sort": row.get("sort"),
"creator": row.get("creator"),
"creatorName": row.get("creator_name") if include_names else None,
"createDate": row.get("create_date"),
"updater": row.get("updater"),
"updaterName": row.get("updater_name") if include_names else None,
"updateDate": row.get("update_date"),
}
class ServerActionService:
def __init__(self, param_service: SysParamService, *, redis: Redis | None = None):
self.param_service = param_service
self.redis = redis or get_redis()
async def server_list(self) -> list[str]:
value = await self.param_service.get_value("server.websocket", from_cache=True)
if value is None or not value.strip():
return []
values = value.split(";")
while values and values[-1] == "":
values.pop()
return values
async def emit(self, dto: EmitServerActionRequest) -> bool:
action = dto.action.lower() if dto.action is not None else None
if action not in {"restart", "update_config"}:
raise AppError(10095)
websocket_text = await self.param_service.get_value("server.websocket", from_cache=True)
if websocket_text is None or not websocket_text.strip():
raise AppError(10096)
if dto.target_ws not in websocket_text.split(";"):
raise AppError(10097)
payload_secret = await self.param_service.get_value("server.secret", from_cache=True)
device_id = str(uuid.uuid4())
client_id = str(uuid.uuid4())
await cast(Any, self.redis.set)(
f"tmp_register_mac:{device_id}",
JavaRedisCodec.encode("true"),
ex=300,
)
authentication_secret = await self.param_service.get_value("server.secret", from_cache=False)
if authentication_secret is None or not authentication_secret.strip():
raise AppError(10045)
timestamp = int(time.time())
content = f"{client_id}|{device_id}|{timestamp}"
signature = hmac.new(authentication_secret.encode(), content.encode(), digestmod=hashlib.sha256).digest()
token = base64.urlsafe_b64encode(signature).rstrip(b"=").decode() + f".{timestamp}"
headers = {
"device-id": device_id,
"client-id": client_id,
"authorization": f"Bearer {token}",
}
if payload_secret is None:
raise AppError(10045)
payload = {"type": "server", "action": action, "content": {"secret": payload_secret}}
try:
async with connect(dto.target_ws, additional_headers=headers, open_timeout=3) as websocket:
await websocket.send(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
deadline = time.monotonic() + 120
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError
raw = await asyncio.wait_for(websocket.recv(), timeout=remaining)
response = json.loads(raw)
if (
isinstance(response, dict)
and response.get("status") == "success"
and response.get("type") == "server"
and isinstance(response.get("content"), dict)
and response["content"].get("action") is not None
):
return True
except Exception as exc:
raise AppError(10045) from exc