123
This commit is contained in:
1
backend/shared/__init__.py
Normal file
1
backend/shared/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared module for Weibo-HotSign backend services."""
|
||||
BIN
backend/shared/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/shared/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/__pycache__/config.cpython-311.pyc
Normal file
BIN
backend/shared/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/__pycache__/crypto.cpython-311.pyc
Normal file
BIN
backend/shared/__pycache__/crypto.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/__pycache__/response.cpython-311.pyc
Normal file
BIN
backend/shared/__pycache__/response.cpython-311.pyc
Normal file
Binary file not shown.
31
backend/shared/config.py
Normal file
31
backend/shared/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Shared configuration for all Weibo-HotSign backend services.
|
||||
Loads settings from environment variables using pydantic-settings.
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class SharedSettings(BaseSettings):
|
||||
"""Shared settings across all backend services."""
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "mysql+aiomysql://root:password@localhost/weibo_hotsign"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRATION_HOURS: int = 24
|
||||
|
||||
# Cookie encryption
|
||||
COOKIE_ENCRYPTION_KEY: str = "change-me-in-production"
|
||||
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
shared_settings = SharedSettings()
|
||||
44
backend/shared/crypto.py
Normal file
44
backend/shared/crypto.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
AES-256-GCM Cookie encryption / decryption utilities.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
|
||||
def derive_key(raw_key: str) -> bytes:
|
||||
"""Derive a 32-byte key from an arbitrary string using SHA-256."""
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def encrypt_cookie(plaintext: str, key: bytes) -> tuple[str, str]:
|
||||
"""
|
||||
Encrypt a cookie string with AES-256-GCM.
|
||||
|
||||
Returns:
|
||||
(ciphertext_b64, iv_b64) — both base64-encoded strings.
|
||||
"""
|
||||
cipher = AES.new(key, AES.MODE_GCM)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8"))
|
||||
# Append the 16-byte tag to the ciphertext so decryption can verify it
|
||||
ciphertext_with_tag = ciphertext + tag
|
||||
ciphertext_b64 = base64.b64encode(ciphertext_with_tag).decode("utf-8")
|
||||
iv_b64 = base64.b64encode(cipher.nonce).decode("utf-8")
|
||||
return ciphertext_b64, iv_b64
|
||||
|
||||
|
||||
def decrypt_cookie(ciphertext_b64: str, iv_b64: str, key: bytes) -> str:
|
||||
"""
|
||||
Decrypt a cookie string previously encrypted with encrypt_cookie.
|
||||
|
||||
Raises ValueError on decryption failure (wrong key, corrupted data, etc.).
|
||||
"""
|
||||
raw = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(iv_b64)
|
||||
# Last 16 bytes are the GCM tag
|
||||
ciphertext, tag = raw[:-16], raw[-16:]
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
return plaintext.decode("utf-8")
|
||||
18
backend/shared/models/__init__.py
Normal file
18
backend/shared/models/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Shared ORM models for Weibo-HotSign."""
|
||||
|
||||
from .base import Base, get_db, engine, AsyncSessionLocal
|
||||
from .user import User
|
||||
from .account import Account
|
||||
from .task import Task
|
||||
from .signin_log import SigninLog
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_db",
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"User",
|
||||
"Account",
|
||||
"Task",
|
||||
"SigninLog",
|
||||
]
|
||||
BIN
backend/shared/models/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/shared/models/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/models/__pycache__/account.cpython-311.pyc
Normal file
BIN
backend/shared/models/__pycache__/account.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/models/__pycache__/base.cpython-311.pyc
Normal file
BIN
backend/shared/models/__pycache__/base.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/models/__pycache__/signin_log.cpython-311.pyc
Normal file
BIN
backend/shared/models/__pycache__/signin_log.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/models/__pycache__/task.cpython-311.pyc
Normal file
BIN
backend/shared/models/__pycache__/task.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/shared/models/__pycache__/user.cpython-311.pyc
Normal file
BIN
backend/shared/models/__pycache__/user.cpython-311.pyc
Normal file
Binary file not shown.
30
backend/shared/models/account.py
Normal file
30
backend/shared/models/account.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Account ORM model."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
weibo_user_id = Column(String(20), nullable=False)
|
||||
remark = Column(String(100))
|
||||
encrypted_cookies = Column(Text, nullable=False)
|
||||
iv = Column(String(32), nullable=False)
|
||||
status = Column(String(20), default="pending")
|
||||
last_checked_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="accounts")
|
||||
tasks = relationship("Task", back_populates="account", cascade="all, delete-orphan")
|
||||
signin_logs = relationship("SigninLog", back_populates="account")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Account(id={self.id}, weibo_user_id='{self.weibo_user_id}')>"
|
||||
33
backend/shared/models/base.py
Normal file
33
backend/shared/models/base.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Database engine, session factory, and declarative base.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
from ..config import shared_settings
|
||||
|
||||
_engine_kwargs: dict = {"echo": False}
|
||||
if "sqlite" not in shared_settings.DATABASE_URL:
|
||||
_engine_kwargs.update(pool_size=20, max_overflow=30, pool_pre_ping=True)
|
||||
|
||||
engine = create_async_engine(shared_settings.DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency that yields an async database session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
23
backend/shared/models/signin_log.py
Normal file
23
backend/shared/models/signin_log.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""SigninLog ORM model."""
|
||||
|
||||
from sqlalchemy import Integer, Column, DateTime, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class SigninLog(Base):
|
||||
__tablename__ = "signin_logs"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
account_id = Column(String(36), ForeignKey("accounts.id"), nullable=False)
|
||||
topic_title = Column(String(100))
|
||||
status = Column(String(20), nullable=False)
|
||||
reward_info = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
signed_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
account = relationship("Account", back_populates="signin_logs")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SigninLog(id={self.id}, status='{self.status}')>"
|
||||
24
backend/shared/models/task.py
Normal file
24
backend/shared/models/task.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Task ORM model."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
account_id = Column(String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
cron_expression = Column(String(50), nullable=False)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
account = relationship("Account", back_populates="tasks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Task(id={self.id}, cron='{self.cron_expression}')>"
|
||||
25
backend/shared/models/user.py
Normal file
25
backend/shared/models/user.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""User ORM model."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
accounts = relationship("Account", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
35
backend/shared/response.py
Normal file
35
backend/shared/response.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Unified API response format utilities.
|
||||
"""
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
def success_response(data: Any = None, message: str = "Operation successful") -> dict:
|
||||
"""Return a standardised success payload."""
|
||||
return {
|
||||
"success": True,
|
||||
"data": data,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def error_response(
|
||||
message: str,
|
||||
code: str,
|
||||
details: Optional[List[dict]] = None,
|
||||
status_code: int = 400,
|
||||
) -> JSONResponse:
|
||||
"""Return a standardised error JSONResponse."""
|
||||
body: dict = {
|
||||
"success": False,
|
||||
"data": None,
|
||||
"message": message,
|
||||
"error": {
|
||||
"code": code,
|
||||
"details": details or [],
|
||||
},
|
||||
}
|
||||
return JSONResponse(status_code=status_code, content=body)
|
||||
Reference in New Issue
Block a user