rainycode 1.0.0__tar.gz → 1.0.1__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.
Files changed (41) hide show
  1. {rainycode-1.0.0 → rainycode-1.0.1}/PKG-INFO +2 -1
  2. {rainycode-1.0.0/common → rainycode-1.0.1/common_base}/exception.py +2 -2
  3. {rainycode-1.0.0/common_depend → rainycode-1.0.1/common_depends}/auth_depend.py +3 -3
  4. {rainycode-1.0.0/common_model → rainycode-1.0.1/common_models}/user_model.py +1 -1
  5. {rainycode-1.0.0/common_model → rainycode-1.0.1/common_models}/wechat_model.py +1 -1
  6. rainycode-1.0.1/common_servers/api/auth_api.py +50 -0
  7. rainycode-1.0.1/common_servers/api/wechat_api.py +48 -0
  8. rainycode-1.0.1/common_servers/router.py +9 -0
  9. rainycode-1.0.1/common_servers/schemas/__init__.py +0 -0
  10. rainycode-1.0.1/common_servers/schemas/auth_schema.py +43 -0
  11. rainycode-1.0.1/common_servers/service/auth_svc.py +131 -0
  12. rainycode-1.0.1/common_servers/service/wechat_svc.py +78 -0
  13. rainycode-1.0.1/common_servers/strategies/base_strategy.py +9 -0
  14. rainycode-1.0.1/common_servers/strategies/password_strategy.py +34 -0
  15. rainycode-1.0.1/common_servers/strategies/wechat_stragegy.py +141 -0
  16. {rainycode-1.0.0 → rainycode-1.0.1}/common_utlis/ip_util.py +1 -2
  17. rainycode-1.0.1/common_utlis/wechat_util.py +95 -0
  18. {rainycode-1.0.0 → rainycode-1.0.1}/core/base_config.py +6 -0
  19. {rainycode-1.0.0 → rainycode-1.0.1}/core/databases/aiodb.py +1 -1
  20. {rainycode-1.0.0 → rainycode-1.0.1}/core/databases/aioredis.py +1 -1
  21. {rainycode-1.0.0 → rainycode-1.0.1}/core/middleware/http_middleware.py +1 -1
  22. {rainycode-1.0.0 → rainycode-1.0.1}/core/start.py +2 -2
  23. {rainycode-1.0.0 → rainycode-1.0.1}/pyproject.toml +2 -1
  24. {rainycode-1.0.0 → rainycode-1.0.1}/rainycode.egg-info/PKG-INFO +2 -1
  25. rainycode-1.0.1/rainycode.egg-info/SOURCES.txt +36 -0
  26. {rainycode-1.0.0 → rainycode-1.0.1}/rainycode.egg-info/requires.txt +1 -0
  27. rainycode-1.0.1/rainycode.egg-info/top_level.txt +6 -0
  28. rainycode-1.0.0/README.md +0 -2
  29. rainycode-1.0.0/rainycode.egg-info/SOURCES.txt +0 -26
  30. rainycode-1.0.0/rainycode.egg-info/top_level.txt +0 -5
  31. {rainycode-1.0.0/common → rainycode-1.0.1/common_base}/aiorequests.py +0 -0
  32. {rainycode-1.0.0/common → rainycode-1.0.1/common_base}/consts.py +1 -1
  33. {rainycode-1.0.0/common → rainycode-1.0.1/common_base}/logging.py +0 -0
  34. {rainycode-1.0.0/common → rainycode-1.0.1/common_base}/response.py +0 -0
  35. {rainycode-1.0.0/common_model → rainycode-1.0.1/common_models}/base_model.py +0 -0
  36. {rainycode-1.0.0 → rainycode-1.0.1}/common_utlis/bcrypt_util.py +0 -0
  37. {rainycode-1.0.0 → rainycode-1.0.1}/common_utlis/captcha_util.py +0 -0
  38. {rainycode-1.0.0 → rainycode-1.0.1}/common_utlis/jwt_util.py +0 -0
  39. {rainycode-1.0.0 → rainycode-1.0.1}/common_utlis/snowflake_util.py +0 -0
  40. {rainycode-1.0.0 → rainycode-1.0.1}/rainycode.egg-info/dependency_links.txt +0 -0
  41. {rainycode-1.0.0 → rainycode-1.0.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rainycode
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: FastAPI base modules
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: build==1.4.0
@@ -10,6 +10,7 @@ Requires-Dist: passlib==1.7.4
10
10
  Requires-Dist: redis==7.2.1
11
11
  Requires-Dist: pyjwt==2.11.0
12
12
  Requires-Dist: pillow==12.1.1
13
+ Requires-Dist: pycryptodome==3.23.0
13
14
  Requires-Dist: uvicorn[standard]==0.41.0
14
15
  Requires-Dist: tortoise-orm[asyncmy]==1.1.5
15
16
  Requires-Dist: pydantic-settings==2.13.1
@@ -1,6 +1,6 @@
1
- from common.logging import logger
1
+ from common_base.logging import logger
2
2
  from typing import cast
3
- from common.response import ErrorReturn
3
+ from common_base.response import ErrorReturn
4
4
  from fastapi import HTTPException, Request, status
5
5
  from fastapi.exceptions import RequestValidationError, ResponseValidationError
6
6
  from starlette.responses import JSONResponse
@@ -1,9 +1,9 @@
1
1
  import json
2
2
  from fastapi import Depends, Request
3
3
  from fastapi.security import OAuth2PasswordBearer, SecurityScopes
4
- from common.consts import REDIS_PREFIX_BLOCKLIST, REDIS_PREFIX_USER_INFO
5
- from common.exception import CustomException
6
- from common_model.user_model import User
4
+ from common_base.consts import REDIS_PREFIX_BLOCKLIST, REDIS_PREFIX_USER_INFO
5
+ from common_base.exception import CustomException
6
+ from common_models.user_model import User
7
7
  from common_utlis.jwt_util import JwtUtil
8
8
  from core.databases.aioredis import AioRedis
9
9
 
@@ -1,5 +1,5 @@
1
1
  from tortoise import fields
2
- from common_model.base_model import UpdateModel
2
+ from common_models.base_model import UpdateModel
3
3
 
4
4
 
5
5
  class User(UpdateModel):
@@ -1,6 +1,6 @@
1
1
  from tortoise import fields
2
2
  from enum import Enum
3
- from common_model.base_model import CreateModel
3
+ from common_models.base_model import CreateModel
4
4
 
5
5
  class AppTypeEnum(str, Enum):
6
6
  MINI_PROGRAM = "mini_program"
@@ -0,0 +1,50 @@
1
+ from common_model.user_model import User
2
+ from fastapi import APIRouter, Security, Body, Depends
3
+ from common.response import SuccessReturn
4
+ from common_depend.auth_depend import login_require, oauth2_scheme
5
+ from common_servers.schemas.auth_schema import *
6
+ from common_servers.service.auth_svc import AuthService
7
+
8
+ router = APIRouter(prefix="/auth")
9
+
10
+ @router.post("/register", summary="用户注册")
11
+ async def register(user_in: UserRegister):
12
+ user = await AuthService.register(user_in)
13
+ return SuccessReturn(
14
+ data={"id": str(user.id), "username": user.username}, msg="注册成功"
15
+ )
16
+
17
+
18
+ @router.post("/login", response_model=TokenResponse, summary="用户密码登录")
19
+ async def login(user_in: UserLogin):
20
+ token_data = await AuthService.login(user_in)
21
+ return SuccessReturn(data=token_data, msg="登录成功")
22
+
23
+
24
+ @router.post("/refresh", response_model=TokenResponse, summary="刷新令牌")
25
+ async def refresh_token(refresh_token: str = Body(..., embed=True)):
26
+ token_data = await AuthService.refresh_token(refresh_token)
27
+ return SuccessReturn(data=token_data, msg="刷新成功")
28
+
29
+
30
+ @router.post("/logout", summary="登出")
31
+ async def logout(
32
+ token: str = Depends(oauth2_scheme),
33
+ refresh_token: str = Body(..., embed=True),
34
+ _: User = Security(login_require),
35
+ ):
36
+ await AuthService.logout(token, refresh_token)
37
+ return SuccessReturn(msg="登出成功")
38
+
39
+
40
+ @router.get("/me", response_model=UserInfo, summary="获取当前用户信息")
41
+ async def get_me(user: User = Security(login_require)):
42
+ return SuccessReturn(data=user)
43
+
44
+
45
+ @router.post(
46
+ "/login/wechat", response_model=TokenResponse, summary="微信登录(小程序/公众号)"
47
+ )
48
+ async def login_wechat(wechat_in: WechatLogin):
49
+ token_data = await AuthService.login_wechat(wechat_in)
50
+ return SuccessReturn(data=token_data, msg="登录成功")
@@ -0,0 +1,48 @@
1
+ from fastapi import APIRouter, Request, Query, Response
2
+ from common_servers.service.wechat_svc import WechatService
3
+
4
+ router = APIRouter(prefix="/wechat")
5
+
6
+
7
+ @router.get("/callback/{app_pk}", summary="微信回调验证")
8
+ async def verify_callback(
9
+ app_pk: int,
10
+ signature: str = Query(..., description="微信加密签名"),
11
+ timestamp: str = Query(..., description="时间戳"),
12
+ nonce: str = Query(..., description="随机数"),
13
+ echostr: str = Query(..., description="随机字符串"),
14
+ ):
15
+ """
16
+ 微信服务器配置验证接口
17
+ """
18
+ is_valid = await WechatService.verify_signature(app_pk, signature, timestamp, nonce)
19
+ if is_valid:
20
+ # 必须直接返回 echostr 字符串,不能包含 json 格式
21
+ return Response(content=echostr, media_type="text/plain")
22
+ return Response(content="fail", media_type="text/plain")
23
+
24
+
25
+ @router.post("/callback/{app_pk}", summary="微信消息回调")
26
+ async def handle_callback(
27
+ app_pk: int,
28
+ request: Request,
29
+ signature: str = Query(..., description="微信加密签名"),
30
+ timestamp: str = Query(..., description="时间戳"),
31
+ nonce: str = Query(..., description="随机数"),
32
+ openid: str = Query(None, description="用户OpenID"),
33
+ encrypt_type: str = Query(None, description="加密类型"),
34
+ msg_signature: str = Query(None, description="消息体签名"),
35
+ ):
36
+ """
37
+ 接收微信推送的消息和事件
38
+ """
39
+ # 获取原始 XML 数据
40
+ body = await request.body()
41
+ xml_data = body.decode("utf-8")
42
+
43
+ # 目前仅实现了明文模式,加密模式需要结合 msg_secret 解密
44
+ # 如果 encrypt_type == "aes",需要解密 (暂未实现,TODO)
45
+ result = await WechatService.handle_message(
46
+ app_pk, xml_data, signature, timestamp, nonce
47
+ )
48
+ return Response(content=result, media_type="application/xml")
@@ -0,0 +1,9 @@
1
+ from fastapi import APIRouter
2
+ from common_servers.api.auth_api import router as auth_router
3
+ from common_servers.api.wechat_api import router as wechat_router
4
+
5
+
6
+ base_router = APIRouter()
7
+
8
+ base_router.include_router(auth_router, tags=["认证模块"])
9
+ base_router.include_router(wechat_router, tags=["微信回调"])
File without changes
@@ -0,0 +1,43 @@
1
+ from pydantic import BaseModel, Field, EmailStr
2
+
3
+ class UserLogin(BaseModel):
4
+ """用户登录模型"""
5
+ username: str = Field(..., description="用户名")
6
+ password: str = Field(..., description="密码")
7
+
8
+ class UserRegister(BaseModel):
9
+ """用户注册模型"""
10
+ username: str = Field(..., description="用户名")
11
+ password: str = Field(..., min_length=6, description="密码")
12
+ email: str | None = Field(None, description="邮箱")
13
+ phone: str | None = Field(None, description="手机号")
14
+ nickname: str | None = Field(None, description="昵称")
15
+
16
+ class TokenResponse(BaseModel):
17
+ """Token响应模型"""
18
+ access_token: str = Field(..., description="访问令牌")
19
+ refresh_token: str = Field(..., description="刷新令牌")
20
+ token_type: str = Field("bearer", description="令牌类型")
21
+ expires_in: int = Field(..., description="过期时间(秒)")
22
+
23
+ class UserInfo(BaseModel):
24
+ """用户信息模型"""
25
+ id: int | str = Field(..., description="用户ID")
26
+ username: str = Field(..., description="用户名")
27
+ email: str | None = Field(None, description="邮箱")
28
+ phone: str | None = Field(None, description="手机号")
29
+ nickname: str | None = Field(None, description="昵称")
30
+ avatar: str | None = Field(None, description="头像")
31
+ is_active: bool = Field(..., description="是否激活")
32
+ is_superuser: bool = Field(..., description="是否超级管理员")
33
+ create_time: str | None = Field(None, description="创建时间")
34
+
35
+ class Config:
36
+ from_attributes = True
37
+
38
+ class WechatLogin(BaseModel):
39
+ """微信登录模型"""
40
+ code: str = Field(..., description="微信授权Code")
41
+ app_id: str = Field(..., description="微信AppID (如 wx...)")
42
+ encrypted_data: str | None = Field(default=None, description="加密数据(小程序一键登录用)")
43
+ iv: str | None = Field(default=None, description="加密算法的初始向量")
@@ -0,0 +1,131 @@
1
+ from datetime import datetime, timedelta
2
+ import uuid
3
+ from common.consts import REDIS_PREFIX_BLOCKLIST, REDIS_PREFIX_REFRESH_TOKEN
4
+ from common.exception import CustomException
5
+ from common_model.user_model import User
6
+ from common_utlis.bcrypt_util import PwdUtil
7
+ from common_utlis.jwt_util import JwtUtil
8
+ from core.databases.aioredis import AioRedis
9
+ from common_servers.schemas.auth_schema import UserRegister, UserLogin, WechatLogin
10
+ from common_servers.strategies.wechat_stragegy import WechatAuthStrategy
11
+ from common_servers.strategies.password_strategy import PasswordStrategy
12
+ from tortoise.transactions import atomic
13
+ from tortoise.expressions import Q
14
+ from core.base_config import base_config
15
+
16
+ class AuthService:
17
+ """认证服务逻辑"""
18
+
19
+ @classmethod
20
+ @atomic('main')
21
+ async def register(cls, user_in: UserRegister) -> User:
22
+ """
23
+ 用户注册
24
+ """
25
+ # 合并检查用户名、邮箱、手机号是否存在
26
+ filters = Q(username=user_in.username)
27
+ if user_in.email:
28
+ filters = filters | Q(email=user_in.email)
29
+ if user_in.phone:
30
+ filters = filters | Q(phone=user_in.phone)
31
+
32
+ existing_user = await User.filter(filters).first()
33
+
34
+ if existing_user:
35
+ if existing_user.username == user_in.username:
36
+ raise CustomException(msg="用户名已存在",)
37
+ if user_in.email and existing_user.email == user_in.email:
38
+ raise CustomException(msg="邮箱已存在")
39
+ if user_in.phone and existing_user.phone == user_in.phone:
40
+ raise CustomException(msg="手机号已存在")
41
+
42
+ # 创建用户
43
+ user = await User.create(
44
+ username=user_in.username,
45
+ password=PwdUtil.set_password_hash(user_in.password),
46
+ email=user_in.email,
47
+ phone=user_in.phone,
48
+ nickname=user_in.nickname
49
+ )
50
+ return user
51
+
52
+ @classmethod
53
+ async def login(cls, user_in: UserLogin) -> dict:
54
+ """
55
+ 用户密码登录
56
+ """
57
+ strategy = PasswordStrategy()
58
+ user = await strategy.authenticate(user_in)
59
+ return await cls._create_tokens(user)
60
+
61
+ @classmethod
62
+ async def login_wechat(cls, wechat_in: WechatLogin) -> dict:
63
+ """
64
+ 微信登录
65
+ """
66
+ strategy = WechatAuthStrategy()
67
+ user = await strategy.authenticate(wechat_in)
68
+ return await cls._create_tokens(user)
69
+
70
+ @classmethod
71
+ async def refresh_token(cls, refresh_token: str) -> dict:
72
+ """
73
+ 刷新 Token
74
+ """
75
+ user_id = await AioRedis.get(f"{REDIS_PREFIX_REFRESH_TOKEN}{refresh_token}")
76
+ user_id = int(user_id) if user_id else None
77
+ if not user_id:
78
+ raise CustomException(msg="无效或已过期的刷新令牌")
79
+
80
+ user = await User.get_or_none(id=user_id)
81
+ if not user or not user.is_active:
82
+ raise CustomException(msg="用户不存在或已被禁用")
83
+
84
+ # Token 轮换:删除旧的,生成新的
85
+ await AioRedis.delete(f"{REDIS_PREFIX_REFRESH_TOKEN}{refresh_token}")
86
+ return await cls._create_tokens(user)
87
+
88
+ @classmethod
89
+ async def logout(cls, access_token: str, refresh_token: str):
90
+ """
91
+ 登出
92
+ 1. 将 Access Token 加入黑名单
93
+ 2. 删除 Refresh Token
94
+ """
95
+ # 1. 处理 Access Token 黑名单
96
+ payload = JwtUtil.decode_token(access_token)
97
+ if payload:
98
+ exp_timestamp = payload.get("exp")
99
+ if exp_timestamp:
100
+ now_timestamp = datetime.now().timestamp()
101
+ expire_seconds = int(exp_timestamp - now_timestamp)
102
+ if expire_seconds > 0:
103
+ # 添加 Token 到黑名单
104
+ await AioRedis.set(f"{REDIS_PREFIX_BLOCKLIST}{access_token}", "1", ex=expire_seconds)
105
+
106
+ # 2. 删除 Refresh Token
107
+ if refresh_token:
108
+ await AioRedis.delete(f"{REDIS_PREFIX_REFRESH_TOKEN}{refresh_token}")
109
+
110
+ @classmethod
111
+ async def _create_tokens(cls, user: User) -> dict:
112
+ """
113
+ 生成双Token并缓存用户信息
114
+ """
115
+ # Access Token
116
+ token_data = {"sub": str(user.id), "username": user.username}
117
+ # 显式传递配置中的过期时间
118
+ expires_delta = timedelta(minutes=base_config.access_token_expire_minutes)
119
+ access_token = JwtUtil.create_access_token(token_data, expires_delta=expires_delta)
120
+
121
+ # Refresh Token
122
+ refresh_token = str(uuid.uuid4())
123
+ expire_seconds = base_config.refresh_token_expire_days * 24 * 60 * 60
124
+ await AioRedis.set(f"{REDIS_PREFIX_REFRESH_TOKEN}{refresh_token}", str(user.id), ex=expire_seconds)
125
+
126
+ return {
127
+ "access_token": access_token,
128
+ "refresh_token": refresh_token,
129
+ "token_type": "bearer",
130
+ "expires_in": base_config.access_token_expire_minutes * 60,
131
+ }
@@ -0,0 +1,78 @@
1
+ import time
2
+ import hashlib
3
+ import xml.etree.ElementTree as ET
4
+ from common.exception import CustomException
5
+ from common.logging import logger
6
+ from common_model.wechat_model import WechatApp
7
+
8
+
9
+ class WechatService:
10
+ @staticmethod
11
+ async def verify_signature(app_pk: int, signature: str, timestamp: str, nonce: str) -> bool:
12
+ """
13
+ 验证微信签名
14
+ """
15
+ app = await WechatApp.get_or_none(id=app_pk)
16
+ if not app:
17
+ raise CustomException(msg="微信应用不存在")
18
+
19
+ token = app.token
20
+ if not token:
21
+ logger.error(f"App {app_pk} 未配置Callback Token")
22
+ # 如果未配置Token,无法验证,视为失败
23
+ return False
24
+
25
+ try:
26
+ tmp_list = [token, timestamp, nonce]
27
+ tmp_list.sort()
28
+ tmp_str = "".join(tmp_list)
29
+ hash_str = hashlib.sha1(tmp_str.encode("utf-8")).hexdigest()
30
+ return hash_str == signature
31
+ except Exception as e:
32
+ logger.error(f"签名验证异常: {e}")
33
+ return False
34
+
35
+ @staticmethod
36
+ async def handle_message(app_pk: int, xml_data: str, signature: str, timestamp: str, nonce: str) -> str:
37
+ """
38
+ 处理微信回调消息
39
+ """
40
+ # 再次验证签名确保安全
41
+ if not await WechatService.verify_signature(app_pk, signature, timestamp, nonce):
42
+ logger.warning(f"消息验证签名失败:{app_pk},签名={signature},时间戳={timestamp},随机数={nonce}")
43
+ return "fail"
44
+
45
+ try:
46
+ root = ET.fromstring(xml_data)
47
+ msg_type = root.findtext("MsgType")
48
+ to_user = root.findtext("ToUserName")
49
+ from_user = root.findtext("FromUserName")
50
+ if from_user is None or to_user is None:
51
+ logger.error(f"消息格式错误:缺少FromUserName或ToUserName")
52
+ return "fail"
53
+
54
+ # 简单的事件分发
55
+ if msg_type == "event":
56
+ event = root.findtext("Event")
57
+ if event == "subscribe":
58
+ # 可以在此处添加用户关注后的业务逻辑,如保存用户,发放优惠券等
59
+ logger.info(f"用户 {from_user} 关注了应用 {app_pk}")
60
+ return WechatService._reply_text(from_user, to_user, "欢迎关注!")
61
+ elif event == "unsubscribe":
62
+ logger.info(f"用户 {from_user} 取消关注应用 {app_pk}")
63
+
64
+ return "success"
65
+ except Exception as e:
66
+ logger.error(f"处理微信消息异常: {e}")
67
+ return "fail"
68
+
69
+ @staticmethod
70
+ def _reply_text(to_user: str, from_user: str, content: str) -> str:
71
+ create_time = int(time.time())
72
+ return f"""<xml>
73
+ <ToUserName><![CDATA[{to_user}]]></ToUserName>
74
+ <FromUserName><![CDATA[{from_user}]]></FromUserName>
75
+ <CreateTime>{create_time}</CreateTime>
76
+ <MsgType><![CDATA[text]]></MsgType>
77
+ <Content><![CDATA[{content}]]></Content>
78
+ </xml>"""
@@ -0,0 +1,9 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+ from common_model.user_model import User
4
+
5
+ class IAuthStrategy(ABC):
6
+ @abstractmethod
7
+ async def authenticate(self, data: Any) -> User:
8
+ """认证方法,成功返回 User,失败抛出异常"""
9
+ pass
@@ -0,0 +1,34 @@
1
+ from datetime import datetime
2
+
3
+ from common.consts import REDIS_PREFIX_USER_INFO
4
+ from core.databases.aioredis import AioRedis
5
+ from common_model.user_model import User
6
+ from common_utlis.bcrypt_util import PwdUtil
7
+ from common.exception import CustomException
8
+ from common_servers.schemas.auth_schema import UserLogin
9
+ from common_servers.strategies.base_strategy import IAuthStrategy
10
+
11
+
12
+ class PasswordStrategy(IAuthStrategy):
13
+ """
14
+ 用户名密码登录策略
15
+ """
16
+ async def authenticate(self, data: UserLogin) -> User:
17
+ user = await User.get_or_none(username=data.username)
18
+ if not user:
19
+ # 统一错误提示,防止用户名枚举
20
+ raise CustomException(msg="用户名或密码错误")
21
+
22
+ if not PwdUtil.verify_password(data.password, user.password):
23
+ raise CustomException(msg="用户名或密码错误")
24
+
25
+ if not user.is_active:
26
+ raise CustomException(msg="用户已被禁用")
27
+
28
+ # 更新最后登录时间
29
+ user.last_login = datetime.now()
30
+ await user.save(update_fields=['last_login'])
31
+
32
+ # 清除缓存,确保下次获取的是最新信息
33
+ await AioRedis.delete(f"{REDIS_PREFIX_USER_INFO}{user.id}")
34
+ return user
@@ -0,0 +1,141 @@
1
+ import random
2
+ from common_model.user_model import User
3
+ from common_model.wechat_model import WechatApp, WechatUser, AppTypeEnum
4
+ from common_servers.schemas.auth_schema import WechatLogin
5
+ from common_utlis.wechat_util import WechatUtil
6
+ from common.exception import CustomException
7
+ from common_servers.strategies.base_strategy import IAuthStrategy
8
+
9
+
10
+ from tortoise.transactions import atomic
11
+
12
+ class WechatAuthStrategy(IAuthStrategy):
13
+ """
14
+ 微信登录策略 (支持小程序和公众号)
15
+ """
16
+ async def authenticate(self, data: WechatLogin) -> User:
17
+ # 1. 获取应用配置
18
+ app_config = await WechatApp.get_or_none(app_id=data.app_id)
19
+ if not app_config:
20
+ raise CustomException(msg="无效的微信应用配置")
21
+
22
+ openid = None
23
+ unionid = None
24
+ session_key = None
25
+
26
+ # 2. 根据类型执行不同流程
27
+ if app_config.app_type == AppTypeEnum.MINI_PROGRAM:
28
+ # 小程序流程
29
+ if data.encrypted_data and data.iv:
30
+ # 一键登录流程 (解密手机号)
31
+ # 前端必须传 code 来换取 session_key (或者确保 redis 中有)
32
+ # 这里强制要求传 code,保证 session_key 是最新的
33
+ wx_res = await WechatUtil.code2session(app_config.app_id, app_config.app_secret, data.code)
34
+ session_key = wx_res.get("session_key")
35
+ openid = wx_res.get("openid")
36
+ unionid = wx_res.get("unionid")
37
+
38
+ if not session_key or not openid:
39
+ raise CustomException(msg="微信登录异常: 缺少必要信息")
40
+
41
+ # 解密手机号
42
+ phone_info = WechatUtil.decrypt_data(session_key, data.encrypted_data, data.iv, app_config.app_id)
43
+ phone_number = phone_info.get("phoneNumber")
44
+
45
+ # 自动注册/绑定逻辑
46
+ return await self._handle_bind_or_register(openid, unionid, app_config, phone=phone_number, session_key=session_key)
47
+
48
+ else:
49
+ # 静默登录流程
50
+ wx_res = await WechatUtil.code2session(app_config.app_id, app_config.app_secret, data.code)
51
+ openid = wx_res.get("openid")
52
+ unionid = wx_res.get("unionid")
53
+ session_key = wx_res.get("session_key")
54
+
55
+ if not openid or not session_key:
56
+ raise CustomException(msg="微信登录异常: 缺少必要信息")
57
+
58
+ # 自动注册或登录 (不再强制要求手机号)
59
+ return await self._handle_bind_or_register(openid, unionid, app_config, session_key=session_key)
60
+
61
+ elif app_config.app_type == AppTypeEnum.OFFICIAL_ACCOUNT:
62
+ # 公众号流程
63
+ # 假设 code 是 oauth code
64
+ wx_res = await WechatUtil.get_oauth_access_token(app_config.app_id, app_config.app_secret, data.code)
65
+ openid = wx_res.get("openid")
66
+ unionid = wx_res.get("unionid")
67
+ access_token = wx_res.get("access_token")
68
+
69
+ if not openid or not access_token:
70
+ raise CustomException(msg="微信授权异常: 缺少必要信息")
71
+
72
+ # 检查是否已绑定
73
+ wechat_user = await WechatUser.get_or_none(openid=openid, wechat_app=app_config).select_related("user")
74
+ if wechat_user:
75
+ return wechat_user.user
76
+
77
+ # 未绑定,获取用户信息并自动注册/绑定
78
+ # 如果是 snsapi_base,可能没有 access_token (获取不到 userinfo),这里假设 snsapi_userinfo
79
+ # 如果 scope 是 snsapi_base,这里只能拿到 openid。
80
+ # 策略:如果是 snsapi_base 且未绑定,返回 428,前端再次请求(scope=snsapi_userinfo)
81
+ scope = wx_res.get("scope", "")
82
+ if "snsapi_userinfo" not in scope and not wechat_user:
83
+ raise CustomException(msg="需授权获取信息", data={"openid": openid})
84
+
85
+ user_info = await WechatUtil.get_user_info(access_token, openid)
86
+ return await self._handle_bind_or_register(openid, unionid, app_config, nickname=user_info.get("nickname"), avatar=user_info.get("headimgurl"))
87
+
88
+ else:
89
+ raise CustomException(msg="不支持的应用类型")
90
+
91
+ @atomic('main')
92
+ async def _handle_bind_or_register(self, openid, unionid, app_config, phone=None, session_key=None, nickname=None, avatar=None) -> User:
93
+ # 1. 检查是否已有 WechatUser
94
+ wechat_user = await WechatUser.get_or_none(openid=openid, wechat_app=app_config).select_related("user")
95
+ if wechat_user:
96
+ # 已存在关联,更新信息
97
+ if session_key: wechat_user.session_key = session_key
98
+ if nickname: wechat_user.nickname = nickname
99
+ if avatar: wechat_user.avatar = avatar
100
+ await wechat_user.save()
101
+ return wechat_user.user
102
+
103
+ # 2. 检查 User 是否存在 (通过 phone 或 unionid)
104
+ user = None
105
+ if phone:
106
+ user = await User.get_or_none(phone=phone)
107
+
108
+ # 如果有 UnionID,检查其他应用下的 WechatUser 是否关联了 User
109
+ if not user and unionid:
110
+ other_wechat_user = await WechatUser.filter(unionid=unionid).exclude(wechat_app=app_config).first().select_related("user")
111
+ if other_wechat_user:
112
+ user = other_wechat_user.user
113
+
114
+ # 3. 创建 User
115
+ if not user:
116
+ # 创建新用户
117
+ username = phone if phone else f"wx_{openid[:8]}"
118
+ # 避免用户名冲突
119
+ while await User.exists(username=username):
120
+ username = f"{username}_{random.randint(1000,9999)}"
121
+
122
+ user = await User.create(
123
+ username=username,
124
+ password="", # 无密码
125
+ phone=phone,
126
+ nickname=nickname or f"用户{username[-4:]}",
127
+ avatar=avatar
128
+ )
129
+
130
+ # 4. 创建 WechatUser 关联
131
+ await WechatUser.create(
132
+ user=user,
133
+ wechat_app=app_config,
134
+ openid=openid,
135
+ unionid=unionid,
136
+ session_key=session_key,
137
+ nickname=nickname,
138
+ avatar=avatar
139
+ )
140
+
141
+ return user
@@ -1,6 +1,5 @@
1
1
  import re
2
-
3
- from common import aiorequests
2
+ from common_base import aiorequests
4
3
 
5
4
 
6
5
  class IpUtil:
@@ -0,0 +1,95 @@
1
+ from common import aiorequests
2
+ from common.exception import CustomException
3
+ from common.logging import logger
4
+ from Crypto.Cipher import AES
5
+ import base64
6
+ import json
7
+
8
+ class WechatUtil:
9
+
10
+ @classmethod
11
+ async def code2session(cls, appid: str, secret: str, code: str) -> dict:
12
+ """
13
+ 小程序登录凭证校验
14
+ https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
15
+ """
16
+ url = "https://api.weixin.qq.com/sns/jscode2session"
17
+ params = {
18
+ "appid": appid,
19
+ "secret": secret,
20
+ "js_code": code,
21
+ "grant_type": "authorization_code"
22
+ }
23
+ try:
24
+ data = await aiorequests.get(url, params=params)
25
+ if "errcode" in data and data["errcode"] != 0:
26
+ logger.error(f"Wechat code2session error: {data}")
27
+ raise CustomException(msg=f"微信登录失败: {data.get('errmsg')}")
28
+ return data
29
+ except Exception as e:
30
+ if isinstance(e, CustomException):
31
+ raise e
32
+ logger.error(f"Wechat API request error: {e}")
33
+ raise CustomException(msg="微信服务暂时不可用")
34
+
35
+ @classmethod
36
+ async def get_oauth_access_token(cls, appid: str, secret: str, code: str) -> dict:
37
+ """
38
+ 公众号通过code获取access_token
39
+ """
40
+ url = "https://api.weixin.qq.com/sns/oauth2/access_token"
41
+ params = {
42
+ "appid": appid,
43
+ "secret": secret,
44
+ "code": code,
45
+ "grant_type": "authorization_code"
46
+ }
47
+ data = await aiorequests.get(url, params=params)
48
+ if "errcode" in data and data["errcode"] != 0:
49
+ raise CustomException(msg=f"微信授权失败: {data.get('errmsg')}")
50
+ return data
51
+
52
+ @classmethod
53
+ async def get_user_info(cls, access_token: str, openid: str) -> dict:
54
+ """
55
+ 公众号获取用户信息
56
+ """
57
+ url = "https://api.weixin.qq.com/sns/userinfo"
58
+ params = {
59
+ "access_token": access_token,
60
+ "openid": openid,
61
+ "lang": "zh_CN"
62
+ }
63
+ data = await aiorequests.get(url, params=params)
64
+ if "errcode" in data and data["errcode"] != 0:
65
+ raise CustomException(msg=f"获取微信用户信息失败: {data.get('errmsg')}")
66
+ return data
67
+
68
+ @classmethod
69
+ def decrypt_data(
70
+ cls, session_key: str, encrypted_data: str, iv: str, appid: str
71
+ ) -> dict:
72
+ """
73
+ 小程序解密用户信息/手机号
74
+ """
75
+ try:
76
+ session_key_b = base64.b64decode(session_key)
77
+ encrypted_data_b = base64.b64decode(encrypted_data)
78
+ iv_b = base64.b64decode(iv)
79
+
80
+ cipher = AES.new(session_key_b, AES.MODE_CBC, iv_b)
81
+ decrypted = cipher.decrypt(encrypted_data_b)
82
+
83
+ # 去除PKCS#7填充
84
+ unpad = lambda s: s[:-ord(s[len(s)-1:])]
85
+ decrypted_json = unpad(decrypted)
86
+
87
+ data = json.loads(decrypted_json)
88
+
89
+ if data['watermark']['appid'] != appid:
90
+ raise CustomException(msg="Invalid Buffer")
91
+
92
+ return data
93
+ except Exception as e:
94
+ logger.error(f"Decrypt wechat data failed: {e}")
95
+ raise CustomException(msg="微信数据解密失败")
@@ -6,6 +6,7 @@ class BaseConfig(BaseSettings):
6
6
  app_title: str = ''
7
7
  app_name: str = ''
8
8
 
9
+ # 数据库
9
10
  redis_url: str = ''
10
11
  mysql_url: str = ''
11
12
 
@@ -18,6 +19,11 @@ class BaseConfig(BaseSettings):
18
19
  # jwt密钥
19
20
  jwt_secret_key: str = "bgb0tnl9d58+6n-6h-ea&u^1#s0ccp!794=krylacjq75vzps$"
20
21
 
22
+ # Access Token 有效期
23
+ access_token_expire_minutes: int = 30
24
+ # Refresh Token 有效期
25
+ refresh_token_expire_days: int = 30
26
+
21
27
  model_config = SettingsConfigDict(
22
28
  env_file=('envs/dev.env', 'envs/pro.env'),
23
29
  env_file_encoding="utf-8",
@@ -7,11 +7,11 @@ from tortoise.models import Model
7
7
  from tortoise.contrib.fastapi import RegisterTortoise
8
8
  from tortoise.queryset import QuerySetSingle, QuerySet
9
9
  from contextlib import asynccontextmanager
10
-
11
10
  from core.base_config import base_config
12
11
 
13
12
  db_models = [
14
13
  'aerich.models',
14
+ 'common_models',
15
15
  'models'
16
16
  ]
17
17
 
@@ -5,7 +5,7 @@ from typing import Optional, Union, Callable, Mapping
5
5
  from redis.asyncio import Redis
6
6
  from redis import asyncio as aioredis
7
7
  from redis.typing import KeyT, EncodableT, ExpiryT, ZScoreBoundT, AnyKeyT
8
- from common.logging import log_exc
8
+ from common_base.logging import log_exc
9
9
  from core.base_config import base_config
10
10
 
11
11
 
@@ -3,7 +3,7 @@ from fastapi import Request
3
3
  from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
4
4
  from starlette.types import ASGIApp
5
5
  from starlette.responses import Response
6
- from common.logging import logger
6
+ from common_base.logging import logger
7
7
 
8
8
  class RequestHttpMiddleware(BaseHTTPMiddleware):
9
9
  def __init__(self, app: ASGIApp) -> None:
@@ -5,8 +5,8 @@ from fastapi import FastAPI, HTTPException
5
5
  from fastapi.exceptions import RequestValidationError, ResponseValidationError
6
6
  from starlette.middleware.cors import CORSMiddleware
7
7
  from starlette.staticfiles import StaticFiles
8
- from common.exception import AllExceptionHandler, CustomException, CustomExceptionHandler, HttpExceptionHandler, RequestValidationHandle, ResponseValidationHandle, ValueExceptionHandler
9
- from common.logging import logger
8
+ from common_base.exception import AllExceptionHandler, CustomException, CustomExceptionHandler, HttpExceptionHandler, RequestValidationHandle, ResponseValidationHandle, ValueExceptionHandler
9
+ from common_base.logging import logger
10
10
  from core.base_config import BaseConfig, base_config
11
11
  from core.databases.aiodb import AioDb
12
12
  from core.middleware.http_middleware import RequestHttpMiddleware
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rainycode"
7
- version = "1.0.0"
7
+ version = "1.0.1"
8
8
  description = "FastAPI base modules"
9
9
  requires-python = ">=3.8"
10
10
 
@@ -16,6 +16,7 @@ dependencies = [
16
16
  "redis == 7.2.1",
17
17
  "pyjwt == 2.11.0",
18
18
  "pillow == 12.1.1",
19
+ "pycryptodome == 3.23.0",
19
20
  "uvicorn[standard] == 0.41.0",
20
21
  "tortoise-orm[asyncmy] == 1.1.5",
21
22
  "pydantic-settings == 2.13.1",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rainycode
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: FastAPI base modules
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: build==1.4.0
@@ -10,6 +10,7 @@ Requires-Dist: passlib==1.7.4
10
10
  Requires-Dist: redis==7.2.1
11
11
  Requires-Dist: pyjwt==2.11.0
12
12
  Requires-Dist: pillow==12.1.1
13
+ Requires-Dist: pycryptodome==3.23.0
13
14
  Requires-Dist: uvicorn[standard]==0.41.0
14
15
  Requires-Dist: tortoise-orm[asyncmy]==1.1.5
15
16
  Requires-Dist: pydantic-settings==2.13.1
@@ -0,0 +1,36 @@
1
+ pyproject.toml
2
+ common_base/aiorequests.py
3
+ common_base/consts.py
4
+ common_base/exception.py
5
+ common_base/logging.py
6
+ common_base/response.py
7
+ common_depends/auth_depend.py
8
+ common_models/base_model.py
9
+ common_models/user_model.py
10
+ common_models/wechat_model.py
11
+ common_servers/router.py
12
+ common_servers/api/auth_api.py
13
+ common_servers/api/wechat_api.py
14
+ common_servers/schemas/__init__.py
15
+ common_servers/schemas/auth_schema.py
16
+ common_servers/service/auth_svc.py
17
+ common_servers/service/wechat_svc.py
18
+ common_servers/strategies/base_strategy.py
19
+ common_servers/strategies/password_strategy.py
20
+ common_servers/strategies/wechat_stragegy.py
21
+ common_utlis/bcrypt_util.py
22
+ common_utlis/captcha_util.py
23
+ common_utlis/ip_util.py
24
+ common_utlis/jwt_util.py
25
+ common_utlis/snowflake_util.py
26
+ common_utlis/wechat_util.py
27
+ core/base_config.py
28
+ core/start.py
29
+ core/databases/aiodb.py
30
+ core/databases/aioredis.py
31
+ core/middleware/http_middleware.py
32
+ rainycode.egg-info/PKG-INFO
33
+ rainycode.egg-info/SOURCES.txt
34
+ rainycode.egg-info/dependency_links.txt
35
+ rainycode.egg-info/requires.txt
36
+ rainycode.egg-info/top_level.txt
@@ -5,6 +5,7 @@ passlib==1.7.4
5
5
  redis==7.2.1
6
6
  pyjwt==2.11.0
7
7
  pillow==12.1.1
8
+ pycryptodome==3.23.0
8
9
  uvicorn[standard]==0.41.0
9
10
  tortoise-orm[asyncmy]==1.1.5
10
11
  pydantic-settings==2.13.1
@@ -0,0 +1,6 @@
1
+ common_base
2
+ common_depends
3
+ common_models
4
+ common_servers
5
+ common_utlis
6
+ core
rainycode-1.0.0/README.md DELETED
@@ -1,2 +0,0 @@
1
- pip uninstall ../fastapi_base/fastapi_base-1.0-py3-none-any.whl -y
2
- pip install ../fastapi_base/fastapi_base-1.0-py3-none-any.whl
@@ -1,26 +0,0 @@
1
- README.md
2
- pyproject.toml
3
- common/aiorequests.py
4
- common/consts.py
5
- common/exception.py
6
- common/logging.py
7
- common/response.py
8
- common_depend/auth_depend.py
9
- common_model/base_model.py
10
- common_model/user_model.py
11
- common_model/wechat_model.py
12
- common_utlis/bcrypt_util.py
13
- common_utlis/captcha_util.py
14
- common_utlis/ip_util.py
15
- common_utlis/jwt_util.py
16
- common_utlis/snowflake_util.py
17
- core/base_config.py
18
- core/start.py
19
- core/databases/aiodb.py
20
- core/databases/aioredis.py
21
- core/middleware/http_middleware.py
22
- rainycode.egg-info/PKG-INFO
23
- rainycode.egg-info/SOURCES.txt
24
- rainycode.egg-info/dependency_links.txt
25
- rainycode.egg-info/requires.txt
26
- rainycode.egg-info/top_level.txt
@@ -1,5 +0,0 @@
1
- common
2
- common_depend
3
- common_model
4
- common_utlis
5
- core
@@ -1,3 +1,3 @@
1
1
  REDIS_PREFIX_REFRESH_TOKEN: str = "auth:refresh:"
2
- REDIS_PREFIX_USER_INFO: str = "user:info:"
3
2
  REDIS_PREFIX_BLOCKLIST: str = "auth:blocklist:"
3
+ REDIS_PREFIX_USER_INFO: str = "user:info:"
File without changes