Fix API compatibility and add user/role/permission and asset import/export
This commit is contained in:
6
backend/app/utils/__init__.py
Normal file
6
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from app.utils.redis_client import redis_client, init_redis, close_redis, RedisClient
|
||||
|
||||
__all__ = ["redis_client", "init_redis", "close_redis", "RedisClient"]
|
||||
97
backend/app/utils/asset_code.py
Normal file
97
backend/app/utils/asset_code.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
资产编码生成工具
|
||||
使用PostgreSQL Advisory Lock保证并发安全
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
async def generate_asset_code(db: AsyncSession) -> str:
|
||||
"""
|
||||
生成资产编码
|
||||
|
||||
格式: AS + YYYYMMDD + 流水号(4位)
|
||||
示例: AS202501240001
|
||||
|
||||
使用PostgreSQL Advisory Lock保证并发安全
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
资产编码
|
||||
"""
|
||||
# 获取当前日期字符串
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
prefix = f"AS{date_str}"
|
||||
|
||||
# 使用Advisory Lock保证并发安全
|
||||
# 使用日期作为锁ID,避免不同日期的锁冲突
|
||||
lock_id = int(date_str)
|
||||
|
||||
try:
|
||||
# 获取锁
|
||||
await db.execute(text(f"SELECT pg_advisory_lock({lock_id})"))
|
||||
|
||||
# 查询今天最大的序号
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT CAST(SUBSTRING(asset_code FROM 13 FOR 4) AS INTEGER) as max_seq
|
||||
FROM assets
|
||||
WHERE asset_code LIKE :prefix
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY asset_code DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"prefix": f"{prefix}%"}
|
||||
)
|
||||
|
||||
row = result.fetchone()
|
||||
max_seq = row[0] if row and row[0] else 0
|
||||
|
||||
# 生成新序号
|
||||
new_seq = max_seq + 1
|
||||
seq_str = f"{new_seq:04d}" # 补零到4位
|
||||
|
||||
# 组合编码
|
||||
asset_code = f"{prefix}{seq_str}"
|
||||
|
||||
return asset_code
|
||||
|
||||
finally:
|
||||
# 释放锁
|
||||
await db.execute(text(f"SELECT pg_advisory_unlock({lock_id})"))
|
||||
|
||||
|
||||
def validate_asset_code(asset_code: str) -> bool:
|
||||
"""
|
||||
验证资产编码格式
|
||||
|
||||
Args:
|
||||
asset_code: 资产编码
|
||||
|
||||
Returns:
|
||||
是否有效
|
||||
"""
|
||||
if not asset_code or len(asset_code) != 14:
|
||||
return False
|
||||
|
||||
# 检查前缀
|
||||
if not asset_code.startswith("AS"):
|
||||
return False
|
||||
|
||||
# 检查日期部分
|
||||
date_str = asset_code[2:10]
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y%m%d")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# 检查序号部分
|
||||
seq_str = asset_code[10:]
|
||||
if not seq_str.isdigit():
|
||||
return False
|
||||
|
||||
return True
|
||||
73
backend/app/utils/case.py
Normal file
73
backend/app/utils/case.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Case conversion utilities for API payloads.
|
||||
"""
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
_FIRST_CAP_RE = re.compile(r"(.)([A-Z][a-z]+)")
|
||||
_ALL_CAP_RE = re.compile(r"([a-z0-9])([A-Z])")
|
||||
|
||||
_SPECIAL_ALIASES = {
|
||||
"avatar_url": "avatar",
|
||||
"avatarUrl": "avatar",
|
||||
}
|
||||
|
||||
|
||||
def to_snake(name: str) -> str:
|
||||
"""Convert camelCase/PascalCase to snake_case."""
|
||||
if not isinstance(name, str) or not name:
|
||||
return name
|
||||
if name.lower() == name:
|
||||
return name
|
||||
s1 = _FIRST_CAP_RE.sub(r"\1_\2", name)
|
||||
s2 = _ALL_CAP_RE.sub(r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
def to_camel(name: str) -> str:
|
||||
"""Convert snake_case to camelCase."""
|
||||
if not isinstance(name, str) or not name:
|
||||
return name
|
||||
if "_" not in name:
|
||||
return name
|
||||
parts = name.split("_")
|
||||
if not parts:
|
||||
return name
|
||||
first = parts[0]
|
||||
rest = "".join(part.capitalize() if part else "_" for part in parts[1:])
|
||||
return first + rest
|
||||
|
||||
|
||||
def convert_keys_to_snake(data: Any) -> Any:
|
||||
"""Recursively convert dict keys to snake_case."""
|
||||
if isinstance(data, list):
|
||||
return [convert_keys_to_snake(item) for item in data]
|
||||
if isinstance(data, dict):
|
||||
converted: Dict[Any, Any] = {}
|
||||
for key, value in data.items():
|
||||
new_key = to_snake(key) if isinstance(key, str) else key
|
||||
converted[new_key] = convert_keys_to_snake(value)
|
||||
return converted
|
||||
return data
|
||||
|
||||
|
||||
def add_camelcase_aliases(data: Any) -> Any:
|
||||
"""Recursively add camelCase aliases for snake_case keys."""
|
||||
if isinstance(data, list):
|
||||
return [add_camelcase_aliases(item) for item in data]
|
||||
if isinstance(data, dict):
|
||||
result: Dict[Any, Any] = {}
|
||||
for key, value in data.items():
|
||||
result[key] = add_camelcase_aliases(value)
|
||||
# Add camelCase aliases without overriding existing keys
|
||||
for key, value in list(result.items()):
|
||||
if isinstance(key, str):
|
||||
camel_key = to_camel(key)
|
||||
if camel_key != key and camel_key not in result:
|
||||
result[camel_key] = value
|
||||
alias_key = _SPECIAL_ALIASES.get(key)
|
||||
if alias_key and alias_key not in result:
|
||||
result[alias_key] = value
|
||||
return result
|
||||
return data
|
||||
86
backend/app/utils/qrcode.py
Normal file
86
backend/app/utils/qrcode.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
二维码生成工具
|
||||
"""
|
||||
import os
|
||||
import qrcode
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def generate_qr_code(asset_code: str, save_path: str = None) -> str:
|
||||
"""
|
||||
生成资产二维码
|
||||
|
||||
Args:
|
||||
asset_code: 资产编码
|
||||
save_path: 保存路径(可选)
|
||||
|
||||
Returns:
|
||||
二维码文件相对路径
|
||||
"""
|
||||
# 如果未指定保存路径,使用默认路径
|
||||
if not save_path:
|
||||
qr_dir = Path(settings.QR_CODE_DIR)
|
||||
else:
|
||||
qr_dir = Path(save_path)
|
||||
|
||||
# 确保目录存在
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件名
|
||||
filename = f"{asset_code}.png"
|
||||
file_path = qr_dir / filename
|
||||
|
||||
# 创建二维码
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=settings.QR_CODE_BORDER,
|
||||
)
|
||||
qr.add_data(asset_code)
|
||||
qr.make(fit=True)
|
||||
|
||||
# 生成图片
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# 保存文件
|
||||
img.save(str(file_path))
|
||||
|
||||
# 返回相对路径
|
||||
return f"{settings.QR_CODE_DIR}/{filename}"
|
||||
|
||||
|
||||
def get_qr_code_url(asset_code: str) -> str:
|
||||
"""
|
||||
获取二维码URL
|
||||
|
||||
Args:
|
||||
asset_code: 资产编码
|
||||
|
||||
Returns:
|
||||
二维码URL
|
||||
"""
|
||||
filename = f"{asset_code}.png"
|
||||
return f"/static/{settings.QR_CODE_DIR}/{filename}"
|
||||
|
||||
|
||||
def delete_qr_code(asset_code: str) -> bool:
|
||||
"""
|
||||
删除二维码文件
|
||||
|
||||
Args:
|
||||
asset_code: 资产编码
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
file_path = Path(settings.QR_CODE_DIR) / f"{asset_code}.png"
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
219
backend/app/utils/redis_client.py
Normal file
219
backend/app/utils/redis_client.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Redis客户端工具类
|
||||
"""
|
||||
import json
|
||||
import asyncio
|
||||
import hashlib
|
||||
from functools import wraps
|
||||
from typing import Optional, Any, List, Callable
|
||||
from redis.asyncio import Redis, ConnectionPool
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class RedisClient:
|
||||
"""Redis客户端"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化Redis客户端"""
|
||||
self.pool: Optional[ConnectionPool] = None
|
||||
self.redis: Optional[Redis] = None
|
||||
|
||||
async def connect(self):
|
||||
"""连接Redis"""
|
||||
if not self.pool:
|
||||
self.pool = ConnectionPool.from_url(
|
||||
settings.REDIS_URL,
|
||||
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
||||
decode_responses=True
|
||||
)
|
||||
self.redis = Redis(connection_pool=self.pool)
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
if self.redis:
|
||||
await self.redis.close()
|
||||
if self.pool:
|
||||
await self.pool.disconnect()
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""获取缓存"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.get(key)
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
expire: Optional[int] = None
|
||||
) -> bool:
|
||||
"""设置缓存"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.set(key, value, ex=expire)
|
||||
|
||||
async def delete(self, key: str) -> int:
|
||||
"""删除缓存"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.delete(key)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""检查键是否存在"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.exists(key) > 0
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> bool:
|
||||
"""设置过期时间"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.expire(key, seconds)
|
||||
|
||||
async def keys(self, pattern: str) -> List[str]:
|
||||
"""获取匹配的键"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.keys(pattern)
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
"""删除匹配的键"""
|
||||
keys = await self.keys(pattern)
|
||||
if keys:
|
||||
return await self.redis.delete(*keys)
|
||||
return 0
|
||||
|
||||
async def setex(self, key: str, time: int, value: str) -> bool:
|
||||
"""设置缓存并指定过期时间(秒)"""
|
||||
if not self.redis:
|
||||
await self.connect()
|
||||
return await self.redis.setex(key, time, value)
|
||||
|
||||
# JSON操作辅助方法
|
||||
|
||||
async def get_json(self, key: str) -> Optional[Any]:
|
||||
"""获取JSON数据"""
|
||||
value = await self.get(key)
|
||||
if value:
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return None
|
||||
|
||||
async def set_json(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
expire: Optional[int] = None
|
||||
) -> bool:
|
||||
"""设置JSON数据"""
|
||||
json_str = json.dumps(value, ensure_ascii=False)
|
||||
return await self.set(key, json_str, expire)
|
||||
|
||||
# 缓存装饰器
|
||||
|
||||
def cache(self, key_prefix: str, expire: int = 300):
|
||||
"""
|
||||
Redis缓存装饰器(改进版)
|
||||
|
||||
Args:
|
||||
key_prefix: 缓存键前缀
|
||||
expire: 过期时间(秒),默认300秒(5分钟)
|
||||
|
||||
Example:
|
||||
@redis_client.cache("device_types", expire=1800)
|
||||
async def get_device_types(...):
|
||||
pass
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 使用MD5生成更稳定的缓存键
|
||||
key_data = f"{key_prefix}:{str(args)}:{str(kwargs)}"
|
||||
cache_key = f"cache:{hashlib.md5(key_data.encode()).hexdigest()}"
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached = await self.get_json(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 存入缓存
|
||||
await self.set_json(cache_key, result, expire)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
# 统计缓存辅助方法
|
||||
|
||||
async def cache_statistics(
|
||||
self,
|
||||
key: str,
|
||||
data: Any,
|
||||
expire: int = 600
|
||||
):
|
||||
"""缓存统计数据"""
|
||||
return await self.set_json(key, data, expire)
|
||||
|
||||
async def get_cached_statistics(self, key: str) -> Optional[Any]:
|
||||
"""获取缓存的统计数据"""
|
||||
return await self.get_json(key)
|
||||
|
||||
async def invalidate_statistics_cache(self, pattern: str = "statistics:*"):
|
||||
"""清除统计数据缓存"""
|
||||
return await self.delete_pattern(pattern)
|
||||
|
||||
# 同步函数的异步缓存包装器
|
||||
|
||||
def cached_async(self, key_prefix: str, expire: int = 300):
|
||||
"""
|
||||
为同步函数提供异步缓存包装的装饰器
|
||||
|
||||
Args:
|
||||
key_prefix: 缓存键前缀
|
||||
expire: 过期时间(秒),默认300秒(5分钟)
|
||||
|
||||
Example:
|
||||
@redis_client.cached_async("device_types", expire=1800)
|
||||
async def cached_get_device_types(db, skip, limit, ...):
|
||||
return device_type_service.get_device_types(...)
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 使用MD5生成更稳定的缓存键
|
||||
key_data = f"{key_prefix}:{str(args)}:{str(kwargs)}"
|
||||
cache_key = f"cache:{hashlib.md5(key_data.encode()).hexdigest()}"
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached = await self.get_json(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 存入缓存
|
||||
await self.set_json(cache_key, result, expire)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
redis_client = RedisClient()
|
||||
|
||||
|
||||
async def init_redis():
|
||||
"""初始化Redis连接"""
|
||||
await redis_client.connect()
|
||||
|
||||
|
||||
async def close_redis():
|
||||
"""关闭Redis连接"""
|
||||
await redis_client.close()
|
||||
Reference in New Issue
Block a user