feat (infra): Trae 完成 asset_helper_backend 微服务基础架构 V1 初始化

核心实现:搭建 Monorepo 架构,完成 shared 共享包、gateway、user-service 基础框架开发
技术落地:严格匹配指定技术栈版本,完成 Docker、gRPC、FastAPI、PostgreSQL、Redis 等配置,实现服务间基础连通
配套文件:生成 Makefile、环境变量模板、数据库 / 脚本初始化文件及启动验证文档
架构定位:仅搭建基础架构骨架,无任何业务逻辑、业务规则及业务相关字段,为后续业务开发提供支撑
This commit is contained in:
fish
2026-03-27 20:38:10 +08:00
parent 1ad8ec9be5
commit 3f4165fe78
44 changed files with 1407 additions and 0 deletions

View 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"

View File

@@ -0,0 +1,10 @@
from .base import AppException, NotFoundError, UnauthorizedError, ForbiddenError, BadRequestError, InternalError
__all__ = [
"AppException",
"NotFoundError",
"UnauthorizedError",
"ForbiddenError",
"BadRequestError",
"InternalError"
]

View 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}
)

View File

@@ -0,0 +1,5 @@
from .correlation_id import CorrelationIdMiddleware
from .logging import LoggingMiddleware
from .exception import ExceptionMiddleware
__all__ = ["CorrelationIdMiddleware", "LoggingMiddleware", "ExceptionMiddleware"]

View File

@@ -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

View File

@@ -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": "服务器内部错误"}
)

View 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

View File

@@ -0,0 +1,3 @@
from .base import BaseModel, BaseDBModel
__all__ = ["BaseModel", "BaseDBModel"]

View 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())

View 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);
}

View 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"
]

View 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()

View 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()

View 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}"
)

View 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()

View 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)