This commit is contained in:
2026-03-09 14:05:00 +08:00
commit 754e720ba7
105 changed files with 5890 additions and 0 deletions

View File

View File

View File

@@ -0,0 +1,9 @@
"""
Configuration settings for API Service.
Re-uses shared settings; add API-specific overrides here if needed.
"""
from shared.config import shared_settings
APP_NAME = "Weibo-HotSign API Service"
APP_VERSION = "1.0.0"

View File

@@ -0,0 +1,50 @@
"""
Shared dependencies for API Service routes.
Provides JWT-based authentication via get_current_user.
"""
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from shared.models import get_db, User
from auth_service.app.utils.security import decode_access_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Validate JWT and return the current User ORM instance."""
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated",
)
return user

View File

@@ -0,0 +1,75 @@
"""
Weibo-HotSign API Service
Main FastAPI application entry point — account management, task config, signin logs.
"""
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from shared.response import success_response, error_response
from api_service.app.routers import accounts, tasks, signin_logs
app = FastAPI(
title="Weibo-HotSign API Service",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:80"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---- Global exception handlers (unified response format) ----
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return error_response(
exc.detail,
f"HTTP_{exc.status_code}",
status_code=exc.status_code,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
details = [
{"field": e["loc"][-1] if e["loc"] else "unknown", "message": e["msg"]}
for e in exc.errors()
]
return error_response(
"Validation failed",
"VALIDATION_ERROR",
details=details,
status_code=400,
)
# ---- Routers ----
app.include_router(accounts.router)
app.include_router(tasks.router)
app.include_router(signin_logs.router)
# ---- Health / root ----
@app.get("/")
async def root():
return success_response(
{"service": "Weibo-HotSign API Service", "version": "1.0.0"},
"Service is running",
)
@app.get("/health")
async def health_check():
return success_response({"status": "healthy"})

View File

@@ -0,0 +1,139 @@
"""
Weibo Account CRUD router.
All endpoints require JWT authentication and enforce resource ownership.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models import get_db, Account, User
from shared.crypto import encrypt_cookie, decrypt_cookie, derive_key
from shared.config import shared_settings
from shared.response import success_response, error_response
from api_service.app.dependencies import get_current_user
from api_service.app.schemas.account import (
AccountCreate,
AccountUpdate,
AccountResponse,
)
router = APIRouter(prefix="/api/v1/accounts", tags=["accounts"])
def _encryption_key() -> bytes:
return derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
def _account_to_dict(account: Account) -> dict:
return AccountResponse.model_validate(account).model_dump(mode="json")
async def _get_owned_account(
account_id: str,
user: User,
db: AsyncSession,
) -> Account:
"""Fetch an account and verify it belongs to the current user."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if account.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
return account
# ---- CREATE ----
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_account(
body: AccountCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
key = _encryption_key()
ciphertext, iv = encrypt_cookie(body.cookie, key)
account = Account(
user_id=user.id,
weibo_user_id=body.weibo_user_id,
remark=body.remark,
encrypted_cookies=ciphertext,
iv=iv,
status="pending",
)
db.add(account)
await db.commit()
await db.refresh(account)
return success_response(_account_to_dict(account), "Account created")
# ---- LIST ----
@router.get("")
async def list_accounts(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Account).where(Account.user_id == user.id)
)
accounts = result.scalars().all()
return success_response(
[_account_to_dict(a) for a in accounts],
"Accounts retrieved",
)
# ---- DETAIL ----
@router.get("/{account_id}")
async def get_account(
account_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
account = await _get_owned_account(account_id, user, db)
return success_response(_account_to_dict(account), "Account retrieved")
# ---- UPDATE ----
@router.put("/{account_id}")
async def update_account(
account_id: str,
body: AccountUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
account = await _get_owned_account(account_id, user, db)
if body.remark is not None:
account.remark = body.remark
if body.cookie is not None:
key = _encryption_key()
ciphertext, iv = encrypt_cookie(body.cookie, key)
account.encrypted_cookies = ciphertext
account.iv = iv
await db.commit()
await db.refresh(account)
return success_response(_account_to_dict(account), "Account updated")
# ---- DELETE ----
@router.delete("/{account_id}")
async def delete_account(
account_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
account = await _get_owned_account(account_id, user, db)
await db.delete(account)
await db.commit()
return success_response(None, "Account deleted")

View File

@@ -0,0 +1,83 @@
"""
Signin Log query router.
All endpoints require JWT authentication and enforce resource ownership.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models import get_db, Account, SigninLog, User
from shared.response import success_response
from api_service.app.dependencies import get_current_user
from api_service.app.schemas.signin_log import SigninLogResponse, PaginatedResponse
router = APIRouter(prefix="/api/v1/accounts", tags=["signin-logs"])
async def _verify_account_ownership(
account_id: str,
user: User,
db: AsyncSession,
) -> Account:
"""Verify that the account belongs to the current user."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if account.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
return account
@router.get("/{account_id}/signin-logs")
async def get_signin_logs(
account_id: str,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
size: int = Query(20, ge=1, le=100, description="Page size (max 100)"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Query signin logs for a specific account with pagination and status filtering.
Returns logs sorted by signed_at in descending order (newest first).
"""
# Verify account ownership
await _verify_account_ownership(account_id, user, db)
# Build base query
query = select(SigninLog).where(SigninLog.account_id == account_id)
# Apply status filter if provided
if status_filter:
query = query.where(SigninLog.status == status_filter)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Apply ordering and pagination
query = query.order_by(SigninLog.signed_at.desc())
offset = (page - 1) * size
query = query.offset(offset).limit(size)
# Execute query
result = await db.execute(query)
logs = result.scalars().all()
# Calculate total pages
total_pages = (total + size - 1) // size if total > 0 else 0
# Build response
paginated = PaginatedResponse(
items=[SigninLogResponse.model_validate(log) for log in logs],
total=total,
page=page,
size=size,
total_pages=total_pages,
)
return success_response(paginated.model_dump(mode="json"), "Signin logs retrieved")

