Files
weibo_signin/.kiro/specs/multi-user-signin/design.md
2026-03-09 14:05:00 +08:00

567 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计文档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<br/>账号/任务/日志管理]
AUTH[Auth_Service :8001<br/>注册/登录/Token刷新]
SCHED[Task_Scheduler<br/>Celery Beat]
EXEC[Signin_Executor<br/>Celery Worker]
end
subgraph "共享层"
SHARED[shared/<br/>ORM Models + DB Session<br/>+ Crypto Utils + Response Format]
end
subgraph "基础设施"
MYSQL[(MySQL)]
REDIS[(Redis<br/>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生成新 tokenToken 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 # 共享 fixturesDB 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 # 签到执行流程
```