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