feat (infra): Trae 完成 asset_helper_backend 微服务基础架构 V1 初始化
核心实现:搭建 Monorepo 架构,完成 shared 共享包、gateway、user-service 基础框架开发 技术落地:严格匹配指定技术栈版本,完成 Docker、gRPC、FastAPI、PostgreSQL、Redis 等配置,实现服务间基础连通 配套文件:生成 Makefile、环境变量模板、数据库 / 脚本初始化文件及启动验证文档 架构定位:仅搭建基础架构骨架,无任何业务逻辑、业务规则及业务相关字段,为后续业务开发提供支撑
This commit is contained in:
23
asset_helper_backend/shared/pyproject.toml
Normal file
23
asset_helper_backend/shared/pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "asset_helper_shared"
|
||||
version = "0.1.0"
|
||||
description = "Asset Helper Shared Package"
|
||||
authors = [
|
||||
{ name = "Author", email = "author@example.com" }
|
||||
]
|
||||
requires-python = ">=3.13.7"
|
||||
|
||||
[tool.uv.dependencies]
|
||||
fastapi = "*"
|
||||
pydantic = "*"
|
||||
pydantic-settings = "*"
|
||||
grpcio = "*"
|
||||
sqlalchemy = "*"
|
||||
asyncpg = "*"
|
||||
redis = "*"
|
||||
loguru = "*"
|
||||
python-dotenv = "*"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -0,0 +1,10 @@
|
||||
from .base import AppException, NotFoundError, UnauthorizedError, ForbiddenError, BadRequestError, InternalError
|
||||
|
||||
__all__ = [
|
||||
"AppException",
|
||||
"NotFoundError",
|
||||
"UnauthorizedError",
|
||||
"ForbiddenError",
|
||||
"BadRequestError",
|
||||
"InternalError"
|
||||
]
|
||||
33
asset_helper_backend/shared/src/shared/exceptions/base.py
Normal file
33
asset_helper_backend/shared/src/shared/exceptions/base.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
class AppException(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
class NotFoundError(AppException):
|
||||
def __init__(self, detail: str = "资源不存在"):
|
||||
super().__init__(status.HTTP_404_NOT_FOUND, detail)
|
||||
|
||||
class UnauthorizedError(AppException):
|
||||
def __init__(self, detail: str = "未授权"):
|
||||
super().__init__(status.HTTP_401_UNAUTHORIZED, detail)
|
||||
|
||||
class ForbiddenError(AppException):
|
||||
def __init__(self, detail: str = "禁止访问"):
|
||||
super().__init__(status.HTTP_403_FORBIDDEN, detail)
|
||||
|
||||
class BadRequestError(AppException):
|
||||
def __init__(self, detail: str = "请求参数错误"):
|
||||
super().__init__(status.HTTP_400_BAD_REQUEST, detail)
|
||||
|
||||
class InternalError(AppException):
|
||||
def __init__(self, detail: str = "服务器内部错误"):
|
||||
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, detail)
|
||||
|
||||
async def exception_handler(request: Request, exc: AppException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from .correlation_id import CorrelationIdMiddleware
|
||||
from .logging import LoggingMiddleware
|
||||
from .exception import ExceptionMiddleware
|
||||
|
||||
__all__ = ["CorrelationIdMiddleware", "LoggingMiddleware", "ExceptionMiddleware"]
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastapi import Request, Response
|
||||
import uuid
|
||||
|
||||
class CorrelationIdMiddleware:
|
||||
async def __call__(self, request: Request, call_next):
|
||||
correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
|
||||
request.state.correlation_id = correlation_id
|
||||
response = await call_next(request)
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
return response
|
||||
@@ -0,0 +1,23 @@
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from shared.exceptions import AppException, exception_handler
|
||||
|
||||
class ExceptionMiddleware:
|
||||
async def __call__(self, request: Request, call_next):
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except AppException as exc:
|
||||
return await exception_handler(request, exc)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Unhandled exception: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误"}
|
||||
)
|
||||
29
asset_helper_backend/shared/src/shared/middleware/logging.py
Normal file
29
asset_helper_backend/shared/src/shared/middleware/logging.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from fastapi import Request
|
||||
from loguru import logger
|
||||
import time
|
||||
|
||||
class LoggingMiddleware:
|
||||
async def __call__(self, request: Request, call_next):
|
||||
start_time = time.time()
|
||||
correlation_id = getattr(request.state, "correlation_id", "-")
|
||||
|
||||
logger.info(
|
||||
f"Request started",
|
||||
method=request.method,
|
||||
url=request.url.path,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
logger.info(
|
||||
f"Request completed",
|
||||
method=request.method,
|
||||
url=request.url.path,
|
||||
status_code=response.status_code,
|
||||
process_time=process_time,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,3 @@
|
||||
from .base import BaseModel, BaseDBModel
|
||||
|
||||
__all__ = ["BaseModel", "BaseDBModel"]
|
||||
17
asset_helper_backend/shared/src/shared/models/base.py
Normal file
17
asset_helper_backend/shared/src/shared/models/base.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
class BaseModel(PydanticBaseModel):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class BaseDBModel(Base):
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
55
asset_helper_backend/shared/src/shared/proto/user.proto
Normal file
55
asset_helper_backend/shared/src/shared/proto/user.proto
Normal file
@@ -0,0 +1,55 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package user;
|
||||
|
||||
message User {
|
||||
int32 id = 1;
|
||||
string username = 2;
|
||||
string password_hash = 3;
|
||||
string created_at = 4;
|
||||
string updated_at = 5;
|
||||
}
|
||||
|
||||
message GetUserRequest {
|
||||
int32 id = 1;
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
string username = 1;
|
||||
string password_hash = 2;
|
||||
}
|
||||
|
||||
message UpdateUserRequest {
|
||||
int32 id = 1;
|
||||
string username = 2;
|
||||
string password_hash = 3;
|
||||
}
|
||||
|
||||
message DeleteUserRequest {
|
||||
int32 id = 1;
|
||||
}
|
||||
|
||||
message ListUsersRequest {
|
||||
int32 page = 1;
|
||||
int32 page_size = 2;
|
||||
}
|
||||
|
||||
message UserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message UsersResponse {
|
||||
repeated User users = 1;
|
||||
int32 total = 2;
|
||||
}
|
||||
|
||||
message EmptyResponse {
|
||||
}
|
||||
|
||||
service UserService {
|
||||
rpc GetUser(GetUserRequest) returns (UserResponse);
|
||||
rpc CreateUser(CreateUserRequest) returns (UserResponse);
|
||||
rpc UpdateUser(UpdateUserRequest) returns (UserResponse);
|
||||
rpc DeleteUser(DeleteUserRequest) returns (EmptyResponse);
|
||||
rpc ListUsers(ListUsersRequest) returns (UsersResponse);
|
||||
}
|
||||
16
asset_helper_backend/shared/src/shared/utils/__init__.py
Normal file
16
asset_helper_backend/shared/src/shared/utils/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from .config import settings
|
||||
from .logger import logger
|
||||
from .security import create_access_token, verify_token, get_password_hash, verify_password
|
||||
from .grpc_client import GrpcClient
|
||||
from .redis_client import RedisClient
|
||||
|
||||
__all__ = [
|
||||
"settings",
|
||||
"logger",
|
||||
"create_access_token",
|
||||
"verify_token",
|
||||
"get_password_hash",
|
||||
"verify_password",
|
||||
"GrpcClient",
|
||||
"RedisClient"
|
||||
]
|
||||
33
asset_helper_backend/shared/src/shared/utils/config.py
Normal file
33
asset_helper_backend/shared/src/shared/utils/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Environment
|
||||
env: str = "development"
|
||||
debug: bool = True
|
||||
|
||||
# PostgreSQL
|
||||
postgres_user: str = "admin"
|
||||
postgres_password: str = "password"
|
||||
postgres_db: str = "postgres"
|
||||
postgres_host: str = "postgres"
|
||||
postgres_port: int = 5432
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
# gRPC
|
||||
grpc_port: int = 50051
|
||||
|
||||
# HTTP
|
||||
http_port: int = 8000
|
||||
|
||||
@property
|
||||
def database_url(self):
|
||||
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
settings = Settings()
|
||||
15
asset_helper_backend/shared/src/shared/utils/grpc_client.py
Normal file
15
asset_helper_backend/shared/src/shared/utils/grpc_client.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import grpc
|
||||
|
||||
class GrpcClient:
|
||||
def __init__(self, host: str, port: int):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.channel = None
|
||||
|
||||
def __enter__(self):
|
||||
self.channel = grpc.insecure_channel(f"{self.host}:{self.port}")
|
||||
return self.channel
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.channel:
|
||||
self.channel.close()
|
||||
20
asset_helper_backend/shared/src/shared/utils/logger.py
Normal file
20
asset_helper_backend/shared/src/shared/utils/logger.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from loguru import logger
|
||||
import sys
|
||||
|
||||
# 配置日志
|
||||
logger.remove()
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level="INFO",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}",
|
||||
filter=None,
|
||||
colorize=True
|
||||
)
|
||||
|
||||
logger.add(
|
||||
"app.log",
|
||||
rotation="500 MB",
|
||||
compression="zip",
|
||||
level="DEBUG",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}"
|
||||
)
|
||||
34
asset_helper_backend/shared/src/shared/utils/redis_client.py
Normal file
34
asset_helper_backend/shared/src/shared/utils/redis_client.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import redis.asyncio as redis
|
||||
from shared.utils.config import settings
|
||||
|
||||
class RedisClient:
|
||||
def __init__(self):
|
||||
self.redis_url = settings.redis_url
|
||||
self.pool = None
|
||||
|
||||
async def connect(self):
|
||||
if not self.pool:
|
||||
self.pool = redis.from_url(self.redis_url, encoding="utf-8", decode_responses=True)
|
||||
return self.pool
|
||||
|
||||
async def disconnect(self):
|
||||
if self.pool:
|
||||
await self.pool.close()
|
||||
self.pool = None
|
||||
|
||||
async def get(self, key: str):
|
||||
pool = await self.connect()
|
||||
return await pool.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = None):
|
||||
pool = await self.connect()
|
||||
if expire:
|
||||
await pool.set(key, value, ex=expire)
|
||||
else:
|
||||
await pool.set(key, value)
|
||||
|
||||
async def delete(self, key: str):
|
||||
pool = await self.connect()
|
||||
await pool.delete(key)
|
||||
|
||||
redis_client = RedisClient()
|
||||
32
asset_helper_backend/shared/src/shared/utils/security.py
Normal file
32
asset_helper_backend/shared/src/shared/utils/security.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from passlib.context import CryptContext
|
||||
|
||||
SECRET_KEY = "your-secret-key"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
def get_password_hash(password: str):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
Reference in New Issue
Block a user