- 新增图像生成接口,支持试用、积分和自定义API Key模式 - 实现生成图片结果异步上传至MinIO存储,带重试机制 - 优化积分预扣除和异常退还逻辑,保障用户积分准确 - 添加获取生成历史记录接口,支持时间范围和分页 - 提供本地字典配置接口,支持模型、比例、提示模板和尺寸 - 实现图片批量上传接口,支持S3兼容对象存储 feat(admin): 增加管理员角色管理与权限分配接口 - 实现角色列表查询、角色创建、更新及删除功能 - 增加权限列表查询接口 - 实现用户角色分配接口,便于统一管理用户权限 - 增加系统字典增删查改接口,支持分类过滤和排序 - 权限控制全面覆盖管理接口,保证安全访问 feat(auth): 完善用户登录注册及权限相关接口与页面 - 实现手机号验证码发送及校验功能,保障注册安全 - 支持手机号注册、登录及退出接口,集成日志记录 - 增加修改密码功能,验证原密码后更新 - 提供动态导航菜单接口,基于权限展示不同菜单 - 实现管理界面路由及日志、角色、字典管理页面访问权限控制 - 添加系统日志查询接口,支持关键词和等级筛选 feat(app): 初始化Flask应用并配置蓝图与数据库 - 创建应用程序工厂,加载配置,初始化数据库和Redis客户端 - 注册认证、API及管理员蓝图,整合路由 - 根路由渲染主页模板 - 应用上下文中自动创建数据库表,保证运行环境准备完毕 feat(database): 提供数据库创建与迁移支持脚本 - 新增数据库创建脚本,支持自动检测是否已存在 - 添加数据库表初始化脚本,支持创建和删除所有表 - 实现RBAC权限初始化,包含基础权限和角色创建 - 新增字段手动修复脚本,添加用户API Key和积分字段 - 强制迁移脚本支持清理连接和修复表结构,初始化默认数据及角色分配 feat(config): 新增系统配置参数 - 配置数据库、Redis、Session和MinIO相关参数 - 添加AI接口地址及试用Key配置 - 集成阿里云短信服务配置及开发模式相关参数 feat(extensions): 初始化数据库、Redis和MinIO客户端 - 创建全局SQLAlchemy数据库实例和Redis客户端 - 配置基于boto3的MinIO兼容S3客户端 chore(logs): 添加示例系统日志文件 - 记录用户请求、验证码发送成功与失败的日志信息
422 lines
11 KiB
Python
422 lines
11 KiB
Python
import datetime
|
|
import logging
|
|
import textwrap
|
|
import warnings
|
|
from collections.abc import Callable
|
|
from contextlib import contextmanager
|
|
from functools import wraps
|
|
from typing import Any, Dict, List, Mapping, Optional, TypeVar, Union
|
|
|
|
from redis.exceptions import DataError
|
|
from redis.typing import AbsExpiryT, EncodableT, ExpiryT
|
|
|
|
try:
|
|
import hiredis # noqa
|
|
|
|
# Only support Hiredis >= 3.0:
|
|
hiredis_version = hiredis.__version__.split(".")
|
|
HIREDIS_AVAILABLE = int(hiredis_version[0]) > 3 or (
|
|
int(hiredis_version[0]) == 3 and int(hiredis_version[1]) >= 2
|
|
)
|
|
if not HIREDIS_AVAILABLE:
|
|
raise ImportError("hiredis package should be >= 3.2.0")
|
|
except ImportError:
|
|
HIREDIS_AVAILABLE = False
|
|
|
|
try:
|
|
import ssl # noqa
|
|
|
|
SSL_AVAILABLE = True
|
|
except ImportError:
|
|
SSL_AVAILABLE = False
|
|
|
|
try:
|
|
import cryptography # noqa
|
|
|
|
CRYPTOGRAPHY_AVAILABLE = True
|
|
except ImportError:
|
|
CRYPTOGRAPHY_AVAILABLE = False
|
|
|
|
from importlib import metadata
|
|
|
|
|
|
def from_url(url, **kwargs):
|
|
"""
|
|
Returns an active Redis client generated from the given database URL.
|
|
|
|
Will attempt to extract the database id from the path url fragment, if
|
|
none is provided.
|
|
"""
|
|
from redis.client import Redis
|
|
|
|
return Redis.from_url(url, **kwargs)
|
|
|
|
|
|
@contextmanager
|
|
def pipeline(redis_obj):
|
|
p = redis_obj.pipeline()
|
|
yield p
|
|
p.execute()
|
|
|
|
|
|
def str_if_bytes(value: Union[str, bytes]) -> str:
|
|
return (
|
|
value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value
|
|
)
|
|
|
|
|
|
def safe_str(value):
|
|
return str(str_if_bytes(value))
|
|
|
|
|
|
def dict_merge(*dicts: Mapping[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Merge all provided dicts into 1 dict.
|
|
*dicts : `dict`
|
|
dictionaries to merge
|
|
"""
|
|
merged = {}
|
|
|
|
for d in dicts:
|
|
merged.update(d)
|
|
|
|
return merged
|
|
|
|
|
|
def list_keys_to_dict(key_list, callback):
|
|
return dict.fromkeys(key_list, callback)
|
|
|
|
|
|
def merge_result(command, res):
|
|
"""
|
|
Merge all items in `res` into a list.
|
|
|
|
This command is used when sending a command to multiple nodes
|
|
and the result from each node should be merged into a single list.
|
|
|
|
res : 'dict'
|
|
"""
|
|
result = set()
|
|
|
|
for v in res.values():
|
|
for value in v:
|
|
result.add(value)
|
|
|
|
return list(result)
|
|
|
|
|
|
def warn_deprecated(name, reason="", version="", stacklevel=2):
|
|
import warnings
|
|
|
|
msg = f"Call to deprecated {name}."
|
|
if reason:
|
|
msg += f" ({reason})"
|
|
if version:
|
|
msg += f" -- Deprecated since version {version}."
|
|
warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
|
|
|
|
|
|
def deprecated_function(reason="", version="", name=None):
|
|
"""
|
|
Decorator to mark a function as deprecated.
|
|
"""
|
|
|
|
def decorator(func):
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
warn_deprecated(name or func.__name__, reason, version, stacklevel=3)
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def warn_deprecated_arg_usage(
|
|
arg_name: Union[list, str],
|
|
function_name: str,
|
|
reason: str = "",
|
|
version: str = "",
|
|
stacklevel: int = 2,
|
|
):
|
|
import warnings
|
|
|
|
msg = (
|
|
f"Call to '{function_name}' function with deprecated"
|
|
f" usage of input argument/s '{arg_name}'."
|
|
)
|
|
if reason:
|
|
msg += f" ({reason})"
|
|
if version:
|
|
msg += f" -- Deprecated since version {version}."
|
|
warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
|
|
|
|
|
|
C = TypeVar("C", bound=Callable)
|
|
|
|
|
|
def deprecated_args(
|
|
args_to_warn: list = ["*"],
|
|
allowed_args: list = [],
|
|
reason: str = "",
|
|
version: str = "",
|
|
) -> Callable[[C], C]:
|
|
"""
|
|
Decorator to mark specified args of a function as deprecated.
|
|
If '*' is in args_to_warn, all arguments will be marked as deprecated.
|
|
"""
|
|
|
|
def decorator(func: C) -> C:
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
# Get function argument names
|
|
arg_names = func.__code__.co_varnames[: func.__code__.co_argcount]
|
|
|
|
provided_args = dict(zip(arg_names, args))
|
|
provided_args.update(kwargs)
|
|
|
|
provided_args.pop("self", None)
|
|
for allowed_arg in allowed_args:
|
|
provided_args.pop(allowed_arg, None)
|
|
|
|
for arg in args_to_warn:
|
|
if arg == "*" and len(provided_args) > 0:
|
|
warn_deprecated_arg_usage(
|
|
list(provided_args.keys()),
|
|
func.__name__,
|
|
reason,
|
|
version,
|
|
stacklevel=3,
|
|
)
|
|
elif arg in provided_args:
|
|
warn_deprecated_arg_usage(
|
|
arg, func.__name__, reason, version, stacklevel=3
|
|
)
|
|
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def _set_info_logger():
|
|
"""
|
|
Set up a logger that log info logs to stdout.
|
|
(This is used by the default push response handler)
|
|
"""
|
|
if "push_response" not in logging.root.manager.loggerDict.keys():
|
|
logger = logging.getLogger("push_response")
|
|
logger.setLevel(logging.INFO)
|
|
handler = logging.StreamHandler()
|
|
handler.setLevel(logging.INFO)
|
|
logger.addHandler(handler)
|
|
|
|
|
|
def get_lib_version():
|
|
try:
|
|
libver = metadata.version("redis")
|
|
except metadata.PackageNotFoundError:
|
|
libver = "99.99.99"
|
|
return libver
|
|
|
|
|
|
def format_error_message(host_error: str, exception: BaseException) -> str:
|
|
if not exception.args:
|
|
return f"Error connecting to {host_error}."
|
|
elif len(exception.args) == 1:
|
|
return f"Error {exception.args[0]} connecting to {host_error}."
|
|
else:
|
|
return (
|
|
f"Error {exception.args[0]} connecting to {host_error}. "
|
|
f"{exception.args[1]}."
|
|
)
|
|
|
|
|
|
def compare_versions(version1: str, version2: str) -> int:
|
|
"""
|
|
Compare two versions.
|
|
|
|
:return: -1 if version1 > version2
|
|
0 if both versions are equal
|
|
1 if version1 < version2
|
|
"""
|
|
|
|
num_versions1 = list(map(int, version1.split(".")))
|
|
num_versions2 = list(map(int, version2.split(".")))
|
|
|
|
if len(num_versions1) > len(num_versions2):
|
|
diff = len(num_versions1) - len(num_versions2)
|
|
for _ in range(diff):
|
|
num_versions2.append(0)
|
|
elif len(num_versions1) < len(num_versions2):
|
|
diff = len(num_versions2) - len(num_versions1)
|
|
for _ in range(diff):
|
|
num_versions1.append(0)
|
|
|
|
for i, ver in enumerate(num_versions1):
|
|
if num_versions1[i] > num_versions2[i]:
|
|
return -1
|
|
elif num_versions1[i] < num_versions2[i]:
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
def ensure_string(key):
|
|
if isinstance(key, bytes):
|
|
return key.decode("utf-8")
|
|
elif isinstance(key, str):
|
|
return key
|
|
else:
|
|
raise TypeError("Key must be either a string or bytes")
|
|
|
|
|
|
def extract_expire_flags(
|
|
ex: Optional[ExpiryT] = None,
|
|
px: Optional[ExpiryT] = None,
|
|
exat: Optional[AbsExpiryT] = None,
|
|
pxat: Optional[AbsExpiryT] = None,
|
|
) -> List[EncodableT]:
|
|
exp_options: list[EncodableT] = []
|
|
if ex is not None:
|
|
exp_options.append("EX")
|
|
if isinstance(ex, datetime.timedelta):
|
|
exp_options.append(int(ex.total_seconds()))
|
|
elif isinstance(ex, int):
|
|
exp_options.append(ex)
|
|
elif isinstance(ex, str) and ex.isdigit():
|
|
exp_options.append(int(ex))
|
|
else:
|
|
raise DataError("ex must be datetime.timedelta or int")
|
|
elif px is not None:
|
|
exp_options.append("PX")
|
|
if isinstance(px, datetime.timedelta):
|
|
exp_options.append(int(px.total_seconds() * 1000))
|
|
elif isinstance(px, int):
|
|
exp_options.append(px)
|
|
else:
|
|
raise DataError("px must be datetime.timedelta or int")
|
|
elif exat is not None:
|
|
if isinstance(exat, datetime.datetime):
|
|
exat = int(exat.timestamp())
|
|
exp_options.extend(["EXAT", exat])
|
|
elif pxat is not None:
|
|
if isinstance(pxat, datetime.datetime):
|
|
pxat = int(pxat.timestamp() * 1000)
|
|
exp_options.extend(["PXAT", pxat])
|
|
|
|
return exp_options
|
|
|
|
|
|
def truncate_text(txt, max_length=100):
|
|
return textwrap.shorten(
|
|
text=txt, width=max_length, placeholder="...", break_long_words=True
|
|
)
|
|
|
|
|
|
def dummy_fail():
|
|
"""
|
|
Fake function for a Retry object if you don't need to handle each failure.
|
|
"""
|
|
pass
|
|
|
|
|
|
async def dummy_fail_async():
|
|
"""
|
|
Async fake function for a Retry object if you don't need to handle each failure.
|
|
"""
|
|
pass
|
|
|
|
|
|
def experimental(cls):
|
|
"""
|
|
Decorator to mark a class as experimental.
|
|
"""
|
|
original_init = cls.__init__
|
|
|
|
@wraps(original_init)
|
|
def new_init(self, *args, **kwargs):
|
|
warnings.warn(
|
|
f"{cls.__name__} is an experimental and may change or be removed in future versions.",
|
|
category=UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
original_init(self, *args, **kwargs)
|
|
|
|
cls.__init__ = new_init
|
|
return cls
|
|
|
|
|
|
def warn_experimental(name, stacklevel=2):
|
|
import warnings
|
|
|
|
msg = (
|
|
f"Call to experimental method {name}. "
|
|
"Be aware that the function arguments can "
|
|
"change or be removed in future versions."
|
|
)
|
|
warnings.warn(msg, category=UserWarning, stacklevel=stacklevel)
|
|
|
|
|
|
def experimental_method() -> Callable[[C], C]:
|
|
"""
|
|
Decorator to mark a function as experimental.
|
|
"""
|
|
|
|
def decorator(func: C) -> C:
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
warn_experimental(func.__name__, stacklevel=2)
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def warn_experimental_arg_usage(
|
|
arg_name: Union[list, str],
|
|
function_name: str,
|
|
stacklevel: int = 2,
|
|
):
|
|
import warnings
|
|
|
|
msg = (
|
|
f"Call to '{function_name}' method with experimental"
|
|
f" usage of input argument/s '{arg_name}'."
|
|
)
|
|
warnings.warn(msg, category=UserWarning, stacklevel=stacklevel)
|
|
|
|
|
|
def experimental_args(
|
|
args_to_warn: list = ["*"],
|
|
) -> Callable[[C], C]:
|
|
"""
|
|
Decorator to mark specified args of a function as experimental.
|
|
"""
|
|
|
|
def decorator(func: C) -> C:
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
# Get function argument names
|
|
arg_names = func.__code__.co_varnames[: func.__code__.co_argcount]
|
|
|
|
provided_args = dict(zip(arg_names, args))
|
|
provided_args.update(kwargs)
|
|
|
|
provided_args.pop("self", None)
|
|
|
|
if len(provided_args) == 0:
|
|
return func(*args, **kwargs)
|
|
|
|
for arg in args_to_warn:
|
|
if arg in provided_args:
|
|
warn_experimental_arg_usage(arg, func.__name__, stacklevel=3)
|
|
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|