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

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