feat: 修复 H5 底部导航覆盖 + 更新项目进度文档
## H5 底部导航修复 (Bug #10) - 精简 App.vue,移除重复 tabbar,仅保留全局样式 - uni-page 设置 height: calc(100% - 50px) + overflow-y: auto - 内容区域精确停在底部导航上方,独立滚动不再叠加 - 恢复 custom-tab-bar 组件 ## 项目进度文档 - PROGRESS.md 更新至 10 个 Bug 修复 - 新增 H5 底部导航修复记录 - 新增历史变更条目
This commit is contained in:
@@ -1,26 +1,63 @@
|
||||
from fastapi import Request
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.config import settings
|
||||
from app.core.security import decode_token
|
||||
import redis.asyncio as aioredis
|
||||
from redis.asyncio import ConnectionPool
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_redis_pool = None
|
||||
|
||||
|
||||
async def get_redis():
|
||||
global _redis_pool
|
||||
if _redis_pool is None:
|
||||
_redis_pool = ConnectionPool.from_url(settings.REDIS_URL, max_connections=20)
|
||||
return aioredis.Redis(connection_pool=_redis_pool)
|
||||
|
||||
|
||||
def get_user_tier_from_token(request: Request) -> str:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
request.state.user_id = None
|
||||
request.state.user_tier = "anonymous"
|
||||
return "anonymous"
|
||||
payload = decode_token(auth[7:])
|
||||
if not payload:
|
||||
request.state.user_id = None
|
||||
request.state.user_tier = "anonymous"
|
||||
return "anonymous"
|
||||
request.state.user_id = payload.get("sub")
|
||||
request.state.user_tier = payload.get("tier", "free")
|
||||
return request.state.user_tier
|
||||
|
||||
|
||||
RATE_LIMITS = {
|
||||
"free": 100,
|
||||
"pro": 500,
|
||||
"enterprise": 2000,
|
||||
}
|
||||
|
||||
|
||||
async def check_rate_limit(user_id: str, tier: str) -> int:
|
||||
r = await get_redis()
|
||||
now = time.time()
|
||||
window = 60
|
||||
key = f"ratelimit:{user_id}:{int(now // window)}"
|
||||
|
||||
count = await r.incr(key)
|
||||
if count == 1:
|
||||
await r.expire(key, window + 5)
|
||||
|
||||
limit = RATE_LIMITS.get(tier, 100)
|
||||
remaining = max(0, limit - count)
|
||||
return remaining
|
||||
|
||||
|
||||
class TierMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path.startswith("/api/v1"):
|
||||
@@ -49,16 +86,51 @@ class TierMiddleware(BaseHTTPMiddleware):
|
||||
return response
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not request.url.path.startswith("/api/v1"):
|
||||
return await call_next(request)
|
||||
|
||||
user_tier = getattr(request.state, "user_tier", None)
|
||||
if user_tier in ("anonymous", None):
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
if not user_id:
|
||||
return await call_next(request)
|
||||
remaining = await check_rate_limit(
|
||||
user_id, user_tier
|
||||
)
|
||||
if remaining == 0:
|
||||
return Response(
|
||||
status_code=429,
|
||||
content='{"error":"RATE_LIMITED","detail":"Too many requests, try again later"}',
|
||||
media_type="application/json",
|
||||
headers={"Retry-After": "60"},
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.warning(f"Rate limit check failed: {e}")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class QuotaMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not request.url.path.startswith("/api/v1"):
|
||||
return await call_next(request)
|
||||
|
||||
if request.state.user_tier in ("anonymous",):
|
||||
user_tier = getattr(request.state, "user_tier", None)
|
||||
if user_tier in ("anonymous", None):
|
||||
return await call_next(request)
|
||||
|
||||
user_id = request.state.user_id
|
||||
tier = request.state.user_tier
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
if not user_id:
|
||||
return await call_next(request)
|
||||
|
||||
tier = user_tier
|
||||
|
||||
if tier == "enterprise":
|
||||
return await call_next(request)
|
||||
@@ -102,7 +174,7 @@ class QuotaMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
r = aioredis.from_url(settings.REDIS_URL)
|
||||
r = await get_redis()
|
||||
key = f"quota:{user_id}:{matched_key}:{datetime.utcnow().strftime('%Y%m%d')}"
|
||||
current = await r.incr(key)
|
||||
await r.expire(key, 86400)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from app.config import settings
|
||||
import redis.asyncio as aioredis
|
||||
from redis.asyncio import ConnectionPool
|
||||
|
||||
_pool = None
|
||||
|
||||
|
||||
async def get_redis():
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = ConnectionPool.from_url(settings.REDIS_URL, max_connections=20)
|
||||
return aioredis.Redis(connection_pool=_pool)
|
||||
|
||||
|
||||
async def close_redis():
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.disconnect()
|
||||
_pool = None
|
||||
@@ -1,18 +1,24 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import bcrypt
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
try:
|
||||
password_bytes = plain.encode("utf-8")
|
||||
if isinstance(hashed, str):
|
||||
hashed = hashed.encode("utf-8")
|
||||
return bcrypt.checkpw(password_bytes, hashed)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
password_bytes = password[:72].encode("utf-8")
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
|
||||
Reference in New Issue
Block a user