View File

@@ -0,0 +1,196 @@
"""
Signin Task CRUD router.
All endpoints require JWT authentication and enforce resource ownership.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from croniter import croniter
import redis.asyncio as aioredis
import json
from shared.models import get_db, Account, Task, User
from shared.config import shared_settings
from shared.response import success_response
from api_service.app.dependencies import get_current_user
from api_service.app.schemas.task import (
TaskCreate,
TaskUpdate,
TaskResponse,
)
router = APIRouter(prefix="/api/v1", tags=["tasks"])
def _task_to_dict(task: Task) -> dict:
return TaskResponse.model_validate(task).model_dump(mode="json")
async def _get_owned_account(
account_id: str,
user: User,
db: AsyncSession,
) -> Account:
"""Fetch an account and verify it belongs to the current user."""
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if account.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
return account
async def _get_owned_task(
task_id: str,
user: User,
db: AsyncSession,
) -> Task:
"""Fetch a task and verify it belongs to the current user."""
from sqlalchemy.orm import selectinload
result = await db.execute(
select(Task)
.options(selectinload(Task.account))
.where(Task.id == task_id)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
# Verify ownership through account
if task.account.user_id != user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
return task
def _validate_cron_expression(cron_expr: str) -> None:
"""Validate cron expression format using croniter."""
try:
croniter(cron_expr)
except (ValueError, KeyError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid cron expression: {str(e)}"
)
async def _notify_scheduler(action: str, task_data: dict) -> None:
"""Notify Task_Scheduler via Redis pub/sub about task changes."""
try:
redis_client = aioredis.from_url(
shared_settings.REDIS_URL,
encoding="utf-8",
decode_responses=True
)
message = {
"action": action, # "create", "update", "delete"
"task": task_data
}
await redis_client.publish("task_updates", json.dumps(message))
await redis_client.close()
except Exception as e:
# Log but don't fail the request if notification fails
print(f"Warning: Failed to notify scheduler: {e}")
# ---- CREATE TASK ----
@router.post("/accounts/{account_id}/tasks", status_code=status.HTTP_201_CREATED)
async def create_task(
account_id: str,
body: TaskCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a new signin task for the specified account."""
# Verify account ownership
account = await _get_owned_account(account_id, user, db)
# Validate cron expression
_validate_cron_expression(body.cron_expression)
# Create task
task = Task(
account_id=account.id,
cron_expression=body.cron_expression,
is_enabled=True,
)
db.add(task)
await db.commit()
await db.refresh(task)
# Notify scheduler
await _notify_scheduler("create", _task_to_dict(task))
return success_response(_task_to_dict(task), "Task created")
# ---- LIST TASKS FOR ACCOUNT ----
@router.get("/accounts/{account_id}/tasks")
async def list_tasks(
account_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get all tasks for the specified account."""
# Verify account ownership
account = await _get_owned_account(account_id, user, db)
# Fetch tasks
result = await db.execute(
select(Task).where(Task.account_id == account.id)
)
tasks = result.scalars().all()
return success_response(
[_task_to_dict(t) for t in tasks],
"Tasks retrieved",
)
# ---- UPDATE TASK ----
@router.put("/tasks/{task_id}")
async def update_task(
task_id: str,
body: TaskUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update task (enable/disable)."""
task = await _get_owned_task(task_id, user, db)
if body.is_enabled is not None:
task.is_enabled = body.is_enabled
await db.commit()
await db.refresh(task)
# Notify scheduler
await _notify_scheduler("update", _task_to_dict(task))
return success_response(_task_to_dict(task), "Task updated")
# ---- DELETE TASK ----
@router.delete("/tasks/{task_id}")
async def delete_task(
task_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a task."""
task = await _get_owned_task(task_id, user, db)
task_data = _task_to_dict(task)
await db.delete(task)
await db.commit()
# Notify scheduler
await _notify_scheduler("delete", task_data)
return success_response(None, "Task deleted")

View File

@@ -0,0 +1,34 @@
"""
Pydantic schemas for Weibo Account CRUD operations.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class AccountCreate(BaseModel):
"""Request body for creating a new Weibo account."""
weibo_user_id: str = Field(..., min_length=1, max_length=20, description="Weibo user ID")
cookie: str = Field(..., min_length=1, description="Raw Weibo cookie string")
remark: Optional[str] = Field(None, max_length=100, description="Optional note")
class AccountUpdate(BaseModel):
"""Request body for updating an existing Weibo account."""
cookie: Optional[str] = Field(None, min_length=1, description="New cookie (will be re-encrypted)")
remark: Optional[str] = Field(None, max_length=100, description="Updated note")
class AccountResponse(BaseModel):
"""Public representation of a Weibo account (no cookie plaintext)."""
id: str
user_id: str
weibo_user_id: str
remark: Optional[str]
status: str
last_checked_at: Optional[datetime]
created_at: Optional[datetime]
class Config:
from_attributes = True

View File

@@ -0,0 +1,30 @@
"""
Pydantic schemas for Signin Log query operations.
"""
from datetime import datetime
from typing import Optional, List, Any, Dict
from pydantic import BaseModel, Field
class SigninLogResponse(BaseModel):
"""Public representation of a signin log entry."""
id: int
account_id: str
topic_title: Optional[str]
status: str
reward_info: Optional[Any]
error_message: Optional[str]
signed_at: datetime
class Config:
from_attributes = True
class PaginatedResponse(BaseModel):
"""Paginated response wrapper for signin logs."""
items: List[SigninLogResponse]
total: int
page: int
size: int
total_pages: int

View File

@@ -0,0 +1,29 @@
"""
Pydantic schemas for Task CRUD operations.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
"""Request body for creating a new signin task."""
cron_expression: str = Field(..., min_length=1, max_length=50, description="Cron expression for scheduling")
class TaskUpdate(BaseModel):
"""Request body for updating an existing task."""
is_enabled: Optional[bool] = Field(None, description="Enable or disable the task")
class TaskResponse(BaseModel):
"""Public representation of a signin task."""
id: str
account_id: str
cron_expression: str
is_enabled: bool
created_at: Optional[datetime]
class Config:
from_attributes = True