up
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""业务错误码(对齐《03-接口文档》1.3 通用错误码)"""
|
||||
|
||||
# 成功
|
||||
OK = 0
|
||||
|
||||
# 1xxx 通用
|
||||
PARAM_ERROR = 1001 # 参数错误
|
||||
UNAUTHORIZED = 1002 # 未登录或 Token 无效/过期
|
||||
FORBIDDEN = 1003 # 无权限(功能需登录/已被封禁)
|
||||
CAPTCHA_ERROR = 1004 # 验证码错误或已过期
|
||||
RATE_LIMITED = 1005 # 请求过于频繁
|
||||
NOT_FOUND = 1006 # 资源不存在
|
||||
ACCOUNT_DEACTIVATED = 1009 # 账号已注销(冻结期内登录可撤销)
|
||||
|
||||
# 2xxx 业务
|
||||
POINTS_NOT_ENOUGH = 2001 # 积分不足
|
||||
FREE_LIMIT_EXCEEDED = 2002 # 今日免费观看次数已用完(游客需登录)
|
||||
VIDEO_NOT_AVAILABLE = 2003 # 视频不存在或未发布
|
||||
LIFETIME_MEMBER = 2004 # 已是终身会员,无需再开通
|
||||
INVITE_CODE_INVALID = 2101 # 邀请码无效或不可绑定
|
||||
ASSET_NOT_ENOUGH = 2102 # 资产余额不足
|
||||
WITHDRAW_TOO_LOW = 2103 # 低于最低提现额度
|
||||
WITHDRAW_PENDING = 2104 # 已有审核中的提现申请
|
||||
SENSITIVE_WORD = 2105 # 内容包含敏感词
|
||||
REPORT_DUPLICATED = 2106 # 请勿重复举报
|
||||
NOT_AUTHOR = 2107 # 无权操作(非作者本人)
|
||||
|
||||
# 3xxx 支付
|
||||
ORDER_NOT_FOUND = 3001 # 订单不存在
|
||||
ORDER_STATUS_ERROR = 3002 # 订单状态异常
|
||||
AMOUNT_MISMATCH = 3003 # 支付金额不匹配
|
||||
|
||||
# 4xxx 后台
|
||||
ADMIN_FORBIDDEN = 4001 # 无管理员权限或角色不允许该操作
|
||||
REASON_REQUIRED = 4002 # 该操作必须填写原因
|
||||
|
||||
# 5xxx 服务端
|
||||
INTERNAL_ERROR = 5000 # 服务器内部错误
|
||||
|
||||
DEFAULT_MESSAGES = {
|
||||
OK: "ok",
|
||||
PARAM_ERROR: "参数错误",
|
||||
UNAUTHORIZED: "未登录或登录已过期",
|
||||
FORBIDDEN: "无权限操作",
|
||||
CAPTCHA_ERROR: "验证码错误或已过期",
|
||||
RATE_LIMITED: "请求过于频繁,请稍后再试",
|
||||
NOT_FOUND: "资源不存在",
|
||||
ACCOUNT_DEACTIVATED: "账号已注销",
|
||||
POINTS_NOT_ENOUGH: "积分不足,请充值或开通会员",
|
||||
FREE_LIMIT_EXCEEDED: "今日免费观看次数已用完",
|
||||
VIDEO_NOT_AVAILABLE: "视频不存在或未发布",
|
||||
LIFETIME_MEMBER: "已是终身会员,无需再开通",
|
||||
INVITE_CODE_INVALID: "邀请码无效或不可绑定",
|
||||
ASSET_NOT_ENOUGH: "资产余额不足",
|
||||
WITHDRAW_TOO_LOW: "低于最低提现额度",
|
||||
WITHDRAW_PENDING: "已有审核中的提现申请",
|
||||
SENSITIVE_WORD: "内容包含敏感词",
|
||||
REPORT_DUPLICATED: "请勿重复举报",
|
||||
NOT_AUTHOR: "无权操作,非作者本人",
|
||||
ORDER_NOT_FOUND: "订单不存在",
|
||||
ORDER_STATUS_ERROR: "订单状态异常",
|
||||
AMOUNT_MISMATCH: "支付金额不匹配",
|
||||
ADMIN_FORBIDDEN: "无管理员权限或角色不允许该操作",
|
||||
REASON_REQUIRED: "该操作必须填写原因",
|
||||
INTERNAL_ERROR: "服务器内部错误",
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""接口限流:Redis 固定窗口计数(Nginx 层之外的第二道,对齐《02-技术文档》4.5)"""
|
||||
from datetime import datetime
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core import errors
|
||||
from app.core.response import BizException
|
||||
|
||||
|
||||
async def hit(redis: aioredis.Redis, key: str, limit: int, window_seconds: int, message: str | None = None) -> None:
|
||||
"""固定窗口计数,超限抛 1005(HTTP 429)。key 需自带维度与窗口标识。"""
|
||||
n = await redis.incr(key)
|
||||
if n == 1:
|
||||
await redis.expire(key, window_seconds)
|
||||
if n > limit:
|
||||
raise BizException(errors.RATE_LIMITED, message, http_status=429)
|
||||
|
||||
|
||||
def window_key(window_seconds: int) -> str:
|
||||
"""窗口标识:分钟 %Y%m%d%H%M / 小时 %Y%m%d%H / 天 %Y%m%d"""
|
||||
now = datetime.now()
|
||||
if window_seconds >= 86400:
|
||||
return f"{now:%Y%m%d}"
|
||||
if window_seconds >= 3600:
|
||||
return f"{now:%Y%m%d%H}"
|
||||
return f"{now:%Y%m%d%H%M}"
|
||||
|
||||
|
||||
async def hit_subject(
|
||||
redis: aioredis.Redis, subject: str, action: str, limit: int, window_seconds: int, message: str | None = None
|
||||
) -> None:
|
||||
"""按主体(用户ID/设备ID/IP)+ 动作限流:rl:{action}:{subject}:{窗口}"""
|
||||
await hit(redis, f"rl:{action}:{subject}:{window_key(window_seconds)}", limit, window_seconds, message)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""统一响应与业务异常(对齐《03-接口文档》1.2 统一响应结构)"""
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core import errors
|
||||
|
||||
# 请求链路 ID(中间件写入,日志/响应共用)
|
||||
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "ok") -> dict:
|
||||
return {
|
||||
"code": errors.OK,
|
||||
"message": message,
|
||||
"data": data if data is not None else {},
|
||||
"request_id": request_id_ctx.get() or str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
|
||||
class BizException(Exception):
|
||||
"""业务异常:抛出后由全局处理器转为统一响应"""
|
||||
|
||||
def __init__(self, code: int, message: str | None = None, http_status: int = 200, data: dict | None = None):
|
||||
self.code = code
|
||||
self.message = message or errors.DEFAULT_MESSAGES.get(code, "error")
|
||||
self.http_status = http_status
|
||||
self.data = data or {}
|
||||
|
||||
|
||||
def _fail_body(code: int, message: str, data: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"data": data or {},
|
||||
"request_id": request_id_ctx.get() or str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(BizException)
|
||||
async def biz_handler(request: Request, exc: BizException) -> JSONResponse:
|
||||
return JSONResponse(status_code=exc.http_status, content=_fail_body(exc.code, exc.message, exc.data))
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=_fail_body(errors.PARAM_ERROR, f"参数错误: {exc.errors()[0].get('msg', '')}"),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=500, content=_fail_body(errors.INTERNAL_ERROR, "服务器内部错误"))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""安全组件:bcrypt 密码哈希 + JWT 双 Token(对齐《02-技术文档》4.2)"""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.core import errors
|
||||
from app.core.response import BizException
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _create_token(subject: int, token_type: str, ttl: int, epoch: int = 0) -> tuple[str, str, int]:
|
||||
"""生成 JWT,返回 (token, jti, 过期时间戳)"""
|
||||
now = datetime.now(timezone.utc)
|
||||
jti = uuid.uuid4().hex
|
||||
exp = now + timedelta(seconds=ttl)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"jti": jti,
|
||||
"type": token_type, # access / refresh / admin:{role}
|
||||
"ep": epoch, # 令牌纪元:改密/注销后递增使旧 Token 全端失效
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": exp,
|
||||
}
|
||||
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
return token, jti, int(exp.timestamp())
|
||||
|
||||
|
||||
def create_access_token(user_id: int, epoch: int = 0) -> tuple[str, str, int]:
|
||||
return _create_token(user_id, "access", settings.access_token_ttl, epoch)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int, epoch: int = 0) -> tuple[str, str, int]:
|
||||
return _create_token(user_id, "refresh", settings.refresh_token_ttl, epoch)
|
||||
|
||||
|
||||
def create_admin_token(admin_id: int, role: str) -> tuple[str, str, int]:
|
||||
"""管理员 Token,payload 带角色,有效期 12h"""
|
||||
token, jti, exp = _create_token(admin_id, f"admin:{role}", settings.admin_token_ttl)
|
||||
return token, jti, exp
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""解码并校验 Token,失败抛 1002"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.PyJWTError:
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
return payload
|
||||
Reference in New Issue
Block a user