moss-mop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mop/__init__.py +3 -0
- mop/conf/__init__.py +1 -0
- mop/conf/settings.py +133 -0
- mop/crud/__init__.py +2 -0
- mop/crud/crud.py +164 -0
- mop/crud/page.py +11 -0
- mop/db/__init__.py +3 -0
- mop/db/async_session.py +33 -0
- mop/db/base.py +12 -0
- mop/db/session.py +29 -0
- mop/entity/__init__.py +1 -0
- mop/entity/entity.py +36 -0
- mop/error/__init__.py +2 -0
- mop/error/biz_error.py +13 -0
- mop/error/err_code.py +28 -0
- mop/logging/__init__.py +1 -0
- mop/logging/logging.py +152 -0
- mop/response/__init__.py +1 -0
- mop/response/response.py +38 -0
- mop/snowflake.py +192 -0
- mop/util/__init__.py +1 -0
- mop/util/dates.py +5 -0
- mop/util/encoder.py +178 -0
- mop/util/files.py +27 -0
- mop/util/strings.py +10 -0
- mop/util/util.py +43 -0
- moss_mop-0.1.0.dist-info/METADATA +10 -0
- moss_mop-0.1.0.dist-info/RECORD +30 -0
- moss_mop-0.1.0.dist-info/WHEEL +5 -0
- moss_mop-0.1.0.dist-info/top_level.txt +1 -0
mop/__init__.py
ADDED
mop/conf/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .settings import *
|
mop/conf/settings.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from pydantic.v1 import BaseSettings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Environment(str, Enum):
|
|
9
|
+
DEVELOPMENT = "dev"
|
|
10
|
+
PRODUCTION = "prod"
|
|
11
|
+
TEST = "test"
|
|
12
|
+
|
|
13
|
+
def get_environment() -> Environment:
|
|
14
|
+
match os.getenv("APP_ENV", "development").lower():
|
|
15
|
+
case "prod" | "production":
|
|
16
|
+
return Environment.PRODUCTION
|
|
17
|
+
case "test":
|
|
18
|
+
return Environment.TEST
|
|
19
|
+
case _:
|
|
20
|
+
return Environment.DEVELOPMENT
|
|
21
|
+
|
|
22
|
+
class Settings(BaseSettings):
|
|
23
|
+
# 基础配置
|
|
24
|
+
PROJECT_NAME: str = "MOSS"
|
|
25
|
+
PROJECT_DESCRIPTION: str = ""
|
|
26
|
+
VERSION: str = "0.1.0"
|
|
27
|
+
DEBUG: bool = True
|
|
28
|
+
ENVIRONMENT = get_environment()
|
|
29
|
+
|
|
30
|
+
# API配置
|
|
31
|
+
API_V1_STR: str = "/api/v1"
|
|
32
|
+
HOST: str = os.getenv("HOST", "0.0.0.0")
|
|
33
|
+
PORT: int = os.getenv("PORT", 8000)
|
|
34
|
+
WORKERS: int = 4
|
|
35
|
+
|
|
36
|
+
# 日志配置
|
|
37
|
+
LOG_DIR: str =os.getenv(
|
|
38
|
+
"LOG_DIR",
|
|
39
|
+
"logs"
|
|
40
|
+
)
|
|
41
|
+
LOG_LEVEL: str = os.getenv(
|
|
42
|
+
"LOG_LEVEL",
|
|
43
|
+
"INFO"
|
|
44
|
+
)
|
|
45
|
+
LOG_FORMAT: str = os.getenv(
|
|
46
|
+
"LOG_FORMAT",
|
|
47
|
+
"json"
|
|
48
|
+
) # json or console
|
|
49
|
+
|
|
50
|
+
# 安全配置
|
|
51
|
+
ALLOWED_ORIGINS: List[str] = [
|
|
52
|
+
"*",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# 数据库配置
|
|
56
|
+
SNOWFLAKE_DATACENTER_ID: int = os.getenv("SNOWFLAKE_DATACENTER_ID", 0)
|
|
57
|
+
|
|
58
|
+
DATABASE_URI: str = os.getenv(
|
|
59
|
+
"DATABASE_URI",
|
|
60
|
+
"sqlite:///./instance/moss.db" # 使用异步SQLite驱动
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
ASYNC_DATABASE_URI: str = os.getenv(
|
|
64
|
+
"ASYNC_DATABASE_URI",
|
|
65
|
+
"sqlite+aiosqlite:///./instance/moss.db" # 使用异步SQLite驱动
|
|
66
|
+
)
|
|
67
|
+
DB_TABLE_PREFIX = os.getenv(
|
|
68
|
+
"DB_TABLE_PREFIX",
|
|
69
|
+
"mo_"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
MINIO_ENDPOINT: str = os.getenv(
|
|
73
|
+
"MINIO_ENDPOINT",
|
|
74
|
+
"http://127.0.0.1:9000"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
MINIO_SHARE_ENDPOINT: str = os.getenv(
|
|
78
|
+
"MINIO_SHARE_ENDPOINT",
|
|
79
|
+
"http://127.0.0.1:9000/minio"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
MINIO_ACCESS_KEY: str = os.getenv(
|
|
83
|
+
"MINIO_ACCESS_KEY",
|
|
84
|
+
""
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
MINIO_SECRET_KEY: str = os.getenv(
|
|
88
|
+
"MINIO_SECRET_KEY",
|
|
89
|
+
""
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
MINIO_DEFAULT_BUCKET: str = os.getenv(
|
|
93
|
+
"MINIO_DEFAULT_BUCKET",
|
|
94
|
+
"mo"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
STORAGE_TYPE: str = os.getenv(
|
|
98
|
+
"STORAGE_TYPE",
|
|
99
|
+
"local"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
OPENDAL_SCHEME: str = os.getenv(
|
|
103
|
+
"OPENDAL_SCHEME",
|
|
104
|
+
"fs"
|
|
105
|
+
)
|
|
106
|
+
OPENDAL_FS_ROOT: str = os.getenv(
|
|
107
|
+
"OPENDAL_FS_ROOT",
|
|
108
|
+
"storage"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
TEMP_DIR: str = os.getenv(
|
|
112
|
+
"TEMP_DIR",
|
|
113
|
+
"temp"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
AUTH_SECRET_KEY: str = os.getenv(
|
|
117
|
+
"AUTH_SECRET_KEY",
|
|
118
|
+
"09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
|
|
119
|
+
)
|
|
120
|
+
AUTH_SECRET_ALGORITHM: str = os.getenv(
|
|
121
|
+
"AUTH_SECRET_ALGORITHM",
|
|
122
|
+
"HS256"
|
|
123
|
+
)
|
|
124
|
+
AUTH_ACCESS_TOKEN_EXPIRE: int = os.getenv(
|
|
125
|
+
"AUTH_ACCESS_TOKEN_EXPIRE",
|
|
126
|
+
30
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
class Config:
|
|
130
|
+
env_file = ".env"
|
|
131
|
+
case_sensitive = True
|
|
132
|
+
|
|
133
|
+
settings = Settings()
|
mop/crud/__init__.py
ADDED
mop/crud/crud.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from typing import Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select, func
|
|
4
|
+
|
|
5
|
+
from .page import ListSlice
|
|
6
|
+
from mop.db import AsyncSessionLocal, SessionLocal
|
|
7
|
+
from mop.entity import BaseEntity
|
|
8
|
+
from mop.error import DATA_NOT_FOUND, BizError
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T", bound=BaseEntity)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrudBiz(Generic[T]):
|
|
14
|
+
def __init__(self, model: type[T]):
|
|
15
|
+
self.model = model
|
|
16
|
+
|
|
17
|
+
def sync_find_all(self, **kwargs):
|
|
18
|
+
filters = [self.model.deleted == False]
|
|
19
|
+
|
|
20
|
+
for key, value in kwargs.items():
|
|
21
|
+
if hasattr(self.model, key):
|
|
22
|
+
filters.append(getattr(self.model, key) == value)
|
|
23
|
+
|
|
24
|
+
with SessionLocal() as db:
|
|
25
|
+
try:
|
|
26
|
+
ret = db.execute(select(self.model).where(*filters))
|
|
27
|
+
items = ret.scalars().all()
|
|
28
|
+
return items
|
|
29
|
+
finally:
|
|
30
|
+
db.close()
|
|
31
|
+
|
|
32
|
+
def sync_find_by_id(self, item_id: str):
|
|
33
|
+
filters = [self.model.id == item_id, self.model.deleted == False]
|
|
34
|
+
with SessionLocal() as db:
|
|
35
|
+
try:
|
|
36
|
+
ret = db.execute(select(self.model).where(*filters))
|
|
37
|
+
ret = ret.scalars().first()
|
|
38
|
+
if not ret:
|
|
39
|
+
raise BizError.init(DATA_NOT_FOUND)
|
|
40
|
+
return ret
|
|
41
|
+
finally:
|
|
42
|
+
db.close()
|
|
43
|
+
|
|
44
|
+
async def find_all(self, **kwargs):
|
|
45
|
+
filters = [self.model.deleted == False]
|
|
46
|
+
|
|
47
|
+
for key, value in kwargs.items():
|
|
48
|
+
if hasattr(self.model, key):
|
|
49
|
+
filters.append(getattr(self.model, key) == value)
|
|
50
|
+
|
|
51
|
+
async with AsyncSessionLocal() as db:
|
|
52
|
+
try:
|
|
53
|
+
ret = await db.execute(select(self.model).where(*filters))
|
|
54
|
+
items = ret.scalars().all()
|
|
55
|
+
return items
|
|
56
|
+
finally:
|
|
57
|
+
await db.close()
|
|
58
|
+
|
|
59
|
+
async def count_by(self, **kwargs):
|
|
60
|
+
filters = [self.model.deleted == False]
|
|
61
|
+
for key, value in kwargs.items():
|
|
62
|
+
if hasattr(self.model, key):
|
|
63
|
+
filters.append(getattr(self.model, key) == value)
|
|
64
|
+
primary_column = self.model.__mapper__.primary_key[0]
|
|
65
|
+
async with AsyncSessionLocal() as db:
|
|
66
|
+
try:
|
|
67
|
+
cnt = await db.execute(select(func.count(primary_column)).where(*filters))
|
|
68
|
+
return cnt.scalar()
|
|
69
|
+
finally:
|
|
70
|
+
await db.close()
|
|
71
|
+
|
|
72
|
+
async def _count_by(self, db: AsyncSessionLocal, *filters):
|
|
73
|
+
primary_column = self.model.__mapper__.primary_key[0]
|
|
74
|
+
cnt = await db.execute(select(func.count(primary_column)).where(*filters))
|
|
75
|
+
return cnt.scalar()
|
|
76
|
+
|
|
77
|
+
async def find_by(self, page_num: int = 1, page_size: int = 100, **kwargs):
|
|
78
|
+
filters = [self.model.deleted == False]
|
|
79
|
+
|
|
80
|
+
for key, value in kwargs.items():
|
|
81
|
+
if hasattr(self.model, key):
|
|
82
|
+
filters.append(getattr(self.model, key) == value)
|
|
83
|
+
|
|
84
|
+
skip = (page_num - 1) * page_size
|
|
85
|
+
async with AsyncSessionLocal() as db:
|
|
86
|
+
try:
|
|
87
|
+
print(filters)
|
|
88
|
+
total = await self._count_by(db, *filters)
|
|
89
|
+
ret = await db.execute(
|
|
90
|
+
select(self.model).where(*filters).offset(skip).limit(page_size)
|
|
91
|
+
)
|
|
92
|
+
items = ret.scalars().all()
|
|
93
|
+
return ListSlice(
|
|
94
|
+
items=items, total=total, page_num=page_num, page_size=page_size
|
|
95
|
+
)
|
|
96
|
+
finally:
|
|
97
|
+
await db.close()
|
|
98
|
+
|
|
99
|
+
async def find_by_id(self, item_id: str):
|
|
100
|
+
filters = [self.model.id == item_id, self.model.deleted == False]
|
|
101
|
+
async with AsyncSessionLocal() as db:
|
|
102
|
+
try:
|
|
103
|
+
ret = await db.execute(select(self.model).where(*filters))
|
|
104
|
+
ret = ret.scalars().first()
|
|
105
|
+
if not ret:
|
|
106
|
+
raise BizError.init(DATA_NOT_FOUND)
|
|
107
|
+
return ret
|
|
108
|
+
finally:
|
|
109
|
+
await db.close()
|
|
110
|
+
|
|
111
|
+
async def find_one(self, **kwargs):
|
|
112
|
+
filters = [self.model.deleted == False]
|
|
113
|
+
|
|
114
|
+
for key, value in kwargs.items():
|
|
115
|
+
if hasattr(self.model, key):
|
|
116
|
+
filters.append(getattr(self.model, key) == value)
|
|
117
|
+
|
|
118
|
+
async with AsyncSessionLocal() as db:
|
|
119
|
+
try:
|
|
120
|
+
ret = await db.execute(select(self.model).where(*filters))
|
|
121
|
+
item = ret.scalars().first()
|
|
122
|
+
return item
|
|
123
|
+
finally:
|
|
124
|
+
await db.close()
|
|
125
|
+
|
|
126
|
+
async def create(self, **kwargs):
|
|
127
|
+
item = self.model(**kwargs)
|
|
128
|
+
item.default_created()
|
|
129
|
+
async with AsyncSessionLocal() as db:
|
|
130
|
+
try:
|
|
131
|
+
db.add(item)
|
|
132
|
+
await db.commit()
|
|
133
|
+
await db.refresh(item)
|
|
134
|
+
return item
|
|
135
|
+
finally:
|
|
136
|
+
await db.close()
|
|
137
|
+
|
|
138
|
+
async def update(self, item_id: str, **kwargs):
|
|
139
|
+
item = await self.find_by_id(item_id)
|
|
140
|
+
|
|
141
|
+
other = self.model(**kwargs)
|
|
142
|
+
item.update_attrs(other)
|
|
143
|
+
|
|
144
|
+
async with AsyncSessionLocal() as db:
|
|
145
|
+
try:
|
|
146
|
+
db.add(item)
|
|
147
|
+
await db.commit()
|
|
148
|
+
await db.refresh(item)
|
|
149
|
+
return item
|
|
150
|
+
finally:
|
|
151
|
+
await db.close()
|
|
152
|
+
|
|
153
|
+
async def delete(self, item_id: str) -> bool:
|
|
154
|
+
item = await self.find_by_id(item_id)
|
|
155
|
+
item.default_deleted()
|
|
156
|
+
async with AsyncSessionLocal() as db:
|
|
157
|
+
try:
|
|
158
|
+
db.add(item)
|
|
159
|
+
await db.commit()
|
|
160
|
+
await db.refresh(item)
|
|
161
|
+
return True
|
|
162
|
+
finally:
|
|
163
|
+
await db.close()
|
|
164
|
+
return False
|
mop/crud/page.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import List, TypeVar, Generic
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
class ListSlice(BaseModel, Generic[T]):
|
|
8
|
+
items: List[T] = Field(description="当前页的数据集合")
|
|
9
|
+
total: int = Field(description="总记录数")
|
|
10
|
+
page_num: int = Field(1, description="当前页面, 默认为1")
|
|
11
|
+
page_size: int = Field(10, description="每页的数据量,默认为10")
|
mop/db/__init__.py
ADDED
mop/db/async_session.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import AsyncGenerator, Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends
|
|
4
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
5
|
+
from sqlalchemy.orm import sessionmaker
|
|
6
|
+
|
|
7
|
+
from mop.conf import settings
|
|
8
|
+
|
|
9
|
+
async_engine = create_async_engine(
|
|
10
|
+
settings.ASYNC_DATABASE_URI,
|
|
11
|
+
echo=settings.DEBUG
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
AsyncSessionLocal = sessionmaker(
|
|
15
|
+
async_engine,
|
|
16
|
+
class_=AsyncSession,
|
|
17
|
+
expire_on_commit=False,
|
|
18
|
+
autocommit=False,
|
|
19
|
+
autoflush=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
|
23
|
+
"""
|
|
24
|
+
获取数据库会话依赖
|
|
25
|
+
:return:
|
|
26
|
+
"""
|
|
27
|
+
async with AsyncSessionLocal() as session:
|
|
28
|
+
try:
|
|
29
|
+
yield session
|
|
30
|
+
finally:
|
|
31
|
+
await session.close()
|
|
32
|
+
|
|
33
|
+
AsyncSessionDep = Annotated[AsyncSession, Depends(get_async_session)]
|
mop/db/base.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from sqlalchemy.orm import declared_attr, declarative_base
|
|
2
|
+
|
|
3
|
+
from mop.conf import settings
|
|
4
|
+
from mop.util.strings import to_underline
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Base:
|
|
8
|
+
@declared_attr
|
|
9
|
+
def __tablename__(self):
|
|
10
|
+
return f"{settings.DB_TABLE_PREFIX}{to_underline(self.__name__)}"
|
|
11
|
+
|
|
12
|
+
Base = declarative_base(cls=Base)
|
mop/db/session.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends
|
|
4
|
+
from sqlalchemy import create_engine
|
|
5
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
6
|
+
|
|
7
|
+
from mop.conf import settings
|
|
8
|
+
|
|
9
|
+
engine = create_engine(
|
|
10
|
+
settings.DATABASE_URI,
|
|
11
|
+
echo=settings.DEBUG
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
SessionLocal = sessionmaker(
|
|
15
|
+
engine,
|
|
16
|
+
class_=Session,
|
|
17
|
+
expire_on_commit=False,
|
|
18
|
+
autocommit=False,
|
|
19
|
+
autoflush=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def get_session():
|
|
23
|
+
with SessionLocal() as session:
|
|
24
|
+
try:
|
|
25
|
+
yield session
|
|
26
|
+
finally:
|
|
27
|
+
session.close()
|
|
28
|
+
|
|
29
|
+
SessionDep = Annotated[Session, Depends(get_session)]
|
mop/entity/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .entity import *
|
mop/entity/entity.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Column, String, Boolean, DateTime, text, Integer
|
|
4
|
+
|
|
5
|
+
from mop.db import Base
|
|
6
|
+
from mop.snowflake import IDWorker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseEntity(Base):
|
|
10
|
+
__abstract__ = True
|
|
11
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
12
|
+
tenant_id = Column(String(32), nullable=False)
|
|
13
|
+
deleted = Column(Boolean, nullable=False, server_default=text("false"))
|
|
14
|
+
created_by = Column(String(32), nullable=True)
|
|
15
|
+
created_at = Column(DateTime, nullable=True, default=datetime.now)
|
|
16
|
+
updated_by = Column(String(32), nullable=True)
|
|
17
|
+
updated_at = Column(DateTime, nullable=True, default=datetime.now)
|
|
18
|
+
deleted_by = Column(String(32), nullable=True)
|
|
19
|
+
deleted_at = Column(DateTime, nullable=True)
|
|
20
|
+
|
|
21
|
+
def default_created(self):
|
|
22
|
+
self.id = IDWorker.gen_id()
|
|
23
|
+
self.tenant_id = '000000'
|
|
24
|
+
|
|
25
|
+
def update_attrs(self, other):
|
|
26
|
+
for attr in other.__dict__.keys():
|
|
27
|
+
if hasattr(self, attr) and attr != 'id' and attr != '_sa_instance_state':
|
|
28
|
+
setattr(self, attr, getattr(other, attr))
|
|
29
|
+
self.updated_at = datetime.now()
|
|
30
|
+
|
|
31
|
+
def default_deleted(self):
|
|
32
|
+
self.deleted = True
|
|
33
|
+
self.deleted_at = datetime.now()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
mop/error/__init__.py
ADDED
mop/error/biz_error.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .err_code import ErrCode
|
|
2
|
+
|
|
3
|
+
class BizError(Exception):
|
|
4
|
+
def __init__(self, code: str, message: str):
|
|
5
|
+
self.code = code
|
|
6
|
+
self.message = message
|
|
7
|
+
|
|
8
|
+
def __str__(self):
|
|
9
|
+
return self.code
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def init(cls, errCode: ErrCode):
|
|
13
|
+
return cls(errCode.code, errCode.message)
|
mop/error/err_code.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class ErrCode:
|
|
2
|
+
def __init__(self, code: str = "0", message: str = ""):
|
|
3
|
+
self.code = code
|
|
4
|
+
self.message = message
|
|
5
|
+
|
|
6
|
+
UNAUTHORIZED = ErrCode("Unauthorized", "未经授权")
|
|
7
|
+
FORBIDDEN = ErrCode("Forbidden", "无操作权限")
|
|
8
|
+
AUTH_FAILED = ErrCode("AuthFailed", "鉴权失败")
|
|
9
|
+
DATA_NOT_FOUND = ErrCode("DataNotFound", "数据未找到")
|
|
10
|
+
SERVER_EXCEPTION = ErrCode("ServerException", "服务端异常")
|
|
11
|
+
BIND_ERROR = ErrCode("BindError", "数据绑定错误")
|
|
12
|
+
DATA_IS_EXIST = ErrCode("DataIsExist", "同类数据已存在")
|
|
13
|
+
DATA_NOT_EDITABLE = ErrCode("DataNotEditable", "数据不可编辑")
|
|
14
|
+
DATA_CHECK_FAILURE = ErrCode("DataCheckFailure", "数据检查失败")
|
|
15
|
+
DATA_IS_RELATION = ErrCode("DataIsRelation", "数据被引用")
|
|
16
|
+
DATA_PARSE_FAILURE = ErrCode("DataParseFailure", "数据解析失败")
|
|
17
|
+
DATA_ENCODE_FAILURE = ErrCode("DataEncodeFailure", "数据编码失败")
|
|
18
|
+
DATA_DECODE_FAILURE = ErrCode("DataDecodeFailure", "数据解码失败")
|
|
19
|
+
BUSINESS_ERROR = ErrCode("BusinessError", "业务逻辑错误")
|
|
20
|
+
INTERNAL_ERROR = ErrCode("InternalError", "服务端异常")
|
|
21
|
+
INVALID_PARAM = ErrCode("InvalidParameter", "无效的请求参数")
|
|
22
|
+
INVALID_JSON = ErrCode("InvalidJson", "无效的JSON请求串")
|
|
23
|
+
INVALID_TOKEN = ErrCode("InvalidToken", "无效的令牌")
|
|
24
|
+
OPERATION_FAILURE = ErrCode("OperationFailure", "操作失败")
|
|
25
|
+
REMOTE_CALL_ERROR = ErrCode("RemoteCallError", "远程调用失败")
|
|
26
|
+
REQUEST_METHOD_NOT_SUPPORTED = ErrCode("RequestMethodNotSupported", "请求的HTTP方法不支持")
|
|
27
|
+
METHOD_NOT_SUPPORTED = ErrCode("MethodNotSupported", "方法不支持")
|
|
28
|
+
|
mop/logging/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .logging import *
|
mop/logging/logging.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
from mop.conf import settings, Environment
|
|
13
|
+
|
|
14
|
+
if not os.path.exists(settings.LOG_DIR):
|
|
15
|
+
os.makedirs(settings.LOG_DIR)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_log_file_path() -> Path:
|
|
19
|
+
env_prefix = settings.ENVIRONMENT.value
|
|
20
|
+
return Path(settings.LOG_DIR) / f"app-{env_prefix}-{datetime.now().strftime('%Y-%m-%d')}.log"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonlFileHandler(logging.Handler):
|
|
24
|
+
def __init__(self, file_path: Path):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.file_path = file_path
|
|
27
|
+
|
|
28
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
29
|
+
try:
|
|
30
|
+
log_entry = {
|
|
31
|
+
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
|
32
|
+
"level": record.levelname,
|
|
33
|
+
"message": record.getMessage(),
|
|
34
|
+
"module": record.module,
|
|
35
|
+
"function": record.funcName,
|
|
36
|
+
"filename": record.filename,
|
|
37
|
+
"line": record.lineno,
|
|
38
|
+
"environment": settings.ENVIRONMENT.value,
|
|
39
|
+
}
|
|
40
|
+
if hasattr(record, "extra"):
|
|
41
|
+
log_entry.update(record.extra)
|
|
42
|
+
with open(self.file_path, "a", encoding="utf-8") as f:
|
|
43
|
+
f.write(json.dumps(log_entry) + "\n")
|
|
44
|
+
except Exception:
|
|
45
|
+
self.handleError(record)
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
super().close()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_structlog_processors(include_file_info: bool = True) -> List[Any]:
|
|
52
|
+
processors = [
|
|
53
|
+
structlog.stdlib.filter_by_level,
|
|
54
|
+
# structlog.stdlib.add_logger_name,
|
|
55
|
+
structlog.stdlib.add_log_level,
|
|
56
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
57
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
58
|
+
structlog.processors.StackInfoRenderer(),
|
|
59
|
+
structlog.processors.format_exc_info,
|
|
60
|
+
structlog.processors.UnicodeDecoder(),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if include_file_info:
|
|
64
|
+
processors.append(
|
|
65
|
+
structlog.processors.CallsiteParameterAdder(
|
|
66
|
+
{
|
|
67
|
+
structlog.processors.CallsiteParameter.FILENAME,
|
|
68
|
+
structlog.processors.CallsiteParameter.FUNC_NAME,
|
|
69
|
+
structlog.processors.CallsiteParameter.LINENO,
|
|
70
|
+
structlog.processors.CallsiteParameter.MODULE,
|
|
71
|
+
structlog.processors.CallsiteParameter.PATHNAME,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
processors.append(lambda _, __, event_dict: {**event_dict})
|
|
77
|
+
|
|
78
|
+
return processors
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def setup_logging():
|
|
82
|
+
# 确保日志目录存在
|
|
83
|
+
file_handler = JsonlFileHandler(get_log_file_path())
|
|
84
|
+
file_handler.setLevel(settings.LOG_LEVEL)
|
|
85
|
+
|
|
86
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
87
|
+
console_handler.setLevel(settings.LOG_LEVEL)
|
|
88
|
+
|
|
89
|
+
shared_processors = get_structlog_processors(
|
|
90
|
+
include_file_info=settings.ENVIRONMENT not in [Environment.PRODUCTION, Environment.TEST])
|
|
91
|
+
|
|
92
|
+
logging.basicConfig(
|
|
93
|
+
format="%(message)s",
|
|
94
|
+
level=settings.LOG_LEVEL,
|
|
95
|
+
handlers=[file_handler, console_handler],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if settings.LOG_FORMAT == "console":
|
|
99
|
+
structlog.configure(
|
|
100
|
+
processors=[
|
|
101
|
+
*shared_processors,
|
|
102
|
+
structlog.dev.ConsoleRenderer(),
|
|
103
|
+
],
|
|
104
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
105
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
106
|
+
cache_logger_on_first_use=True,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
structlog.configure(
|
|
110
|
+
processors=[
|
|
111
|
+
*shared_processors,
|
|
112
|
+
structlog.processors.JSONRenderer(),
|
|
113
|
+
],
|
|
114
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
115
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
116
|
+
cache_logger_on_first_use=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# # 配置日志格式
|
|
120
|
+
# formatter = logging.Formatter(
|
|
121
|
+
# "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
122
|
+
# )
|
|
123
|
+
|
|
124
|
+
# # 文件处理器
|
|
125
|
+
# file_handler = RotatingFileHandler(
|
|
126
|
+
# os.path.join(log_dir, "app.log"),
|
|
127
|
+
# maxBytes=10 * 1024 * 1024, # 10MB
|
|
128
|
+
# backupCount=5
|
|
129
|
+
# )
|
|
130
|
+
# file_handler.setFormatter(formatter)
|
|
131
|
+
# file_handler.setLevel(logging.INFO)
|
|
132
|
+
#
|
|
133
|
+
# # 控制台处理器
|
|
134
|
+
# console_handler = logging.StreamHandler()
|
|
135
|
+
# console_handler.setFormatter(formatter)
|
|
136
|
+
# console_handler.setLevel(logging.DEBUG)
|
|
137
|
+
#
|
|
138
|
+
# # 配置根日志器
|
|
139
|
+
# root_logger = logging.getLogger()
|
|
140
|
+
# root_logger.setLevel(logging.INFO)
|
|
141
|
+
# root_logger.addHandler(file_handler)
|
|
142
|
+
# root_logger.addHandler(console_handler)
|
|
143
|
+
|
|
144
|
+
# 设置其他库的日志级别
|
|
145
|
+
logging.getLogger("uvicorn").setLevel(logging.WARNING)
|
|
146
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
setup_logging()
|
|
150
|
+
|
|
151
|
+
logger = structlog.get_logger()
|
|
152
|
+
logger.info("logging_initialized", log_format=settings.LOG_FORMAT)
|
mop/response/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .response import *
|
mop/response/response.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Optional, Any, TypeVar, Generic
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from mop.error import ErrCode, BizError
|
|
6
|
+
|
|
7
|
+
DataT = TypeVar("DataT")
|
|
8
|
+
|
|
9
|
+
class Response(BaseModel, Generic[DataT]):
|
|
10
|
+
model_config = ConfigDict(from_attributes=True)
|
|
11
|
+
|
|
12
|
+
code: str
|
|
13
|
+
message: str
|
|
14
|
+
data: Optional[DataT] = None
|
|
15
|
+
|
|
16
|
+
class SuccessResponse(Response[Any]):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class ErrorResponse(Response[None]):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def res_success(data: Any = None, code: str = "0", message: str = "success") -> Response:
|
|
23
|
+
return Response(code=code, message=message, data=data)
|
|
24
|
+
|
|
25
|
+
def res_ok(data: Any = None, code: str = "0", message: str = "ok") -> Response:
|
|
26
|
+
return res_success(data=data, code=code, message=message)
|
|
27
|
+
|
|
28
|
+
def res_err(code: str = "0", message: str = "error") -> Response:
|
|
29
|
+
return Response(code=code, message=message, data=None)
|
|
30
|
+
|
|
31
|
+
def res_errcode(code: ErrCode) -> Response:
|
|
32
|
+
return Response(code=code.code, message=code.message, data=None)
|
|
33
|
+
|
|
34
|
+
def res_ex(err: BizError) -> Response:
|
|
35
|
+
return Response(code=err.code, message=err.message, data=None)
|
|
36
|
+
|
|
37
|
+
def res_err_json(code: str = "0", message: str = "error"):
|
|
38
|
+
return res_err(code=code, message=message).model_dump()
|
mop/snowflake.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
雪花算法:https://github.com/twitter-archive/snowflake
|
|
3
|
+
参考:https://github.com/cablehead/python-snowflake
|
|
4
|
+
https://github.com/falcondai/python-snowflake
|
|
5
|
+
https://zhuanlan.zhihu.com/p/85837641 其中的python写法
|
|
6
|
+
其核心思想是:
|
|
7
|
+
- 第一位 占用1bit,其值始终是0,没有任何意义。在计算机的表示中,第一位是符号位,0表示整数,第一位如果是1则表示负数,我们用的ID默认就是正数,所以默认就是0,那么这一位默认就没有意义。
|
|
8
|
+
- 时间戳占用41位,具体到毫秒,41位的二进制可以使用69年,因为时间理论上永恒递增,所以根据这个排序是可以的。
|
|
9
|
+
- 机器标识占用10位,可以全部用作机器ID,也可以用来标识机房ID + 机器ID,10位最多可以表示1024台机器。
|
|
10
|
+
- 递增计数序列号占用12位,也就是同一台机器上同一时间,理论上还可以同时生成不同的ID。每个节点每毫秒从0开始不断累加,最多可以累加到4095,一共可以产生4096个ID。
|
|
11
|
+
|
|
12
|
+
雪花算法的优点
|
|
13
|
+
- 有业务含义,并且可自定义。 雪花算法的 ID 每一位都有特殊的含义,我们从 ID 的不同位数就可以推断出对应的含义。此外,我们还可根据自身需要,自行增删每个部分的位数,从而实现自定义的雪花算法。
|
|
14
|
+
- ID 单调增加,有利于提高写入性能。 雪花算法的 ID 最后部分是递增的序列号,因此其生成的 ID 是递增的,将其作为数据库主键 ID 时可以实现顺序写入,从而提高写入性能。
|
|
15
|
+
- 不依赖第三方系统。 雪花算法的生成方式,不依赖第三方系统或中间件,因此其稳定性较高。
|
|
16
|
+
- 解决了安全问题。 雪花算法生成的 ID 是单调递增的,但其递增步长又不是确定的,因此无法从 ID 的差值推断出生成的数量,从而可以保护业务隐私。
|
|
17
|
+
|
|
18
|
+
雪花算法的缺点
|
|
19
|
+
- 强依赖机器时间
|
|
20
|
+
|
|
21
|
+
雪花算法的一些问题处理
|
|
22
|
+
1. 69年时限问题
|
|
23
|
+
由于算法中用长度41位来表示时间戳,我们的时间计算通常是从1970年开始的,只能使用69年,为了不浪费,其实我们可以用时间的相对值,也就是以项目开始的时间为基准时间,往后可以使用69年,比如现在是2023年,那么往后推69年,就可以使用到2092年。获取唯一ID的服务,对处理速度要求比较高,所以我们全部使用位运算以及位移操作。
|
|
24
|
+
2. 时间回拨问题
|
|
25
|
+
在获取时间的时候,可能会出现`时间回拨`的问题,什么是时间回拨问题呢?就是服务器上的时间突然倒退到之前的时间。
|
|
26
|
+
1. 人为原因,把系统环境的时间改了。
|
|
27
|
+
2. 有时候不同的机器上需要同步时间,可能不同机器之间存在误差,那么可能会出现时间回拨问题。
|
|
28
|
+
|
|
29
|
+
解决方案
|
|
30
|
+
- 回拨时间小的时候,不生成 ID,循环等待到时间点到达。
|
|
31
|
+
- 上面的方案只适合时钟回拨较小的,如果间隔过大,阻塞等待,肯定是不可取的,因此
|
|
32
|
+
- 要么超过一定大小的回拨直接报错,拒绝服务,
|
|
33
|
+
- 另一种方案是利用拓展位,回拨之后在拓展位上加1就可以了,这样ID依然可以保持唯一。但是这个要求我们提前预留出位数,要么从机器id中,要么从序列号中,腾出一定的位,在时间回拨的时候,这个位置 `+1`。
|
|
34
|
+
|
|
35
|
+
雪花算法
|
|
36
|
+
结合以上理论知识,以及项目的情况,64位做一下调整
|
|
37
|
+
|
|
38
|
+
- 首位不变,保持为0
|
|
39
|
+
- 时间戳占用41位,具体到毫秒,
|
|
40
|
+
- 机器标识占用8位,其中数据中心1位,机器id 7位
|
|
41
|
+
- 时间回拨占用2位
|
|
42
|
+
- 递增计数序列号占用12位
|
|
43
|
+
所以结构为
|
|
44
|
+
0{41-时间戳}{1-数据中心}{7-机器id}{2-时间回拨}{12-递增计数序列号}
|
|
45
|
+
"""
|
|
46
|
+
import os
|
|
47
|
+
import pathlib
|
|
48
|
+
import time
|
|
49
|
+
|
|
50
|
+
from mop.conf import settings
|
|
51
|
+
|
|
52
|
+
# 64位ID的划分,其中有22位自由分配
|
|
53
|
+
WORKER_ID_BITS = 7 # 机器标识,7bit
|
|
54
|
+
DATACENTER_ID_BITS = 1 # 数据中心,1bit
|
|
55
|
+
FIX_SYSTEM_CLOCK_BITS = 2 # 时间回拨拓展位,2bit
|
|
56
|
+
SEQUENCE_ID_BITS = 12 # 累计序列号,12bit
|
|
57
|
+
|
|
58
|
+
# 最大取值计算
|
|
59
|
+
MAX_WORKER_ID = -1 ^ (-1 << WORKER_ID_BITS) # 最大机器数,2**7-1 0b1111111, 0b代表正数,-0b代表负数
|
|
60
|
+
MAX_DATACENTER_ID = -1 ^ (-1 << DATACENTER_ID_BITS) # 最大数据中心,2**1-1 0b1
|
|
61
|
+
MAX_FIX_SYSTEM_CLOCK = -1 ^ (-1 << FIX_SYSTEM_CLOCK_BITS) # 最大支持的时钟回拨次数,2**2-1 0b11
|
|
62
|
+
SEQUENCE_MASK = MAX_SEQUENCE_ID = -1 ^ (-1 << SEQUENCE_ID_BITS) # 最大序列号,2**12-1, 总共可以表示4096个ID
|
|
63
|
+
|
|
64
|
+
# 移位偏移计算
|
|
65
|
+
WORKER_ID_SHIFT = SEQUENCE_ID_BITS
|
|
66
|
+
FIX_SYSTEM_CLOCK_SHIFT = SEQUENCE_ID_BITS + FIX_SYSTEM_CLOCK_BITS
|
|
67
|
+
DATACENTER_ID_SHIFT = SEQUENCE_ID_BITS + FIX_SYSTEM_CLOCK_BITS + WORKER_ID_BITS
|
|
68
|
+
TIMESTAMP_LEFT_SHIFT = SEQUENCE_ID_BITS + FIX_SYSTEM_CLOCK_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS
|
|
69
|
+
|
|
70
|
+
# 开始时间截 (2023-01-01)
|
|
71
|
+
TWEPOCH = 1672531200000
|
|
72
|
+
root_path = pathlib.Path(__file__).parent.parent.resolve() # 使用绝对路径,防止环境不同导致的异常
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SnowflakeGenerator(object):
|
|
76
|
+
"""
|
|
77
|
+
用于生成IDs
|
|
78
|
+
"""
|
|
79
|
+
def __init__(self, datacenter_id, worker_id, sequence=0, fix_system_clock=0):
|
|
80
|
+
"""
|
|
81
|
+
初始化
|
|
82
|
+
:param datacenter_id: 数据中心(机器区域)ID
|
|
83
|
+
:param worker_id: 机器ID
|
|
84
|
+
:param sequence: 递增序号
|
|
85
|
+
:param fix_system_clock: 时钟回拨的次数
|
|
86
|
+
"""
|
|
87
|
+
if worker_id > MAX_WORKER_ID or worker_id < 0:
|
|
88
|
+
raise ValueError(f'worker_id:{worker_id}越界,支持 0 - {MAX_WORKER_ID}')
|
|
89
|
+
|
|
90
|
+
if datacenter_id > MAX_DATACENTER_ID or datacenter_id < 0:
|
|
91
|
+
raise ValueError(f'datacenter_id:{datacenter_id}越界,支持 0 - {MAX_DATACENTER_ID}')
|
|
92
|
+
|
|
93
|
+
self.worker_id = worker_id
|
|
94
|
+
self.datacenter_id = datacenter_id
|
|
95
|
+
self.sequence = sequence
|
|
96
|
+
self.fix_system_clock = fix_system_clock
|
|
97
|
+
|
|
98
|
+
self.last_timestamp = self._read_last_timestamp() # 上次计算的时间戳
|
|
99
|
+
|
|
100
|
+
self._save_last_timestamp(self._gen_timestamp()) # 实例启动时写入当前时间
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _save_last_timestamp(_last_timestamp):
|
|
104
|
+
"""
|
|
105
|
+
上次的时间戳写入文件
|
|
106
|
+
:return:int timestamp
|
|
107
|
+
"""
|
|
108
|
+
file_path = root_path.parent.joinpath("last_timestamp.txt")
|
|
109
|
+
with open(file_path, 'w') as file: # 覆盖写
|
|
110
|
+
file.write(str(_last_timestamp))
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _read_last_timestamp():
|
|
114
|
+
"""
|
|
115
|
+
从文件读取上次的时间戳
|
|
116
|
+
:return:int timestamp
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
version_path = root_path.parent.joinpath("last_timestamp.txt")
|
|
120
|
+
with open(version_path, 'r') as file:
|
|
121
|
+
_last_timestamp = int(file.read().rstrip())
|
|
122
|
+
except Exception as e:
|
|
123
|
+
print(f"读取last_timestamp.txt失败,{str(e)}")
|
|
124
|
+
_last_timestamp = -1 # 默认值为-1,则一定可以生成
|
|
125
|
+
return _last_timestamp
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _gen_timestamp():
|
|
129
|
+
"""
|
|
130
|
+
生成整数时间戳
|
|
131
|
+
:return:int timestamp
|
|
132
|
+
"""
|
|
133
|
+
return int(time.time() * 1000)
|
|
134
|
+
|
|
135
|
+
def gen_id(self, current_timestamp: int = None):
|
|
136
|
+
"""
|
|
137
|
+
获取新ID
|
|
138
|
+
:return:
|
|
139
|
+
"""
|
|
140
|
+
timestamp = current_timestamp or self._gen_timestamp()
|
|
141
|
+
|
|
142
|
+
if timestamp > self.last_timestamp:
|
|
143
|
+
self.sequence = 0
|
|
144
|
+
else:
|
|
145
|
+
if timestamp < self.last_timestamp: # 时钟回拨
|
|
146
|
+
self.fix_system_clock = (self.fix_system_clock + 1) & MAX_FIX_SYSTEM_CLOCK
|
|
147
|
+
print(f"系统时钟回拨,fix_system_clock 加 1: {self.fix_system_clock}")
|
|
148
|
+
if self.fix_system_clock == 0: # 时钟回拨位到达上限;此时,计数不变,时间等待到下一毫秒,保证id唯一
|
|
149
|
+
timestamp = self._til_next_millis(self.last_timestamp)
|
|
150
|
+
|
|
151
|
+
self.sequence = (self.sequence + 1) & SEQUENCE_MASK
|
|
152
|
+
if self.sequence == 0: # 序列号到达上限;此时,计数不变,时间等待到下一毫秒,保证id唯一
|
|
153
|
+
timestamp = self._til_next_millis(self.last_timestamp)
|
|
154
|
+
|
|
155
|
+
self.last_timestamp = timestamp
|
|
156
|
+
|
|
157
|
+
new_id = ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) | (self.datacenter_id << DATACENTER_ID_SHIFT) | \
|
|
158
|
+
(self.worker_id << WORKER_ID_SHIFT) | self.fix_system_clock << FIX_SYSTEM_CLOCK_SHIFT | self.sequence
|
|
159
|
+
return new_id
|
|
160
|
+
|
|
161
|
+
def _til_next_millis(self, last_timestamp):
|
|
162
|
+
"""
|
|
163
|
+
等到下一毫秒
|
|
164
|
+
"""
|
|
165
|
+
timestamp = self._gen_timestamp()
|
|
166
|
+
while timestamp <= last_timestamp:
|
|
167
|
+
timestamp = self._gen_timestamp()
|
|
168
|
+
return timestamp
|
|
169
|
+
|
|
170
|
+
def melt(self, snowflake_id, twepoch=TWEPOCH):
|
|
171
|
+
"""
|
|
172
|
+
翻译id个部分组成的明文
|
|
173
|
+
"""
|
|
174
|
+
sequence_id = snowflake_id & MAX_SEQUENCE_ID
|
|
175
|
+
fix_system_clock_id = (snowflake_id >> SEQUENCE_ID_BITS) & MAX_FIX_SYSTEM_CLOCK
|
|
176
|
+
worker_id = (snowflake_id >> SEQUENCE_ID_BITS >> FIX_SYSTEM_CLOCK_BITS) & MAX_WORKER_ID
|
|
177
|
+
datacenter_id = (snowflake_id >> SEQUENCE_ID_BITS >> FIX_SYSTEM_CLOCK_BITS >> WORKER_ID_BITS) & MAX_DATACENTER_ID
|
|
178
|
+
timestamp_ms = snowflake_id >> SEQUENCE_ID_BITS >> FIX_SYSTEM_CLOCK_BITS >> WORKER_ID_BITS >> DATACENTER_ID_BITS
|
|
179
|
+
timestamp_ms += twepoch
|
|
180
|
+
|
|
181
|
+
return timestamp_ms, int(datacenter_id), int(worker_id), int(fix_system_clock_id), int(sequence_id)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def local_datetime(timestamp_ms):
|
|
185
|
+
import datetime
|
|
186
|
+
"""convert millisecond timestamp to local datetime object."""
|
|
187
|
+
return datetime.datetime.fromtimestamp(timestamp_ms / 1000)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# 数据中心id由配置文件获取,默认是0
|
|
191
|
+
# 机器id取进程id的最后2位
|
|
192
|
+
IDWorker = SnowflakeGenerator(settings.SNOWFLAKE_DATACENTER_ID, int(str(os.getpid())[-2:]))
|
mop/util/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .util import *
|
mop/util/dates.py
ADDED
mop/util/encoder.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
from collections import deque
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, IPv4Interface, IPv6Interface
|
|
7
|
+
from pathlib import PurePath, Path
|
|
8
|
+
from types import GeneratorType
|
|
9
|
+
from typing import Any, Optional, Callable, Literal, Union, Pattern
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from fastapi.encoders import encoders_by_class_tuples
|
|
13
|
+
from pydantic import BaseModel, NameEmail, SecretBytes, SecretStr, AnyUrl
|
|
14
|
+
from pydantic_core import Url
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs):
|
|
18
|
+
return model.model_dump(mode=mode, **kwargs)
|
|
19
|
+
|
|
20
|
+
def isoformat(o: Union[datetime.date, datetime.time]) -> str:
|
|
21
|
+
return o.isoformat()
|
|
22
|
+
|
|
23
|
+
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
|
|
24
|
+
if dec_value.as_tuple().exponent >= 0:
|
|
25
|
+
return int(dec_value)
|
|
26
|
+
return float(dec_value)
|
|
27
|
+
|
|
28
|
+
ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = {
|
|
29
|
+
bytes: lambda x: x.decode(),
|
|
30
|
+
# Color: str,
|
|
31
|
+
datetime.date: isoformat,
|
|
32
|
+
datetime.datetime: isoformat,
|
|
33
|
+
datetime.timedelta: lambda x: x.total_seconds(),
|
|
34
|
+
Decimal: decimal_encoder,
|
|
35
|
+
Enum: lambda x: x.value,
|
|
36
|
+
frozenset: list,
|
|
37
|
+
deque: list,
|
|
38
|
+
GeneratorType: list,
|
|
39
|
+
IPv4Address: str,
|
|
40
|
+
IPv4Interface: str,
|
|
41
|
+
IPv4Network: str,
|
|
42
|
+
IPv6Address: str,
|
|
43
|
+
IPv6Interface: str,
|
|
44
|
+
IPv6Network: str,
|
|
45
|
+
NameEmail: str,
|
|
46
|
+
Path: str,
|
|
47
|
+
Pattern: lambda x: x.pattern,
|
|
48
|
+
SecretBytes: str,
|
|
49
|
+
SecretStr: str,
|
|
50
|
+
set: list,
|
|
51
|
+
UUID: str,
|
|
52
|
+
Url: str,
|
|
53
|
+
AnyUrl: str,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def jsonable_encoder(
|
|
57
|
+
obj: Any,
|
|
58
|
+
by_alias: bool = True,
|
|
59
|
+
exclude_unset: bool = False,
|
|
60
|
+
exclude_defaults: bool = False,
|
|
61
|
+
exclude_none: bool = False,
|
|
62
|
+
custom_encoder: Optional[dict[Any, Callable[[Any], Any]]] = None,
|
|
63
|
+
sqlalchemy_safe: bool = True,
|
|
64
|
+
):
|
|
65
|
+
custom_encoder = custom_encoder or {}
|
|
66
|
+
if custom_encoder:
|
|
67
|
+
if type(obj) in custom_encoder:
|
|
68
|
+
return custom_encoder[type(obj)](obj)
|
|
69
|
+
else:
|
|
70
|
+
for encoder_type, encoder_instance in custom_encoder.items():
|
|
71
|
+
if isinstance(obj, encoder_type):
|
|
72
|
+
return encoder_instance(obj)
|
|
73
|
+
if isinstance(obj, BaseModel):
|
|
74
|
+
obj_dict = _model_dump(
|
|
75
|
+
obj,
|
|
76
|
+
mode="json",
|
|
77
|
+
include=None,
|
|
78
|
+
exclude=None,
|
|
79
|
+
by_alias=by_alias,
|
|
80
|
+
exclude_unset=exclude_unset,
|
|
81
|
+
exclude_none=exclude_none,
|
|
82
|
+
exclude_defaults=exclude_defaults,
|
|
83
|
+
)
|
|
84
|
+
if "__root__" in obj_dict:
|
|
85
|
+
obj_dict = obj_dict["__root__"]
|
|
86
|
+
return jsonable_encoder(
|
|
87
|
+
obj_dict,
|
|
88
|
+
exclude_none=exclude_none,
|
|
89
|
+
exclude_defaults=exclude_defaults,
|
|
90
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if dataclasses.is_dataclass(obj):
|
|
94
|
+
if not isinstance(obj, type):
|
|
95
|
+
obj_dict = dataclasses.asdict(obj)
|
|
96
|
+
return jsonable_encoder(
|
|
97
|
+
obj_dict,
|
|
98
|
+
by_alias=by_alias,
|
|
99
|
+
exclude_unset=exclude_unset,
|
|
100
|
+
exclude_defaults=exclude_defaults,
|
|
101
|
+
exclude_none=exclude_none,
|
|
102
|
+
custom_encoder=custom_encoder,
|
|
103
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
104
|
+
)
|
|
105
|
+
if isinstance(obj, Enum):
|
|
106
|
+
return obj.value
|
|
107
|
+
if isinstance(obj, PurePath):
|
|
108
|
+
return str(obj)
|
|
109
|
+
if isinstance(obj, str | int | float | type(None)):
|
|
110
|
+
return obj
|
|
111
|
+
if isinstance(obj, Decimal):
|
|
112
|
+
return format(obj, "f")
|
|
113
|
+
if isinstance(obj, dict):
|
|
114
|
+
encoded_dict = {}
|
|
115
|
+
for key, value in obj.items():
|
|
116
|
+
if (not sqlalchemy_safe or (not isinstance(key, str)) or (not key.startswith("_sa"))) and (value is not None or not exclude_none):
|
|
117
|
+
encoded_key = jsonable_encoder(
|
|
118
|
+
key,
|
|
119
|
+
by_alias=by_alias,
|
|
120
|
+
exclude_unset=exclude_unset,
|
|
121
|
+
exclude_none=exclude_none,
|
|
122
|
+
custom_encoder=custom_encoder,
|
|
123
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
124
|
+
)
|
|
125
|
+
encoded_value = jsonable_encoder(
|
|
126
|
+
value,
|
|
127
|
+
by_alias=by_alias,
|
|
128
|
+
exclude_unset=exclude_unset,
|
|
129
|
+
exclude_none=exclude_none,
|
|
130
|
+
custom_encoder=custom_encoder,
|
|
131
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
132
|
+
)
|
|
133
|
+
encoded_dict[encoded_key] = encoded_value
|
|
134
|
+
return encoded_dict
|
|
135
|
+
if isinstance(obj, list | set | frozenset | GeneratorType | tuple | deque):
|
|
136
|
+
encoded_list = []
|
|
137
|
+
for item in obj:
|
|
138
|
+
encoded_list.append(
|
|
139
|
+
jsonable_encoder(
|
|
140
|
+
item,
|
|
141
|
+
by_alias=by_alias,
|
|
142
|
+
exclude_unset=exclude_unset,
|
|
143
|
+
exclude_defaults=exclude_defaults,
|
|
144
|
+
exclude_none=exclude_none,
|
|
145
|
+
custom_encoder=custom_encoder,
|
|
146
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return encoded_list
|
|
150
|
+
|
|
151
|
+
if type(obj) in ENCODERS_BY_TYPE:
|
|
152
|
+
return ENCODERS_BY_TYPE[type(obj)](obj)
|
|
153
|
+
|
|
154
|
+
for encoder, classes_tuple in encoders_by_class_tuples.items():
|
|
155
|
+
if isinstance(obj, classes_tuple):
|
|
156
|
+
return encoder(obj)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
data = dict(obj)
|
|
160
|
+
except Exception as ex:
|
|
161
|
+
errors: list[Exception] = []
|
|
162
|
+
errors.append(ex)
|
|
163
|
+
try:
|
|
164
|
+
data = vars(obj)
|
|
165
|
+
except Exception as ex:
|
|
166
|
+
errors.append(ex)
|
|
167
|
+
raise ValueError(errors) from ex
|
|
168
|
+
|
|
169
|
+
return jsonable_encoder(
|
|
170
|
+
data,
|
|
171
|
+
by_alias=by_alias,
|
|
172
|
+
exclude_unset=exclude_unset,
|
|
173
|
+
exclude_defaults=exclude_defaults,
|
|
174
|
+
exclude_none=exclude_none,
|
|
175
|
+
custom_encoder=custom_encoder,
|
|
176
|
+
sqlalchemy_safe=sqlalchemy_safe,
|
|
177
|
+
)
|
|
178
|
+
|
mop/util/files.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from ascii_colors import ASCIIColors
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def file_size_format(size: int) -> str:
|
|
8
|
+
power = 2 ** 10
|
|
9
|
+
n = 0
|
|
10
|
+
power_labels = {0: '', 1: 'K', 2: 'M', 3: 'G', 4: 'T'}
|
|
11
|
+
while size > power:
|
|
12
|
+
size /= power
|
|
13
|
+
n += 1
|
|
14
|
+
return f"{size:.2f} {power_labels[n]}B"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_env_file():
|
|
18
|
+
if not os.path.exists('.env'):
|
|
19
|
+
warning_msg = "Warning: Startup directory must contain .env file for multi-instance support."
|
|
20
|
+
ASCIIColors.yellow(warning_msg)
|
|
21
|
+
|
|
22
|
+
if sys.stdin.isatty():
|
|
23
|
+
response = input("Do you want to continue? [y/N]: ")
|
|
24
|
+
if response.lower() != "y":
|
|
25
|
+
ASCIIColors.red("Server startup cancelled")
|
|
26
|
+
return False
|
|
27
|
+
return True
|
mop/util/strings.py
ADDED
mop/util/util.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from ascii_colors import ASCIIColors
|
|
4
|
+
|
|
5
|
+
from mop.conf import settings
|
|
6
|
+
from mop import __version__ as core_version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_args(is_uvicorn_mode: bool=False):
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
description="FastAPI Server with separate working and input directories"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--host",
|
|
16
|
+
default=settings.HOST,
|
|
17
|
+
help="Server host (default: from env or 0.0.0.0)"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--port",
|
|
21
|
+
type=int,
|
|
22
|
+
default=settings.PORT,
|
|
23
|
+
help="Server port (default: from env or 8000)"
|
|
24
|
+
)
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
return args
|
|
27
|
+
|
|
28
|
+
def display_splash_screen(args: argparse.Namespace):
|
|
29
|
+
ASCIIColors.cyan(f"""
|
|
30
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
31
|
+
║ 🚀 MoP Server v{core_version} ║
|
|
32
|
+
║ ------------------------------------------- ║
|
|
33
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
34
|
+
""")
|
|
35
|
+
ASCIIColors.magenta("\n📡 Server Configuration:")
|
|
36
|
+
ASCIIColors.white(" ├─ Host: ", end="")
|
|
37
|
+
ASCIIColors.yellow(f"{args.host}")
|
|
38
|
+
ASCIIColors.white(" ├─ Port: ", end="")
|
|
39
|
+
ASCIIColors.yellow(f"{args.port}")
|
|
40
|
+
ASCIIColors.white(" ├─ Address: ", end="")
|
|
41
|
+
ASCIIColors.yellow(f"http://{args.host}:{args.port}")
|
|
42
|
+
ASCIIColors.white(" ├─ Docs: ", end="")
|
|
43
|
+
ASCIIColors.yellow(f"http://{args.host}:{args.port}/api/v1/docs")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moss-mop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight FastAPI + SQLAlchemy backend toolkit with logging, CRUD, and utilities
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: ascii-colors>=0.11.6
|
|
7
|
+
Requires-Dist: fastapi>=0.124.4
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5
|
|
9
|
+
Requires-Dist: sqlalchemy>=2.0.45
|
|
10
|
+
Requires-Dist: structlog>=25.5.0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
mop/__init__.py,sha256=sPqG1eteHPPEAyaj0yF6UkpZhYXQF2Zfdu-5nVromUE,75
|
|
2
|
+
mop/snowflake.py,sha256=J86ViNazFa1HxZ2PHlBlL9WRF_pHNxzqL13Y9yrtgSw,9776
|
|
3
|
+
mop/conf/__init__.py,sha256=LeIvAa1BS1_-Zd0PN1cUepAEEmyFaaBT_QVWHd0HS_c,23
|
|
4
|
+
mop/conf/settings.py,sha256=2f_YO0LwlasgiKrw0ABZVXfVyNaSGbUhIk2fbjt2aFs,2891
|
|
5
|
+
mop/crud/__init__.py,sha256=3help3fmgxYVRMMogfvo9WITwLuGB95Hsk-EHHWIxzw,39
|
|
6
|
+
mop/crud/crud.py,sha256=27eFPTl_Zu2jxMQGpqChjBUmdLXwBFuxFhewKBUlo1A,5571
|
|
7
|
+
mop/crud/page.py,sha256=ZfqDF86lzkeTSYTtHstCTJ-O_hNRA2cb5MMJeHRAn6A,408
|
|
8
|
+
mop/db/__init__.py,sha256=BResfFTBPcTsH0QryV1fWHhTDXH0F876WM8OMt4wqUY,71
|
|
9
|
+
mop/db/async_session.py,sha256=OEYUb_keQ0E9QmFpL01iFAun2EEYE7b8nX86iMLWc0U,800
|
|
10
|
+
mop/db/base.py,sha256=Ys1qN-Aw6bwUxrzw5MJ0chYSBYfubkq0TQLipn_PhVw,302
|
|
11
|
+
mop/db/session.py,sha256=zl1fh5hPlWdLs2T9_Cr6djwaziv9xyoCCKLVDNr8Ato,582
|
|
12
|
+
mop/entity/__init__.py,sha256=8xDz5NhH7UqKGzFsksUNRn989EXS14WX3s7jXXl1c3E,21
|
|
13
|
+
mop/entity/entity.py,sha256=K0KTfXVz3i5rH1ntn0x__MFVXigwr_naXgrd1m3wNUE,1209
|
|
14
|
+
mop/error/__init__.py,sha256=oBh5truG69yWujbw_9z741yMGIW9-_g013wWQByCeQc,48
|
|
15
|
+
mop/error/biz_error.py,sha256=jHRBxnPU1BxjIzEDv1LY691jq3XnCf3uzzy1Om_WLwc,316
|
|
16
|
+
mop/error/err_code.py,sha256=jPThTaKr9PIWosc46PaVf6Zvbm3oAMAmfXoiJsL6EW8,1580
|
|
17
|
+
mop/logging/__init__.py,sha256=n3gPZp-5JMkNwlaWF7GyDwk40E-QYlIeLNruo6MB7B0,22
|
|
18
|
+
mop/logging/logging.py,sha256=JFH6sC8mqitVE6ligLv9u0iEju1aQJKxoI89m2f02wM,4843
|
|
19
|
+
mop/response/__init__.py,sha256=pohzkvpThf_VonwWU0HgJIGkJ3xl6M5ejVMKFHwWBhg,23
|
|
20
|
+
mop/response/response.py,sha256=RepIafmXu4QAKkTqhyWVOIw0vpsg4EnJZeewQWFnh8Y,1177
|
|
21
|
+
mop/util/__init__.py,sha256=4g0apI8-3diyfdBaBOt6VKY0WGXTK8FpybbNTMHNR2o,19
|
|
22
|
+
mop/util/dates.py,sha256=p5PYGs3ZcE81Goblu-uvmpbN4FqziPGAy7_2gArWMbA,127
|
|
23
|
+
mop/util/encoder.py,sha256=EQ1vSVW819YDQ0wPPq9rWcMmgiGWS7eytn5DSpRc6-o,5900
|
|
24
|
+
mop/util/files.py,sha256=bpTrVmM45i8yD0OxSPvKrTVFXrTqOEnxP_lcjtnhJNs,737
|
|
25
|
+
mop/util/strings.py,sha256=bBt2p-VnQcuu8mysLfUfZlqtya7uadmLU8NSf1uylGc,223
|
|
26
|
+
mop/util/util.py,sha256=MJj-JRAUi7zFnJ1sI8GyQb_eQsVDe103nUXgsX87CQg,1768
|
|
27
|
+
moss_mop-0.1.0.dist-info/METADATA,sha256=IzIKmtYw1lneZKCCukE5KWJqCBGTvgkJ4jEmeQn3lbo,335
|
|
28
|
+
moss_mop-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
29
|
+
moss_mop-0.1.0.dist-info/top_level.txt,sha256=4y0z_w1bQ9rx4tDt1scq6AUB1N0WAqHm98kjNjy1xDQ,4
|
|
30
|
+
moss_mop-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mop
|