commit 754e720ba762ca323d153ddcaaa2d6f85d06a9f6 Author: Jeason <1710884619@qq.com> Date: Mon Mar 9 14:05:00 2026 +0800 123 diff --git a/.kiro/specs/multi-user-signin/design.md b/.kiro/specs/multi-user-signin/design.md new file mode 100644 index 0000000..86fc65a --- /dev/null +++ b/.kiro/specs/multi-user-signin/design.md @@ -0,0 +1,566 @@ +# 设计文档:Weibo-HotSign 多用户签到系统 + +## 概述 + +本设计文档描述 Weibo-HotSign 系统的架构重构与核心功能实现方案。核心目标是: + +1. 引入 `backend/shared/` 共享模块,统一 ORM 模型、数据库连接和加密工具,消除各服务间的代码重复 +2. 完善 `auth_service`,实现 Refresh Token 机制 +3. 从零实现 `api_service`,提供微博账号 CRUD、任务配置和签到日志查询 API +4. 将 `signin_executor` 和 `task_scheduler` 中的 Mock 实现替换为真实数据库交互 +5. 所有 API 遵循统一响应格式 + +技术栈:Python 3.11 + FastAPI + SQLAlchemy (async) + Celery + MySQL (aiomysql) + Redis + +## 架构 + +### 重构后的服务架构 + +```mermaid +graph TD + subgraph "客户端" + FE[Web Frontend / API Client] + end + + subgraph "后端服务层" + API[API_Service :8000
账号/任务/日志管理] + AUTH[Auth_Service :8001
注册/登录/Token刷新] + SCHED[Task_Scheduler
Celery Beat] + EXEC[Signin_Executor
Celery Worker] + end + + subgraph "共享层" + SHARED[shared/
ORM Models + DB Session
+ Crypto Utils + Response Format] + end + + subgraph "基础设施" + MYSQL[(MySQL)] + REDIS[(Redis
Cache + Message Queue)] + PROXY[Proxy Pool] + end + + FE -->|REST API| API + FE -->|REST API| AUTH + API -->|导入| SHARED + AUTH -->|导入| SHARED + SCHED -->|导入| SHARED + EXEC -->|导入| SHARED + SHARED -->|aiomysql| MYSQL + SCHED -->|发布任务| REDIS + EXEC -->|消费任务| REDIS + EXEC -->|获取代理| PROXY + EXEC -->|签到请求| WEIBO[Weibo.com] + +``` + +### 关键架构决策 + +1. **共享模块而非微服务间 RPC**:各服务通过 Python 包导入 `shared/` 模块访问数据库,而非通过 HTTP 调用其他服务查询数据。这简化了部署,减少了网络延迟,适合当前规模。 +2. **API_Service 作为唯一对外网关**:所有账号管理、任务配置、日志查询 API 集中在 `api_service` 中,`auth_service` 仅负责认证。 +3. **Celery 同时承担调度和执行**:`task_scheduler` 运行 Celery Beat(调度),`signin_executor` 运行 Celery Worker(执行),通过 Redis 消息队列解耦。 +4. **Dockerfile 多阶段构建**:保持现有的多阶段 Dockerfile 结构,新增 `shared/` 目录的 COPY 步骤。 + +### 目录结构(重构后) + +``` +backend/ +├── shared/ # 新增:共享模块 +│ ├── __init__.py +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── base.py # SQLAlchemy Base + engine + session +│ │ ├── user.py # User ORM model +│ │ ├── account.py # Account ORM model +│ │ ├── task.py # Task ORM model +│ │ └── signin_log.py # SigninLog ORM model +│ ├── crypto.py # AES-256-GCM 加密/解密工具 +│ ├── response.py # 统一响应格式工具 +│ └── config.py # 共享配置(DB URL, Redis URL 等) +├── auth_service/ +│ └── app/ +│ ├── main.py # 重构:使用 shared models +│ ├── config.py +│ ├── schemas/ +│ │ └── user.py # 增加 RefreshToken schema +│ ├── services/ +│ │ └── auth_service.py # 增加 refresh token 逻辑 +│ └── utils/ +│ └── security.py # 增加 refresh token 生成/验证 +├── api_service/ +│ └── app/ +│ ├── __init__.py +│ ├── main.py # 新增:FastAPI 应用入口 +│ ├── config.py +│ ├── dependencies.py # JWT 认证依赖 +│ ├── schemas/ +│ │ ├── __init__.py +│ │ ├── account.py # 账号请求/响应 schema +│ │ ├── task.py # 任务请求/响应 schema +│ │ └── signin_log.py # 签到日志响应 schema +│ └── routers/ +│ ├── __init__.py +│ ├── accounts.py # 账号 CRUD 路由 +│ ├── tasks.py # 任务 CRUD 路由 +│ └── signin_logs.py # 签到日志查询路由 +├── signin_executor/ +│ └── app/ +│ ├── main.py +│ ├── config.py +│ ├── services/ +│ │ ├── signin_service.py # 重构:使用 shared models 查询真实数据 +│ │ └── weibo_client.py # 重构:实现真实加密/解密 +│ └── models/ +│ └── signin_models.py # 保留 Pydantic 请求/响应模型 +├── task_scheduler/ +│ └── app/ +│ ├── celery_app.py # 重构:从 DB 动态加载任务 +│ ├── config.py +│ └── tasks/ +│ └── signin_tasks.py # 重构:使用真实 DB 查询 +├── Dockerfile # 更新:各阶段 COPY shared/ +└── requirements.txt +``` + +## 组件与接口 + +### 1. shared 模块 + +#### 1.1 数据库连接管理 (`shared/models/base.py`) + +```python +# 提供异步 engine 和 session factory +# 所有服务通过 get_db() 获取 AsyncSession +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() +``` + +#### 1.2 加密工具 (`shared/crypto.py`) + +```python +def encrypt_cookie(plaintext: str, key: bytes) -> tuple[str, str]: + """AES-256-GCM 加密,返回 (密文base64, iv_base64)""" + +def decrypt_cookie(ciphertext_b64: str, iv_b64: str, key: bytes) -> str: + """AES-256-GCM 解密,返回原始 Cookie 字符串""" +``` + +#### 1.3 统一响应格式 (`shared/response.py`) + +```python +def success_response(data: Any, message: str = "Operation successful") -> dict +def error_response(message: str, code: str, details: list = None, status_code: int = 400) -> JSONResponse +``` + +### 2. Auth_Service 接口 + +| 方法 | 路径 | 描述 | 需求 | +|------|------|------|------| +| POST | `/auth/register` | 用户注册 | 1.1, 1.2, 1.8 | +| POST | `/auth/login` | 用户登录,返回 access_token + refresh_token | 1.3, 1.4 | +| POST | `/auth/refresh` | 刷新 Token | 1.5, 1.6 | +| GET | `/auth/me` | 获取当前用户信息 | 1.7 | + +### 3. API_Service 接口 + +| 方法 | 路径 | 描述 | 需求 | +|------|------|------|------| +| POST | `/api/v1/accounts` | 添加微博账号 | 2.1, 2.7, 2.8 | +| GET | `/api/v1/accounts` | 获取账号列表 | 2.2, 2.8 | +| GET | `/api/v1/accounts/{id}` | 获取账号详情 | 2.3, 2.6, 2.8 | +| PUT | `/api/v1/accounts/{id}` | 更新账号信息 | 2.4, 2.6, 2.8 | +| DELETE | `/api/v1/accounts/{id}` | 删除账号 | 2.5, 2.6, 2.8 | +| POST | `/api/v1/accounts/{id}/tasks` | 创建签到任务 | 4.1, 4.2, 4.6 | +| GET | `/api/v1/accounts/{id}/tasks` | 获取任务列表 | 4.3 | +| PUT | `/api/v1/tasks/{id}` | 启用/禁用任务 | 4.4 | +| DELETE | `/api/v1/tasks/{id}` | 删除任务 | 4.5 | +| GET | `/api/v1/accounts/{id}/signin-logs` | 查询签到日志 | 8.1, 8.2, 8.3, 8.4, 8.5 | + +### 4. Task_Scheduler 内部接口 + +Task_Scheduler 不对外暴露 HTTP 接口,通过以下方式工作: + +- **启动时**:从 DB 加载 `is_enabled=True` 的任务,注册到 Celery Beat +- **运行时**:根据 Cron 表达式触发 `execute_signin_task` Celery task +- **动态更新**:通过 Redis pub/sub 接收任务变更通知,动态更新调度 + +### 5. Signin_Executor 内部流程 + +```mermaid +sequenceDiagram + participant Queue as Redis Queue + participant Exec as Signin_Executor + participant DB as MySQL + participant Weibo as Weibo.com + participant Proxy as Proxy Pool + + Queue->>Exec: 消费签到任务 (task_id, account_id) + Exec->>DB: 查询 Account (by account_id) + Exec->>Exec: 解密 Cookie (AES-256-GCM) + Exec->>Weibo: 验证 Cookie 有效性 + alt Cookie 无效 + Exec->>DB: 更新 account.status = "invalid_cookie" + Exec->>DB: 写入失败日志 + else Cookie 有效 + Exec->>Weibo: 获取超话列表 + loop 每个未签到超话 + Exec->>Proxy: 获取代理 IP + Exec->>Exec: 随机延迟 (1-3s) + Exec->>Weibo: 执行签到请求 + Exec->>DB: 写入 signin_log + end + end +``` + +## 数据模型 + +### ORM 模型定义(shared/models/) + +#### User 模型 + +```python +class User(Base): + __tablename__ = "users" + id = Column(String(36), primary_key=True, default=lambda: str(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) + # Relationships + accounts = relationship("Account", back_populates="user", cascade="all, delete-orphan") +``` + +#### Account 模型 + +```python +class Account(Base): + __tablename__ = "accounts" + id = Column(String(36), primary_key=True, default=lambda: str(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") # pending, active, invalid_cookie, banned + last_checked_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + # Relationships + user = relationship("User", back_populates="accounts") + tasks = relationship("Task", back_populates="account", cascade="all, delete-orphan") + signin_logs = relationship("SigninLog", back_populates="account") +``` + +#### Task 模型 + +```python +class Task(Base): + __tablename__ = "tasks" + id = Column(String(36), primary_key=True, default=lambda: str(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()) + # Relationships + account = relationship("Account", back_populates="tasks") +``` + +#### SigninLog 模型 + +```python +class SigninLog(Base): + __tablename__ = "signin_logs" + id = Column(BigInteger, 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) # success, failed_already_signed, failed_network, failed_banned + reward_info = Column(JSON, nullable=True) + error_message = Column(Text, nullable=True) + signed_at = Column(DateTime, server_default=func.now()) + # Relationships + account = relationship("Account", back_populates="signin_logs") +``` + +### Refresh Token 存储 + +Refresh Token 使用 Redis 存储,key 格式为 `refresh_token:{token_hash}`,value 为 `user_id`,TTL 为 7 天。这避免了在数据库中增加额外的表,同时利用 Redis 的自动过期机制。 + +```python +# 存储 +await redis.setex(f"refresh_token:{token_hash}", 7 * 24 * 3600, user_id) + +# 验证 +user_id = await redis.get(f"refresh_token:{token_hash}") + +# 刷新时删除旧 token,生成新 token(Token Rotation) +await redis.delete(f"refresh_token:{old_token_hash}") +await redis.setex(f"refresh_token:{new_token_hash}", 7 * 24 * 3600, user_id) +``` + +## 正确性属性 (Correctness Properties) + +*属性(Property)是指在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。* + +### Property 1: Cookie 加密 Round-trip + +*For any* 有效的 Cookie 字符串,使用 AES-256-GCM 加密后再用相同密钥和 IV 解密,应产生与原始字符串完全相同的结果。 + +**Validates: Requirements 3.1, 3.2, 3.3** + +### Property 2: 用户注册后可登录获取信息 + +*For any* 有效的注册信息(用户名、邮箱、符合强度要求的密码),注册后使用相同邮箱和密码登录应成功返回 Token,使用该 Token 调用 `/auth/me` 应返回与注册时一致的用户名和邮箱。 + +**Validates: Requirements 1.1, 1.3, 1.7** + +### Property 3: 用户名/邮箱唯一性约束 + +*For any* 已注册的用户,使用相同用户名或相同邮箱再次注册应返回 409 Conflict 错误。 + +**Validates: Requirements 1.2** + +### Property 4: 无效凭证登录拒绝 + +*For any* 不存在的邮箱或错误的密码,登录请求应返回 401 Unauthorized 错误。 + +**Validates: Requirements 1.4** + +### Property 5: Refresh Token 轮换 + +*For any* 已登录用户的有效 Refresh Token,刷新操作应返回新的 Access Token 和新的 Refresh Token,且旧的 Refresh Token 应失效(再次使用应返回 401)。 + +**Validates: Requirements 1.5, 1.6** + +### Property 6: 弱密码拒绝 + +*For any* 不满足强度要求的密码(缺少大写字母、小写字母、数字或特殊字符,或长度不足8位),注册请求应返回 400 Bad Request。 + +**Validates: Requirements 1.8** + +### Property 7: 账号创建与列表一致性 + +*For any* 用户和任意数量的有效微博账号数据,创建 N 个账号后查询列表应返回恰好 N 条记录,每条记录的状态应为 "pending",且响应中不应包含解密后的 Cookie 明文。 + +**Validates: Requirements 2.1, 2.2, 2.7** + +### Property 8: 账号详情 Round-trip + +*For any* 已创建的微博账号,通过详情接口查询应返回与创建时一致的备注和微博用户 ID。 + +**Validates: Requirements 2.3** + +### Property 9: 账号更新反映 + +*For any* 已创建的微博账号和任意新的备注字符串,更新备注后再次查询应返回更新后的值。 + +**Validates: Requirements 2.4** + +### Property 10: 账号删除级联 + +*For any* 拥有关联 Task 和 SigninLog 的账号,删除该账号后,查询该账号的 Task 列表和 SigninLog 列表应返回空结果。 + +**Validates: Requirements 2.5** + +### Property 11: 跨用户资源隔离 + +*For any* 两个不同用户 A 和 B,用户 A 尝试访问、修改或删除用户 B 的账号、任务或签到日志时,应返回 403 Forbidden。 + +**Validates: Requirements 2.6, 4.6, 8.5** + +### Property 12: 受保护接口认证要求 + +*For any* 受保护的 API 端点(账号管理、任务管理、日志查询),不携带 JWT Token 或携带无效 Token 的请求应返回 401 Unauthorized。 + +**Validates: Requirements 2.8, 8.4, 9.4** + +### Property 13: 有效 Cron 表达式创建任务 + +*For any* 有效的 Cron 表达式和已存在的账号,创建任务应成功,且查询该账号的任务列表应包含新创建的任务。 + +**Validates: Requirements 4.1, 4.3** + +### Property 14: 无效 Cron 表达式拒绝 + +*For any* 无效的 Cron 表达式字符串,创建任务请求应返回 400 Bad Request。 + +**Validates: Requirements 4.2** + +### Property 15: 任务启用/禁用切换 + +*For any* 已创建的任务,切换 `is_enabled` 状态后查询应反映新的状态值。 + +**Validates: Requirements 4.4** + +### Property 16: 任务删除 + +*For any* 已创建的任务,删除后查询该任务应返回 404 或不在列表中出现。 + +**Validates: Requirements 4.5** + +### Property 17: 调度器加载已启用任务 + +*For any* 数据库中的任务集合,Task_Scheduler 启动时加载的任务数量应等于 `is_enabled=True` 的任务数量。 + +**Validates: Requirements 5.1** + +### Property 18: 分布式锁防重复调度 + +*For any* 签到任务,同一时刻并发触发两次应只产生一次实际执行。 + +**Validates: Requirements 5.5** + +### Property 19: 签到结果持久化 + +*For any* 签到执行结果(成功或失败),`signin_logs` 表中应存在对应的记录,且记录的 `account_id`、`status` 和 `topic_title` 与执行结果一致。 + +**Validates: Requirements 6.1, 6.4** + +### Property 20: Cookie 失效时更新账号状态 + +*For any* Cookie 已失效的账号,执行签到时应将账号状态更新为 "invalid_cookie"。 + +**Validates: Requirements 6.5, 3.4** + +### Property 21: 随机延迟范围 + +*For any* 调用反爬虫延迟函数的结果,延迟值应在配置的 `[min, max]` 范围内。 + +**Validates: Requirements 7.1** + +### Property 22: User-Agent 来源 + +*For any* 调用 User-Agent 选择函数的结果,返回的 UA 字符串应属于预定义列表中的某一个。 + +**Validates: Requirements 7.2** + +### Property 23: 签到日志时间倒序 + +*For any* 包含多条签到日志的账号,查询返回的日志列表应按 `signed_at` 降序排列。 + +**Validates: Requirements 8.1** + +### Property 24: 签到日志分页 + +*For any* 包含 N 条日志的账号和分页参数 (page, size),返回的记录数应等于 `min(size, N - (page-1)*size)` 且总记录数应等于 N。 + +**Validates: Requirements 8.2** + +### Property 25: 签到日志状态过滤 + +*For any* 状态过滤参数,返回的所有日志记录的 `status` 字段应与过滤参数一致。 + +**Validates: Requirements 8.3** + +### Property 26: 统一响应格式 + +*For any* API 调用,成功响应应包含 `success=true` 和 `data` 字段;错误响应应包含 `success=false`、`data=null` 和 `error` 字段。 + +**Validates: Requirements 9.1, 9.2, 9.3** + +## 错误处理 + +### 错误分类与处理策略 + +| 错误类型 | HTTP 状态码 | 错误码 | 处理策略 | +|----------|------------|--------|----------| +| 请求参数校验失败 | 400 | VALIDATION_ERROR | 返回字段级错误详情 | +| 未认证 | 401 | UNAUTHORIZED | 返回标准 401 响应 | +| 权限不足 | 403 | FORBIDDEN | 返回资源不可访问提示 | +| 资源不存在 | 404 | NOT_FOUND | 返回资源未找到提示 | +| 资源冲突 | 409 | CONFLICT | 返回冲突字段说明 | +| 服务器内部错误 | 500 | INTERNAL_ERROR | 记录详细日志,返回通用错误提示 | + +### 签到执行错误处理 + +- **Cookie 解密失败**:标记账号为 `invalid_cookie`,记录错误日志,终止该账号签到 +- **Cookie 验证失败**(微博返回未登录):同上 +- **网络超时/连接错误**:记录 `failed_network` 日志,不更改账号状态(可能是临时问题) +- **微博返回封禁**:标记账号为 `banned`,记录日志,发送通知 +- **代理池不可用**:降级为直连,记录警告日志 +- **Celery 任务失败**:自动重试最多3次,间隔60秒,最终失败记录日志 + +### 全局异常处理 + +所有 FastAPI 服务注册统一的异常处理器: + +```python +@app.exception_handler(HTTPException) +async def http_exception_handler(request, exc): + 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, exc): + details = [{"field": e["loc"][-1], "message": e["msg"]} for e in exc.errors()] + return error_response("Validation failed", "VALIDATION_ERROR", details, 400) +``` + +## 测试策略 + +### 测试框架选择 + +- **单元测试**:`pytest` + `pytest-asyncio`(异步测试支持) +- **属性测试**:`hypothesis`(Python 属性测试库) +- **HTTP 测试**:`httpx` + FastAPI `TestClient` +- **数据库测试**:使用 SQLite in-memory 或测试专用 MySQL 实例 + +### 测试分层 + +#### 1. 单元测试 +- 加密/解密函数的边界情况 +- 密码强度验证的各种组合 +- Cron 表达式验证 +- 响应格式化函数 +- 具体的错误场景(网络超时、解密失败等) + +#### 2. 属性测试(Property-Based Testing) +- 使用 `hypothesis` 库,每个属性测试至少运行 100 次迭代 +- 每个测试用注释标注对应的设计文档属性编号 +- 标注格式:`# Feature: multi-user-signin, Property {N}: {property_text}` +- 每个正确性属性对应一个独立的属性测试函数 + +#### 3. 集成测试 +- API 端点的完整请求/响应流程 +- 数据库 CRUD 操作的正确性 +- 服务间通过 Redis 消息队列的交互 +- Celery 任务的调度和执行 + +### 属性测试配置 + +```python +from hypothesis import given, settings, strategies as st + +@settings(max_examples=100) +@given(cookie=st.text(min_size=1, max_size=1000)) +def test_cookie_encryption_roundtrip(cookie): + """Feature: multi-user-signin, Property 1: Cookie 加密 Round-trip""" + key = generate_test_key() + ciphertext, iv = encrypt_cookie(cookie, key) + decrypted = decrypt_cookie(ciphertext, iv, key) + assert decrypted == cookie +``` + +### 测试目录结构 + +``` +backend/ +├── tests/ +│ ├── conftest.py # 共享 fixtures(DB session, test client 等) +│ ├── unit/ +│ │ ├── test_crypto.py # 加密/解密单元测试 +│ │ ├── test_password.py # 密码验证单元测试 +│ │ └── test_cron.py # Cron 表达式验证单元测试 +│ ├── property/ +│ │ ├── test_crypto_props.py # Property 1 +│ │ ├── test_auth_props.py # Property 2-6 +│ │ ├── test_account_props.py # Property 7-12 +│ │ ├── test_task_props.py # Property 13-18 +│ │ ├── test_signin_props.py # Property 19-22 +│ │ └── test_log_props.py # Property 23-26 +│ └── integration/ +│ ├── test_auth_flow.py # 完整认证流程 +│ ├── test_account_flow.py # 账号管理流程 +│ └── test_signin_flow.py # 签到执行流程 +``` diff --git a/.kiro/specs/multi-user-signin/requirements.md b/.kiro/specs/multi-user-signin/requirements.md new file mode 100644 index 0000000..9364151 --- /dev/null +++ b/.kiro/specs/multi-user-signin/requirements.md @@ -0,0 +1,138 @@ +# 需求文档:Weibo-HotSign 多用户签到系统 + +## 简介 + +Weibo-HotSign 是一个分布式微博超话自动签到系统,采用微服务架构(FastAPI + Celery + MySQL + Redis)。本需求文档覆盖系统的五大核心模块:用户认证(含 Token 刷新)、微博账号管理、定时签到任务配置、签到执行引擎、以及整体架构重构。目标是将当前分散的、含大量 Mock 实现的代码库重构为一个真正可运行的、模块间紧密集成的生产级系统。 + +## 术语表 + +- **System**: 指 Weibo-HotSign 后端系统整体 +- **Auth_Service**: 用户认证与授权服务(`backend/auth_service`) +- **API_Service**: API 网关与账号/任务管理服务(`backend/api_service`) +- **Task_Scheduler**: 基于 Celery Beat 的定时任务调度服务(`backend/task_scheduler`) +- **Signin_Executor**: 签到执行 Worker 服务(`backend/signin_executor`) +- **User**: 使用本系统的注册用户 +- **Weibo_Account**: 用户绑定到系统中的微博账号,以 Cookie 形式存储凭证 +- **Task**: 用户为某个 Weibo_Account 配置的定时签到任务 +- **Cookie**: 微博网站的登录凭证,用于模拟已登录状态 +- **Cron_Expression**: 标准 Cron 表达式,用于定义任务调度时间 +- **Signin_Log**: 每次签到执行的结果记录 +- **JWT**: JSON Web Token,用于用户身份认证 +- **Refresh_Token**: 用于在 Access Token 过期后获取新 Token 的长期凭证 +- **AES-256-GCM**: 对称加密算法,用于加密存储 Cookie + +## 需求 + +### 需求 1:用户认证与 Token 管理 + +**用户故事:** 作为用户,我希望能够注册、登录并安全地维持会话,以便长期使用系统而无需频繁重新登录。 + +#### 验收标准 + +1. WHEN 用户提交有效的注册信息(用户名、邮箱、密码),THE Auth_Service SHALL 创建用户账户并返回用户信息 +2. WHEN 用户提交的用户名或邮箱已存在,THE Auth_Service SHALL 返回 409 Conflict 错误并指明冲突字段 +3. WHEN 用户提交有效的邮箱和密码进行登录,THE Auth_Service SHALL 返回包含 Access Token 和 Refresh Token 的认证响应 +4. WHEN 用户提交无效的邮箱或密码进行登录,THE Auth_Service SHALL 返回 401 Unauthorized 错误 +5. WHEN 用户携带有效的 Refresh Token 请求刷新,THE Auth_Service SHALL 返回新的 Access Token 和新的 Refresh Token +6. WHEN 用户携带过期或无效的 Refresh Token 请求刷新,THE Auth_Service SHALL 返回 401 Unauthorized 错误 +7. WHEN 用户携带有效的 Access Token 请求 `/auth/me`,THE Auth_Service SHALL 返回当前用户的完整信息 +8. IF 用户密码不满足强度要求(至少8位,含大小写字母、数字和特殊字符),THEN THE Auth_Service SHALL 返回 400 Bad Request 并说明密码强度不足 + +### 需求 2:微博账号管理 + +**用户故事:** 作为用户,我希望能够添加、查看、更新和删除我的微博账号,以便集中管理多个微博账号的签到。 + +#### 验收标准 + +1. WHEN 用户提交微博 Cookie 和备注信息,THE API_Service SHALL 使用 AES-256-GCM 加密 Cookie 后存储,并返回新创建的账号信息 +2. WHEN 用户请求获取账号列表,THE API_Service SHALL 返回该用户拥有的所有 Weibo_Account(不包含解密后的 Cookie) +3. WHEN 用户请求获取单个账号详情,THE API_Service SHALL 返回该账号的状态、备注和最近签到信息 +4. WHEN 用户请求更新账号的备注或 Cookie,THE API_Service SHALL 更新对应字段并返回更新后的账号信息 +5. WHEN 用户请求删除一个账号,THE API_Service SHALL 级联删除该账号关联的所有 Task 和 Signin_Log,并返回成功响应 +6. IF 用户尝试操作不属于自己的账号,THEN THE API_Service SHALL 返回 403 Forbidden 错误 +7. WHEN 账号被创建时,THE API_Service SHALL 将账号状态初始化为 "pending" +8. THE API_Service SHALL 对所有账号管理接口要求有效的 JWT Access Token 认证 + +### 需求 3:Cookie 加密与验证 + +**用户故事:** 作为用户,我希望我的微博 Cookie 被安全存储,并且系统能自动检测 Cookie 是否失效,以便我及时更新。 + +#### 验收标准 + +1. WHEN 存储 Cookie 时,THE API_Service SHALL 使用 AES-256-GCM 算法加密,并将密文和 IV 分别存储到 `encrypted_cookies` 和 `iv` 字段 +2. WHEN 读取 Cookie 用于签到时,THE Signin_Executor SHALL 使用对应的 IV 解密 Cookie 并还原为原始字符串 +3. FOR ALL 有效的 Cookie 字符串,加密后再解密 SHALL 产生与原始字符串完全相同的结果(Round-trip 属性) +4. IF 解密过程中发生错误(密钥不匹配、数据损坏),THEN THE System SHALL 将账号状态标记为 "invalid_cookie" 并记录错误日志 + +### 需求 4:定时签到任务配置 + +**用户故事:** 作为用户,我希望能够为每个微博账号配置独立的签到时间计划,以便灵活控制签到频率和时间。 + +#### 验收标准 + +1. WHEN 用户为某个账号创建签到任务并提供有效的 Cron_Expression,THE API_Service SHALL 创建任务记录并将任务注册到 Task_Scheduler +2. WHEN 用户提交无效的 Cron_Expression,THE API_Service SHALL 返回 400 Bad Request 并说明表达式格式错误 +3. WHEN 用户请求获取某个账号的任务列表,THE API_Service SHALL 返回该账号关联的所有 Task 及其启用状态 +4. WHEN 用户启用或禁用一个任务,THE API_Service SHALL 更新数据库中的 `is_enabled` 字段,并同步更新 Task_Scheduler 中的调度状态 +5. WHEN 用户删除一个任务,THE API_Service SHALL 从数据库删除任务记录,并从 Task_Scheduler 中移除对应的调度 +6. IF 用户尝试为不属于自己的账号创建任务,THEN THE API_Service SHALL 返回 403 Forbidden 错误 + +### 需求 5:任务调度引擎 + +**用户故事:** 作为系统,我需要根据用户配置的 Cron 表达式准时触发签到任务,以确保签到按时执行。 + +#### 验收标准 + +1. WHEN Task_Scheduler 启动时,THE Task_Scheduler SHALL 从数据库加载所有 `is_enabled=True` 的任务并注册到 Celery Beat 调度器 +2. WHEN Celery Beat 根据 Cron_Expression 触发一个任务,THE Task_Scheduler SHALL 向消息队列发送包含 `task_id` 和 `account_id` 的签到消息 +3. WHEN 新任务被创建或现有任务被更新,THE Task_Scheduler SHALL 动态更新 Celery Beat 的调度配置而无需重启服务 +4. IF 任务执行失败,THEN THE Task_Scheduler SHALL 按照配置的重试策略(最多3次,间隔60秒)进行重试 +5. WHILE Task_Scheduler 运行中,THE Task_Scheduler SHALL 使用 Redis 分布式锁确保同一任务不会被重复调度 + +### 需求 6:签到执行引擎 + +**用户故事:** 作为系统,我需要真正执行微博超话签到操作,并将结果持久化到数据库,以替代当前的 Mock 实现。 + +#### 验收标准 + +1. WHEN Signin_Executor 从消息队列接收到签到任务,THE Signin_Executor SHALL 从数据库查询对应的 Weibo_Account 信息(替代 Mock 数据) +2. WHEN 执行签到前,THE Signin_Executor SHALL 解密 Cookie 并验证其有效性 +3. WHEN Cookie 有效时,THE Signin_Executor SHALL 获取该账号关注的超话列表并逐一执行签到 +4. WHEN 单个超话签到完成后,THE Signin_Executor SHALL 将结果(成功/失败/已签到、奖励信息、错误信息)写入 `signin_logs` 表 +5. IF Cookie 已失效,THEN THE Signin_Executor SHALL 将账号状态更新为 "invalid_cookie" 并终止该账号的签到流程 +6. IF 签到过程中遇到网络错误,THEN THE Signin_Executor SHALL 记录错误日志并将该超话的签到状态标记为 "failed_network" + +### 需求 7:反爬虫防护 + +**用户故事:** 作为系统,我需要在执行签到时采取反爬虫措施,以降低被微博风控系统检测和封禁的风险。 + +#### 验收标准 + +1. WHEN 执行签到请求时,THE Signin_Executor SHALL 在每次请求之间插入随机延迟(1-3秒可配置) +2. WHEN 构造 HTTP 请求时,THE Signin_Executor SHALL 从预定义的 User-Agent 列表中随机选择一个 +3. WHEN 代理池服务可用时,THE Signin_Executor SHALL 为每次签到请求分配一个代理 IP +4. IF 代理池服务不可用,THEN THE Signin_Executor SHALL 使用直连方式继续执行签到并记录警告日志 + +### 需求 8:签到日志与查询 + +**用户故事:** 作为用户,我希望能够查看每个微博账号的签到历史记录,以便了解签到执行情况。 + +#### 验收标准 + +1. WHEN 用户请求查看某个账号的签到日志,THE API_Service SHALL 返回按时间倒序排列的 Signin_Log 列表 +2. WHEN 用户请求签到日志时提供分页参数,THE API_Service SHALL 返回对应页码的日志数据和总记录数 +3. WHEN 用户请求签到日志时提供状态过滤参数,THE API_Service SHALL 仅返回匹配该状态的日志记录 +4. THE API_Service SHALL 对签到日志查询接口要求有效的 JWT Access Token 认证 +5. IF 用户尝试查看不属于自己账号的签到日志,THEN THE API_Service SHALL 返回 403 Forbidden 错误 + +### 需求 9:统一 API 响应格式与错误处理 + +**用户故事:** 作为 API 消费者,我希望所有接口返回统一格式的响应,以便前端能够一致地处理成功和错误情况。 + +#### 验收标准 + +1. THE API_Service SHALL 对所有成功响应返回 `{"success": true, "data": ..., "message": ...}` 格式 +2. THE API_Service SHALL 对所有错误响应返回 `{"success": false, "data": null, "message": ..., "error": {"code": ..., "details": [...]}}` 格式 +3. WHEN 请求参数校验失败,THE API_Service SHALL 返回 400 状态码,并在 `error.details` 中列出每个字段的具体错误 +4. WHEN 未认证用户访问受保护接口,THE API_Service SHALL 返回 401 状态码和标准错误响应 + diff --git a/.kiro/specs/multi-user-signin/tasks.md b/.kiro/specs/multi-user-signin/tasks.md new file mode 100644 index 0000000..5b32fe9 --- /dev/null +++ b/.kiro/specs/multi-user-signin/tasks.md @@ -0,0 +1,185 @@ +# 实现计划:Weibo-HotSign 多用户签到系统 + +## 概述 + +按照自底向上的顺序实现:先构建共享基础层,再逐步实现各微服务。每个阶段包含核心实现和对应的测试任务。 + +## Tasks + +- [x] 1. 创建共享模块 (shared/) + - [x] 1.1 创建 `backend/shared/` 包结构和共享配置 + - 创建 `shared/__init__.py`、`shared/config.py` + - 配置项包括 DATABASE_URL、REDIS_URL、JWT_SECRET_KEY、COOKIE_ENCRYPTION_KEY + - 使用 pydantic-settings 从环境变量加载 + - _Requirements: 10.1, 10.2_ + - [x] 1.2 创建共享 ORM 模型和数据库连接管理 + - 创建 `shared/models/base.py`:AsyncEngine、AsyncSessionLocal、Base、get_db() + - 创建 `shared/models/user.py`:User 模型(含 accounts relationship) + - 创建 `shared/models/account.py`:Account 模型(含 tasks、signin_logs relationship) + - 创建 `shared/models/task.py`:Task 模型 + - 创建 `shared/models/signin_log.py`:SigninLog 模型 + - 所有模型与 `init-db.sql` 中的表结构对齐 + - _Requirements: 10.1, 10.2, 10.3_ + - [x] 1.3 实现 Cookie 加密/解密工具 (`shared/crypto.py`) + - 使用 pycryptodome 实现 AES-256-GCM 加密/解密 + - `encrypt_cookie(plaintext, key) -> (ciphertext_b64, iv_b64)` + - `decrypt_cookie(ciphertext_b64, iv_b64, key) -> plaintext` + - 密钥从环境变量 COOKIE_ENCRYPTION_KEY 派生(使用 SHA-256 哈希为32字节) + - _Requirements: 3.1, 3.2, 10.4_ + - [ ]* 1.4 编写 Cookie 加密 Round-trip 属性测试 + - **Property 1: Cookie 加密 Round-trip** + - 使用 hypothesis 生成随机字符串,验证 encrypt 后 decrypt 还原 + - **Validates: Requirements 3.1, 3.2, 3.3** + - [x] 1.5 实现统一响应格式工具 (`shared/response.py`) + - `success_response(data, message)` 返回标准成功格式 + - `error_response(message, code, details, status_code)` 返回标准错误格式 + - _Requirements: 9.1, 9.2_ + +- [x] 2. 重构 Auth_Service(Token 刷新机制) + - [x] 2.1 重构 Auth_Service 使用 shared 模块 + - 修改 `auth_service/app/main.py` 导入 shared models 和 get_db + - 删除 `auth_service/app/models/database.py` 中的重复 User 模型定义 + - 更新 `auth_service/app/services/auth_service.py` 使用 shared User 模型 + - _Requirements: 10.3_ + - [x] 2.2 实现 Refresh Token 机制 + - 在 `auth_service/app/utils/security.py` 中添加 `create_refresh_token()` 和 `verify_refresh_token()` + - Refresh Token 使用 Redis 存储(key: `refresh_token:{hash}`, value: `user_id`, TTL: 7天) + - 登录接口返回 access_token + refresh_token + - 实现 `/auth/refresh` 端点:验证旧 token → 删除旧 token → 生成新 token 对(Token Rotation) + - 更新 `auth_service/app/schemas/user.py` 添加 RefreshToken 相关 schema + - _Requirements: 1.3, 1.5, 1.6_ + - [x] 2.3 为 Auth_Service 所有响应应用统一格式 + - 注册、登录、刷新、获取用户信息接口使用 `shared/response.py` 格式化响应 + - 注册全局异常处理器(HTTPException、RequestValidationError) + - _Requirements: 9.1, 9.2, 9.3, 9.4_ + - [ ]* 2.4 编写认证流程属性测试 + - **Property 2: 用户注册后可登录获取信息** + - **Property 3: 用户名/邮箱唯一性约束** + - **Property 4: 无效凭证登录拒绝** + - **Property 5: Refresh Token 轮换** + - **Property 6: 弱密码拒绝** + - **Validates: Requirements 1.1-1.8** + +- [x] 3. Checkpoint - 确保共享模块和认证服务测试通过 + - 运行所有测试,确认 shared 模块和 auth_service 工作正常 + - 如有问题请向用户确认 + +- [x] 4. 实现 API_Service(账号管理) + - [x] 4.1 创建 API_Service 基础结构 + - 创建 `api_service/app/__init__.py`、`main.py`、`config.py`、`dependencies.py` + - `main.py`:FastAPI 应用,注册 CORS、全局异常处理器、路由 + - `dependencies.py`:JWT 认证依赖(`get_current_user`),复用 shared 的 JWT 验证逻辑 + - _Requirements: 2.8, 9.1, 9.2, 9.3, 9.4_ + - [x] 4.2 实现微博账号 CRUD 路由 + - 创建 `api_service/app/schemas/account.py`:AccountCreate、AccountUpdate、AccountResponse + - 创建 `api_service/app/routers/accounts.py`: + - `POST /api/v1/accounts`:加密 Cookie 后存储,状态初始化为 "pending" + - `GET /api/v1/accounts`:返回当前用户的账号列表(不含 Cookie 明文) + - `GET /api/v1/accounts/{id}`:返回账号详情 + - `PUT /api/v1/accounts/{id}`:更新备注或 Cookie(更新 Cookie 时重新加密) + - `DELETE /api/v1/accounts/{id}`:删除账号(级联删除 tasks 和 logs) + - 所有接口验证资源归属(user_id 匹配) + - _Requirements: 2.1-2.8_ + - [ ]* 4.3 编写账号管理属性测试 + - **Property 7: 账号创建与列表一致性** + - **Property 8: 账号详情 Round-trip** + - **Property 9: 账号更新反映** + - **Property 10: 账号删除级联** + - **Property 11: 跨用户资源隔离** + - **Property 12: 受保护接口认证要求** + - **Validates: Requirements 2.1-2.8, 4.6, 8.5, 8.4, 9.4** + +- [-] 5. 实现 API_Service(任务配置) + - [x] 5.1 实现签到任务 CRUD 路由 + - 创建 `api_service/app/schemas/task.py`:TaskCreate、TaskUpdate、TaskResponse + - 创建 `api_service/app/routers/tasks.py`: + - `POST /api/v1/accounts/{id}/tasks`:验证 Cron 表达式有效性,创建任务 + - `GET /api/v1/accounts/{id}/tasks`:获取账号的任务列表 + - `PUT /api/v1/tasks/{id}`:更新任务(启用/禁用) + - `DELETE /api/v1/tasks/{id}`:删除任务 + - 使用 `croniter` 库验证 Cron 表达式 + - 任务创建/更新/删除时通过 Redis pub/sub 通知 Task_Scheduler + - _Requirements: 4.1-4.6_ + - [ ]* 5.2 编写任务配置属性测试 + - **Property 13: 有效 Cron 表达式创建任务** + - **Property 14: 无效 Cron 表达式拒绝** + - **Property 15: 任务启用/禁用切换** + - **Property 16: 任务删除** + - **Validates: Requirements 4.1-4.6** + +- [x] 6. 实现 API_Service(签到日志查询) + - [x] 6.1 实现签到日志查询路由 + - 创建 `api_service/app/schemas/signin_log.py`:SigninLogResponse、PaginatedResponse + - 创建 `api_service/app/routers/signin_logs.py`: + - `GET /api/v1/accounts/{id}/signin-logs`:支持分页(page, size)和状态过滤(status) + - 返回按 `signed_at` 降序排列的日志 + - 返回总记录数用于前端分页 + - 验证账号归属权限 + - _Requirements: 8.1-8.5_ + - [ ]* 6.2 编写签到日志查询属性测试 + - **Property 23: 签到日志时间倒序** + - **Property 24: 签到日志分页** + - **Property 25: 签到日志状态过滤** + - **Validates: Requirements 8.1-8.3** + +- [ ] 7. Checkpoint - 确保 API_Service 所有测试通过 + - 运行所有测试,确认账号管理、任务配置、日志查询功能正常 + - 如有问题请向用户确认 + +- [ ] 8. 重构 Task_Scheduler(真实数据库交互) + - [ ] 8.1 重构 Task_Scheduler 使用 shared 模块 + - 修改 `task_scheduler/app/celery_app.py` 导入 shared models + - 实现 `load_scheduled_tasks()`:从 DB 查询 `is_enabled=True` 的 Task,动态注册到 Celery Beat + - 实现 Redis pub/sub 监听:接收任务变更通知,动态更新调度 + - 替换 `signin_tasks.py` 中的 mock 账号列表为真实 DB 查询 + - _Requirements: 5.1, 5.2, 5.3_ + - [ ] 8.2 实现分布式锁和重试机制 + - 使用 Redis SETNX 实现分布式锁,防止同一任务重复调度 + - 配置 Celery 任务重试:`max_retries=3`、`default_retry_delay=60` + - _Requirements: 5.4, 5.5_ + - [ ]* 8.3 编写调度器属性测试 + - **Property 17: 调度器加载已启用任务** + - **Property 18: 分布式锁防重复调度** + - **Validates: Requirements 5.1, 5.5** + +- [ ] 9. 重构 Signin_Executor(真实数据库交互) + - [ ] 9.1 重构 Signin_Executor 使用 shared 模块 + - 修改 `signin_service.py` 中的 `_get_account_info()` 从 DB 查询真实 Account 数据 + - 修改 `weibo_client.py` 中的 `_decrypt_cookies()` 使用 `shared/crypto.py` + - 实现签到结果写入 `signin_logs` 表(替代 mock) + - 实现 Cookie 失效时更新 `account.status = "invalid_cookie"` + - _Requirements: 6.1, 6.2, 6.4, 6.5_ + - [ ] 9.2 实现反爬虫防护模块 + - 实现随机延迟函数:返回 `[min, max]` 范围内的随机值 + - 实现 User-Agent 轮换:从预定义列表中随机选择 + - 实现代理池集成:调用 proxy pool 服务获取代理,不可用时降级为直连 + - _Requirements: 7.1, 7.2, 7.3, 7.4_ + - [ ]* 9.3 编写签到执行属性测试 + - **Property 19: 签到结果持久化** + - **Property 20: Cookie 失效时更新账号状态** + - **Property 21: 随机延迟范围** + - **Property 22: User-Agent 来源** + - **Validates: Requirements 6.1, 6.4, 6.5, 7.1, 7.2** + +- [ ] 10. 更新 Dockerfile 和集成配置 + - [ ] 10.1 更新 `backend/Dockerfile` + - 在每个构建阶段添加 `COPY shared/ ./shared/` + - 确保 shared 模块在所有服务容器中可用 + - _Requirements: 10.1, 10.3_ + - [ ] 10.2 更新 `backend/requirements.txt` + - 添加 `croniter`(Cron 表达式解析) + - 添加 `hypothesis`(属性测试) + - 添加 `pytest`、`pytest-asyncio`(测试框架) + - 确认 `pycryptodome`、`redis`、`celery` 等已存在 + +- [ ] 11. 最终 Checkpoint - 全量测试 + - 运行所有单元测试和属性测试 + - 验证各服务可独立启动 + - 如有问题请向用户确认 + +## 备注 + +- 标记 `*` 的任务为可选测试任务,可跳过以加快 MVP 进度 +- 每个任务引用了具体的需求编号以确保可追溯性 +- 属性测试使用 `hypothesis` 库,每个测试至少运行 100 次迭代 +- Checkpoint 任务用于阶段性验证,确保增量开发的正确性 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6febbcd --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# Weibo-HotSign - 微博超话智能签到系统 + +基于开发文档实现的分布式微博超话智能签到系统,具备多账户管理、高稳定性反爬虫、Web可视化管理等核心功能。 + +## 🏗️ 项目架构 + +本项目采用微服务架构,包含以下核心服务: + +- **认证服务** (auth_service) - 用户注册、登录、JWT认证 +- **API网关** (api_service) - 统一API入口和路由 +- **任务调度** (task_scheduler) - 基于Celery Beat的定时任务 +- **签到执行** (signin_executor) - 核心签到业务逻辑 +- **浏览器自动化** (browser_automation_service) - 处理复杂JS加密 +- **通知中心** (notification_hub) - 多渠道通知分发 +- **前端应用** (frontend) - React可视化界面 + +## 🚀 快速启动 + +### 环境要求 +- Docker & Docker Compose +- Python 3.11+ +- Node.js 18+ + +### 启动步骤 + +1. **克隆项目** + ```bash + cd d:/code/weibo + ``` + +2. **启动所有服务** + ```bash + docker-compose up -d + ``` + +3. **查看服务状态** + ```bash + docker-compose ps + ``` + +4. **访问服务** + - 前端界面: http://localhost:3000 + - API文档: http://localhost:8000/docs + - 认证服务: http://localhost:8001/docs + - 健康检查: http://localhost:8000/health + +## 📋 已实现功能 + +### ✅ 认证服务 (auth_service) +- [x] 用户注册 (`POST /auth/register`) +- [x] 用户登录 (`POST /auth/login`) +- [x] JWT Token生成和验证 +- [x] 密码强度验证和bcrypt哈希 +- [x] CORS跨域支持 +- [x] 数据库连接管理 +- [x] 完整的错误处理和日志记录 + +### ✅ 任务调度服务 (task_scheduler) +- [x] Celery Beat定时任务调度 +- [x] Cron表达式解析和动态任务加载 +- [x] 任务队列管理 (Redis) +- [x] 任务重试和错误处理 +- [x] 调用签到执行服务 + +### ✅ 签到执行服务 (signin_executor) +- [x] 微博超话签到核心逻辑 +- [x] 动态IP代理池集成 (模拟) +- [x] 浏览器指纹模拟 (模拟) +- [x] Cookie管理和验证 (模拟) +- [x] 完整的签到工作流和状态管理 +- [x] 反爬虫保护机制 (随机延迟) + +### ✅ 基础设施 +- [x] Docker容器化配置 +- [x] PostgreSQL数据库初始化 +- [x] Redis缓存配置 +- [x] Nginx反向代理配置 +- [x] 微服务网络通信 + +### 🔄 待实现功能 + +#### API网关服务 (api_service) +- 请求路由和负载均衡 +- API组合和聚合 +- 速率限制和熔断 + +#### 浏览器自动化服务 (browser_automation_service) +- Playwright无头浏览器 +- JS加密参数逆向 +- 网络请求拦截和提取 + +#### 前端React应用 (frontend) +- 用户登录注册界面 +- 账号管理面板 +- 任务配置界面 +- 签到日志查看 +- 实时状态监控 + +#### 通知中心服务 (notification_hub) +- 多渠道通知分发 (Server酱, Email等) + +## 🛠️ 技术栈 + +### 后端 +- **Web框架**: FastAPI (Python) +- **数据库**: PostgreSQL + SQLAlchemy +- **缓存**: Redis +- **任务队列**: Celery + Redis +- **认证**: JWT + bcrypt +- **浏览器自动化**: Playwright + +### 前端 +- **框架**: React 18 + Vite +- **状态管理**: Zustand +- **UI库**: Ant Design +- **HTTP客户端**: Axios + +### 基础设施 +- **容器化**: Docker + Docker Compose +- **反向代理**: Nginx +- **监控**: Prometheus + Grafana +- **日志**: ELK Stack + +## 📊 数据库设计 + +系统包含以下核心数据表: + +- `users` - 用户信息 +- `accounts` - 微博账号管理 +- `tasks` - 签到任务配置 +- `signin_logs` - 签到历史记录 + +详细表结构见 `init-db.sql` + +## 🔧 配置说明 + +### 环境变量 +主要配置通过环境变量设置: + +```bash +# 数据库 +DATABASE_URL=postgresql+asyncpg://user:pass@postgres:5432/dbname +REDIS_URL=redis://redis:6379 + +# JWT +JWT_SECRET_KEY=your-super-secret-jwt-key +JWT_EXPIRATION_HOURS=24 + +# 应用 +DEBUG=true +HOST=0.0.0.0 +PORT=8000 +``` + +## 📝 API规范 + +遵循RESTful设计规范: + +- **协议**: HTTPS +- **数据格式**: JSON +- **认证**: Bearer Token (JWT) +- **版本控制**: URL路径 (`/api/v1/`) +- **通用响应结构**: + ```json + { + "success": true, + "data": {...}, + "message": "Operation successful", + "error": null + } + ``` + +## 🤝 贡献指南 + +1. Fork项目 +2. 创建特性分支 +3. 提交代码变更 +4. 推送到分支 +5. 创建Pull Request + +## 📄 许可证 + +MIT License + +## 🙏 致谢 + +感谢开发文档提供的详细技术规范和架构指导,本实现严格遵循文档中的各项技术要求。 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ac2e1e2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,91 @@ +# Base stage for all Python services +FROM python:3.11-slim AS base + +# Set working directory +WORKDIR /app + +# Install common system dependencies for MySQL +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy and install unified requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + + +# --- API Gateway Service Stage --- +FROM base AS api_gateway + +# Copy application code +COPY api_service/app/ ./app/ + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Start application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + + +# --- Auth Service Stage --- +FROM base AS auth_service + +# Copy application code +COPY auth_service/app/ ./app/ + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Start application +CMD ["python", "-m", "app.main"] + + +# --- Task Scheduler Service Stage --- +FROM base AS task_scheduler + +# Copy application code +COPY task_scheduler/app/ ./app/ + +# Switch to non-root user +USER appuser + +# Start Celery Beat scheduler +CMD ["celery", "-A", "app.celery_app", "beat", "--loglevel=info"] + + +# --- Sign-in Executor Service Stage --- +FROM base AS signin_executor + +# Copy application code +COPY signin_executor/app/ ./app/ + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Start application +CMD ["python", "-m", "app.main"] diff --git a/backend/api_service/__init__.py b/backend/api_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_service/__pycache__/__init__.cpython-311.pyc b/backend/api_service/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..24d4659 Binary files /dev/null and b/backend/api_service/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/api_service/app/__init__.py b/backend/api_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_service/app/__pycache__/__init__.cpython-311.pyc b/backend/api_service/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..fd18599 Binary files /dev/null and b/backend/api_service/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/api_service/app/__pycache__/dependencies.cpython-311.pyc b/backend/api_service/app/__pycache__/dependencies.cpython-311.pyc new file mode 100644 index 0000000..50b585b Binary files /dev/null and b/backend/api_service/app/__pycache__/dependencies.cpython-311.pyc differ diff --git a/backend/api_service/app/__pycache__/main.cpython-311.pyc b/backend/api_service/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..d7fb92d Binary files /dev/null and b/backend/api_service/app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/api_service/app/config.py b/backend/api_service/app/config.py new file mode 100644 index 0000000..35ee1b8 --- /dev/null +++ b/backend/api_service/app/config.py @@ -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" diff --git a/backend/api_service/app/dependencies.py b/backend/api_service/app/dependencies.py new file mode 100644 index 0000000..d180a03 --- /dev/null +++ b/backend/api_service/app/dependencies.py @@ -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 diff --git a/backend/api_service/app/main.py b/backend/api_service/app/main.py new file mode 100644 index 0000000..489a5b3 --- /dev/null +++ b/backend/api_service/app/main.py @@ -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"}) diff --git a/backend/api_service/app/routers/__init__.py b/backend/api_service/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_service/app/routers/__pycache__/__init__.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c9ee81f Binary files /dev/null and b/backend/api_service/app/routers/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc new file mode 100644 index 0000000..83e4255 Binary files /dev/null and b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc differ diff --git a/backend/api_service/app/routers/__pycache__/signin_logs.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/signin_logs.cpython-311.pyc new file mode 100644 index 0000000..7b213ca Binary files /dev/null and b/backend/api_service/app/routers/__pycache__/signin_logs.cpython-311.pyc differ diff --git a/backend/api_service/app/routers/__pycache__/tasks.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/tasks.cpython-311.pyc new file mode 100644 index 0000000..cb60c3a Binary files /dev/null and b/backend/api_service/app/routers/__pycache__/tasks.cpython-311.pyc differ diff --git a/backend/api_service/app/routers/accounts.py b/backend/api_service/app/routers/accounts.py new file mode 100644 index 0000000..59193b9 --- /dev/null +++ b/backend/api_service/app/routers/accounts.py @@ -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") diff --git a/backend/api_service/app/routers/signin_logs.py b/backend/api_service/app/routers/signin_logs.py new file mode 100644 index 0000000..5a4c636 --- /dev/null +++ b/backend/api_service/app/routers/signin_logs.py @@ -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") diff --git a/backend/api_service/app/routers/tasks.py b/backend/api_service/app/routers/tasks.py new file mode 100644 index 0000000..e4fa910 --- /dev/null +++ b/backend/api_service/app/routers/tasks.py @@ -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") diff --git a/backend/api_service/app/schemas/__init__.py b/backend/api_service/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api_service/app/schemas/__pycache__/__init__.cpython-311.pyc b/backend/api_service/app/schemas/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f2e6748 Binary files /dev/null and b/backend/api_service/app/schemas/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/api_service/app/schemas/__pycache__/account.cpython-311.pyc b/backend/api_service/app/schemas/__pycache__/account.cpython-311.pyc new file mode 100644 index 0000000..35da6b5 Binary files /dev/null and b/backend/api_service/app/schemas/__pycache__/account.cpython-311.pyc differ diff --git a/backend/api_service/app/schemas/__pycache__/signin_log.cpython-311.pyc b/backend/api_service/app/schemas/__pycache__/signin_log.cpython-311.pyc new file mode 100644 index 0000000..ead6415 Binary files /dev/null and b/backend/api_service/app/schemas/__pycache__/signin_log.cpython-311.pyc differ diff --git a/backend/api_service/app/schemas/__pycache__/task.cpython-311.pyc b/backend/api_service/app/schemas/__pycache__/task.cpython-311.pyc new file mode 100644 index 0000000..d009044 Binary files /dev/null and b/backend/api_service/app/schemas/__pycache__/task.cpython-311.pyc differ diff --git a/backend/api_service/app/schemas/account.py b/backend/api_service/app/schemas/account.py new file mode 100644 index 0000000..0333813 --- /dev/null +++ b/backend/api_service/app/schemas/account.py @@ -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 diff --git a/backend/api_service/app/schemas/signin_log.py b/backend/api_service/app/schemas/signin_log.py new file mode 100644 index 0000000..8a3dd56 --- /dev/null +++ b/backend/api_service/app/schemas/signin_log.py @@ -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 diff --git a/backend/api_service/app/schemas/task.py b/backend/api_service/app/schemas/task.py new file mode 100644 index 0000000..0b442bf --- /dev/null +++ b/backend/api_service/app/schemas/task.py @@ -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 diff --git a/backend/auth_service/Dockerfile b/backend/auth_service/Dockerfile new file mode 100644 index 0000000..31cadce --- /dev/null +++ b/backend/auth_service/Dockerfile @@ -0,0 +1,34 @@ +# Weibo-HotSign Authentication Service Dockerfile +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY ../requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Start application +CMD ["python", "-m", "app.main"] diff --git a/backend/auth_service/__init__.py b/backend/auth_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth_service/__pycache__/__init__.cpython-311.pyc b/backend/auth_service/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c574947 Binary files /dev/null and b/backend/auth_service/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/auth_service/app/__init__.py b/backend/auth_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth_service/app/__pycache__/__init__.cpython-311.pyc b/backend/auth_service/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8402def Binary files /dev/null and b/backend/auth_service/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/auth_service/app/__pycache__/main.cpython-311.pyc b/backend/auth_service/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..c384761 Binary files /dev/null and b/backend/auth_service/app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/auth_service/app/config.py b/backend/auth_service/app/config.py new file mode 100644 index 0000000..4b4190d --- /dev/null +++ b/backend/auth_service/app/config.py @@ -0,0 +1,50 @@ +""" +Configuration settings for Authentication Service +Loads environment variables and provides configuration object +""" + +import os +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + """Application settings using Pydantic BaseSettings""" + + # Database settings + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + # If DATABASE_URL is not set, raise an error to force proper configuration + # For development, you can create a .env file with DATABASE_URL=mysql+aiomysql://user:password@host/dbname + ) + + # JWT settings + JWT_SECRET_KEY: str = os.getenv( + "JWT_SECRET_KEY", + # If JWT_SECRET_KEY is not set, raise an error to force proper configuration + # For development, you can create a .env file with JWT_SECRET_KEY=your-secret-key + ) + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRATION_HOURS: int = 24 + + # Security settings + BCRYPT_ROUNDS: int = 12 + + # Application settings + APP_NAME: str = "Weibo-HotSign Authentication Service" + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", 8000)) + + # CORS settings + ALLOWED_ORIGINS: list = [ + "http://localhost:3000", + "http://localhost:80", + "http://127.0.0.1:3000" + ] + + class Config: + case_sensitive = True + env_file = ".env" + +# Create global settings instance +settings = Settings() diff --git a/backend/auth_service/app/main.py b/backend/auth_service/app/main.py new file mode 100644 index 0000000..5564cf5 --- /dev/null +++ b/backend/auth_service/app/main.py @@ -0,0 +1,223 @@ +""" +Weibo-HotSign Authentication Service +Main FastAPI application entry point +""" + +from fastapi import FastAPI, Depends, HTTPException, status, Security +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uvicorn +import os +import logging + +from shared.models import get_db, User +from auth_service.app.models.database import create_tables +from auth_service.app.schemas.user import ( + UserCreate, UserLogin, UserResponse, Token, TokenData, RefreshTokenRequest, +) +from auth_service.app.services.auth_service import AuthService +from auth_service.app.utils.security import ( + verify_password, create_access_token, decode_access_token, + create_refresh_token, verify_refresh_token, revoke_refresh_token, +) + +# Configure logger +logger = logging.getLogger(__name__) + +# Initialize FastAPI app +app = FastAPI( + title="Weibo-HotSign Authentication Service", + description="Handles user authentication and authorization for Weibo-HotSign system", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS middleware configuration +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://localhost:80"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Security scheme for JWT +security = HTTPBearer() + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Security(security), + db: AsyncSession = Depends(get_db) +) -> UserResponse: + """ + Dependency to get current user from JWT token + """ + token = credentials.credentials + payload = decode_access_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + auth_service = AuthService(db) + user = await auth_service.get_user_by_id(user_id) + + 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 UserResponse.from_orm(user) + +@app.on_event("startup") +async def startup_event(): + """Initialize database tables on startup""" + await create_tables() + +@app.get("/") +async def root(): + return { + "service": "Weibo-HotSign Authentication Service", + "status": "running", + "version": "1.0.0" + } + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + +@app.post("/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)): + """ + Register a new user account + """ + auth_service = AuthService(db) + + # Check if user already exists - optimized with single query + email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username) + + if email_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User with this email already exists" + ) + + if username_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already taken" + ) + + # Create new user + try: + user = await auth_service.create_user(user_data) + return UserResponse.from_orm(user) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create user: {str(e)}" + ) + +@app.post("/auth/login", response_model=Token) +async def login_user(login_data: UserLogin, db: AsyncSession = Depends(get_db)): + """ + Authenticate user and return JWT token + """ + auth_service = AuthService(db) + + # Find user by email + user = await auth_service.get_user_by_email(login_data.email) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password" + ) + + # Verify password + if not verify_password(login_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password" + ) + + # Check if user is active + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is deactivated" + ) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) + + # Create refresh token (stored in Redis) + refresh_token = await create_refresh_token(str(user.id)) + + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + expires_in=3600 # 1 hour + ) + +@app.post("/auth/refresh", response_model=Token) +async def refresh_token(body: RefreshTokenRequest, db: AsyncSession = Depends(get_db)): + """ + Exchange a valid refresh token for a new access + refresh token pair (Token Rotation). + The old refresh token is revoked immediately. + """ + # Verify the incoming refresh token + user_id = await verify_refresh_token(body.refresh_token) + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + # Ensure the user still exists and is active + auth_service = AuthService(db) + user = await auth_service.get_user_by_id(user_id) + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or deactivated", + ) + + # Revoke old token, issue new pair + await revoke_refresh_token(body.refresh_token) + new_access = create_access_token(data={"sub": str(user.id), "username": user.username}) + new_refresh = await create_refresh_token(str(user.id)) + + return Token( + access_token=new_access, + refresh_token=new_refresh, + token_type="bearer", + expires_in=3600, + ) + +@app.get("/auth/me", response_model=UserResponse) +async def get_current_user_info(current_user: UserResponse = Depends(get_current_user)): + """ + Get current user information + """ + return current_user diff --git a/backend/auth_service/app/models/__init__.py b/backend/auth_service/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth_service/app/models/__pycache__/__init__.cpython-311.pyc b/backend/auth_service/app/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..39dbf16 Binary files /dev/null and b/backend/auth_service/app/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/auth_service/app/models/__pycache__/database.cpython-311.pyc b/backend/auth_service/app/models/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000..4132a09 Binary files /dev/null and b/backend/auth_service/app/models/__pycache__/database.cpython-311.pyc differ diff --git a/backend/auth_service/app/models/database.py b/backend/auth_service/app/models/database.py new file mode 100644 index 0000000..1393c92 --- /dev/null +++ b/backend/auth_service/app/models/database.py @@ -0,0 +1,15 @@ +""" +Database models and connection management for Authentication Service. +Re-exports shared module components for backward compatibility. +""" + +# Re-export everything from the shared module +from shared.models import Base, get_db, engine, AsyncSessionLocal, User + +__all__ = ["Base", "get_db", "engine", "AsyncSessionLocal", "User"] + + +async def create_tables(): + """Create all tables in the database.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/auth_service/app/schemas/__init__.py b/backend/auth_service/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth_service/app/schemas/__pycache__/__init__.cpython-311.pyc b/backend/auth_service/app/schemas/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d219f94 Binary files /dev/null and b/backend/auth_service/app/schemas/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc b/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc new file mode 100644 index 0000000..74d031d Binary files /dev/null and b/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc differ diff --git a/backend/auth_service/app/schemas/user.py b/backend/auth_service/app/schemas/user.py new file mode 100644 index 0000000..89b3152 --- /dev/null +++ b/backend/auth_service/app/schemas/user.py @@ -0,0 +1,57 @@ +""" +Pydantic schemas for User-related data structures +Defines request/response models for authentication endpoints +""" + +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime +from uuid import UUID + +class UserBase(BaseModel): + """Base schema for user data""" + username: str = Field(..., min_length=3, max_length=50, description="Unique username") + email: EmailStr = Field(..., description="Valid email address") + +class UserCreate(UserBase): + """Schema for user registration request""" + password: str = Field(..., min_length=8, description="Password (min 8 characters)") + +class UserLogin(BaseModel): + """Schema for user login request""" + email: EmailStr = Field(..., description="User's email address") + password: str = Field(..., description="User's password") + +class UserUpdate(BaseModel): + """Schema for user profile updates""" + username: Optional[str] = Field(None, min_length=3, max_length=50) + email: Optional[EmailStr] = None + is_active: Optional[bool] = None + +class UserResponse(UserBase): + """Schema for user response data""" + id: UUID + created_at: datetime + is_active: bool + + class Config: + from_attributes = True # Enable ORM mode + +class Token(BaseModel): + """Schema for JWT token response (login / refresh)""" + access_token: str = Field(..., description="JWT access token") + refresh_token: str = Field(..., description="Opaque refresh token") + token_type: str = Field(default="bearer", description="Token type") + expires_in: int = Field(..., description="Access token expiration time in seconds") + + +class RefreshTokenRequest(BaseModel): + """Schema for token refresh request""" + refresh_token: str = Field(..., description="The refresh token to exchange") + + +class TokenData(BaseModel): + """Schema for decoded token payload""" + sub: str = Field(..., description="Subject (user ID)") + username: str = Field(..., description="Username") + exp: Optional[int] = None diff --git a/backend/auth_service/app/services/__init__.py b/backend/auth_service/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth_service/app/services/__pycache__/__init__.cpython-311.pyc b/backend/auth_service/app/services/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d972cd9 Binary files /dev/null and b/backend/auth_service/app/services/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/auth_service/app/services/__pycache__/auth_service.cpython-311.pyc b/backend/auth_service/app/services/__pycache__/auth_service.cpython-311.pyc new file mode 100644 index 0000000..b421c77 Binary files /dev/null and b/backend/auth_service/app/services/__pycache__/auth_service.cpython-311.pyc differ diff --git a/backend/auth_service/app/services/auth_service.py b/backend/auth_service/app/services/auth_service.py new file mode 100644 index 0000000..d30a5ff --- /dev/null +++ b/backend/auth_service/app/services/auth_service.py @@ -0,0 +1,191 @@ +""" +Authentication service business logic +Handles user registration, login, and user management operations +""" + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_ +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +import logging +from typing import Optional + +from shared.models import User +from ..schemas.user import UserCreate, UserLogin +from ..utils.security import hash_password, validate_password_strength, verify_password + +# Configure logger +logger = logging.getLogger(__name__) + +class AuthService: + """Service class for authentication and user management""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_user_by_email(self, email: str) -> Optional[User]: + """Find user by email address""" + try: + stmt = select(User).where(User.email == email) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + except Exception as e: + logger.error(f"Error fetching user by email {email}: {e}") + return None + + async def get_user_by_username(self, username: str) -> Optional[User]: + """Find user by username""" + try: + stmt = select(User).where(User.username == username) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + except Exception as e: + logger.error(f"Error fetching user by username {username}: {e}") + return None + + async def get_user_by_id(self, user_id: str) -> Optional[User]: + """Find user by UUID""" + try: + # For MySQL, user_id is already a string, no need to convert to UUID + stmt = select(User).where(User.id == user_id) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + except Exception as e: + logger.error(f"Error fetching user by ID {user_id}: {e}") + return None + + async def create_user(self, user_data: UserCreate) -> User: + """Create a new user account with validation""" + + # Validate password strength + is_strong, message = validate_password_strength(user_data.password) + if not is_strong: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Password too weak: {message}" + ) + + # Hash password + hashed_password = hash_password(user_data.password) + + # Create user instance + user = User( + username=user_data.username, + email=user_data.email, + hashed_password=hashed_password, + is_active=True + ) + + try: + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + + logger.info(f"Successfully created user: {user.username} ({user.email})") + return user + + except IntegrityError as e: + await self.db.rollback() + logger.error(f"Integrity error creating user {user_data.username}: {e}") + + # Check which constraint was violated + if "users_username_key" in str(e.orig): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already exists" + ) + elif "users_email_key" in str(e.orig): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered" + ) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user due to database constraint" + ) + + except Exception as e: + await self.db.rollback() + logger.error(f"Unexpected error creating user {user_data.username}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during user creation" + ) + + async def check_user_exists(self, email: str, username: str) -> tuple[Optional[User], Optional[User]]: + """Check if user exists by email or username in a single query""" + try: + stmt = select(User).where(or_(User.email == email, User.username == username)) + result = await self.db.execute(stmt) + users = result.scalars().all() + + email_user = None + username_user = None + + for user in users: + if user.email == email: + email_user = user + if user.username == username: + username_user = user + + return email_user, username_user + except Exception as e: + logger.error(f"Error checking user existence: {e}") + return None, None + + async def authenticate_user(self, login_data: UserLogin) -> Optional[User]: + """Authenticate user credentials""" + user = await self.get_user_by_email(login_data.email) + + if not user: + return None + + # Verify password + if not verify_password(login_data.password, user.hashed_password): + return None + + # Check if user is active + if not user.is_active: + logger.warning(f"Login attempt for deactivated user: {user.email}") + return None + + logger.info(f"Successful authentication for user: {user.username}") + return user + + async def update_user_status(self, user_id: str, is_active: bool) -> Optional[User]: + """Update user active status""" + user = await self.get_user_by_id(user_id) + if not user: + return None + + user.is_active = is_active + try: + await self.db.commit() + await self.db.refresh(user) + logger.info(f"Updated user {user.username} status to: {is_active}") + return user + except Exception as e: + await self.db.rollback() + logger.error(f"Error updating user status: {e}") + return None + + async def get_all_users(self, skip: int = 0, limit: int = 100) -> list[User]: + """Get list of all users (admin function)""" + try: + stmt = select(User).offset(skip).limit(limit) + result = await self.db.execute(stmt) + return result.scalars().all() + except Exception as e: + logger.error(f"Error fetching users list: {e}") + return [] + + async def check_database_health(self) -> bool: + """Check if database connection is healthy""" + try: + stmt = select(User).limit(1) + await self.db.execute(stmt) + return True + except Exception as e: + logger.error(f"Database health check failed: {e}") + return False diff --git a/backend/auth_service/app/utils/__init__.py b/backend/auth_service/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth_service/app/utils/__pycache__/__init__.cpython-311.pyc b/backend/auth_service/app/utils/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..bba665d Binary files /dev/null and b/backend/auth_service/app/utils/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/auth_service/app/utils/__pycache__/security.cpython-311.pyc b/backend/auth_service/app/utils/__pycache__/security.cpython-311.pyc new file mode 100644 index 0000000..9888e7e Binary files /dev/null and b/backend/auth_service/app/utils/__pycache__/security.cpython-311.pyc differ diff --git a/backend/auth_service/app/utils/security.py b/backend/auth_service/app/utils/security.py new file mode 100644 index 0000000..8f5f294 --- /dev/null +++ b/backend/auth_service/app/utils/security.py @@ -0,0 +1,148 @@ +""" +Security utilities for password hashing and JWT token management +""" + +import bcrypt +import hashlib +import jwt +import secrets +from datetime import datetime, timedelta +from typing import Optional + +import redis.asyncio as aioredis + +from shared.config import shared_settings + +# Auth-specific defaults +BCRYPT_ROUNDS = 12 +REFRESH_TOKEN_TTL = 7 * 24 * 3600 # 7 days in seconds + +# Lazy-initialised async Redis client +_redis_client: Optional[aioredis.Redis] = None + + +async def get_redis() -> aioredis.Redis: + """Return a shared async Redis connection.""" + global _redis_client + if _redis_client is None: + _redis_client = aioredis.from_url( + shared_settings.REDIS_URL, decode_responses=True + ) + return _redis_client + +def hash_password(password: str) -> str: + """ + Hash a password using bcrypt + Returns the hashed password as a string + """ + salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS) + hashed = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed.decode('utf-8') + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a plain text password against a hashed password + Returns True if passwords match, False otherwise + """ + try: + return bcrypt.checkpw( + plain_password.encode('utf-8'), + hashed_password.encode('utf-8') + ) + except Exception: + return False + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=shared_settings.JWT_EXPIRATION_HOURS) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, shared_settings.JWT_SECRET_KEY, algorithm=shared_settings.JWT_ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> Optional[dict]: + """ + Decode and validate a JWT access token + Returns the payload if valid, None otherwise + """ + try: + payload = jwt.decode(token, shared_settings.JWT_SECRET_KEY, algorithms=[shared_settings.JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + +def generate_password_reset_token(email: str) -> str: + """ + Generate a secure token for password reset + """ + data = {"email": email, "type": "password_reset"} + return create_access_token(data, timedelta(hours=1)) + +# Password strength validation +def validate_password_strength(password: str) -> tuple[bool, str]: + """ + Validate password meets strength requirements + Returns (is_valid, error_message) + """ + if len(password) < 8: + return False, "Password must be at least 8 characters long" + + if not any(c.isupper() for c in password): + return False, "Password must contain at least one uppercase letter" + + if not any(c.islower() for c in password): + return False, "Password must contain at least one lowercase letter" + + if not any(c.isdigit() for c in password): + return False, "Password must contain at least one digit" + + if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password): + return False, "Password must contain at least one special character" + + return True, "Password is strong" + + +# --------------- Refresh Token helpers --------------- + +def _hash_token(token: str) -> str: + """SHA-256 hash of a refresh token for safe Redis key storage.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +async def create_refresh_token(user_id: str) -> str: + """ + Generate a cryptographically random refresh token, store its hash in Redis + with a 7-day TTL, and return the raw token string. + """ + token = secrets.token_urlsafe(48) + token_hash = _hash_token(token) + r = await get_redis() + await r.setex(f"refresh_token:{token_hash}", REFRESH_TOKEN_TTL, user_id) + return token + + +async def verify_refresh_token(token: str) -> Optional[str]: + """ + Verify a refresh token by looking up its hash in Redis. + Returns the associated user_id if valid, None otherwise. + """ + token_hash = _hash_token(token) + r = await get_redis() + user_id = await r.get(f"refresh_token:{token_hash}") + return user_id + + +async def revoke_refresh_token(token: str) -> None: + """Delete a refresh token from Redis (used during rotation).""" + token_hash = _hash_token(token) + r = await get_redis() + await r.delete(f"refresh_token:{token_hash}") diff --git a/backend/auth_service/requirements.txt b/backend/auth_service/requirements.txt new file mode 100644 index 0000000..f31887f --- /dev/null +++ b/backend/auth_service/requirements.txt @@ -0,0 +1,31 @@ +# Weibo-HotSign Authentication Service Requirements +# Web Framework +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic-settings==2.0.3 + +# Database +sqlalchemy==2.0.23 +aiomysql==0.2.0 +PyMySQL==1.1.0 + +# Security +bcrypt==4.1.2 +PyJWT[crypto]==2.8.0 + +# Validation and Serialization +pydantic==2.5.0 +python-multipart==0.0.6 + +# Utilities +python-dotenv==1.0.0 +requests==2.31.0 + +# Logging and Monitoring +structlog==23.2.0 + +# Development tools (optional) +# pytest==7.4.3 +# pytest-asyncio==0.21.1 +# black==23.11.0 +# flake8==6.1.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..fc0c210 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,33 @@ +# Weibo-HotSign Unified Backend Requirements + +# Web Framework & Server +fastapi==0.104.1 +uvicorn[standard]==0.24.0 + +# Task Queue +celery==5.3.6 +redis==5.0.1 + +# Database +sqlalchemy==2.0.23 +aiomysql==0.2.0 +PyMySQL==1.1.0 + +# Configuration, Validation, and Serialization +pydantic-settings==2.0.3 +pydantic==2.5.0 +python-multipart==0.0.6 + +# Security +bcrypt==4.1.2 +PyJWT[crypto]==2.8.0 +pycryptodome==3.19.0 + +# HTTP & Utilities +httpx==0.25.2 +requests==2.31.0 +python-dotenv==1.0.0 +croniter==2.0.1 + +# Logging and Monitoring +structlog==23.2.0 diff --git a/backend/shared/__init__.py b/backend/shared/__init__.py new file mode 100644 index 0000000..980d9ff --- /dev/null +++ b/backend/shared/__init__.py @@ -0,0 +1 @@ +"""Shared module for Weibo-HotSign backend services.""" diff --git a/backend/shared/__pycache__/__init__.cpython-311.pyc b/backend/shared/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..057a5fc Binary files /dev/null and b/backend/shared/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/shared/__pycache__/config.cpython-311.pyc b/backend/shared/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..2d6f344 Binary files /dev/null and b/backend/shared/__pycache__/config.cpython-311.pyc differ diff --git a/backend/shared/__pycache__/crypto.cpython-311.pyc b/backend/shared/__pycache__/crypto.cpython-311.pyc new file mode 100644 index 0000000..8f09353 Binary files /dev/null and b/backend/shared/__pycache__/crypto.cpython-311.pyc differ diff --git a/backend/shared/__pycache__/response.cpython-311.pyc b/backend/shared/__pycache__/response.cpython-311.pyc new file mode 100644 index 0000000..44e4d42 Binary files /dev/null and b/backend/shared/__pycache__/response.cpython-311.pyc differ diff --git a/backend/shared/config.py b/backend/shared/config.py new file mode 100644 index 0000000..7a80a83 --- /dev/null +++ b/backend/shared/config.py @@ -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() diff --git a/backend/shared/crypto.py b/backend/shared/crypto.py new file mode 100644 index 0000000..9ff196a --- /dev/null +++ b/backend/shared/crypto.py @@ -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") diff --git a/backend/shared/models/__init__.py b/backend/shared/models/__init__.py new file mode 100644 index 0000000..d8c6290 --- /dev/null +++ b/backend/shared/models/__init__.py @@ -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", +] diff --git a/backend/shared/models/__pycache__/__init__.cpython-311.pyc b/backend/shared/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8f36003 Binary files /dev/null and b/backend/shared/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/account.cpython-311.pyc b/backend/shared/models/__pycache__/account.cpython-311.pyc new file mode 100644 index 0000000..3d11478 Binary files /dev/null and b/backend/shared/models/__pycache__/account.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/base.cpython-311.pyc b/backend/shared/models/__pycache__/base.cpython-311.pyc new file mode 100644 index 0000000..aa1acf7 Binary files /dev/null and b/backend/shared/models/__pycache__/base.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/signin_log.cpython-311.pyc b/backend/shared/models/__pycache__/signin_log.cpython-311.pyc new file mode 100644 index 0000000..37e5483 Binary files /dev/null and b/backend/shared/models/__pycache__/signin_log.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/task.cpython-311.pyc b/backend/shared/models/__pycache__/task.cpython-311.pyc new file mode 100644 index 0000000..04bd74c Binary files /dev/null and b/backend/shared/models/__pycache__/task.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/user.cpython-311.pyc b/backend/shared/models/__pycache__/user.cpython-311.pyc new file mode 100644 index 0000000..9ebac97 Binary files /dev/null and b/backend/shared/models/__pycache__/user.cpython-311.pyc differ diff --git a/backend/shared/models/account.py b/backend/shared/models/account.py new file mode 100644 index 0000000..35a62cc --- /dev/null +++ b/backend/shared/models/account.py @@ -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"" diff --git a/backend/shared/models/base.py b/backend/shared/models/base.py new file mode 100644 index 0000000..22c2085 --- /dev/null +++ b/backend/shared/models/base.py @@ -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() diff --git a/backend/shared/models/signin_log.py b/backend/shared/models/signin_log.py new file mode 100644 index 0000000..4f6a5ea --- /dev/null +++ b/backend/shared/models/signin_log.py @@ -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"" diff --git a/backend/shared/models/task.py b/backend/shared/models/task.py new file mode 100644 index 0000000..fceee62 --- /dev/null +++ b/backend/shared/models/task.py @@ -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"" diff --git a/backend/shared/models/user.py b/backend/shared/models/user.py new file mode 100644 index 0000000..616d1aa --- /dev/null +++ b/backend/shared/models/user.py @@ -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"" diff --git a/backend/shared/response.py b/backend/shared/response.py new file mode 100644 index 0000000..6cee047 --- /dev/null +++ b/backend/shared/response.py @@ -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) diff --git a/backend/signin_executor/Dockerfile b/backend/signin_executor/Dockerfile new file mode 100644 index 0000000..0858081 --- /dev/null +++ b/backend/signin_executor/Dockerfile @@ -0,0 +1,34 @@ +# Weibo-HotSign Sign-in Executor Service Dockerfile +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Start application +CMD ["python", "-m", "app.main"] diff --git a/backend/signin_executor/app/config.py b/backend/signin_executor/app/config.py new file mode 100644 index 0000000..91d1488 --- /dev/null +++ b/backend/signin_executor/app/config.py @@ -0,0 +1,56 @@ +""" +Configuration for Sign-in Executor Service +""" + +import os +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + """Sign-in Executor settings""" + + # Server settings + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", 8000)) + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + + # Database settings + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "mysql+aiomysql://weibo:123456789@118.195.133.163/weibo" + ) + REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379") + + # External service URLs + PROXY_POOL_URL: str = os.getenv("PROXY_POOL_URL", "http://proxy-pool:8080") + BROWSER_AUTOMATION_URL: str = os.getenv("BROWSER_AUTOMATION_URL", "http://browser-automation:3001") + TASK_SCHEDULER_URL: str = os.getenv("TASK_SCHEDULER_URL", "http://task-scheduler:8000") + + # Weibo API settings + WEIBO_LOGIN_URL: str = "https://weibo.com/login.php" + WEIBO_SUPER_TOPIC_URL: str = "https://weibo.com/p/aj/general/button" + + # Anti-bot protection settings + RANDOM_DELAY_MIN: float = float(os.getenv("RANDOM_DELAY_MIN", "1.0")) + RANDOM_DELAY_MAX: float = float(os.getenv("RANDOM_DELAY_MAX", "3.0")) + USER_AGENT_ROTATION: bool = os.getenv("USER_AGENT_ROTATION", "True").lower() == "true" + + # Cookie and session settings + COOKIE_ENCRYPTION_KEY: str = os.getenv("COOKIE_ENCRYPTION_KEY", "your-cookie-encryption-key") + SESSION_TIMEOUT_MINUTES: int = int(os.getenv("SESSION_TIMEOUT_MINUTES", "30")) + + # Browser automation settings + BROWSER_HEADLESS: bool = os.getenv("BROWSER_HEADLESS", "True").lower() == "true" + BROWSER_TIMEOUT_SECONDS: int = int(os.getenv("BROWSER_TIMEOUT_SECONDS", "30")) + + # Task execution settings + MAX_CONCURRENT_SIGNIN: int = int(os.getenv("MAX_CONCURRENT_SIGNIN", "5")) + TASK_TIMEOUT_SECONDS: int = int(os.getenv("TASK_TIMEOUT_SECONDS", "300")) + + # Logging + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + + class Config: + case_sensitive = True + env_file = ".env" + +settings = Settings() diff --git a/backend/signin_executor/app/main.py b/backend/signin_executor/app/main.py new file mode 100644 index 0000000..63e84b0 --- /dev/null +++ b/backend/signin_executor/app/main.py @@ -0,0 +1,226 @@ +""" +Weibo-HotSign Sign-in Executor Service +Core service that executes sign-in tasks and handles Weibo interactions +""" + +from fastapi import FastAPI, BackgroundTasks, HTTPException, status, Depends, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +import uvicorn +import asyncio +import httpx +import logging +from datetime import datetime +from typing import Dict, Any, Optional +import os + +from app.config import settings +from app.services.signin_service import SignInService +from app.services.weibo_client import WeiboClient +from app.models.signin_models import SignInRequest, SignInResult, TaskStatus + +# Initialize FastAPI app +app = FastAPI( + title="Weibo-HotSign Sign-in Executor", + description="Core service for executing Weibo super topic sign-in tasks", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify actual origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize services +signin_service = SignInService() +weibo_client = WeiboClient() + +@app.on_event("startup") +async def startup_event(): + """Initialize executor service on startup""" + print("🚀 Weibo-HotSign Sign-in Executor starting up...") + print(f"📡 Service Documentation: http://{settings.HOST}:{settings.PORT}/docs") + print("🔧 Ready to process sign-in tasks...") + +@app.on_event("shutdown") +async def shutdown_event(): + """Cleanup on shutdown""" + print("👋 Weibo-HotSign Sign-in Executor shutting down...") + +@app.get("/") +async def root(): + return { + "service": "Weibo-HotSign Sign-in Executor", + "status": "running", + "version": "1.0.0", + "description": "Core sign-in execution service for Weibo super topics", + "capabilities": [ + "Weibo login and verification", + "Super topic sign-in automation", + "Anti-bot protection handling", + "Proxy integration", + "Browser fingerprint simulation" + ] + } + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "service": "signin-executor", + "timestamp": datetime.now().isoformat(), + "dependencies": { + "database": "connected", + "redis": "connected", + "proxy_pool": f"{settings.PROXY_POOL_URL}", + "browser_automation": f"{settings.BROWSER_AUTOMATION_URL}" + } + } + +@app.post("/api/v1/signin/execute", response_model=SignInResult) +async def execute_signin_task( + signin_request: SignInRequest, + background_tasks: BackgroundTasks +): + """ + Execute sign-in task for specified account + This endpoint is called by the task scheduler + """ + try: + logger.info(f"🎯 Received sign-in request for account: {signin_request.account_id}") + + # Execute sign-in in background to avoid timeout + background_tasks.add_task( + signin_service.execute_signin_task, + signin_request.account_id, + signin_request.task_id + ) + + # Return immediate response + return SignInResult( + task_id=signin_request.task_id, + account_id=signin_request.account_id, + status="accepted", + message="Sign-in task accepted and queued for execution", + started_at=datetime.now(), + estimated_completion=None + ) + + except Exception as e: + logger.error(f"❌ Failed to accept sign-in task: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to accept sign-in task: {str(e)}" + ) + +@app.get("/api/v1/signin/status/{task_id}", response_model=TaskStatus) +async def get_task_status(task_id: str): + """Get status of a sign-in task""" + try: + status_info = await signin_service.get_task_status(task_id) + if not status_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Task {task_id} not found" + ) + return status_info + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error getting task status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + +@app.post("/api/v1/signin/test") +async def test_signin_capability(): + """Test sign-in service capabilities (for debugging)""" + try: + # Test basic service connectivity + tests = { + "weibo_connectivity": await _test_weibo_connectivity(), + "proxy_pool_access": await _test_proxy_pool(), + "browser_automation": await _test_browser_automation(), + "database_connection": await _test_database_connection() + } + + return { + "test_timestamp": datetime.now().isoformat(), + "tests": tests, + "overall_status": "operational" if all(tests.values()) else "degraded" + } + except Exception as e: + logger.error(f"❌ Capability test failed: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Capability test failed: {str(e)}" + ) + +async def _test_weibo_connectivity() -> bool: + """Test connectivity to Weibo""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get("https://weibo.com", follow_redirects=True) + return response.status_code == 200 + except: + return False + +async def _test_proxy_pool() -> bool: + """Test proxy pool service availability""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{settings.PROXY_POOL_URL}/health", timeout=5.0) + return response.status_code == 200 + except: + return False + +async def _test_browser_automation() -> bool: + """Test browser automation service availability""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{settings.BROWSER_AUTOMATION_URL}/health", timeout=5.0) + return response.status_code == 200 + except: + return False + +async def _test_database_connection() -> bool: + """Test database connectivity""" + try: + # Simple database ping test + return True # Simplified for demo + except: + return False + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + """Global HTTP exception handler""" + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "data": None, + "message": exc.detail, + "error": { + "code": f"HTTP_{exc.status_code}", + "details": [] + } + } + ) + +if __name__ == "__main__": + host = os.getenv("HOST", settings.HOST) + port = int(os.getenv("PORT", settings.PORT)) + uvicorn.run( + app, + host=host, + port=port, + log_level="info" if not settings.DEBUG else "debug" + ) diff --git a/backend/signin_executor/app/models/signin_models.py b/backend/signin_executor/app/models/signin_models.py new file mode 100644 index 0000000..b576ac9 --- /dev/null +++ b/backend/signin_executor/app/models/signin_models.py @@ -0,0 +1,89 @@ +""" +Data models for Sign-in Executor Service +""" + +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List +from datetime import datetime +from uuid import UUID + +class SignInRequest(BaseModel): + """Request model for sign-in task execution""" + task_id: str = Field(..., description="Unique task identifier") + account_id: str = Field(..., description="Weibo account identifier") + timestamp: Optional[datetime] = Field(default_factory=datetime.now, description="Request timestamp") + requested_by: Optional[str] = Field(default="task_scheduler", description="Request source") + +class SignInResult(BaseModel): + """Result model for sign-in task execution""" + task_id: str = Field(..., description="Task identifier") + account_id: str = Field(..., description="Account identifier") + status: str = Field(..., description="Task status: accepted, running, success, failed") + message: str = Field(..., description="Human readable result message") + started_at: datetime = Field(..., description="Task start timestamp") + completed_at: Optional[datetime] = Field(None, description="Task completion timestamp") + estimated_completion: Optional[datetime] = Field(None, description="Estimated completion time") + reward_info: Optional[Dict[str, Any]] = Field(None, description="Reward details like exp, credits") + error_message: Optional[str] = Field(None, description="Error details if failed") + signed_topics: Optional[List[str]] = Field(None, description="List of successfully signed topics") + total_topics: Optional[int] = Field(None, description="Total number of topics attempted") + +class TaskStatus(BaseModel): + """Status model for tracking sign-in task progress""" + task_id: str = Field(..., description="Task identifier") + account_id: str = Field(..., description="Account identifier") + status: str = Field(..., description="Current status: pending, running, success, failed") + progress_percentage: int = Field(default=0, ge=0, le=100, description="Progress percentage") + current_step: Optional[str] = Field(None, description="Current execution step") + steps_completed: List[str] = Field(default_factory=list, description="Completed steps") + steps_remaining: List[str] = Field(default_factory=list, description="Remaining steps") + started_at: datetime = Field(..., description="Start timestamp") + updated_at: datetime = Field(default_factory=datetime.now, description="Last update timestamp") + estimated_completion: Optional[datetime] = Field(None, description="Estimated completion") + +class WeiboAccount(BaseModel): + """Weibo account information for sign-in""" + id: UUID = Field(..., description="Account UUID") + user_id: UUID = Field(..., description="Owner user UUID") + weibo_user_id: str = Field(..., description="Weibo user ID") + remark: Optional[str] = Field(None, description="User remark") + encrypted_cookies: str = Field(..., description="Encrypted Weibo cookies") + iv: str = Field(..., description="Encryption initialization vector") + status: str = Field(default="active", description="Account status: active, invalid_cookie, banned") + last_checked_at: Optional[datetime] = Field(None, description="Last validation timestamp") + +class SignInLog(BaseModel): + """Sign-in operation log entry""" + id: Optional[int] = Field(None, description="Log entry ID") + account_id: UUID = Field(..., description="Account UUID") + topic_title: Optional[str] = Field(None, description="Signed topic title") + status: str = Field(..., description="Sign-in status") + reward_info: Optional[Dict[str, Any]] = Field(None, description="Reward information") + error_message: Optional[str] = Field(None, description="Error details") + signed_at: datetime = Field(default_factory=datetime.now, description="Sign-in timestamp") + execution_time_ms: Optional[int] = Field(None, description="Execution time in milliseconds") + +class WeiboSuperTopic(BaseModel): + """Weibo super topic information""" + id: str = Field(..., description="Topic ID") + title: str = Field(..., description="Topic title") + url: str = Field(..., description="Topic URL") + is_signed: bool = Field(default=False, description="Whether already signed") + sign_url: Optional[str] = Field(None, description="Sign-in API URL") + reward_exp: Optional[int] = Field(None, description="Experience points reward") + reward_credit: Optional[int] = Field(None, description="Credit points reward") + +class AntiBotConfig(BaseModel): + """Anti-bot protection configuration""" + random_delay_min: float = Field(default=1.0, description="Minimum random delay seconds") + random_delay_max: float = Field(default=3.0, description="Maximum random delay seconds") + user_agent_rotation: bool = Field(default=True, description="Enable user agent rotation") + proxy_enabled: bool = Field(default=True, description="Enable proxy usage") + fingerprint_simulation: bool = Field(default=True, description="Enable browser fingerprint simulation") + +class BrowserAutomationRequest(BaseModel): + """Request for browser automation service""" + target_url: str = Field(..., description="Target URL to automate") + action_type: str = Field(..., description="Action type: signin, extract, click") + context_data: Optional[Dict[str, Any]] = Field(None, description="Additional context data") + timeout_seconds: int = Field(default=30, description="Operation timeout") diff --git a/backend/signin_executor/app/services/signin_service.py b/backend/signin_executor/app/services/signin_service.py new file mode 100644 index 0000000..4e996af --- /dev/null +++ b/backend/signin_executor/app/services/signin_service.py @@ -0,0 +1,271 @@ +""" +Core sign-in business logic service +Handles Weibo super topic sign-in operations +""" + +import asyncio +import httpx +import logging +import random +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional +from uuid import UUID + +from app.config import settings +from app.models.signin_models import SignInRequest, SignInResult, TaskStatus, WeiboAccount, WeiboSuperTopic, AntiBotConfig +from app.services.weibo_client import WeiboClient + +logger = logging.getLogger(__name__) + +class SignInService: + """Main service for handling sign-in operations""" + + def __init__(self): + self.weibo_client = WeiboClient() + self.active_tasks: Dict[str, TaskStatus] = {} + self.antibot_config = AntiBotConfig( + random_delay_min=settings.RANDOM_DELAY_MIN, + random_delay_max=settings.RANDOM_DELAY_MAX, + user_agent_rotation=settings.USER_AGENT_ROTATION, + proxy_enabled=True, + fingerprint_simulation=True + ) + + async def execute_signin_task(self, account_id: str, task_id: str): + """ + Execute complete sign-in workflow for an account + This is the main business logic method + """ + logger.info(f"🎯 Starting sign-in execution for account {account_id}, task {task_id}") + + # Initialize task status + task_status = TaskStatus( + task_id=task_id, + account_id=account_id, + status="running", + progress_percentage=0, + current_step="initializing", + steps_completed=[], + steps_remaining=[ + "validate_account", + "setup_session", + "get_super_topics", + "execute_signin", + "record_results" + ], + started_at=datetime.now() + ) + self.active_tasks[task_id] = task_status + + try: + # Step 1: Validate account + task_status.current_step = "validate_account" + await self._update_task_progress(task_id, 10) + + account = await self._get_account_info(account_id) + if not account or account.status != "active": + raise Exception(f"Account {account_id} not found or inactive") + + task_status.steps_completed.append("validate_account") + task_status.steps_remaining.remove("validate_account") + task_status.progress_percentage = 20 + + # Step 2: Setup session with proxy and fingerprint + task_status.current_step = "setup_session" + await self._apply_anti_bot_protection() + + task_status.steps_completed.append("setup_session") + task_status.steps_remaining.remove("setup_session") + task_status.progress_percentage = 30 + + # Step 3: Get super topics list + task_status.current_step = "get_super_topics" + await self._update_task_progress(task_id, 40) + + super_topics = await self._get_super_topics_list(account) + if not super_topics: + logger.warning(f"No super topics found for account {account_id}") + + task_status.steps_completed.append("get_super_topics") + task_status.steps_remaining.remove("get_super_topics") + task_status.progress_percentage = 50 + + # Step 4: Execute signin for each topic + task_status.current_step = "execute_signin" + signin_results = await self._execute_topic_signin(account, super_topics, task_id) + + task_status.steps_completed.append("execute_signin") + task_status.steps_remaining.remove("execute_signin") + task_status.progress_percentage = 80 + + # Step 5: Record results + task_status.current_step = "record_results" + await self._update_task_progress(task_id, 90) + + result = SignInResult( + task_id=task_id, + account_id=account_id, + status="success", + message=f"Successfully processed {len(signin_results['signed'])} topics", + started_at=task_status.started_at, + completed_at=datetime.now(), + signed_topics=signin_results['signed'], + total_topics=len(super_topics) if super_topics else 0, + reward_info={ + "topics_signed": len(signin_results['signed']), + "topics_already_signed": len(signin_results['already_signed']), + "errors": len(signin_results['errors']) + } + ) + + task_status.status = "success" + task_status.progress_percentage = 100 + task_status.current_step = "completed" + + logger.info(f"✅ Sign-in task {task_id} completed successfully") + return result + + except Exception as e: + logger.error(f"❌ Sign-in task {task_id} failed: {e}") + + # Update task status to failed + if task_id in self.active_tasks: + task_status = self.active_tasks[task_id] + task_status.status = "failed" + task_status.error_message = str(e) + + # Return failed result + return SignInResult( + task_id=task_id, + account_id=account_id, + status="failed", + message=f"Sign-in failed: {str(e)}", + started_at=task_status.started_at if task_id in self.active_tasks else datetime.now(), + completed_at=datetime.now(), + error_message=str(e) + ) + + async def get_task_status(self, task_id: str) -> Optional[TaskStatus]: + """Get current status of a sign-in task""" + return self.active_tasks.get(task_id) + + async def _update_task_progress(self, task_id: str, percentage: int): + """Update task progress percentage""" + if task_id in self.active_tasks: + self.active_tasks[task_id].progress_percentage = percentage + self.active_tasks[task_id].updated_at = datetime.now() + + async def _get_account_info(self, account_id: str) -> Optional[WeiboAccount]: + """Get Weibo account information from database""" + try: + # Mock implementation - in real system, query database + # For demo, return mock account + return WeiboAccount( + id=UUID(account_id), + user_id=UUID("12345678-1234-5678-9012-123456789012"), + weibo_user_id="1234567890", + remark="Demo Account", + encrypted_cookies="mock_encrypted_cookies", + iv="mock_iv_16_bytes", + status="active", + last_checked_at=datetime.now() - timedelta(hours=1) + ) + except Exception as e: + logger.error(f"Error fetching account {account_id}: {e}") + return None + + async def _apply_anti_bot_protection(self): + """Apply anti-bot protection measures""" + # Random delay to mimic human behavior + delay = random.uniform( + self.antibot_config.random_delay_min, + self.antibot_config.random_delay_max + ) + logger.debug(f"Applying random delay: {delay:.2f}s") + await asyncio.sleep(delay) + + # Additional anti-bot measures would go here: + # - User agent rotation + # - Proxy selection + # - Browser fingerprint simulation + # - Request header randomization + + async def _get_super_topics_list(self, account: WeiboAccount) -> List[WeiboSuperTopic]: + """Get list of super topics for account""" + try: + # Mock implementation - in real system, fetch from Weibo API + # Simulate API call delay + await asyncio.sleep(1) + + # Return mock super topics + return [ + WeiboSuperTopic( + id="topic_001", + title="Python编程", + url="https://weibo.com/p/100808xxx", + is_signed=False, + sign_url="https://weibo.com/p/aj/general/button", + reward_exp=2, + reward_credit=1 + ), + WeiboSuperTopic( + id="topic_002", + title="人工智能", + url="https://weibo.com/p/100808yyy", + is_signed=False, + sign_url="https://weibo.com/p/aj/general/button", + reward_exp=2, + reward_credit=1 + ), + WeiboSuperTopic( + id="topic_003", + title="机器学习", + url="https://weibo.com/p/100808zzz", + is_signed=True, # Already signed + sign_url="https://weibo.com/p/aj/general/button", + reward_exp=2, + reward_credit=1 + ) + ] + except Exception as e: + logger.error(f"Error fetching super topics: {e}") + return [] + + async def _execute_topic_signin(self, account: WeiboAccount, topics: List[WeiboSuperTopic], task_id: str) -> Dict[str, List[str]]: + """Execute sign-in for each super topic""" + signed = [] + already_signed = [] + errors = [] + + for topic in topics: + try: + # Add small delay between requests + await asyncio.sleep(random.uniform(0.5, 1.5)) + + if topic.is_signed: + already_signed.append(topic.title) + continue + + # Execute signin for this topic + success = await self.weibo_client.sign_super_topic( + account=account, + topic=topic, + task_id=task_id + ) + + if success: + signed.append(topic.title) + logger.info(f"✅ Successfully signed topic: {topic.title}") + else: + errors.append(f"Failed to sign topic: {topic.title}") + + except Exception as e: + error_msg = f"Error signing topic {topic.title}: {str(e)}" + logger.error(error_msg) + errors.append(error_msg) + + return { + "signed": signed, + "already_signed": already_signed, + "errors": errors + } diff --git a/backend/signin_executor/app/services/weibo_client.py b/backend/signin_executor/app/services/weibo_client.py new file mode 100644 index 0000000..041324c --- /dev/null +++ b/backend/signin_executor/app/services/weibo_client.py @@ -0,0 +1,167 @@ +""" +Weibo API Client +Handles all interactions with Weibo.com, including login, sign-in, and data fetching +""" + +import httpx +import asyncio +import logging +import random +from typing import Dict, Any, Optional, List + +from app.config import settings +from app.models.signin_models import WeiboAccount, WeiboSuperTopic + +logger = logging.getLogger(__name__) + +class WeiboClient: + """Client for interacting with Weibo API""" + + def __init__(self): + self.base_headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7", + "Connection": "keep-alive", + "Referer": "https://weibo.com/" + } + + async def verify_cookies(self, account: WeiboAccount) -> bool: + """Verify if Weibo cookies are still valid""" + try: + # Decrypt cookies + cookies = self._decrypt_cookies(account.encrypted_cookies, account.iv) + + async with httpx.AsyncClient(cookies=cookies, headers=self.base_headers) as client: + response = await client.get("https://weibo.com/mygroups", follow_redirects=True) + + if response.status_code == 200 and "我的首页" in response.text: + logger.info(f"Cookies for account {account.weibo_user_id} are valid") + return True + else: + logger.warning(f"Cookies for account {account.weibo_user_id} are invalid") + return False + except Exception as e: + logger.error(f"Error verifying cookies: {e}") + return False + + async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]: + """Get list of super topics for an account""" + try: + # Mock implementation - in real system, this would involve complex API calls + # Simulate API call delay + await asyncio.sleep(random.uniform(1.0, 2.0)) + + # Return mock data + return [ + WeiboSuperTopic(id="topic_001", title="Python编程", url="...", is_signed=False), + WeiboSuperTopic(id="topic_002", title="人工智能", url="...", is_signed=False), + WeiboSuperTopic(id="topic_003", title="机器学习", url="...", is_signed=True) + ] + except Exception as e: + logger.error(f"Error fetching super topics: {e}") + return [] + + async def sign_super_topic(self, account: WeiboAccount, topic: WeiboSuperTopic, task_id: str) -> bool: + """ + Execute sign-in for a single super topic + """ + try: + # Decrypt cookies + cookies = self._decrypt_cookies(account.encrypted_cookies, account.iv) + + # Prepare request payload + payload = { + "ajwvr": "6", + "api": "http://i.huati.weibo.com/aj/super/checkin", + "id": topic.id, + "location": "page_100808_super_index", + "refer_flag": "100808_-_1", + "refer_lflag": "100808_-_1", + "ua": self.base_headers["User-Agent"], + "is_new": "1", + "is_from_ad": "0", + "ext": "mi_898_1_0_0" + } + + # In a real scenario, we might need to call browser automation service + # to get signed parameters or handle JS challenges + + # Simulate API call + await asyncio.sleep(random.uniform(0.5, 1.5)) + + # Mock response - assume success + response_data = { + "code": "100000", + "msg": "签到成功", + "data": { + "tip": "签到成功", + "alert_title": "签到成功", + "alert_subtitle": "恭喜你成为今天第12345位签到的人", + "reward": {"exp": 2, "credit": 1} + } + } + + if response_data.get("code") == "100000": + logger.info(f"Successfully signed topic: {topic.title}") + return True + elif response_data.get("code") == "382004": + logger.info(f"Topic {topic.title} already signed today") + return True # Treat as success + else: + logger.error(f"Failed to sign topic {topic.title}: {response_data.get('msg')}") + return False + + except Exception as e: + logger.error(f"Exception signing topic {topic.title}: {e}") + return False + + def _decrypt_cookies(self, encrypted_cookies: str, iv: str) -> Dict[str, str]: + """ + Decrypt cookies using AES-256-GCM + In a real system, this would use a proper crypto library + """ + try: + # Mock implementation - return dummy cookies + return { + "SUB": "_2A25z...", + "SUBP": "0033Wr...", + "ALF": "16...", + "SSOLoginState": "16...", + "SCF": "...", + "UN": "testuser" + } + except Exception as e: + logger.error(f"Failed to decrypt cookies: {e}") + return {} + + async def get_proxy(self) -> Optional[Dict[str, str]]: + """Get a proxy from the proxy pool service""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{settings.PROXY_POOL_URL}/get") + if response.status_code == 200: + proxy_info = response.json() + return { + "http://": f"http://{proxy_info['proxy']}", + "https://": f"https://{proxy_info['proxy']}" + } + else: + return None + except Exception as e: + logger.error(f"Failed to get proxy: {e}") + return None + + async def get_browser_fingerprint(self) -> Dict[str, Any]: + """Get a browser fingerprint from the generator service""" + try: + # Mock implementation + return { + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "screen_resolution": "1920x1080", + "timezone": "Asia/Shanghai", + "plugins": ["PDF Viewer", "Chrome PDF Viewer", "Native Client"] + } + except Exception as e: + logger.error(f"Failed to get browser fingerprint: {e}") + return {} diff --git a/backend/signin_executor/requirements.txt b/backend/signin_executor/requirements.txt new file mode 100644 index 0000000..b94e148 --- /dev/null +++ b/backend/signin_executor/requirements.txt @@ -0,0 +1,23 @@ +# Weibo-HotSign Sign-in Executor Service Requirements +# Web Framework +fastapi==0.104.1 +uvicorn[standard]==0.24.0 + +# Database +sqlalchemy==2.0.23 +aiomysql==0.2.0 +PyMySQL==1.1.0 +redis==5.0.1 + +# Configuration +pydantic-settings==2.0.3 +pydantic==2.5.0 + +# HTTP Client +httpx==0.25.2 + +# Utilities +python-dotenv==1.0.0 + +# Security (for cookie decryption) +pycryptodome==3.19.0 diff --git a/backend/task_scheduler/Dockerfile b/backend/task_scheduler/Dockerfile new file mode 100644 index 0000000..9404bf1 --- /dev/null +++ b/backend/task_scheduler/Dockerfile @@ -0,0 +1,30 @@ +# Weibo-HotSign Task Scheduler Service Dockerfile +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser +USER appuser + +# Expose port (optional, as scheduler doesn't need external access) +# EXPOSE 8000 + +# Start Celery Beat scheduler +CMD ["celery", "-A", "app.celery_app", "beat", "--loglevel=info"] diff --git a/backend/task_scheduler/app/celery_app.py b/backend/task_scheduler/app/celery_app.py new file mode 100644 index 0000000..e463d0c --- /dev/null +++ b/backend/task_scheduler/app/celery_app.py @@ -0,0 +1,97 @@ +""" +Weibo-HotSign Task Scheduler Service +Celery Beat configuration for scheduled sign-in tasks +""" + +import os +from celery import Celery +from celery.schedules import crontab +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select +import asyncio +from datetime import datetime + +from ..config import settings + +# Create Celery app +celery_app = Celery( + "weibo_hot_sign_scheduler", + broker=settings.CELERY_BROKER_URL, + backend=settings.CELERY_RESULT_BACKEND, + include=["app.tasks.signin_tasks"] +) + +# Celery configuration +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="Asia/Shanghai", + enable_utc=True, + beat_schedule_filename="celerybeat-schedule", + beat_max_loop_interval=5, +) + +# Database configuration for task scheduler +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_size=10, + max_overflow=20 +) + +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + +async def get_db(): + """Get database session for task scheduler""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + +class TaskSchedulerService: + """Service to manage scheduled tasks from database""" + + def __init__(self): + self.engine = engine + + async def load_scheduled_tasks(self): + """Load enabled tasks from database and schedule them""" + from app.models.task_models import Task + + try: + async with AsyncSessionLocal() as session: + # Query all enabled tasks + stmt = select(Task).where(Task.is_enabled == True) + result = await session.execute(stmt) + tasks = result.scalars().all() + + print(f"📅 Loaded {len(tasks)} enabled tasks from database") + + # Here we would dynamically add tasks to Celery Beat + # For now, we'll use static configuration in celery_config.py + return tasks + + except Exception as e: + print(f"❌ Error loading tasks from database: {e}") + return [] + +# Synchronous wrapper for async function +def sync_load_tasks(): + """Synchronous wrapper to load tasks""" + service = TaskSchedulerService() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(service.load_scheduled_tasks()) + finally: + loop.close() + +# Import task modules to register them +from app.tasks import signin_tasks diff --git a/backend/task_scheduler/app/config.py b/backend/task_scheduler/app/config.py new file mode 100644 index 0000000..6cd4a5a --- /dev/null +++ b/backend/task_scheduler/app/config.py @@ -0,0 +1,47 @@ +""" +Configuration for Task Scheduler Service +""" + +import os +from pydantic_settings import BaseSettings +from typing import List + +class Settings(BaseSettings): + """Task Scheduler settings""" + + # Database settings + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "mysql+aiomysql://weibo:123456789@43.134.68.207/weibo" + ) + + # Celery settings + CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") + CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://redis:6379/0") + + # Task execution settings + MAX_CONCURRENT_TASKS: int = int(os.getenv("MAX_CONCURRENT_TASKS", "10")) + TASK_TIMEOUT_SECONDS: int = int(os.getenv("TASK_TIMEOUT_SECONDS", "300")) + + # Scheduler settings + SCHEDULER_TIMEZONE: str = os.getenv("SCHEDULER_TIMEZONE", "Asia/Shanghai") + BEAT_SCHEDULE_FILE: str = os.getenv("BEAT_SCHEDULE_FILE", "/tmp/celerybeat-schedule") + + # Retry settings + MAX_RETRY_ATTEMPTS: int = int(os.getenv("MAX_RETRY_ATTEMPTS", "3")) + RETRY_DELAY_SECONDS: int = int(os.getenv("RETRY_DELAY_SECONDS", "60")) + + # Logging + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + + # Service URLs + SIGNIN_EXECUTOR_URL: str = os.getenv("SIGNIN_EXECUTOR_URL", "http://signin-executor:8000") + PROXY_POOL_URL: str = os.getenv("PROXY_POOL_URL", "http://proxy-pool:8080") + BROWSER_AUTOMATION_URL: str = os.getenv("BROWSER_AUTOMATION_URL", "http://browser-automation:3001") + + class Config: + case_sensitive = True + env_file = ".env" + +settings = Settings() diff --git a/backend/task_scheduler/app/tasks/signin_tasks.py b/backend/task_scheduler/app/tasks/signin_tasks.py new file mode 100644 index 0000000..7484089 --- /dev/null +++ b/backend/task_scheduler/app/tasks/signin_tasks.py @@ -0,0 +1,196 @@ +""" +Weibo-HotSign Sign-in Task Definitions +Celery tasks for scheduled sign-in operations +""" + +import asyncio +import httpx +import json +import logging +from datetime import datetime +from typing import Dict, Any, Optional + +from celery import current_task +from ..celery_app import celery_app +from ..config import settings + +# Configure logger +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True, max_retries=3, default_retry_delay=60) +def execute_signin_task(self, task_id: str, account_id: str, cron_expression: str): + """ + Execute scheduled sign-in task for a specific account + This task is triggered by Celery Beat based on cron schedule + """ + logger.info(f"🎯 Starting sign-in task {task_id} for account {account_id}") + + try: + # Update task status + current_task.update_state( + state="PROGRESS", + meta={ + "current": 10, + "total": 100, + "status": "Initializing sign-in process...", + "account_id": account_id + } + ) + + # Call signin executor service + result = _call_signin_executor(account_id, task_id) + + # Update task status + current_task.update_state( + state="SUCCESS", + meta={ + "current": 100, + "total": 100, + "status": "Sign-in completed successfully", + "result": result, + "account_id": account_id + } + ) + + logger.info(f"✅ Sign-in task {task_id} completed successfully for account {account_id}") + return result + + except Exception as exc: + logger.error(f"❌ Sign-in task {task_id} failed for account {account_id}: {exc}") + + # Retry logic + if self.request.retries < settings.MAX_RETRY_ATTEMPTS: + logger.info(f"🔄 Retrying task {task_id} (attempt {self.request.retries + 1})") + raise self.retry(exc=exc, countdown=settings.RETRY_DELAY_SECONDS) + + # Final failure + current_task.update_state( + state="FAILURE", + meta={ + "current": 100, + "total": 100, + "status": f"Task failed after {settings.MAX_RETRY_ATTEMPTS} attempts", + "error": str(exc), + "account_id": account_id + } + ) + raise exc + +@celery_app.task +def schedule_daily_signin(): + """ + Daily sign-in task - example of scheduled task + Can be configured in Celery Beat schedule + """ + logger.info("📅 Executing daily sign-in schedule") + + # This would typically query database for accounts that need daily sign-in + # For demo purposes, we'll simulate processing multiple accounts + + accounts = ["account_1", "account_2", "account_3"] # Mock account IDs + results = [] + + for account_id in accounts: + try: + # Submit individual sign-in task for each account + task = execute_signin_task.delay( + task_id=f"daily_{datetime.now().strftime('%Y%m%d_%H%M%S')}", + account_id=account_id, + cron_expression="0 8 * * *" # Daily at 8 AM + ) + results.append({ + "account_id": account_id, + "task_id": task.id, + "status": "submitted" + }) + except Exception as e: + logger.error(f"Failed to submit task for account {account_id}: {e}") + results.append({ + "account_id": account_id, + "status": "failed", + "error": str(e) + }) + + return { + "scheduled_date": datetime.now().isoformat(), + "accounts_processed": len(accounts), + "results": results + } + +@celery_app.task +def process_pending_tasks(): + """ + Process pending sign-in tasks from database + This can be called manually or via external trigger + """ + logger.info("🔄 Processing pending sign-in tasks from database") + + # In real implementation, this would: + # 1. Query database for tasks that need to be executed + # 2. Check if they're due based on cron expressions + # 3. Submit them to Celery for execution + + try: + # Mock implementation - query enabled tasks + result = { + "processed_at": datetime.now().isoformat(), + "tasks_found": 5, # Mock number + "tasks_submitted": 3, + "tasks_skipped": 2, + "status": "completed" + } + + logger.info(f"✅ Processed pending tasks: {result}") + return result + + except Exception as e: + logger.error(f"❌ Failed to process pending tasks: {e}") + raise + +def _call_signin_executor(account_id: str, task_id: str) -> Dict[str, Any]: + """ + Call the signin executor service to perform actual sign-in + """ + try: + signin_data = { + "task_id": task_id, + "account_id": account_id, + "timestamp": datetime.now().isoformat(), + "requested_by": "task_scheduler" + } + + # Call signin executor service + with httpx.Client(timeout=30.0) as client: + response = client.post( + f"{settings.SIGNIN_EXECUTOR_URL}/api/v1/signin/execute", + json=signin_data + ) + + if response.status_code == 200: + result = response.json() + logger.info(f"Sign-in executor response: {result}") + return result + else: + raise Exception(f"Sign-in executor returned error: {response.status_code} - {response.text}") + + except httpx.RequestError as e: + logger.error(f"Network error calling signin executor: {e}") + raise Exception(f"Failed to connect to signin executor: {e}") + except Exception as e: + logger.error(f"Error calling signin executor: {e}") + raise + +# Periodic task definitions for Celery Beat +celery_app.conf.beat_schedule = { + "daily-signin-at-8am": { + "task": "app.tasks.signin_tasks.schedule_daily_signin", + "schedule": { + "hour": 8, + "minute": 0, + }, + }, + "process-pending-every-15-minutes": { + "task": "app.tasks.signin_tasks.process_pending_tasks", + "schedule": 900.0, # Every 15 minutes + }, +} diff --git a/backend/task_scheduler/requirements.txt b/backend/task_scheduler/requirements.txt new file mode 100644 index 0000000..03ac96a --- /dev/null +++ b/backend/task_scheduler/requirements.txt @@ -0,0 +1,18 @@ +# Weibo-HotSign Task Scheduler Service Requirements +# Task Queue +celery==5.3.6 +redis==5.0.1 + +# Database +sqlalchemy==2.0.23 +aiomysql==0.2.0 +PyMySQL==1.1.0 + +# Configuration +pydantic-settings==2.0.3 + +# HTTP Client +httpx==0.25.2 + +# Utilities +python-dotenv==1.0.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/__pycache__/__init__.cpython-311.pyc b/backend/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..ed1b0da Binary files /dev/null and b/backend/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/tests/__pycache__/conftest.cpython-311-pytest-8.3.3.pyc b/backend/tests/__pycache__/conftest.cpython-311-pytest-8.3.3.pyc new file mode 100644 index 0000000..28196b1 Binary files /dev/null and b/backend/tests/__pycache__/conftest.cpython-311-pytest-8.3.3.pyc differ diff --git a/backend/tests/__pycache__/test_api_accounts.cpython-311-pytest-8.3.3.pyc b/backend/tests/__pycache__/test_api_accounts.cpython-311-pytest-8.3.3.pyc new file mode 100644 index 0000000..08b52ec Binary files /dev/null and b/backend/tests/__pycache__/test_api_accounts.cpython-311-pytest-8.3.3.pyc differ diff --git a/backend/tests/__pycache__/test_api_signin_logs.cpython-311-pytest-8.3.3.pyc b/backend/tests/__pycache__/test_api_signin_logs.cpython-311-pytest-8.3.3.pyc new file mode 100644 index 0000000..a5984c7 Binary files /dev/null and b/backend/tests/__pycache__/test_api_signin_logs.cpython-311-pytest-8.3.3.pyc differ diff --git a/backend/tests/__pycache__/test_api_tasks.cpython-311-pytest-8.3.3.pyc b/backend/tests/__pycache__/test_api_tasks.cpython-311-pytest-8.3.3.pyc new file mode 100644 index 0000000..2b82c2e Binary files /dev/null and b/backend/tests/__pycache__/test_api_tasks.cpython-311-pytest-8.3.3.pyc differ diff --git a/backend/tests/__pycache__/test_auth_service.cpython-311-pytest-8.3.3.pyc b/backend/tests/__pycache__/test_auth_service.cpython-311-pytest-8.3.3.pyc new file mode 100644 index 0000000..188b052 Binary files /dev/null and b/backend/tests/__pycache__/test_auth_service.cpython-311-pytest-8.3.3.pyc differ diff --git a/backend/tests/__pycache__/test_shared.cpython-311-pytest-8.3.3.pyc b/backend/tests/__pycache__/test_shared.cpython-311-pytest-8.3.3.pyc new file mode 100644 index 0000000..dfc511f Binary files /dev/null and b/backend/tests/__pycache__/test_shared.cpython-311-pytest-8.3.3.pyc differ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f9269e2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,86 @@ +""" +Shared test fixtures for Weibo-HotSign backend tests. + +Uses SQLite in-memory for database tests and a simple dict-based +fake Redis for refresh-token tests, so no external services are needed. +""" + +import asyncio +import sys +import os +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +# Ensure backend/ is on sys.path so `shared` and `app` imports work +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# --------------- override shared settings BEFORE any app import --------------- +os.environ["DATABASE_URL"] = "sqlite+aiosqlite://" +os.environ["REDIS_URL"] = "redis://localhost:6379/0" +os.environ["JWT_SECRET_KEY"] = "test-secret-key" +os.environ["COOKIE_ENCRYPTION_KEY"] = "test-cookie-key" + +# Create the test engine BEFORE importing shared.models so we can swap it in +TEST_ENGINE = create_async_engine("sqlite+aiosqlite://", echo=False) +TestSessionLocal = sessionmaker(TEST_ENGINE, class_=AsyncSession, expire_on_commit=False) + +# Now patch shared.models.base module-level objects before they get used +import shared.models.base as _base_mod # noqa: E402 + +_base_mod.engine = TEST_ENGINE +_base_mod.AsyncSessionLocal = TestSessionLocal + +from shared.models.base import Base # noqa: E402 +from shared.models import User # noqa: E402 + + +@pytest.fixture(scope="session") +def event_loop(): + """Create a single event loop for the whole test session.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(autouse=True) +async def setup_db(): + """Create all tables before each test, drop after.""" + async with TEST_ENGINE.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with TEST_ENGINE.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """Yield a fresh async DB session.""" + async with TestSessionLocal() as session: + yield session + + +# --------------- Fake Redis for refresh-token tests --------------- + +class FakeRedis: + """Minimal async Redis stand-in backed by a plain dict.""" + + def __init__(self): + self._store: dict[str, str] = {} + + async def setex(self, key: str, ttl: int, value: str): + self._store[key] = value + + async def get(self, key: str): + return self._store.get(key) + + async def delete(self, key: str): + self._store.pop(key, None) + + +@pytest.fixture +def fake_redis(): + return FakeRedis() diff --git a/backend/tests/test_api_accounts.py b/backend/tests/test_api_accounts.py new file mode 100644 index 0000000..6361e2f --- /dev/null +++ b/backend/tests/test_api_accounts.py @@ -0,0 +1,214 @@ +""" +Tests for api_service account CRUD endpoints. +Validates tasks 4.1 and 4.2. +""" + +import pytest +import pytest_asyncio +from unittest.mock import patch +from httpx import AsyncClient, ASGITransport + +from shared.models import get_db +from tests.conftest import TEST_ENGINE, TestSessionLocal, Base, FakeRedis + + +@pytest_asyncio.fixture +async def client(): + """ + Provide an httpx AsyncClient wired to the api_service app, + with DB overridden to test SQLite and a fake Redis for auth tokens. + """ + fake_redis = FakeRedis() + + async with TEST_ENGINE.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Import apps after DB is ready + from api_service.app.main import app as api_app + from auth_service.app.main import app as auth_app + + async def override_get_db(): + async with TestSessionLocal() as session: + yield session + + async def _fake_get_redis(): + return fake_redis + + api_app.dependency_overrides[get_db] = override_get_db + auth_app.dependency_overrides[get_db] = override_get_db + + with patch( + "auth_service.app.utils.security.get_redis", + new=_fake_get_redis, + ): + # We need both clients: auth for getting tokens, api for account ops + async with AsyncClient( + transport=ASGITransport(app=auth_app), base_url="http://auth" + ) as auth_client, AsyncClient( + transport=ASGITransport(app=api_app), base_url="http://api" + ) as api_client: + yield auth_client, api_client + + api_app.dependency_overrides.clear() + auth_app.dependency_overrides.clear() + + +async def _register_and_login(auth_client: AsyncClient, suffix: str = "1") -> str: + """Helper: register a user and return an access token.""" + reg = await auth_client.post("/auth/register", json={ + "username": f"acctuser{suffix}", + "email": f"acct{suffix}@example.com", + "password": "Str0ng!Pass1", + }) + assert reg.status_code == 201, f"Register failed: {reg.json()}" + resp = await auth_client.post("/auth/login", json={ + "email": f"acct{suffix}@example.com", + "password": "Str0ng!Pass1", + }) + login_body = resp.json() + assert resp.status_code == 200, f"Login failed: {login_body}" + # Handle both wrapped (success_response) and unwrapped token formats + if "data" in login_body: + return login_body["data"]["access_token"] + return login_body["access_token"] + + +def _auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ===================== Basic structure tests ===================== + + +class TestAPIServiceBase: + + @pytest.mark.asyncio + async def test_health(self, client): + _, api = client + resp = await api.get("/health") + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_root(self, client): + _, api = client + resp = await api.get("/") + assert resp.status_code == 200 + assert "API Service" in resp.json()["data"]["service"] + + +# ===================== Account CRUD tests ===================== + + +class TestAccountCRUD: + + @pytest.mark.asyncio + async def test_create_account(self, client): + auth, api = client + token = await _register_and_login(auth) + + resp = await api.post("/api/v1/accounts", json={ + "weibo_user_id": "12345", + "cookie": "SUB=abc; SUBP=xyz;", + "remark": "test account", + }, headers=_auth_header(token)) + + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + assert body["data"]["weibo_user_id"] == "12345" + assert body["data"]["status"] == "pending" + assert body["data"]["remark"] == "test account" + # Cookie plaintext must NOT appear in response + assert "SUB=abc" not in str(body) + + @pytest.mark.asyncio + async def test_list_accounts(self, client): + auth, api = client + token = await _register_and_login(auth, "list") + + # Create two accounts + for i in range(2): + await api.post("/api/v1/accounts", json={ + "weibo_user_id": f"uid{i}", + "cookie": f"cookie{i}", + }, headers=_auth_header(token)) + + resp = await api.get("/api/v1/accounts", headers=_auth_header(token)) + assert resp.status_code == 200 + data = resp.json()["data"] + assert len(data) == 2 + + @pytest.mark.asyncio + async def test_get_account_detail(self, client): + auth, api = client + token = await _register_and_login(auth, "detail") + + create_resp = await api.post("/api/v1/accounts", json={ + "weibo_user_id": "99", + "cookie": "c=1", + "remark": "my remark", + }, headers=_auth_header(token)) + account_id = create_resp.json()["data"]["id"] + + resp = await api.get(f"/api/v1/accounts/{account_id}", headers=_auth_header(token)) + assert resp.status_code == 200 + assert resp.json()["data"]["remark"] == "my remark" + + @pytest.mark.asyncio + async def test_update_account_remark(self, client): + auth, api = client + token = await _register_and_login(auth, "upd") + + create_resp = await api.post("/api/v1/accounts", json={ + "weibo_user_id": "55", + "cookie": "c=old", + }, headers=_auth_header(token)) + account_id = create_resp.json()["data"]["id"] + + resp = await api.put(f"/api/v1/accounts/{account_id}", json={ + "remark": "updated remark", + }, headers=_auth_header(token)) + assert resp.status_code == 200 + assert resp.json()["data"]["remark"] == "updated remark" + + @pytest.mark.asyncio + async def test_delete_account(self, client): + auth, api = client + token = await _register_and_login(auth, "del") + + create_resp = await api.post("/api/v1/accounts", json={ + "weibo_user_id": "77", + "cookie": "c=del", + }, headers=_auth_header(token)) + account_id = create_resp.json()["data"]["id"] + + resp = await api.delete(f"/api/v1/accounts/{account_id}", headers=_auth_header(token)) + assert resp.status_code == 200 + + # Verify it's gone + resp2 = await api.get(f"/api/v1/accounts/{account_id}", headers=_auth_header(token)) + assert resp2.status_code == 404 + + @pytest.mark.asyncio + async def test_access_other_users_account_forbidden(self, client): + auth, api = client + token_a = await _register_and_login(auth, "ownerA") + token_b = await _register_and_login(auth, "ownerB") + + # User A creates an account + create_resp = await api.post("/api/v1/accounts", json={ + "weibo_user_id": "111", + "cookie": "c=a", + }, headers=_auth_header(token_a)) + account_id = create_resp.json()["data"]["id"] + + # User B tries to access it + resp = await api.get(f"/api/v1/accounts/{account_id}", headers=_auth_header(token_b)) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_unauthenticated_request_rejected(self, client): + _, api = client + resp = await api.get("/api/v1/accounts") + assert resp.status_code in (401, 403) diff --git a/backend/tests/test_api_signin_logs.py b/backend/tests/test_api_signin_logs.py new file mode 100644 index 0000000..43cc014 --- /dev/null +++ b/backend/tests/test_api_signin_logs.py @@ -0,0 +1,238 @@ +""" +Tests for api_service signin log query endpoints. +Validates task 6.1. +""" + +import pytest +import pytest_asyncio +from datetime import datetime, timedelta +from unittest.mock import patch +from httpx import AsyncClient, ASGITransport + +from shared.models import get_db, Account, SigninLog +from tests.conftest import TEST_ENGINE, TestSessionLocal, Base, FakeRedis + + +@pytest_asyncio.fixture +async def client(): + """ + Provide an httpx AsyncClient wired to the api_service app, + with DB overridden to test SQLite and a fake Redis for auth tokens. + """ + fake_redis = FakeRedis() + + async with TEST_ENGINE.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Import apps after DB is ready + from api_service.app.main import app as api_app + from auth_service.app.main import app as auth_app + + async def override_get_db(): + async with TestSessionLocal() as session: + yield session + + async def _fake_get_redis(): + return fake_redis + + api_app.dependency_overrides[get_db] = override_get_db + auth_app.dependency_overrides[get_db] = override_get_db + + with patch( + "auth_service.app.utils.security.get_redis", + new=_fake_get_redis, + ): + async with AsyncClient( + transport=ASGITransport(app=auth_app), base_url="http://auth" + ) as auth_client, AsyncClient( + transport=ASGITransport(app=api_app), base_url="http://api" + ) as api_client: + yield auth_client, api_client + + api_app.dependency_overrides.clear() + auth_app.dependency_overrides.clear() + + +async def _register_and_login(auth_client: AsyncClient, suffix: str = "1") -> str: + """Helper: register a user and return an access token.""" + reg = await auth_client.post("/auth/register", json={ + "username": f"loguser{suffix}", + "email": f"log{suffix}@example.com", + "password": "Str0ng!Pass1", + }) + assert reg.status_code == 201 + resp = await auth_client.post("/auth/login", json={ + "email": f"log{suffix}@example.com", + "password": "Str0ng!Pass1", + }) + login_body = resp.json() + assert resp.status_code == 200 + if "data" in login_body: + return login_body["data"]["access_token"] + return login_body["access_token"] + + +def _auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def _create_account(api: AsyncClient, token: str, weibo_id: str) -> str: + """Helper: create an account and return its ID.""" + resp = await api.post("/api/v1/accounts", json={ + "weibo_user_id": weibo_id, + "cookie": f"cookie_{weibo_id}", + }, headers=_auth_header(token)) + assert resp.status_code == 201 + return resp.json()["data"]["id"] + + +async def _create_signin_logs(db, account_id: str, count: int, statuses: list = None): + """Helper: create signin logs for testing.""" + if statuses is None: + statuses = ["success"] * count + + base_time = datetime.utcnow() + for i in range(count): + log = SigninLog( + account_id=account_id, + topic_title=f"Topic {i}", + status=statuses[i] if i < len(statuses) else "success", + signed_at=base_time - timedelta(hours=i), # Descending order + ) + db.add(log) + await db.commit() + + +# ===================== Signin Log Query Tests ===================== + + +class TestSigninLogQuery: + + @pytest.mark.asyncio + async def test_get_signin_logs_empty(self, client): + """Test querying logs for an account with no logs.""" + auth, api = client + token = await _register_and_login(auth, "empty") + account_id = await _create_account(api, token, "empty_acc") + + resp = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs", + headers=_auth_header(token) + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["total"] == 0 + assert len(data["items"]) == 0 + + @pytest.mark.asyncio + async def test_get_signin_logs_with_data(self, client): + """Test querying logs returns data in descending order.""" + auth, api = client + token = await _register_and_login(auth, "data") + account_id = await _create_account(api, token, "data_acc") + + # Create logs directly in DB + async with TestSessionLocal() as db: + await _create_signin_logs(db, account_id, 5) + + resp = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs", + headers=_auth_header(token) + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["total"] == 5 + assert len(data["items"]) == 5 + + # Verify descending order by signed_at + items = data["items"] + for i in range(len(items) - 1): + assert items[i]["signed_at"] >= items[i + 1]["signed_at"] + + @pytest.mark.asyncio + async def test_signin_logs_pagination(self, client): + """Test pagination works correctly.""" + auth, api = client + token = await _register_and_login(auth, "page") + account_id = await _create_account(api, token, "page_acc") + + # Create 10 logs + async with TestSessionLocal() as db: + await _create_signin_logs(db, account_id, 10) + + # Page 1, size 3 + resp = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs?page=1&size=3", + headers=_auth_header(token) + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["total"] == 10 + assert len(data["items"]) == 3 + assert data["page"] == 1 + assert data["size"] == 3 + assert data["total_pages"] == 4 + + # Page 2, size 3 + resp2 = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs?page=2&size=3", + headers=_auth_header(token) + ) + data2 = resp2.json()["data"] + assert len(data2["items"]) == 3 + assert data2["page"] == 2 + + @pytest.mark.asyncio + async def test_signin_logs_status_filter(self, client): + """Test status filtering works correctly.""" + auth, api = client + token = await _register_and_login(auth, "filter") + account_id = await _create_account(api, token, "filter_acc") + + # Create logs with different statuses + async with TestSessionLocal() as db: + statuses = ["success", "success", "failed_network", "success", "failed_already_signed"] + await _create_signin_logs(db, account_id, 5, statuses) + + # Filter by success + resp = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs?status=success", + headers=_auth_header(token) + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["total"] == 3 + assert all(item["status"] == "success" for item in data["items"]) + + # Filter by failed_network + resp2 = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs?status=failed_network", + headers=_auth_header(token) + ) + data2 = resp2.json()["data"] + assert data2["total"] == 1 + assert data2["items"][0]["status"] == "failed_network" + + @pytest.mark.asyncio + async def test_access_other_users_logs_forbidden(self, client): + """Test that users cannot access other users' signin logs.""" + auth, api = client + token_a = await _register_and_login(auth, "logA") + token_b = await _register_and_login(auth, "logB") + + # User A creates an account + account_id = await _create_account(api, token_a, "logA_acc") + + # User B tries to access logs + resp = await api.get( + f"/api/v1/accounts/{account_id}/signin-logs", + headers=_auth_header(token_b) + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_unauthenticated_logs_request_rejected(self, client): + """Test that unauthenticated requests are rejected.""" + _, api = client + resp = await api.get("/api/v1/accounts/fake-id/signin-logs") + assert resp.status_code in (401, 403) diff --git a/backend/tests/test_api_tasks.py b/backend/tests/test_api_tasks.py new file mode 100644 index 0000000..a12a68e --- /dev/null +++ b/backend/tests/test_api_tasks.py @@ -0,0 +1,226 @@ +""" +Tests for API_Service task management endpoints. +""" + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models import User, Account, Task +from auth_service.app.utils.security import create_access_token +from shared.crypto import encrypt_cookie, derive_key + + +@pytest_asyncio.fixture +async def test_user(db_session: AsyncSession) -> User: + """Create a test user.""" + user = User( + username="testuser", + email="test@example.com", + hashed_password="hashed_password", + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_account(db_session: AsyncSession, test_user: User) -> Account: + """Create a test account.""" + key = derive_key("test-cookie-key") + ciphertext, iv = encrypt_cookie("test_cookie_data", key) + + account = Account( + user_id=test_user.id, + weibo_user_id="123456", + remark="Test Account", + encrypted_cookies=ciphertext, + iv=iv, + status="pending", + ) + db_session.add(account) + await db_session.commit() + await db_session.refresh(account) + return account + + +@pytest_asyncio.fixture +async def auth_headers(test_user: User) -> dict: + """Generate JWT auth headers for test user.""" + token = create_access_token({"sub": test_user.id}) + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_create_task_valid_cron( + db_session: AsyncSession, + test_user: User, + test_account: Account, + auth_headers: dict, +): + """Test creating a task with valid cron expression.""" + from api_service.app.main import app + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.post( + f"/api/v1/accounts/{test_account.id}/tasks", + json={"cron_expression": "0 9 * * *"}, + headers=auth_headers, + ) + + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["cron_expression"] == "0 9 * * *" + assert data["data"]["is_enabled"] is True + assert data["data"]["account_id"] == test_account.id + + +@pytest.mark.asyncio +async def test_create_task_invalid_cron( + db_session: AsyncSession, + test_user: User, + test_account: Account, + auth_headers: dict, +): + """Test creating a task with invalid cron expression.""" + from api_service.app.main import app + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.post( + f"/api/v1/accounts/{test_account.id}/tasks", + json={"cron_expression": "invalid cron"}, + headers=auth_headers, + ) + + assert response.status_code == 400 + data = response.json() + assert data["success"] is False + + +@pytest.mark.asyncio +async def test_list_tasks( + db_session: AsyncSession, + test_user: User, + test_account: Account, + auth_headers: dict, +): + """Test listing tasks for an account.""" + # Create two tasks + task1 = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True) + task2 = Task(account_id=test_account.id, cron_expression="0 18 * * *", is_enabled=False) + db_session.add_all([task1, task2]) + await db_session.commit() + + from api_service.app.main import app + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.get( + f"/api/v1/accounts/{test_account.id}/tasks", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) == 2 + + +@pytest.mark.asyncio +async def test_update_task( + db_session: AsyncSession, + test_user: User, + test_account: Account, + auth_headers: dict, +): + """Test updating a task (enable/disable).""" + task = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True) + db_session.add(task) + await db_session.commit() + await db_session.refresh(task) + + from api_service.app.main import app + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.put( + f"/api/v1/tasks/{task.id}", + json={"is_enabled": False}, + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["is_enabled"] is False + + +@pytest.mark.asyncio +async def test_delete_task( + db_session: AsyncSession, + test_user: User, + test_account: Account, + auth_headers: dict, +): + """Test deleting a task.""" + task = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True) + db_session.add(task) + await db_session.commit() + await db_session.refresh(task) + + from api_service.app.main import app + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.delete( + f"/api/v1/tasks/{task.id}", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify task is deleted + from sqlalchemy import select + result = await db_session.execute(select(Task).where(Task.id == task.id)) + deleted_task = result.scalar_one_or_none() + assert deleted_task is None + + +@pytest.mark.asyncio +async def test_access_other_user_task_forbidden( + db_session: AsyncSession, + test_account: Account, +): + """Test that users cannot access tasks from other users' accounts.""" + # Create another user + other_user = User( + username="otheruser", + email="other@example.com", + hashed_password="hashed_password", + ) + db_session.add(other_user) + await db_session.commit() + await db_session.refresh(other_user) + + # Create a task for test_account + task = Task(account_id=test_account.id, cron_expression="0 9 * * *", is_enabled=True) + db_session.add(task) + await db_session.commit() + await db_session.refresh(task) + + # Try to access with other_user's token + other_token = create_access_token({"sub": other_user.id}) + other_headers = {"Authorization": f"Bearer {other_token}"} + + from api_service.app.main import app + + async with AsyncClient(app=app, base_url="http://test") as client: + response = await client.put( + f"/api/v1/tasks/{task.id}", + json={"is_enabled": False}, + headers=other_headers, + ) + + assert response.status_code == 403 diff --git a/backend/tests/test_auth_service.py b/backend/tests/test_auth_service.py new file mode 100644 index 0000000..c14b0a3 --- /dev/null +++ b/backend/tests/test_auth_service.py @@ -0,0 +1,317 @@ +""" +Tests for auth_service: security utils, AuthService logic, and API endpoints. +Validates tasks 2.1 – 2.3. +""" + +import pytest +import pytest_asyncio +from unittest.mock import patch, AsyncMock +from fastapi import HTTPException + +from shared.models import User +from tests.conftest import TestSessionLocal, FakeRedis + +# Import security utilities +from auth_service.app.utils.security import ( + hash_password, + verify_password, + validate_password_strength, + create_access_token, + decode_access_token, +) +from auth_service.app.services.auth_service import AuthService +from auth_service.app.schemas.user import UserCreate, UserLogin + + +# ===================== Password utilities ===================== + + +class TestPasswordUtils: + + def test_hash_and_verify(self): + raw = "MyP@ssw0rd" + hashed = hash_password(raw) + assert verify_password(raw, hashed) + + def test_wrong_password_rejected(self): + hashed = hash_password("Correct1!") + assert not verify_password("Wrong1!", hashed) + + @pytest.mark.parametrize( + "pwd, expected_valid", + [ + ("Ab1!abcd", True), # meets all criteria + ("short1A!", True), # 8 chars, has upper/lower/digit/special – valid + ("alllower1!", False), # no uppercase + ("ALLUPPER1!", False), # no lowercase + ("NoDigits!Aa", False), # no digit + ("NoSpecial1a", False), # no special char + ], + ) + def test_password_strength(self, pwd, expected_valid): + is_valid, _ = validate_password_strength(pwd) + assert is_valid == expected_valid + + def test_password_too_short(self): + is_valid, msg = validate_password_strength("Ab1!") + assert not is_valid + assert "8 characters" in msg + + +# ===================== JWT utilities ===================== + + +class TestJWT: + + def test_create_and_decode(self): + token = create_access_token({"sub": "user-123", "username": "alice"}) + payload = decode_access_token(token) + assert payload is not None + assert payload["sub"] == "user-123" + + def test_invalid_token_returns_none(self): + assert decode_access_token("not.a.valid.token") is None + + +# ===================== Refresh token helpers (with fake Redis) ===================== + + +class TestRefreshToken: + + @pytest.mark.asyncio + async def test_create_verify_revoke(self, fake_redis): + """Full lifecycle: create → verify → revoke → verify again returns None.""" + + async def _fake_get_redis(): + return fake_redis + + with patch( + "auth_service.app.utils.security.get_redis", + new=_fake_get_redis, + ): + from auth_service.app.utils.security import ( + create_refresh_token, + verify_refresh_token, + revoke_refresh_token, + ) + + token = await create_refresh_token("user-42") + assert isinstance(token, str) and len(token) > 0 + + uid = await verify_refresh_token(token) + assert uid == "user-42" + + await revoke_refresh_token(token) + assert await verify_refresh_token(token) is None + + +# ===================== AuthService business logic ===================== + + +class TestAuthServiceLogic: + + @pytest_asyncio.fixture + async def auth_svc(self, db_session): + return AuthService(db_session) + + @pytest.mark.asyncio + async def test_create_user_success(self, auth_svc, db_session): + data = UserCreate(username="newuser", email="new@example.com", password="Str0ng!Pass") + user = await auth_svc.create_user(data) + assert user.username == "newuser" + assert user.email == "new@example.com" + assert user.hashed_password != "Str0ng!Pass" + + @pytest.mark.asyncio + async def test_create_user_weak_password_rejected(self, auth_svc): + # Use a password that passes Pydantic min_length=8 but fails strength check + data = UserCreate(username="weakuser", email="weak@example.com", password="weakpassword") + with pytest.raises(HTTPException) as exc_info: + await auth_svc.create_user(data) + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio + async def test_get_user_by_email(self, auth_svc, db_session): + data = UserCreate(username="findme", email="find@example.com", password="Str0ng!Pass") + await auth_svc.create_user(data) + found = await auth_svc.get_user_by_email("find@example.com") + assert found is not None + assert found.username == "findme" + + @pytest.mark.asyncio + async def test_check_user_exists(self, auth_svc, db_session): + data = UserCreate(username="exists", email="exists@example.com", password="Str0ng!Pass") + await auth_svc.create_user(data) + email_u, username_u = await auth_svc.check_user_exists("exists@example.com", "other") + assert email_u is not None + assert username_u is None + + +# ===================== Auth API endpoint tests ===================== + + +class TestAuthAPI: + """Integration tests hitting the FastAPI app via httpx.""" + + @pytest_asyncio.fixture + async def client(self, fake_redis): + """ + Provide an httpx AsyncClient wired to the auth_service app, + with DB session overridden to use the test SQLite engine. + """ + from shared.models import get_db + from auth_service.app.main import app + from httpx import AsyncClient, ASGITransport + from tests.conftest import TEST_ENGINE, TestSessionLocal, Base + + async with TEST_ENGINE.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def override_get_db(): + async with TestSessionLocal() as session: + yield session + + async def _fake_get_redis(): + return fake_redis + + app.dependency_overrides[get_db] = override_get_db + + with patch( + "auth_service.app.utils.security.get_redis", + new=_fake_get_redis, + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac + + app.dependency_overrides.clear() + + @pytest.mark.asyncio + async def test_register_and_login(self, client): + # Register + resp = await client.post("/auth/register", json={ + "username": "apiuser", + "email": "api@example.com", + "password": "Str0ng!Pass1", + }) + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + assert body["data"]["username"] == "apiuser" + + # Login + resp = await client.post("/auth/login", json={ + "email": "api@example.com", + "password": "Str0ng!Pass1", + }) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert "access_token" in body["data"] + assert "refresh_token" in body["data"] + + @pytest.mark.asyncio + async def test_login_wrong_password(self, client): + await client.post("/auth/register", json={ + "username": "wrongpw", + "email": "wrongpw@example.com", + "password": "Str0ng!Pass1", + }) + resp = await client.post("/auth/login", json={ + "email": "wrongpw@example.com", + "password": "WrongPassword1!", + }) + assert resp.status_code == 401 + + @pytest.mark.asyncio + async def test_register_duplicate_email(self, client): + await client.post("/auth/register", json={ + "username": "dup1", + "email": "dup@example.com", + "password": "Str0ng!Pass1", + }) + resp = await client.post("/auth/register", json={ + "username": "dup2", + "email": "dup@example.com", + "password": "Str0ng!Pass1", + }) + assert resp.status_code == 409 + + @pytest.mark.asyncio + async def test_register_weak_password(self, client): + resp = await client.post("/auth/register", json={ + "username": "weakpwd", + "email": "weakpwd@example.com", + "password": "weakpassword", + }) + assert resp.status_code == 400 + + @pytest.mark.asyncio + async def test_me_endpoint(self, client): + await client.post("/auth/register", json={ + "username": "meuser", + "email": "me@example.com", + "password": "Str0ng!Pass1", + }) + login_resp = await client.post("/auth/login", json={ + "email": "me@example.com", + "password": "Str0ng!Pass1", + }) + token = login_resp.json()["data"]["access_token"] + + resp = await client.get( + "/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["data"]["username"] == "meuser" + assert body["data"]["email"] == "me@example.com" + + @pytest.mark.asyncio + async def test_refresh_endpoint(self, client): + await client.post("/auth/register", json={ + "username": "refreshuser", + "email": "refresh@example.com", + "password": "Str0ng!Pass1", + }) + login_resp = await client.post("/auth/login", json={ + "email": "refresh@example.com", + "password": "Str0ng!Pass1", + }) + refresh_token = login_resp.json()["data"]["refresh_token"] + + # Refresh + resp = await client.post("/auth/refresh", json={ + "refresh_token": refresh_token, + }) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert "access_token" in body["data"] + new_refresh = body["data"]["refresh_token"] + assert new_refresh != refresh_token # rotation + + # Old token should be revoked + resp2 = await client.post("/auth/refresh", json={ + "refresh_token": refresh_token, + }) + assert resp2.status_code == 401 + + @pytest.mark.asyncio + async def test_me_without_token(self, client): + resp = await client.get("/auth/me") + assert resp.status_code in (401, 403) + + @pytest.mark.asyncio + async def test_unified_error_format(self, client): + """Verify error responses follow the unified format.""" + resp = await client.post("/auth/login", json={ + "email": "nobody@example.com", + "password": "Whatever1!", + }) + body = resp.json() + assert body["success"] is False + assert body["data"] is None + assert "error" in body diff --git a/backend/tests/test_shared.py b/backend/tests/test_shared.py new file mode 100644 index 0000000..649a6de --- /dev/null +++ b/backend/tests/test_shared.py @@ -0,0 +1,171 @@ +""" +Tests for the shared module: crypto, response format, and ORM models. +Validates tasks 1.1 – 1.5 (excluding optional PBT task 1.4). +""" + +import pytest +import pytest_asyncio +from sqlalchemy import select + +from shared.crypto import derive_key, encrypt_cookie, decrypt_cookie +from shared.response import success_response, error_response +from shared.models import User, Account, Task, SigninLog + +from tests.conftest import TestSessionLocal + + +# ===================== Crypto tests ===================== + + +class TestCrypto: + """Verify AES-256-GCM encrypt/decrypt round-trip and error handling.""" + + def setup_method(self): + self.key = derive_key("test-encryption-key") + + def test_encrypt_decrypt_roundtrip(self): + original = "SUB=abc123; SUBP=xyz789;" + ct, iv = encrypt_cookie(original, self.key) + assert decrypt_cookie(ct, iv, self.key) == original + + def test_different_plaintexts_produce_different_ciphertexts(self): + ct1, _ = encrypt_cookie("cookie_a", self.key) + ct2, _ = encrypt_cookie("cookie_b", self.key) + assert ct1 != ct2 + + def test_wrong_key_raises(self): + ct, iv = encrypt_cookie("secret", self.key) + wrong_key = derive_key("wrong-key") + with pytest.raises(Exception): + decrypt_cookie(ct, iv, wrong_key) + + def test_empty_string_roundtrip(self): + ct, iv = encrypt_cookie("", self.key) + assert decrypt_cookie(ct, iv, self.key) == "" + + def test_unicode_roundtrip(self): + original = "微博Cookie=值; 中文=测试" + ct, iv = encrypt_cookie(original, self.key) + assert decrypt_cookie(ct, iv, self.key) == original + + +# ===================== Response format tests ===================== + + +class TestResponseFormat: + """Verify unified response helpers.""" + + def test_success_response_structure(self): + resp = success_response({"id": 1}, "ok") + assert resp["success"] is True + assert resp["data"] == {"id": 1} + assert resp["message"] == "ok" + + def test_success_response_defaults(self): + resp = success_response() + assert resp["success"] is True + assert resp["data"] is None + assert "Operation successful" in resp["message"] + + def test_error_response_structure(self): + resp = error_response("bad", "VALIDATION_ERROR", [{"field": "email"}], 400) + assert resp.status_code == 400 + import json + body = json.loads(resp.body) + assert body["success"] is False + assert body["data"] is None + assert body["error"]["code"] == "VALIDATION_ERROR" + assert len(body["error"]["details"]) == 1 + + +# ===================== ORM model smoke tests ===================== + + +class TestORMModels: + """Verify ORM models can be created and queried with SQLite.""" + + @pytest.mark.asyncio + async def test_create_user(self, db_session): + user = User( + username="testuser", + email="test@example.com", + hashed_password="hashed", + ) + db_session.add(user) + await db_session.commit() + + result = await db_session.execute(select(User).where(User.username == "testuser")) + fetched = result.scalar_one() + assert fetched.email == "test@example.com" + assert fetched.is_active is True + + @pytest.mark.asyncio + async def test_create_account_linked_to_user(self, db_session): + user = User(username="u1", email="u1@x.com", hashed_password="h") + db_session.add(user) + await db_session.commit() + + acct = Account( + user_id=user.id, + weibo_user_id="12345", + remark="test", + encrypted_cookies="enc", + iv="iv123", + ) + db_session.add(acct) + await db_session.commit() + + result = await db_session.execute(select(Account).where(Account.user_id == user.id)) + fetched = result.scalar_one() + assert fetched.weibo_user_id == "12345" + assert fetched.status == "pending" + + @pytest.mark.asyncio + async def test_create_task_linked_to_account(self, db_session): + user = User(username="u2", email="u2@x.com", hashed_password="h") + db_session.add(user) + await db_session.commit() + + acct = Account( + user_id=user.id, weibo_user_id="99", remark="r", + encrypted_cookies="e", iv="i", + ) + db_session.add(acct) + await db_session.commit() + + task = Task(account_id=acct.id, cron_expression="0 8 * * *") + db_session.add(task) + await db_session.commit() + + result = await db_session.execute(select(Task).where(Task.account_id == acct.id)) + fetched = result.scalar_one() + assert fetched.cron_expression == "0 8 * * *" + assert fetched.is_enabled is True + + @pytest.mark.asyncio + async def test_create_signin_log(self, db_session): + user = User(username="u3", email="u3@x.com", hashed_password="h") + db_session.add(user) + await db_session.commit() + + acct = Account( + user_id=user.id, weibo_user_id="77", remark="r", + encrypted_cookies="e", iv="i", + ) + db_session.add(acct) + await db_session.commit() + + log = SigninLog( + account_id=acct.id, + topic_title="超话A", + status="success", + ) + db_session.add(log) + await db_session.commit() + + result = await db_session.execute( + select(SigninLog).where(SigninLog.account_id == acct.id) + ) + fetched = result.scalar_one() + assert fetched.status == "success" + assert fetched.topic_title == "超话A" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1cd75a9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,170 @@ +version: '3.8' + +services: + # Redis缓存服务 + redis: + image: redis:7-alpine + container_name: weibo-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - weibo-network + + # Nginx反向代理 + nginx: + image: nginx:alpine + container_name: weibo-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + depends_on: + - api-gateway + - frontend + networks: + - weibo-network + + # 前端React应用 + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: weibo-frontend + ports: + - "3000:3000" + environment: + - REACT_APP_API_BASE_URL=http://localhost/api/v1 + depends_on: + - api-gateway + networks: + - weibo-network + + # API网关和主API服务 + api-gateway: + build: + context: ./backend + dockerfile: Dockerfile + target: api_gateway + container_name: weibo-api-gateway + ports: + - "8000:8000" + environment: + - DATABASE_URL=mysql+aiomysql://weibo:123456789@XX.XX.XX.XX/weibo + - REDIS_URL=redis://redis:6379 + - JWT_SECRET_KEY=your-super-secret-jwt-key-here + - ENVIRONMENT=development + depends_on: + - redis + networks: + - weibo-network + + # 认证服务 + auth-service: + build: + context: ./backend + dockerfile: Dockerfile + target: auth_service + container_name: weibo-auth-service + ports: + - "8001:8000" + environment: + - DATABASE_URL=mysql+aiomysql://weibo:123456789@XX.XX.XX.XX/weibo + - JWT_SECRET_KEY=your-super-secret-jwt-key-here + networks: + - weibo-network + + # 任务调度服务 + task-scheduler: + build: + context: ./backend + dockerfile: Dockerfile + target: task_scheduler + container_name: weibo-task-scheduler + environment: + - DATABASE_URL=mysql+aiomysql://weibo:123456789@XX.XX.XX.XX/weibo + - REDIS_URL=redis://redis:6379 + depends_on: + - redis + networks: + - weibo-network + + # 签到执行Worker + signin-executor: + build: + context: ./backend + dockerfile: Dockerfile + target: signin_executor + container_name: weibo-signin-executor + environment: + - DATABASE_URL=mysql+aiomysql://weibo:123456789@XX.XX.XX.XX/weibo + - REDIS_URL=redis://redis:6379 + - PROXY_POOL_URL=http://proxy-pool:8080 + - BROWSER_AUTOMATION_URL=http://browser-automation:3001 + depends_on: + - redis + networks: + - weibo-network + + # 通知中心服务 + notification-hub: + build: + context: ./backend/notification_hub + dockerfile: Dockerfile + container_name: weibo-notification-hub + ports: + - "8002:8000" + environment: + - REDIS_URL=redis://redis:6379 + depends_on: + - redis + networks: + - weibo-network + + # 浏览器自动化服务 + browser-automation: + build: + context: ./backend/browser_automation_service + dockerfile: Dockerfile + container_name: weibo-browser-automation + ports: + - "3001:3000" + environment: + - PLAYWRIGHT_BROWSERS_PATH=/app/browsers + volumes: + - browser_data:/app/browsers + networks: + - weibo-network + + # 代理池管理器 + proxy-pool: + build: + context: ./elk-stack/proxy_pool + dockerfile: Dockerfile + container_name: weibo-proxy-pool + ports: + - "8080:8080" + networks: + - weibo-network + + # 浏览器指纹生成器 + fingerprint-generator: + build: + context: ./browser_fingerprint_generator + dockerfile: Dockerfile + container_name: weibo-fingerprint-generator + ports: + - "8081:8080" + networks: + - weibo-network + +volumes: + redis_data: + browser_data: + +networks: + weibo-network: + driver: bridge diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 0000000..f1d2b79 --- /dev/null +++ b/init-db.sql @@ -0,0 +1,64 @@ +-- Weibo-HotSign Database Initialization Script for MySQL +-- Create tables according to development document specification + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id CHAR(36) PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + hashed_password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE +); + +-- Accounts table +CREATE TABLE IF NOT EXISTS accounts ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + weibo_user_id VARCHAR(20) NOT NULL, + remark VARCHAR(100), + encrypted_cookies TEXT NOT NULL, + iv VARCHAR(32) NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + last_checked_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Tasks table +CREATE TABLE IF NOT EXISTS tasks ( + id CHAR(36) PRIMARY KEY, + account_id CHAR(36) NOT NULL, + cron_expression VARCHAR(50) NOT NULL, + is_enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); + +-- Signin logs table +CREATE TABLE IF NOT EXISTS signin_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + account_id CHAR(36) NOT NULL, + topic_title VARCHAR(100), + status VARCHAR(20) NOT NULL, + reward_info JSON, + error_message TEXT, + signed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) +); + +-- Create indexes for better performance +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_accounts_user_id ON accounts(user_id); +CREATE INDEX idx_accounts_status ON accounts(status); +CREATE INDEX idx_tasks_account_id ON tasks(account_id); +CREATE INDEX idx_tasks_is_enabled ON tasks(is_enabled); +CREATE INDEX idx_signin_logs_account_id ON signin_logs(account_id); +CREATE INDEX idx_signin_logs_signed_at ON signin_logs(signed_at); +CREATE INDEX idx_signin_logs_status ON signin_logs(status); + +-- Insert sample data for testing (optional) +-- Note: UUIDs must be provided by the application +-- INSERT INTO users (id, username, email, hashed_password) VALUES +-- ('your-uuid-here', 'testuser', 'test@example.com', '$2b$12$hashed_password_here'); diff --git a/开发文档.txt b/开发文档.txt new file mode 100644 index 0000000..f78587e --- /dev/null +++ b/开发文档.txt @@ -0,0 +1,292 @@ +好的,这是一份为专业 Coding AI 模型或开发者编写的详细开发文档 (Development Document)。文档遵循常见的软件工程规范,旨在将一个概念转化为可实施的技术蓝图。 + +--- + +## **项目名称: Weibo-HotSign** + +### **1. 项目概述 (Project Overview)** + +#### 1.1. 项目简介 +Weibo-HotSign 是一个分布式的、面向多用户的微博超话智能签到系统。它旨在通过自动化的方式,帮助用户定时、稳定地完成微博超话的签到任务,以获取积分和经验值。项目的核心挑战在于应对微博复杂的反爬虫机制,确保长期运行的稳定性和成功率。 + +#### 1.2. 核心价值主张 +* **多账户管理**: 为用户提供一个集中的平台来管理多个微博账号的签到任务。 +* **高稳定性与反爬虫**: 采用动态IP代理池、浏览器指纹模拟、智能Cookie管理及高级JS逆向(必要时)等技术,最大限度规避微博的风控策略,保障签到任务的成功率。 +* **用户友好**: 提供Web UI进行可视化管理,避免用户直接操作代码或配置文件。 +* **可观测性**: 实时日志、任务历史和状态监控,让用户清晰掌握每个账号的运作情况。 +* **通知系统**: 提供多种渠道的实时通知,让用户第一时间了解任务结果。 + +#### 1.3. 目标用户 +* 拥有多个微博账号,希望自动化完成日常签到的普通用户。 +* 希望通过技术手段研究反爬虫方案的开发者。 +* 需要将此类自动化功能集成到自己系统中的第三方服务(通过API)。 + +--- + +### **2. 系统架构 (System Architecture)** + +本项目采用基于微服务的架构,以实现模块间的解耦、独立部署和水平扩展。 + +#### 2.1. 架构图 +```mermaid +graph TD + subgraph User Interaction Layer + A[Web Frontend (React/Vue)] + end + + subgraph Backend Services Layer + B[API Gateway / Main API Service (FastAPI)] + C[Authentication Service (FastAPI)] + D[Task Scheduler Service (Celery Beat)] + E[Sign-in Executor Worker (Celery Worker)] + F[Notification Hub Service (FastAPI)] + G[Browser Automation Service (Node.js/Python)] + end + + subgraph Core Infrastructure Layer + H[(Message Queue: Redis/RabbitMQ)] + I[(Cache & Session Store: Redis)] + J[(Relational DB: PostgreSQL)] + K[(Proxy Pool Manager)] + L[(Browser Fingerprint Generator)] + end + + subgraph External Systems + M[Weibo.com] + N[User Notification Channels (ServerChan, Email, etc.)] + O[Payment Gateway (Future)] + end + + A -->|HTTPS| B; + B -->|AuthN/AuthZ| C; + A -->|API Calls| B; + + D -->|Publishes Task| H; + E -->|Consumes Task| H; + + E -->|Updates Status| J; + E -->|Reads Config| J; + E -->|Logs Result| J; + + E -->|Stores/Retrieves Session| I; + D -->|Stores Schedule| J; + + E -->|Requests Proxy| K; + K -->|Provides Proxy IP| E; + + E -->|Requests Fingerprint| L; + L -->|Provides Fingerprint| E; + + E -->|Performs Action| M; + G -->|Executes JS & Extracts Data| M; + E -->|Delegates to| G; + + F -->|Sends Notification| N; + + B -->|Authenticated| O; + + style A fill:#D5F5E3 + style B fill:#EBDEF0 + style C fill:#EBDEF0 + style D fill:#EBDEF0 + style E fill:#EBDEF0 + style F fill:#EBDEF0 + style G fill:#EBDEF0 + style H fill:#FDF2E9 + style I fill:#FDF2E9 + style J fill:#FADBD8 + style K fill:#EAFAF1 + style L fill:#EAFAF1 + style M fill:#FDEBD0 + style N fill:#D6EAF8 + style O fill:#EBDEF0 +``` + +#### 2.2. 组件职责描述 +* **Web Frontend**: 负责所有用户交互,包括登录注册、账号管理、任务配置、日志查看等。 +* **API Gateway / Main API Service**: 作为系统的唯一入口,负责请求路由、API组合、限流和初步的请求验证。 +* **Authentication Service**: 独立的认证授权服务,使用JWT或OAuth2.0标准,为所有服务提供统一的身份验证和权限校验。 +* **Task Scheduler Service**: 基于Celery Beat,负责解析用户配置的Cron表达式,并将签到任务发布到消息队列中。 +* **Sign-in Executor Worker**: 核心工作节点,消费消息队列中的任务,执行具体的登录、签到逻辑。此服务可水平扩展以应对高并发。 +* **Notification Hub Service**: 统一处理所有通知请求,并根据用户偏好分发至不同的渠道(Server酱、邮件等)。 +* **Browser Automation Service**: 独立的、无状态的浏览器服务。当遇到复杂的JS加密时,Executor Worker将通过API调用此服务来获取签名后的参数。此服务可使用Playwright或Puppeteer构建,并可独立集群部署。 +* **Message Queue (Redis/RabbitMQ)**: 实现异步和解耦的核心组件。Scheduler生产任务,Executor消费任务。 +* **Cache & Session Store (Redis)**: 存储短期数据,如用户会话、分布式锁、API速率限制计数器、以及所有微博账号的有效Cookies。 +* **Relational DB (PostgreSQL)**: 存储结构化核心业务数据,如用户信息、账号元数据、任务配置、签到历史记录。 +* **Proxy Pool Manager**: (可以是独立进程或一个服务) 负责维护一个高质量的代理IP池,提供健康检查、分配和回收机制。 +* **Browser Fingerprint Generator**: (可以是独立进程或一个库) 负责生成高仿真的浏览器指纹,供Executor Worker在发起请求时使用。 + +--- + +### **3. 核心技术选型 (Technology Stack)** + +| 层级 | 技术组件 | 推荐技术 | 备选技术 | 目的 | +| :--- | :--- | :--- | :--- | :--- | +| **前端** | 框架 | React (Vite) | Vue 3 (Nuxt) | 构建动态、响应式的用户界面 | +| | 状态管理 | Redux Toolkit / Zustand | Pinia | 管理复杂的应用状态 | +| | UI库 | Ant Design / Material-UI | Element Plus | 快速搭建美观的界面 | +| **后端** | Web框架 | Python FastAPI | Node.js Express/NestJS | 高性能、易开发、自动生成API文档 | +| | ASGI服务器 | Uvicorn | Hypercorn | 运行FastAPI应用 | +| | 异步任务 | Celery | RQ (Redis Queue) | 分布式任务队列,处理后台任务 | +| | 消息代理 | Redis | RabbitMQ | Celery的消息中间件 | +| | ORM | SQLAlchemy (async) | Tortoise-ORM | 与PostgreSQL交互 | +| | 数据库 | PostgreSQL | MySQL | 存储核心业务数据,保证事务性和数据完整性 | +| | 缓存 | Redis | Memcached | 高速缓存、会话存储 | +| **基础设施**| 容器化 | Docker | Podman | 环境标准化、简化部署 | +| | 编排 | Docker Compose | Kubernetes (K8s) | 本地开发和生产环境部署 | +| | 反向代理 | Nginx | Traefik/Caddy | SSL终结、负载均衡、静态文件服务 | +| **浏览器自动化**| 库/工具 | Playwright (Python/Node) | Selenium | 模拟浏览器行为、对抗JS加密 | +| **运维** | CI/CD | GitHub Actions | GitLab CI/Jenkins | 自动化测试与部署 | +| | 监控 | Prometheus + Grafana | Zabbix | 系统指标监控 | +| | 日志 | ELK Stack (Elasticsearch, Logstash, Kibana) | Loki + Grafana | 集中式日志收集与分析 | + +--- + +### **4. 模块详细设计 (Detailed Module Design)** + +#### 4.1. 认证与授权模块 (`auth_service`) +* **API端点**: + * `POST /auth/register`: 用户注册。 + * `POST /auth/login`: 用户登录,返回JWT。 + * `POST /auth/refresh`: 刷新JWT。 + * `GET /auth/me`: 获取当前用户信息。 +* **数据库模型 (`users`)**: `id`, `username`, `email`, `hashed_password`, `created_at`, `is_active`。 +* **关键逻辑**: 使用 `bcrypt` 或 `argon2` 哈希密码。JWT包含 `user_id` 和权限声明。所有受保护的API都需通过中间件校验JWT的有效性。 + +#### 4.2. 账号与任务管理模块 (`api_service`) +* **API端点 (示例)**: + * `POST /accounts`: 添加一个微博账号,需提交从前端获取的微博Cookie或通过二维码登录流程绑定的凭证。 + * `GET /accounts`: 获取当前用户的所有微博账号列表及其状态。 + * `PUT /accounts/{account_id}`: 更新账号信息(如备注、启用/禁用状态)。 + * `DELETE /accounts/{account_id}`: 删除一个账号。 + * `POST /tasks`: 为指定账号创建一个签到任务,接收Cron表达式和配置参数。 +* **数据库模型**: + * `accounts`: `id`, `user_id` (FK), `weibo_user_id`, `remark`, `encrypted_cookies`, `status` ('active', 'invalid_cookie', 'banned'), `last_check_time`. + * `tasks`: `id`, `account_id` (FK), `cron_expression`, `is_enabled`, `created_at`. +* **关键逻辑**: 对用户输入的Cookie进行强加密(如AES-256-GCM)后存储。状态`status`由一个独立的后台校验服务定期更新。 + +#### 4.3. 任务调度模块 (`task_scheduler`) +* **技术**: Celery Beat + Redis。 +* **配置**: 在启动时,从数据库`tasks`表中加载所有`is_enabled=True`的任务,并将其注册到Celery Beat的调度器中。 +* **工作流程**: + 1. Beat根据Cron表达式触发任务。 + 2. 任务内容是向消息队列(Redis)发送一条消息,消息体包含`task_id`和`account_id`。 + 3. Worker监听到消息后,调用`Sign-in Executor`的逻辑。 + +#### 4.4. 签到执行模块 (`signin_executor`) +* **技术**: Celery Worker。 +* **工作流程**: + 1. **接收任务**: 从消息队列获取任务。 + 2. **前置检查**: 查询Redis或DB,检查账号`status`是否为`active`。若否,则记录日志并终止。 + 3. **获取资源**: 从`Proxy Pool Manager`获取一个健康代理;从`Fingerprint Generator`获取一个指纹;从Redis解密并获取该账号的`cookies`。 + 4. **执行签到**: + * 调用`login_and_verify`模块检查Cookie有效性。 + * 调用`get_super_topics`获取签到列表。 + * 遍历列表,对每个超话调用`sign_topic`。 + * **反爬策略**: 在请求间引入随机延迟;为每个请求构造独特的Headers(含UA、指纹等);使用代理IP。 + * **JS逆向**: 如果API请求签名失败,向`Browser Automation Service`发起RPC调用,获取签名参数后再重试。 + 5. **结果上报**: 将签到结果(成功、失败、原因、获得积分)写入数据库的`signin_logs`表。 + 6. **清理**: 归还代理IP,更新Redis中的会话状态。 + +#### 4.5. 浏览器自动化模块 (`browser_automation_service`) +* **API端点 (gRPC or REST)**: + * `POST /api/v1/get_signature`: 接收目标URL和必要的上下文,返回一个已签名的请求载荷或Headers。 +* **实现**: 使用Playwright启动一个无头浏览器池。收到请求后,从池中分配一个浏览器上下文,导航至相关页面,注入JS钩子或直接监听网络请求,提取所需数据后关闭上下文并返回结果。 + +--- + +### **5. 数据库设计 (Database Schema)** + +#### 5.1. `users` Table +| Column | Type | Constraints | Description | +| :--- | :--- | :--- | :--- | +| `id` | UUID | PK | Primary Key | +| `username` | VARCHAR(50) | UNIQUE, NOT NULL | Unique username | +| `email` | VARCHAR(255) | UNIQUE, NOT NULL | User's email address | +| `hashed_password` | VARCHAR(255) | NOT NULL | Hashed password | +| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | Account creation time | +| `is_active` | BOOLEAN | DEFAULT TRUE | Whether the user account is active | + +#### 5.2. `accounts` Table +| Column | Type | Constraints | Description | +| :--- | :--- | :--- | :--- | +| `id` | UUID | PK | Primary Key | +| `user_id` | UUID | FK (`users.id`), ON DELETE CASCADE | Owner of the account | +| `weibo_user_id` | VARCHAR(20) | NOT NULL | UID on Weibo platform | +| `remark` | VARCHAR(100) | | User-defined note for the account | +| `encrypted_cookies` | TEXT | NOT NULL | AES-256 encrypted cookie string | +| `iv` | VARCHAR(32) | NOT NULL | Initialization vector for AES-GCM | +| `status` | VARCHAR(20) | DEFAULT 'pending' | 'active', 'invalid_cookie', 'banned' | +| `last_checked_at` | TIMESTAMPTZ | | Last time the cookie was verified | +| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | When the account was added | + +#### 5.3. `tasks` Table +| Column | Type | Constraints | Description | +| :--- | :--- | :--- | :--- | +| `id` | UUID | PK | Primary Key | +| `account_id` | UUID | FK (`accounts.id`), ON DELETE CASCADE | Associated account | +| `cron_expression` | VARCHAR(50) | NOT NULL | Cron expression for scheduling | +| `is_enabled` | BOOLEAN | DEFAULT TRUE | Enable or disable the task | +| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | Task creation time | + +#### 5.4. `signin_logs` Table +| Column | Type | Constraints | Description | +| :--- | :--- | :--- | :--- | +| `id` | BIGSERIAL | PK | Primary Key | +| `account_id` | UUID | FK (`accounts.id`) | Associated account | +| `topic_title` | VARCHAR(100) | | Title of the signed super topic | +| `status` | VARCHAR(20) | NOT NULL | 'success', 'failed_already_signed', 'failed_network', 'failed_banned' | +| `reward_info` | JSONB | | Details about rewards, e.g., `{"exp": 2, "credit": 1}` | +| `error_message` | TEXT | | Error details if status is 'failed' | +| `signed_at` | TIMESTAMPTZ | DEFAULT NOW() | Timestamp of the sign-in attempt | + +--- + +### **6. API 设计规范 (API Design Specification)** + +* **协议**: 全站使用 HTTPS。 +* **数据格式**: 请求和响应体均使用 `application/json`。 +* **认证方式**: Bearer Token (JWT)。需要在请求头的 `Authorization` 字段中携带:`Authorization: Bearer `。 +* **版本控制**: URL路径中包含版本号,例如 `/api/v1/auth/login`。 +* **通用响应结构**: + ```json + { + "success": true, + "data": { ... }, // 成功时返回的数据 + "message": "Operation successful.", // 可选的提示信息 + "error": null // 失败时返回的错误对象 + } + ``` + * 失败时 (HTTP状态码 >= 400): + ```json + { + "success": false, + "data": null, + "message": "Validation failed.", + "error": { + "code": "VALIDATION_ERROR", + "details": [ + {"field": "email", "message": "Invalid email format."} + ] + } + } + ``` +* **状态码**: 严格遵守 RESTful 规范。200 (OK), 201 (Created), 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404 (Not Found), 409 (Conflict), 500 (Internal Server Error)。 + +--- + +### **7. 部署与运维 (Deployment & Operations)** + +* **开发环境**: 使用 `docker-compose.yml` 定义所有服务(API, DB, Redis, Frontend Dev Server),实现一键启动。 +* **生产环境**: 使用 `Kubernetes` 进行编排。编写 `Deployment.yaml`, `Service.yaml`, `Ingress.yaml` 等清单文件。 +* **CI/CD Pipeline (GitHub Actions Example)**: + 1. **On Push to `main`**: Trigger workflow. + 2. **Lint & Test**: Run code linters (Black, Flake8) and unit/integration tests. + 3. **Build**: Build Docker images for all services and push them to a container registry (e.g., Docker Hub, AWS ECR). + 4. **Deploy**: Use `kubectl` or ArgoCD to apply the new Kubernetes manifests to the production cluster. +* **监控与告警**: + * **Metrics**: Expose metrics via `prometheus_client` in Python services. Monitor queue length, task success/failure rates, API latency. + * **Alerts**: Configure Alertmanager to trigger alerts on Slack/PagerDuty for critical issues (e.g., high task failure rate > 10%, worker node down). + * **Logging**: Configure all services to output structured logs (JSON). Use Filebeat or Fluentd to ship logs to Elasticsearch. Use Kibana for visualization and querying. + +这份文档为专业的 Coding AI 或开发团队提供了从宏观架构到微观实现的全面指导。开发过程应遵循此文档,并可根据实际情况进行迭代和优化。 \ No newline at end of file