rainycode 1.4.4__tar.gz → 1.5.7__tar.gz
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.
- {rainycode-1.4.4 → rainycode-1.5.7}/PKG-INFO +2 -1
- rainycode-1.5.7/common_base/aiorequests.py +55 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_base/exception.py +8 -6
- {rainycode-1.4.4 → rainycode-1.5.7}/common_depends/auth_depend.py +9 -7
- {rainycode-1.4.4 → rainycode-1.5.7}/common_models/__init__.py +2 -1
- rainycode-1.5.7/common_models/file_model.py +25 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_servers/api/auth_api.py +20 -6
- {rainycode-1.4.4 → rainycode-1.5.7}/common_servers/api/wechat_api.py +3 -2
- {rainycode-1.4.4 → rainycode-1.5.7}/common_servers/router.py +2 -1
- {rainycode-1.4.4 → rainycode-1.5.7}/common_servers/service/auth_svc.py +10 -9
- rainycode-1.5.7/common_servers/service/file_svc.py +52 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/datetime_util.py +2 -2
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/ip_util.py +8 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/jwt_util.py +3 -1
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/snowflake_util.py +1 -1
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/wechat_util.py +3 -3
- {rainycode-1.4.4 → rainycode-1.5.7}/core/base_config.py +10 -1
- {rainycode-1.4.4 → rainycode-1.5.7}/core/start.py +1 -1
- rainycode-1.5.7/pyproject.toml +61 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/rainycode.egg-info/PKG-INFO +2 -1
- {rainycode-1.4.4 → rainycode-1.5.7}/rainycode.egg-info/SOURCES.txt +2 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/rainycode.egg-info/requires.txt +1 -0
- rainycode-1.4.4/common_base/aiorequests.py +0 -39
- rainycode-1.4.4/pyproject.toml +0 -36
- {rainycode-1.4.4 → rainycode-1.5.7}/common_base/consts.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_base/logging.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_base/response.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_models/base_model.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_models/user_model.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_models/wechat_model.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_servers/schemas/auth_schema.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_servers/service/wechat_svc.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/bcrypt_util.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/common_utils/captcha_util.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/core/databases/aiodb.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/core/databases/aioredis.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/core/middleware/http_middleware.py +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/rainycode.egg-info/dependency_links.txt +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/rainycode.egg-info/top_level.txt +0 -0
- {rainycode-1.4.4 → rainycode-1.5.7}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rainycode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.7
|
|
4
4
|
Summary: FastAPI base modules
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: aerich==0.9.2
|
|
@@ -17,6 +17,7 @@ Requires-Dist: pycryptodome==3.23.0
|
|
|
17
17
|
Requires-Dist: uvicorn[standard]==0.41.0
|
|
18
18
|
Requires-Dist: tortoise-orm[asyncmy]==0.25.4
|
|
19
19
|
Requires-Dist: pydantic-settings==2.13.1
|
|
20
|
+
Requires-Dist: python-multipart==0.0.32
|
|
20
21
|
Provides-Extra: release
|
|
21
22
|
Requires-Dist: build==1.4.0; extra == "release"
|
|
22
23
|
Requires-Dist: twine==6.1.0; extra == "release"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import ssl
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
import certifi
|
|
6
|
+
|
|
7
|
+
# 模块级别 SSL 上下文,使用 certifi 证书,避免重复创建
|
|
8
|
+
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
|
9
|
+
|
|
10
|
+
# 连接池:跨请求复用 keep-alive 长连接(而非每次新建 connector)。
|
|
11
|
+
# limit: 全池同时最大连接数(含活跃与 keep-alive 空闲复用)
|
|
12
|
+
# limit_per_host: 单个 host 端点同时最大连接数,限制对单一对端的并发/占用
|
|
13
|
+
# 惰性初始化:TCPConnector 构造需要运行中的事件循环,只能在首次请求(async 上下文)时创建,
|
|
14
|
+
# 不能在模块 import 时同步 create(否则抛 RuntimeError: no running event loop)。
|
|
15
|
+
_CONNECTOR: aiohttp.TCPConnector | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_connector() -> aiohttp.TCPConnector:
|
|
19
|
+
global _CONNECTOR
|
|
20
|
+
if _CONNECTOR is None:
|
|
21
|
+
_CONNECTOR = aiohttp.TCPConnector(ssl=_SSL_CONTEXT, limit=300, limit_per_host=100)
|
|
22
|
+
return _CONNECTOR
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_url(url, params=None, suffix=None):
|
|
26
|
+
if params:
|
|
27
|
+
query = "&".join([f"{k}={params[k]}" for k in sorted(params.keys())])
|
|
28
|
+
if "?" in url:
|
|
29
|
+
url += "&" + query
|
|
30
|
+
else:
|
|
31
|
+
url += "?" + query
|
|
32
|
+
if suffix:
|
|
33
|
+
url += suffix
|
|
34
|
+
return url
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def get(url, params=None, headers=None, suffix=None):
|
|
38
|
+
url = parse_url(url, params, suffix)
|
|
39
|
+
async with (
|
|
40
|
+
aiohttp.ClientSession(connector=_get_connector(), trust_env=True) as session,
|
|
41
|
+
session.get(url, headers=headers) as response,
|
|
42
|
+
):
|
|
43
|
+
text = await response.text()
|
|
44
|
+
return json.loads(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def post(url, data=None, headers=None):
|
|
48
|
+
if isinstance(data, dict):
|
|
49
|
+
data = json.dumps(data)
|
|
50
|
+
async with (
|
|
51
|
+
aiohttp.ClientSession(connector=_get_connector(), trust_env=True) as session,
|
|
52
|
+
session.post(url, data=data, headers=headers) as response,
|
|
53
|
+
):
|
|
54
|
+
text = await response.text()
|
|
55
|
+
return json.loads(text)
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
from typing import cast
|
|
2
2
|
|
|
3
|
-
from common_base.logging import logger
|
|
4
|
-
from common_base.response import ErrorReturn
|
|
5
3
|
from fastapi import HTTPException, Request, status
|
|
6
4
|
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
|
7
5
|
from starlette.responses import JSONResponse
|
|
8
6
|
|
|
7
|
+
from common_base.logging import logger
|
|
8
|
+
from common_base.response import ErrorReturn
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
class CustomException(Exception):
|
|
11
12
|
"""自定义异常"""
|
|
12
13
|
def __init__(
|
|
13
|
-
self,
|
|
14
|
+
self,
|
|
14
15
|
msg: str | None = None,
|
|
15
|
-
code: int =
|
|
16
|
+
code: int = -1,
|
|
16
17
|
data: object | None = None,
|
|
17
18
|
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
18
19
|
) -> None:
|
|
@@ -41,10 +42,11 @@ async def RequestValidationHandle(request: Request, exc: RequestValidationError)
|
|
|
41
42
|
"""请求参数验证异常处理器"""
|
|
42
43
|
error_mapping = {
|
|
43
44
|
"Field required": "请求失败,缺少必填项!",
|
|
44
|
-
"value is not a valid list": "类型错误,提交参数应该为列表!",
|
|
45
|
+
"value is not a valid list": "类型错误,提交参数应该为列表!",
|
|
45
46
|
"value is not a valid int": "类型错误,提交参数应该为整数!",
|
|
46
47
|
"value could not be parsed to a boolean": "类型错误,提交参数应该为布尔值!",
|
|
47
|
-
"Input should be a valid list": "类型错误,输入应该是一个有效的列表!"
|
|
48
|
+
"Input should be a valid list": "类型错误,输入应该是一个有效的列表!",
|
|
49
|
+
"String should have at least 6 characters": "密码长度不能小于6位!"
|
|
48
50
|
}
|
|
49
51
|
msg = error_mapping.get(exc.errors()[0].get('msg'), exc.errors()[0].get('msg'))
|
|
50
52
|
logger.error(f"请求地址: {request.url}, 错误信息: {msg}, 错误详情: {exc}")
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
+
from fastapi import Depends, Request
|
|
4
|
+
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
|
|
5
|
+
|
|
3
6
|
from common_base.consts import (
|
|
4
7
|
REDIS_PREFIX_BLOCKLIST,
|
|
5
8
|
REDIS_PREFIX_USER_INFO,
|
|
@@ -8,10 +11,9 @@ from common_base.exception import CustomException
|
|
|
8
11
|
from common_models.user_model import User
|
|
9
12
|
from common_utils.jwt_util import JwtUtil
|
|
10
13
|
from core.databases.aioredis import AioRedis
|
|
11
|
-
from
|
|
12
|
-
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
|
|
14
|
+
from core.start import base_config
|
|
13
15
|
|
|
14
|
-
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/
|
|
16
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/api/{base_config.app_name}/auth/login", auto_error=False)
|
|
15
17
|
|
|
16
18
|
async def get_current_user(request: Request, token: str, security_scopes: SecurityScopes) -> User:
|
|
17
19
|
"""
|
|
@@ -19,15 +21,15 @@ async def get_current_user(request: Request, token: str, security_scopes: Securi
|
|
|
19
21
|
"""
|
|
20
22
|
payload = JwtUtil.decode_token(token)
|
|
21
23
|
if not payload:
|
|
22
|
-
raise CustomException(msg="无效的令牌")
|
|
24
|
+
raise CustomException(msg="无效的令牌", status_code=401)
|
|
23
25
|
|
|
24
26
|
# 检查是否在黑名单中
|
|
25
27
|
if await AioRedis.get(f"{REDIS_PREFIX_BLOCKLIST}{token}"):
|
|
26
|
-
raise CustomException(msg="令牌已失效")
|
|
28
|
+
raise CustomException(msg="令牌已失效", status_code=401)
|
|
27
29
|
|
|
28
30
|
user_id = payload.get("sub")
|
|
29
31
|
if not user_id:
|
|
30
|
-
raise CustomException(msg="无效的令牌")
|
|
32
|
+
raise CustomException(msg="无效的令牌", status_code=401)
|
|
31
33
|
|
|
32
34
|
# 1. 尝试从缓存获取
|
|
33
35
|
user = None
|
|
@@ -64,7 +66,7 @@ async def get_current_user(request: Request, token: str, security_scopes: Securi
|
|
|
64
66
|
async def login_require(request: Request, security_scopes: SecurityScopes, token=Depends(oauth2_scheme)) -> User:
|
|
65
67
|
"""获取用户,获取不到报错"""
|
|
66
68
|
if not token:
|
|
67
|
-
raise CustomException('请先登录')
|
|
69
|
+
raise CustomException('请先登录', status_code=401)
|
|
68
70
|
|
|
69
71
|
return await get_current_user(request, token, security_scopes)
|
|
70
72
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from tortoise import fields
|
|
2
|
+
|
|
3
|
+
from common_models.base_model import CreateModel
|
|
4
|
+
from core.base_config import base_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class File(CreateModel):
|
|
8
|
+
"""
|
|
9
|
+
文件表
|
|
10
|
+
"""
|
|
11
|
+
id = fields.IntField(primary_key=True, description="主键")
|
|
12
|
+
name = fields.CharField(max_length=255, description="原始文件名")
|
|
13
|
+
url = fields.CharField(max_length=255, description="相对路径(不含前缀, 如 dishes/xxx.jpg)")
|
|
14
|
+
size = fields.IntField(default=0, description="文件字节数")
|
|
15
|
+
ext = fields.CharField(max_length=10, description="扩展名")
|
|
16
|
+
md5 = fields.CharField(max_length=32, null=True, description="文件内容 MD5(用于识别相同文件)")
|
|
17
|
+
|
|
18
|
+
class Meta:
|
|
19
|
+
table = "file"
|
|
20
|
+
table_description = "上传文件表"
|
|
21
|
+
|
|
22
|
+
def fix_data(self, result: dict) -> None:
|
|
23
|
+
"""序列化后钩子(库 model2dict 调用):若含 url 则拼接可配置前缀。"""
|
|
24
|
+
if 'url' in result:
|
|
25
|
+
result['url'] = f"{base_config.upload_url_prefix}/{result['url']}"
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
+
from fastapi import APIRouter, Body, Depends, Query, Request, Security
|
|
2
|
+
|
|
3
|
+
from common_base.exception import CustomException
|
|
1
4
|
from common_base.response import SuccessReturn
|
|
2
5
|
from common_depends.auth_depend import login_require
|
|
3
6
|
from common_models.user_model import User
|
|
4
|
-
from common_servers.schemas.auth_schema import
|
|
5
|
-
from common_servers.service.auth_svc import AuthService
|
|
6
|
-
from
|
|
7
|
+
from common_servers.schemas.auth_schema import TokenResponse, UserInfo
|
|
8
|
+
from common_servers.service.auth_svc import AuthService, UserLogin, UserRegister, WechatLogin
|
|
9
|
+
from core.base_config import base_config
|
|
7
10
|
|
|
8
11
|
router = APIRouter(prefix="/auth")
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
def password_login_require() -> None:
|
|
15
|
+
"""账号密码登录门禁。
|
|
16
|
+
|
|
17
|
+
以 APP_DEBUG 区分环境:生产(APP_DEBUG=False)关闭 /auth/register、/auth/login,
|
|
18
|
+
仅保留微信网页授权与小程序登录;本地开发/测试(APP_DEBUG=True)不受影响。
|
|
19
|
+
"""
|
|
20
|
+
if not base_config.app_debug:
|
|
21
|
+
raise CustomException(msg="账号密码登录已停用,请使用微信登录", status_code=403)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.post("/register", summary="用户注册", dependencies=[Depends(password_login_require)])
|
|
11
25
|
async def register(user_in: UserRegister):
|
|
12
26
|
user = await AuthService.register(user_in)
|
|
13
27
|
return SuccessReturn(
|
|
@@ -15,7 +29,7 @@ async def register(user_in: UserRegister):
|
|
|
15
29
|
)
|
|
16
30
|
|
|
17
31
|
|
|
18
|
-
@router.post("/login", summary="用户密码登录")
|
|
32
|
+
@router.post("/login", summary="用户密码登录", dependencies=[Depends(password_login_require)])
|
|
19
33
|
async def login(user_in: UserLogin, request: Request):
|
|
20
34
|
client_ip = request.client.host if request.client else "unknown"
|
|
21
35
|
token_data = await AuthService.login(user_in, client_ip)
|
|
@@ -70,4 +84,4 @@ async def get_wechat_oauth_url(
|
|
|
70
84
|
- 如果用户未注册,自动重定向到 snsapi_userinfo 授权
|
|
71
85
|
"""
|
|
72
86
|
authorize_url = await AuthService.get_wechat_oauth_url(app_pk, redirect_uri, state)
|
|
73
|
-
return SuccessReturn(data={"authorize_url": authorize_url})
|
|
87
|
+
return SuccessReturn(data={"authorize_url": authorize_url})
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from urllib.parse import urlencode
|
|
3
3
|
|
|
4
|
+
from fastapi import APIRouter, Query, Request, Response
|
|
5
|
+
from fastapi.responses import RedirectResponse
|
|
6
|
+
|
|
4
7
|
from common_base.consts import REDIS_PREFIX_OAUTH_STATE
|
|
5
8
|
from common_base.exception import CustomException
|
|
6
9
|
from common_servers.service.auth_svc import AuthService
|
|
7
10
|
from common_servers.service.wechat_svc import WechatService
|
|
8
11
|
from core.databases.aioredis import AioRedis
|
|
9
|
-
from fastapi import APIRouter, Query, Request, Response
|
|
10
|
-
from fastapi.responses import RedirectResponse
|
|
11
12
|
|
|
12
13
|
router = APIRouter(prefix="/wechat")
|
|
13
14
|
|
|
@@ -4,6 +4,10 @@ import uuid
|
|
|
4
4
|
from datetime import datetime, timedelta
|
|
5
5
|
from urllib.parse import urlencode
|
|
6
6
|
|
|
7
|
+
from fastapi.responses import RedirectResponse
|
|
8
|
+
from tortoise.expressions import Q
|
|
9
|
+
from tortoise.transactions import atomic
|
|
10
|
+
|
|
7
11
|
from common_base.consts import (
|
|
8
12
|
REDIS_PREFIX_BLOCKLIST,
|
|
9
13
|
REDIS_PREFIX_LOGIN_LOCK_IP,
|
|
@@ -28,9 +32,6 @@ from common_utils.jwt_util import JwtUtil
|
|
|
28
32
|
from common_utils.wechat_util import WechatUtil
|
|
29
33
|
from core.base_config import base_config
|
|
30
34
|
from core.databases.aioredis import AioRedis
|
|
31
|
-
from fastapi.responses import RedirectResponse
|
|
32
|
-
from tortoise.expressions import Q
|
|
33
|
-
from tortoise.transactions import atomic
|
|
34
35
|
|
|
35
36
|
# 登录限流配置
|
|
36
37
|
LOGIN_FAIL_LIMIT = 5 # 最大失败次数
|
|
@@ -105,11 +106,11 @@ class AuthService:
|
|
|
105
106
|
"""
|
|
106
107
|
user_id = await AioRedis.get(f"{REDIS_PREFIX_REFRESH_TOKEN}{refresh_token}")
|
|
107
108
|
if not user_id:
|
|
108
|
-
raise CustomException(msg="无效或已过期的刷新令牌")
|
|
109
|
+
raise CustomException(msg="无效或已过期的刷新令牌", status_code=401)
|
|
109
110
|
try:
|
|
110
111
|
user_id = int(user_id)
|
|
111
112
|
except (ValueError, TypeError):
|
|
112
|
-
raise CustomException(msg="无效的刷新令牌")
|
|
113
|
+
raise CustomException(msg="无效的刷新令牌", status_code=401) from None
|
|
113
114
|
|
|
114
115
|
user = await User.get_or_none(id=user_id)
|
|
115
116
|
if not user or not user.is_active:
|
|
@@ -178,7 +179,7 @@ class AuthService:
|
|
|
178
179
|
|
|
179
180
|
# 构建回调地址
|
|
180
181
|
api_prefix = f"/api/{base_config.app_name}" if base_config.app_name else ""
|
|
181
|
-
callback_url = f"{base_config.base_url}{api_prefix}/wechat/
|
|
182
|
+
callback_url = f"{base_config.base_url}{api_prefix}/wechat/callback"
|
|
182
183
|
|
|
183
184
|
return WechatUtil.build_oauth_url(
|
|
184
185
|
appid=app_config.app_id,
|
|
@@ -208,8 +209,8 @@ class AuthService:
|
|
|
208
209
|
cached_data = json.loads(cache_value)
|
|
209
210
|
redirect_uri = cached_data.get("redirect_uri")
|
|
210
211
|
app_pk = cached_data.get("app_pk")
|
|
211
|
-
except (json.JSONDecodeError, TypeError):
|
|
212
|
-
raise CustomException(msg="授权数据格式错误")
|
|
212
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
213
|
+
raise CustomException(msg="授权数据格式错误") from e
|
|
213
214
|
|
|
214
215
|
if not redirect_uri or not app_pk:
|
|
215
216
|
raise CustomException(msg="授权数据不完整")
|
|
@@ -277,7 +278,7 @@ class AuthService:
|
|
|
277
278
|
|
|
278
279
|
# 构建 snsapi_userinfo 授权链接
|
|
279
280
|
api_prefix = f"/api/{base_config.app_name}" if base_config.app_name else ""
|
|
280
|
-
callback_url = f"{base_config.base_url}{api_prefix}/wechat/
|
|
281
|
+
callback_url = f"{base_config.base_url}{api_prefix}/wechat/callback"
|
|
281
282
|
userinfo_auth_url = WechatUtil.build_oauth_url(
|
|
282
283
|
appid=wechat_app.app_id,
|
|
283
284
|
redirect_uri=callback_url,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import uuid
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from common_base.exception import CustomException
|
|
6
|
+
from common_models.file_model import File
|
|
7
|
+
from common_models.user_model import User
|
|
8
|
+
from core.base_config import base_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileService:
|
|
12
|
+
"""通用文件上传服务:校验格式/大小、写盘、落 File 注册表、按 md5 去重。"""
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
async def upload(
|
|
16
|
+
cls,
|
|
17
|
+
user: User,
|
|
18
|
+
file,
|
|
19
|
+
*,
|
|
20
|
+
subdir: str,
|
|
21
|
+
allowed_exts: set[str] = base_config.upload_allowed_image_exts,
|
|
22
|
+
max_bytes: int = base_config.upload_max_bytes,
|
|
23
|
+
) -> dict:
|
|
24
|
+
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
|
25
|
+
if ext not in allowed_exts:
|
|
26
|
+
raise CustomException(msg="不支持的文件格式")
|
|
27
|
+
|
|
28
|
+
content = await file.read()
|
|
29
|
+
if len(content) > max_bytes:
|
|
30
|
+
raise CustomException(msg="文件大小超限")
|
|
31
|
+
|
|
32
|
+
md5 = hashlib.md5(content).hexdigest()
|
|
33
|
+
existing = await File.get_or_none(md5=md5)
|
|
34
|
+
if existing:
|
|
35
|
+
# 相同文件已存在,直接复用(不重复写盘),仅返回其注册信息
|
|
36
|
+
return await File.model2dict(existing)
|
|
37
|
+
|
|
38
|
+
filename = f"{uuid.uuid4().hex}.{ext}"
|
|
39
|
+
relative = f"{subdir}/{filename}" # 磁盘相对路径 & File.url(不含前缀)
|
|
40
|
+
path = Path(base_config.upload_root) / relative
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
path.write_bytes(content)
|
|
43
|
+
|
|
44
|
+
row = await File.create(
|
|
45
|
+
name=file.filename or filename,
|
|
46
|
+
url=relative,
|
|
47
|
+
size=len(content),
|
|
48
|
+
ext=ext,
|
|
49
|
+
md5=md5,
|
|
50
|
+
creator_id=user.id,
|
|
51
|
+
)
|
|
52
|
+
return await File.model2dict(row)
|
|
@@ -7,6 +7,6 @@ from zoneinfo import ZoneInfo
|
|
|
7
7
|
CN_TZ = ZoneInfo('Asia/Shanghai')
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
"""返回当前时刻的 aware Asia/Shanghai datetime
|
|
10
|
+
def chinese_now() -> datetime:
|
|
11
|
+
"""返回当前时刻的 aware Asia/Shanghai datetime"""
|
|
12
12
|
return datetime.now(CN_TZ)
|
|
@@ -81,3 +81,11 @@ class IpUtil:
|
|
|
81
81
|
except Exception as e:
|
|
82
82
|
# 获取IP归属地失败
|
|
83
83
|
return "未知"
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def get_client_ip(request) -> str:
|
|
87
|
+
"""从 Request 解析客户端 IP:代理场景优先 X-Forwarded-For,否则取直连 client.host。"""
|
|
88
|
+
forwarded = request.headers.get('x-forwarded-for')
|
|
89
|
+
if forwarded:
|
|
90
|
+
return forwarded.split(',')[0].strip()
|
|
91
|
+
return request.client.host if request.client else "unknown"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from datetime import datetime, timedelta
|
|
2
2
|
from typing import Any
|
|
3
|
+
from zoneinfo import ZoneInfo
|
|
3
4
|
|
|
4
5
|
import jwt
|
|
6
|
+
|
|
5
7
|
from core.base_config import base_config
|
|
6
8
|
|
|
7
9
|
|
|
@@ -25,7 +27,7 @@ class JwtUtil:
|
|
|
25
27
|
str: JWT Token
|
|
26
28
|
"""
|
|
27
29
|
to_encode = data.copy()
|
|
28
|
-
expire = datetime.now() + expires_delta
|
|
30
|
+
expire = datetime.now(ZoneInfo('Asia/Shanghai')) + expires_delta
|
|
29
31
|
|
|
30
32
|
to_encode.update({"exp": expire})
|
|
31
33
|
encoded_jwt = jwt.encode(to_encode, cls.SECRET_KEY, algorithm=cls.ALGORITHM)
|
|
@@ -50,9 +50,9 @@ class WechatUtil:
|
|
|
50
50
|
return data
|
|
51
51
|
except Exception as e:
|
|
52
52
|
if isinstance(e, CustomException):
|
|
53
|
-
raise e
|
|
53
|
+
raise e from e
|
|
54
54
|
logger.error(f"Wechat API request error: {e}")
|
|
55
|
-
raise CustomException(msg="微信服务暂时不可用")
|
|
55
|
+
raise CustomException(msg="微信服务暂时不可用") from None
|
|
56
56
|
|
|
57
57
|
@classmethod
|
|
58
58
|
async def get_oauth_access_token(cls, appid: str, secret: str, code: str) -> dict:
|
|
@@ -116,4 +116,4 @@ class WechatUtil:
|
|
|
116
116
|
return data
|
|
117
117
|
except Exception as e:
|
|
118
118
|
logger.error(f"Decrypt wechat data failed: {e}")
|
|
119
|
-
raise CustomException(msg="微信数据解密失败")
|
|
119
|
+
raise CustomException(msg="微信数据解密失败") from None
|
|
@@ -13,6 +13,11 @@ class BaseConfig(BaseSettings):
|
|
|
13
13
|
# 基础 URL
|
|
14
14
|
base_url: str = ""
|
|
15
15
|
|
|
16
|
+
# 文件上传:本地存储根目录 + 访问 URL 前缀。
|
|
17
|
+
# upload_url_prefix 可为相对路径(/uploads)或完整 URL(https://cdn.xxx.com/uploads),
|
|
18
|
+
upload_root: str = 'uploads'
|
|
19
|
+
upload_url_prefix: str = '/uploads'
|
|
20
|
+
|
|
16
21
|
# 跨域
|
|
17
22
|
cors_origins: list[str] = ['*']
|
|
18
23
|
cors_allow_credentials: bool = True
|
|
@@ -23,10 +28,14 @@ class BaseConfig(BaseSettings):
|
|
|
23
28
|
jwt_secret_key: str = "bgb0tnl9d58+6n-6h-ea&u^1#s0ccp!794=krylacjq75vzps$"
|
|
24
29
|
|
|
25
30
|
# Access Token 有效期
|
|
26
|
-
access_token_expire_minutes: int =
|
|
31
|
+
access_token_expire_minutes: int = 1440
|
|
27
32
|
# Refresh Token 有效期
|
|
28
33
|
refresh_token_expire_days: int = 30
|
|
29
34
|
|
|
35
|
+
# 文件上传约束(common_servers/service/file_svc.py)
|
|
36
|
+
upload_max_bytes: int = 5 * 1024 * 1024 # 5MB
|
|
37
|
+
upload_allowed_image_exts: set[str] = {'jpg', 'jpeg', 'png', 'webp'}
|
|
38
|
+
|
|
30
39
|
model_config = SettingsConfigDict(
|
|
31
40
|
env_file=('envs/dev.env', 'envs/pro.env'),
|
|
32
41
|
env_file_encoding="utf-8",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rainycode"
|
|
7
|
+
version = "1.5.7"
|
|
8
|
+
description = "FastAPI base modules"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
"aerich == 0.9.2",
|
|
13
|
+
"fastapi == 0.133.1",
|
|
14
|
+
"aiohttp == 3.13.3",
|
|
15
|
+
"passlib==1.7.4",
|
|
16
|
+
"redis == 7.2.1",
|
|
17
|
+
"pyjwt == 2.11.0",
|
|
18
|
+
"pillow == 12.1.1",
|
|
19
|
+
"bcrypt == 4.0.1",
|
|
20
|
+
"certifi == 2026.2.25",
|
|
21
|
+
"cryptography == 46.0.5",
|
|
22
|
+
"pycryptodome == 3.23.0",
|
|
23
|
+
"uvicorn[standard] == 0.41.0",
|
|
24
|
+
"tortoise-orm[asyncmy] == 0.25.4",
|
|
25
|
+
"pydantic-settings == 2.13.1",
|
|
26
|
+
"python-multipart==0.0.32"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
release = [
|
|
31
|
+
"build == 1.4.0",
|
|
32
|
+
"twine == 6.1.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["."]
|
|
37
|
+
include = ["core*", "common*"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
target-version = "py312" # 按 Python 3.12 语法判断,UP 规则才不会误降级
|
|
42
|
+
line-length = 120 # 中文注释 + FastAPI 调用链较长,120 比 88 舒服
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = [
|
|
46
|
+
"E", "W", # pycodestyle:基础风格(空格/空行/换行)
|
|
47
|
+
"F", # pyflakes:未用 import / 变量、未定义名
|
|
48
|
+
"I", # isort:import 自动排序分组
|
|
49
|
+
"B", # bugbear:常见 bug 模式(可变默认值、裸 except 等)
|
|
50
|
+
"UP", # pyupgrade:语法现代化(Optional[X] → X | None 等)
|
|
51
|
+
]
|
|
52
|
+
ignore = [
|
|
53
|
+
"B008", # 函数调用作为默认值 —— FastAPI 的 Security/Depends/Query/Body 必须这么写
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"migrations/*" = ["ALL"] # aerich 自动生成的迁移文件,不检查
|
|
58
|
+
"__init__.py" = ["F401"] # 模块聚合导出,允许未使用 import
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint.isort]
|
|
61
|
+
known-first-party = ["servers", "models"] # 本项目模块,排序时与第三方库分组
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rainycode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.7
|
|
4
4
|
Summary: FastAPI base modules
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: aerich==0.9.2
|
|
@@ -17,6 +17,7 @@ Requires-Dist: pycryptodome==3.23.0
|
|
|
17
17
|
Requires-Dist: uvicorn[standard]==0.41.0
|
|
18
18
|
Requires-Dist: tortoise-orm[asyncmy]==0.25.4
|
|
19
19
|
Requires-Dist: pydantic-settings==2.13.1
|
|
20
|
+
Requires-Dist: python-multipart==0.0.32
|
|
20
21
|
Provides-Extra: release
|
|
21
22
|
Requires-Dist: build==1.4.0; extra == "release"
|
|
22
23
|
Requires-Dist: twine==6.1.0; extra == "release"
|
|
@@ -7,6 +7,7 @@ common_base/response.py
|
|
|
7
7
|
common_depends/auth_depend.py
|
|
8
8
|
common_models/__init__.py
|
|
9
9
|
common_models/base_model.py
|
|
10
|
+
common_models/file_model.py
|
|
10
11
|
common_models/user_model.py
|
|
11
12
|
common_models/wechat_model.py
|
|
12
13
|
common_servers/router.py
|
|
@@ -14,6 +15,7 @@ common_servers/api/auth_api.py
|
|
|
14
15
|
common_servers/api/wechat_api.py
|
|
15
16
|
common_servers/schemas/auth_schema.py
|
|
16
17
|
common_servers/service/auth_svc.py
|
|
18
|
+
common_servers/service/file_svc.py
|
|
17
19
|
common_servers/service/wechat_svc.py
|
|
18
20
|
common_utils/bcrypt_util.py
|
|
19
21
|
common_utils/captcha_util.py
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import ssl
|
|
3
|
-
|
|
4
|
-
import aiohttp
|
|
5
|
-
import certifi
|
|
6
|
-
|
|
7
|
-
# 模块级别 SSL 上下文,使用 certifi 证书,避免重复创建
|
|
8
|
-
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def parse_url(url, params=None, suffix=None):
|
|
12
|
-
if params:
|
|
13
|
-
query = "&".join([f"{k}={params[k]}" for k in sorted(params.keys())])
|
|
14
|
-
if "?" in url:
|
|
15
|
-
url += "&" + query
|
|
16
|
-
else:
|
|
17
|
-
url += "?" + query
|
|
18
|
-
if suffix:
|
|
19
|
-
url += suffix
|
|
20
|
-
return url
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
async def get(url, params=None, headers=None, suffix=None):
|
|
24
|
-
url = parse_url(url, params, suffix)
|
|
25
|
-
connector = aiohttp.TCPConnector(ssl=_SSL_CONTEXT)
|
|
26
|
-
async with aiohttp.ClientSession(connector=connector, trust_env=True) as session:
|
|
27
|
-
async with session.get(url, headers=headers) as response:
|
|
28
|
-
text = await response.text()
|
|
29
|
-
return json.loads(text)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
async def post(url, data=None, headers=None):
|
|
33
|
-
connector = aiohttp.TCPConnector(ssl=_SSL_CONTEXT)
|
|
34
|
-
async with aiohttp.ClientSession(connector=connector, trust_env=True) as session:
|
|
35
|
-
if isinstance(data, dict):
|
|
36
|
-
data = json.dumps(data)
|
|
37
|
-
async with session.post(url, data=data, headers=headers) as response:
|
|
38
|
-
text = await response.text()
|
|
39
|
-
return json.loads(text)
|
rainycode-1.4.4/pyproject.toml
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "rainycode"
|
|
7
|
-
version = "1.4.4"
|
|
8
|
-
description = "FastAPI base modules"
|
|
9
|
-
requires-python = ">=3.10"
|
|
10
|
-
|
|
11
|
-
dependencies = [
|
|
12
|
-
"aerich == 0.9.2",
|
|
13
|
-
"fastapi == 0.133.1",
|
|
14
|
-
"aiohttp == 3.13.3",
|
|
15
|
-
"passlib==1.7.4",
|
|
16
|
-
"redis == 7.2.1",
|
|
17
|
-
"pyjwt == 2.11.0",
|
|
18
|
-
"pillow == 12.1.1",
|
|
19
|
-
"bcrypt == 4.0.1",
|
|
20
|
-
"certifi == 2026.2.25",
|
|
21
|
-
"cryptography == 46.0.5",
|
|
22
|
-
"pycryptodome == 3.23.0",
|
|
23
|
-
"uvicorn[standard] == 0.41.0",
|
|
24
|
-
"tortoise-orm[asyncmy] == 0.25.4",
|
|
25
|
-
"pydantic-settings == 2.13.1",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
[project.optional-dependencies]
|
|
29
|
-
release = [
|
|
30
|
-
"build == 1.4.0",
|
|
31
|
-
"twine == 6.1.0",
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
[tool.setuptools.packages.find]
|
|
35
|
-
where = ["."]
|
|
36
|
-
include = ["core*", "common*"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|