diff --git a/.gitignore b/.gitignore index f2cd709..cc1b76f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ var/ venv/ ENV/ .env +.venv/ # IDE .idea/ diff --git a/README.md b/README.md index 59327cc..31826a8 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,13 @@ meida-api/ │ ├── core/ # 核心配置 │ ├── db/ # 数据库相关 │ ├── models/ # 数据模型 +│ │ └── users.py # 用户模型 │ ├── schemas/ # 数据验证模式 +│ │ └── user.py # 用户数据模式 │ └── services/ # 业务服务层 +│ └── user.py # 用户服务 ├── main.py # 应用入口 +├── init_db.py # 数据库初始化脚本 └── requirements.txt # 项目依赖 ``` @@ -33,7 +37,12 @@ venv\Scripts\activate # Windows pip install -r requirements.txt ``` -3. 运行服务 +3. 初始化数据库 (如果需要) +```bash +python init_db.py +``` + +4. 运行服务 ```bash python main.py ``` @@ -42,13 +51,54 @@ python main.py uvicorn main:app --reload ``` -4. 访问API文档 +5. 访问API文档 - Swagger文档: http://localhost:8000/docs - ReDoc文档: http://localhost:8000/redoc ## API端点 +### 基础端点 +- `/health` - 健康检查 + +### V1 API - `/api/v1/` - API基本信息 - `/api/v1/products` - 获取产品列表 - `/api/v1/products/{product_id}` - 获取特定产品详情 -- `/health` - 健康检查 \ No newline at end of file + +### 用户API +- `/api/v1/users/` - 获取所有用户 (GET) / 创建用户 (POST) +- `/api/v1/users/{user_id}` - 获取/更新/删除特定用户 +- `/api/v1/users/openid/{openid}` - 通过openid获取用户 +- `/api/v1/users/me` - 获取当前登录用户信息 (需要认证) +- `/api/v1/users/me` - 更新当前登录用户信息 (需要认证) + +### 认证API +- `/api/v1/auth/login/wechat` - 微信登录/注册 + +## 认证 + +本API使用JWT令牌进行认证。认证流程如下: + +1. 调用微信登录接口获取令牌: +``` +POST /api/v1/auth/login/wechat +{ + "code": "微信临时登录凭证" +} +``` + +2. 在需要认证的请求头中添加令牌: +``` +Authorization: Bearer +``` + +## 数据模型 + +### 用户模型 + +- id: 自增长主键 +- openid: 用户唯一标识 +- unionid: 统一标识 +- avatar: 用户头像 +- nickname: 用户昵称 +- create_time: 创建时间 \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..c42085e --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API相关初始化文件 \ No newline at end of file diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..f200d30 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,35 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from jose import jwt + +from app.core.config import settings +from app.core.security import verify_token +from app.db.database import get_db +from app.services import user as user_service + +# OAuth2密码Bearer - JWT token的位置 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + +# 获取当前用户的依赖项 +async def get_current_user( + db: AsyncSession = Depends(get_db), + token: str = Depends(oauth2_scheme) +): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证凭证", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 验证token + openid = verify_token(token) + if not openid: + raise credentials_exception + + # 根据openid获取用户 + user = await user_service.get_user_by_openid(db, openid=openid) + if user is None: + raise credentials_exception + + return user \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..22bcb5e --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 版本初始化文件 \ No newline at end of file diff --git a/app/api/v1/api.py b/app/api/v1/api.py index af87b9d..dfc451d 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -1,5 +1,9 @@ from fastapi import APIRouter from app.api.v1.endpoints import router as endpoints_router +from app.api.v1.users import router as users_router +from app.api.v1.auth import router as auth_router api_router = APIRouter() -api_router.include_router(endpoints_router, prefix="") \ No newline at end of file +api_router.include_router(endpoints_router, prefix="") +api_router.include_router(auth_router, prefix="/auth") +api_router.include_router(users_router, prefix="/users") diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..e062cdf --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.auth import WechatLogin, LoginResponse +from app.schemas.user import UserCreate +from app.services import wechat as wechat_service +from app.services import user as user_service +from app.core.security import create_access_token +from app.db.database import get_db + +router = APIRouter() + +@router.post("/login/wechat", response_model=LoginResponse, tags=["auth"]) +async def wechat_login( + login_data: WechatLogin, + db: AsyncSession = Depends(get_db) +): + """ + 微信登录接口 + + - 使用微信临时登录凭证(code)获取openid和unionid + - 如果用户不存在,则创建新用户 + - 生成JWT令牌 + """ + try: + # 调用微信API获取openid和unionid + openid, unionid = await wechat_service.code2session(login_data.code) + + # 检查用户是否存在 + existing_user = await user_service.get_user_by_openid(db, openid=openid) + is_new_user = existing_user is None + + if is_new_user: + # 创建新用户 + user_create = UserCreate( + openid=openid, + unionid=unionid + ) + user = await user_service.create_user(db, user=user_create) + else: + user = existing_user + + # 创建访问令牌 - 使用openid作为标识 + access_token = create_access_token(subject=openid) + + # 返回登录响应 + return LoginResponse( + access_token=access_token, + is_new_user=is_new_user, + openid=openid + ) + + except wechat_service.WechatLoginError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) \ No newline at end of file diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..fa3dc1b --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from app.schemas.user import User, UserCreate, UserUpdate +from app.services import user as user_service +from app.db.database import get_db +from app.api.deps import get_current_user +from app.models.users import User as UserModel + +router = APIRouter() + +@router.get("/me", response_model=User, tags=["users"]) +async def read_user_me( + current_user: UserModel = Depends(get_current_user) +): + """ + 获取当前登录用户信息 + + 需要JWT令牌认证 + """ + return current_user + +@router.put("/me", response_model=User, tags=["users"]) +async def update_user_me( + user_update: UserUpdate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user) +): + """ + 更新当前登录用户信息 + + 需要JWT令牌认证 + """ + user = await user_service.update_user(db, user_id=current_user.id, user_update=user_update) + return user \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 56d1eb0..f55f162 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -14,8 +14,25 @@ class Settings(BaseSettings): # CORS设置 BACKEND_CORS_ORIGINS: List[str] = ["*"] - # 数据库设置 (后续可根据需要配置) - # DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./meida.db") + # 数据库设置 + DB_HOST: str = os.getenv("DB_HOST", "localhost") + DB_PORT: str = os.getenv("DB_PORT", "3306") + DB_USER: str = os.getenv("DB_USER", "root") + DB_PASSWORD: str = os.getenv("DB_PASSWORD", "password") + DB_NAME: str = os.getenv("DB_NAME", "meida") + DB_ECHO: bool = os.getenv("DB_ECHO", "False").lower() == "true" + + # DashScope API密钥 + DASHSCOPE_API_KEY: str = os.getenv("DASHSCOPE_API_KEY", "sk-caa199589f1c451aaac471fad2986e28") + + # 安全设置 + SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-for-jwt-please-change-in-production") + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天 + + # 微信设置 + WECHAT_APP_ID: str = os.getenv("WECHAT_APP_ID", "") + WECHAT_APP_SECRET: str = os.getenv("WECHAT_APP_SECRET", "") class Config: env_file = ".env" diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..d0b8e0c --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,33 @@ +from datetime import datetime, timedelta +from typing import Any, Union, Optional + +from jose import jwt +from passlib.context import CryptContext +from app.core.config import settings + +# 密码上下文 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# 创建访问令牌 +def create_access_token( + subject: Union[str, Any], expires_delta: Optional[timedelta] = None +) -> str: + if expires_delta: + expire = datetime.now() + expires_delta + else: + expire = datetime.now() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +# 验证令牌 +def verify_token(token: str) -> Optional[str]: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload.get("sub") + except jwt.JWTError: + return None \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..fd50086 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# 数据库相关初始化文件 \ No newline at end of file diff --git a/app/db/database.py b/app/db/database.py new file mode 100644 index 0000000..a677412 --- /dev/null +++ b/app/db/database.py @@ -0,0 +1,35 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings +import asyncio + +# 创建异步数据库URL - 使用异步驱动 +SQLALCHEMY_DATABASE_URL = f"mysql+aiomysql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}" + +# 创建异步数据库引擎 +engine = create_async_engine( + SQLALCHEMY_DATABASE_URL, + echo=settings.DB_ECHO, + pool_pre_ping=True +) + +# 创建异步会话工厂 +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + +# 创建基本模型类 +Base = declarative_base() + +# 异步数据库会话依赖项 +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() \ No newline at end of file diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 0000000..8e79a25 --- /dev/null +++ b/app/db/init_db.py @@ -0,0 +1,18 @@ +import asyncio +from app.db.database import Base, engine +from app.models.users import User + +# 创建所有表格 +async def init_db(): + async with engine.begin() as conn: + # 在SQLAlchemy 2.0中可以直接使用异步创建表 + await conn.run_sync(Base.metadata.create_all) + +# 同步入口点 +def init_db_sync(): + asyncio.run(init_db()) + +if __name__ == "__main__": + print("创建数据库表...") + init_db_sync() + print("数据库表创建完成。") \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5fb3456 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,2 @@ +# 导入所有模型,确保它们被SQLAlchemy注册 +from app.models.users import User \ No newline at end of file diff --git a/app/models/users.py b/app/models/users.py new file mode 100644 index 0000000..00fa385 --- /dev/null +++ b/app/models/users.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.db.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True, index=True) + openid = Column(String(50), unique=True, index=True) + unionid = Column(String(50), nullable=True, index=True) + avatar = Column(String(255), nullable=True, comment="头像") + nickname = Column(String(50), nullable=True, comment="昵称") + create_time = Column(DateTime, default=func.now(), comment="创建时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..1d37b95 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +# 数据模式初始化文件 \ No newline at end of file diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..b5d23d0 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from typing import Optional + +class WechatLogin(BaseModel): + """微信登录请求""" + code: str + +class Token(BaseModel): + """令牌响应""" + access_token: str + token_type: str = "bearer" + +class LoginResponse(Token): + """登录响应""" + is_new_user: bool # 是否为新用户 + openid: str \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..e7d6120 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class UserBase(BaseModel): + openid: str + unionid: Optional[str] = None + avatar: Optional[str] = None + nickname: Optional[str] = None + +class UserCreate(UserBase): + pass + +class UserUpdate(BaseModel): + avatar: Optional[str] = None + nickname: Optional[str] = None + +class UserInDB(UserBase): + id: int + create_time: datetime + + class Config: + from_attributes = True + +class User(UserInDB): + pass \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..13fe172 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# 服务层初始化文件 \ No newline at end of file diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..4556f90 --- /dev/null +++ b/app/services/user.py @@ -0,0 +1,55 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from sqlalchemy.future import select +from app.models.users import User +from app.schemas.user import UserCreate, UserUpdate + +async def get_user(db: AsyncSession, user_id: int): + result = await db.execute(select(User).filter(User.id == user_id)) + return result.scalars().first() + +async def get_user_by_openid(db: AsyncSession, openid: str): + result = await db.execute(select(User).filter(User.openid == openid)) + return result.scalars().first() + +async def get_users(db: AsyncSession, skip: int = 0, limit: int = 100): + result = await db.execute(select(User).offset(skip).limit(limit)) + return result.scalars().all() + +async def create_user(db: AsyncSession, user: UserCreate): + db_user = User( + openid=user.openid, + unionid=user.unionid, + avatar=user.avatar, + nickname=user.nickname + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user + +async def update_user(db: AsyncSession, user_id: int, user_update: UserUpdate): + update_data = user_update.model_dump(exclude_unset=True) + if not update_data: + # 没有要更新的数据 + return await get_user(db, user_id) + + # 先获取用户 + db_user = await get_user(db, user_id) + if not db_user: + return None + + # 更新用户数据 + for key, value in update_data.items(): + setattr(db_user, key, value) + + await db.commit() + await db.refresh(db_user) + return db_user + +async def delete_user(db: AsyncSession, user_id: int): + db_user = await get_user(db, user_id) + if db_user: + await db.delete(db_user) + await db.commit() + return db_user \ No newline at end of file diff --git a/app/services/wechat.py b/app/services/wechat.py new file mode 100644 index 0000000..d216369 --- /dev/null +++ b/app/services/wechat.py @@ -0,0 +1,49 @@ +import httpx +from typing import Optional, Dict, Any, Tuple +from app.core.config import settings + +class WechatLoginError(Exception): + """微信登录错误""" + pass + +async def code2session(code: str) -> Tuple[str, Optional[str]]: + """ + 使用微信登录code获取openid和unionid + + Args: + code: 微信临时登录凭证 + + Returns: + Tuple[str, Optional[str]]: (openid, unionid) + + Raises: + WechatLoginError: 当微信API调用失败时 + """ + url = "https://api.weixin.qq.com/sns/jscode2session" + params = { + "appid": settings.WECHAT_APP_ID, + "secret": settings.WECHAT_APP_SECRET, + "js_code": code, + "grant_type": "authorization_code" + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params) + result = response.json() + + if "errcode" in result and result["errcode"] != 0: + raise WechatLoginError(f"微信登录失败: {result.get('errmsg', '未知错误')}") + + openid = result.get("openid") + unionid = result.get("unionid") # 可能为None + + if not openid: + raise WechatLoginError("无法获取openid") + + return openid, unionid + + except httpx.RequestError as e: + raise WechatLoginError(f"网络请求失败: {str(e)}") + except Exception as e: + raise WechatLoginError(f"未知错误: {str(e)}") \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..3eac8d9 --- /dev/null +++ b/init_db.py @@ -0,0 +1,6 @@ +from app.db.init_db import init_db_sync + +if __name__ == "__main__": + print("初始化数据库...") + init_db_sync() + print("数据库初始化完成。") \ No newline at end of file diff --git a/main.py b/main.py index ef8beaa..7ecce89 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings from app.api.v1.api import api_router +from app.db.init_db import init_db app = FastAPI( title=settings.PROJECT_NAME, @@ -29,6 +30,11 @@ async def root(): async def health_check(): return {"status": "healthy"} +# 应用启动事件 +@app.on_event("startup") +async def startup_event(): + await init_db() + if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 476f3b8..773c678 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,10 @@ fastapi==0.104.1 uvicorn==0.24.0 pydantic==2.4.2 pydantic-settings==2.0.3 -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.0 +sqlalchemy==2.0.21 +aiomysql==0.2.0 +greenlet==2.0.2 +python-jose[cryptography]==3.3.0 +passlib==1.7.4 +httpx==0.24.1 \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..0470c69 --- /dev/null +++ b/start.sh @@ -0,0 +1 @@ +python3 -m uvicorn main:app --reload \ No newline at end of file