Compare commits
26 Commits
70c4a813ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dccfae5227 | |||
| 4fea75c49f | |||
| 905125f23b | |||
| 420033c61f | |||
| c2bcde5e59 | |||
| 7fed107c3c | |||
| 9e69d34f81 | |||
| 31d862dfa0 | |||
| 30bddea103 | |||
| 3c50cfcfc5 | |||
| 82f673d806 | |||
| c25c766223 | |||
| 1e25c085a5 | |||
| 9272f416a2 | |||
| 2427237116 | |||
| 07097091fe | |||
| fecb42838c | |||
| 8f4e0a2411 | |||
| 642cf76b61 | |||
| 9a3e846be9 | |||
| 633e4249de | |||
| e514a11e62 | |||
| 2fb27aa714 | |||
| 0a3d7daf2c | |||
| c64d8e67dc | |||
| cdf08d28fa |
@@ -1,214 +0,0 @@
|
||||
# 微博 OAuth2 扫码授权配置指南
|
||||
|
||||
## 功能说明
|
||||
|
||||
实现了微博 OAuth2 扫码授权功能,用户可以通过手机微博 APP 扫码快速添加账号,无需手动复制 Cookie。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. 注册微博开放平台应用
|
||||
|
||||
1. 访问 [微博开放平台](https://open.weibo.com/)
|
||||
2. 登录你的微博账号
|
||||
3. 进入"微连接" > "网站接入"
|
||||
4. 创建新应用,填写应用信息:
|
||||
- 应用名称:Weibo-HotSign(或自定义)
|
||||
- 应用简介:微博自动签到系统
|
||||
- 应用类型:网站
|
||||
- 应用地址:http://localhost:5000(开发环境)
|
||||
|
||||
5. 提交审核(测试阶段可以使用未审核的应用)
|
||||
|
||||
### 2. 配置回调地址
|
||||
|
||||
在应用管理页面:
|
||||
1. 进入"应用信息" > "高级信息"
|
||||
2. 设置"授权回调页"为:`http://localhost:5000/auth/weibo/callback`
|
||||
3. 保存设置
|
||||
|
||||
### 3. 获取 APP KEY 和 APP SECRET
|
||||
|
||||
在应用管理页面:
|
||||
1. 进入"应用信息" > "基本信息"
|
||||
2. 复制 `App Key` 和 `App Secret`
|
||||
|
||||
### 4. 配置环境变量
|
||||
|
||||
编辑 `backend/.env` 和 `frontend/.env` 文件:
|
||||
|
||||
```env
|
||||
# 微博 OAuth2 配置
|
||||
WEIBO_APP_KEY=你的_App_Key
|
||||
WEIBO_APP_SECRET=你的_App_Secret
|
||||
WEIBO_REDIRECT_URI=http://localhost:5000/auth/weibo/callback
|
||||
```
|
||||
|
||||
### 5. 重启服务
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
stop_all.bat
|
||||
|
||||
# 启动所有服务
|
||||
start_all.bat
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 用户端操作
|
||||
|
||||
1. 登录系统后,进入"添加账号"页面
|
||||
2. 切换到"微博授权"标签页
|
||||
3. 点击"生成授权二维码"按钮
|
||||
4. 使用手机微博 APP 扫描二维码
|
||||
5. 在手机上点击"同意授权"
|
||||
6. 等待页面自动完成账号添加
|
||||
7. 自动跳转到 Dashboard
|
||||
|
||||
### 技术流程
|
||||
|
||||
1. **生成授权 URL**
|
||||
- 前端调用 `/auth/weibo/authorize` 接口
|
||||
- 后端生成包含 `state` 参数的授权 URL
|
||||
- 前端使用 QRCode.js 生成二维码
|
||||
|
||||
2. **用户扫码授权**
|
||||
- 用户用手机微博扫码
|
||||
- 跳转到微博授权页面(移动端适配)
|
||||
- 用户点击"同意授权"
|
||||
|
||||
3. **微博回调**
|
||||
- 微博跳转到 `/auth/weibo/callback?code=xxx&state=xxx`
|
||||
- 后端用 `code` 换取 `access_token`
|
||||
- 调用微博 API 获取用户信息
|
||||
- 更新授权状态为成功
|
||||
|
||||
4. **前端轮询**
|
||||
- 前端每 2 秒轮询 `/auth/weibo/check/<state>`
|
||||
- 检测到授权成功后,调用 `/api/weibo/add-account`
|
||||
- 自动添加账号到系统
|
||||
|
||||
5. **完成添加**
|
||||
- 账号添加成功
|
||||
- 跳转到 Dashboard
|
||||
|
||||
## API 接口说明
|
||||
|
||||
### 1. 生成授权 URL
|
||||
|
||||
```
|
||||
GET /auth/weibo/authorize
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"auth_url": "https://api.weibo.com/oauth2/authorize?...",
|
||||
"state": "random_state_string",
|
||||
"expires_in": 180
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 检查授权状态
|
||||
|
||||
```
|
||||
GET /auth/weibo/check/<state>
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"status": "pending|success|error|expired",
|
||||
"account_info": {...} // 仅在 success 时返回
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 微博回调
|
||||
|
||||
```
|
||||
GET /auth/weibo/callback?code=xxx&state=xxx
|
||||
```
|
||||
|
||||
返回 HTML 页面,显示授权结果
|
||||
|
||||
### 4. 添加账号
|
||||
|
||||
```
|
||||
POST /api/weibo/add-account
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"state": "state_string",
|
||||
"remark": "备注(可选)"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Account added successfully",
|
||||
"account": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. **State 参数**:用于防止 CSRF 攻击,每次授权生成唯一的 state
|
||||
2. **Session 存储**:授权状态临时存储在 session 中(生产环境建议使用 Redis)
|
||||
3. **Token 加密**:access_token 会被加密存储在数据库中
|
||||
4. **HTTPS**:生产环境必须使用 HTTPS
|
||||
|
||||
## 生产环境配置
|
||||
|
||||
### 1. 更新回调地址
|
||||
|
||||
```env
|
||||
WEIBO_REDIRECT_URI=https://yourdomain.com/auth/weibo/callback
|
||||
```
|
||||
|
||||
### 2. 在微博开放平台更新回调地址
|
||||
|
||||
进入应用管理 > 高级信息 > 授权回调页:
|
||||
```
|
||||
https://yourdomain.com/auth/weibo/callback
|
||||
```
|
||||
|
||||
### 3. 使用 Redis 存储授权状态
|
||||
|
||||
修改 `frontend/app.py`,将 session 存储改为 Redis 存储。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1:二维码生成失败
|
||||
|
||||
- 检查 `WEIBO_APP_KEY` 是否配置正确
|
||||
- 检查网络连接
|
||||
|
||||
### 问题2:扫码后提示"回调地址不匹配"
|
||||
|
||||
- 检查 `WEIBO_REDIRECT_URI` 是否与微博开放平台配置一致
|
||||
- 确保包含协议(http:// 或 https://)
|
||||
|
||||
### 问题3:授权成功但添加账号失败
|
||||
|
||||
- 检查后端 API 服务是否正常运行
|
||||
- 查看后端日志排查错误
|
||||
|
||||
### 问题4:二维码过期
|
||||
|
||||
- 默认有效期 3 分钟
|
||||
- 重新生成二维码即可
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 微博开放平台应用需要审核,测试阶段可以使用未审核的应用
|
||||
2. 未审核的应用只能授权给应用创建者和测试账号
|
||||
3. 建议使用小号或测试账号进行测试
|
||||
4. access_token 有效期通常为 30 天,过期后需要重新授权
|
||||
5. 生产环境必须使用 HTTPS
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [微博 OAuth2 文档](https://open.weibo.com/wiki/Oauth2)
|
||||
- [微博 API 文档](https://open.weibo.com/wiki/API)
|
||||
@@ -1,240 +0,0 @@
|
||||
# 微博扫码登录功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
实现了基于微博网页版扫码登录接口的账号添加功能,无需注册微博开放平台应用,直接使用微博官方的扫码登录 API。
|
||||
|
||||
## 实现原理
|
||||
|
||||
通过逆向微博网页版(weibo.com)的扫码登录流程,调用微博的内部 API:
|
||||
|
||||
1. **生成二维码**:调用 `https://login.sina.com.cn/sso/qrcode/image` 获取二维码图片
|
||||
2. **轮询状态**:调用 `https://login.sina.com.cn/sso/qrcode/check` 检查扫码状态
|
||||
3. **获取 Cookie**:扫码成功后通过跳转 URL 获取登录 Cookie
|
||||
4. **获取用户信息**:使用 Cookie 调用微博 API 获取用户 UID 和昵称
|
||||
5. **自动添加账号**:将获取的信息自动添加到系统
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 用户操作
|
||||
|
||||
1. 登录系统后,进入"添加账号"页面
|
||||
2. 切换到"扫码添加"标签页
|
||||
3. 点击"生成二维码"按钮
|
||||
4. 使用手机微博 APP 扫描二维码
|
||||
5. 在手机上点击"确认登录"
|
||||
6. 等待页面自动完成账号添加
|
||||
7. 自动跳转到 Dashboard
|
||||
|
||||
### 技术流程
|
||||
|
||||
```
|
||||
用户点击生成二维码
|
||||
↓
|
||||
调用 /api/weibo/qrcode/generate
|
||||
↓
|
||||
请求微博 API 获取二维码
|
||||
↓
|
||||
显示二维码图片
|
||||
↓
|
||||
前端开始轮询 /api/weibo/qrcode/check/<qrid>
|
||||
↓
|
||||
后端轮询微博 API 检查状态
|
||||
↓
|
||||
状态变化:waiting → scanned → success
|
||||
↓
|
||||
获取跳转 URL 和 Cookie
|
||||
↓
|
||||
调用微博 API 获取用户信息
|
||||
↓
|
||||
前端调用 /api/weibo/qrcode/add-account
|
||||
↓
|
||||
添加账号到系统
|
||||
↓
|
||||
跳转到 Dashboard
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 生成二维码
|
||||
|
||||
```
|
||||
POST /api/weibo/qrcode/generate
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"qrid": "qr_id_string",
|
||||
"qr_image": "data:image/png;base64,...",
|
||||
"expires_in": 180
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 检查扫码状态
|
||||
|
||||
```
|
||||
GET /api/weibo/qrcode/check/<qrid>
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"status": "waiting|scanned|success|expired|cancelled|error",
|
||||
"weibo_uid": "123456789", // 仅在 success 时返回
|
||||
"screen_name": "用户昵称" // 仅在 success 时返回
|
||||
}
|
||||
```
|
||||
|
||||
状态说明:
|
||||
- `waiting`: 等待扫码
|
||||
- `scanned`: 已扫码,等待确认
|
||||
- `success`: 确认成功
|
||||
- `expired`: 二维码过期
|
||||
- `cancelled`: 取消登录
|
||||
- `error`: 发生错误
|
||||
|
||||
### 3. 添加账号
|
||||
|
||||
```
|
||||
POST /api/weibo/qrcode/add-account
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"qrid": "qr_id_string",
|
||||
"remark": "备注(可选)"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Account added successfully",
|
||||
"account": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 微博 API 说明
|
||||
|
||||
### 生成二维码接口
|
||||
|
||||
```
|
||||
GET https://login.sina.com.cn/sso/qrcode/image?entry=weibo&size=180&callback=STK_xxx
|
||||
```
|
||||
|
||||
返回 JSONP 格式:
|
||||
```javascript
|
||||
STK_xxx({
|
||||
"retcode": 20000000,
|
||||
"qrid": "xxx",
|
||||
"image": "data:image/png;base64,..."
|
||||
})
|
||||
```
|
||||
|
||||
### 检查扫码状态接口
|
||||
|
||||
```
|
||||
GET https://login.sina.com.cn/sso/qrcode/check?entry=weibo&qrid=xxx&callback=STK_xxx
|
||||
```
|
||||
|
||||
返回 JSONP 格式:
|
||||
```javascript
|
||||
STK_xxx({
|
||||
"retcode": 20000000, // 或其他状态码
|
||||
"alt": "跳转URL" // 仅在登录成功时返回
|
||||
})
|
||||
```
|
||||
|
||||
状态码说明:
|
||||
- `20000000`: 等待扫码
|
||||
- `50050001`: 已扫码,等待确认
|
||||
- `20000001`: 确认成功
|
||||
- `50050002`: 二维码过期
|
||||
- `50050004`: 取消授权
|
||||
|
||||
### 获取用户信息接口
|
||||
|
||||
```
|
||||
GET https://weibo.com/ajax/profile/info
|
||||
Cookie: xxx
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"ok": 1,
|
||||
"data": {
|
||||
"user": {
|
||||
"idstr": "123456789",
|
||||
"screen_name": "用户昵称",
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
1. **无需注册应用**:不需要在微博开放平台注册应用
|
||||
2. **无需配置**:不需要配置 APP_KEY 和 APP_SECRET
|
||||
3. **真实 Cookie**:获取的是真实的登录 Cookie,可用于签到
|
||||
4. **用户体验好**:扫码即可完成,无需手动复制 Cookie
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **接口稳定性**:使用的是微博内部 API,可能会变化
|
||||
2. **Cookie 有效期**:获取的 Cookie 有效期通常较长,但仍可能过期
|
||||
3. **安全性**:Cookie 会被加密存储在数据库中
|
||||
4. **二维码有效期**:默认 3 分钟,过期后需重新生成
|
||||
5. **轮询频率**:前端每 2 秒轮询一次状态
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1:生成二维码失败
|
||||
|
||||
- 检查网络连接
|
||||
- 检查微博 API 是否可访问
|
||||
- 查看后端日志
|
||||
|
||||
### 问题2:扫码后长时间无响应
|
||||
|
||||
- 检查轮询是否正常
|
||||
- 查看浏览器控制台是否有错误
|
||||
- 刷新页面重试
|
||||
|
||||
### 问题3:添加账号失败
|
||||
|
||||
- 检查后端 API 服务是否正常
|
||||
- 查看后端日志排查错误
|
||||
- 确认 Cookie 是否有效
|
||||
|
||||
### 问题4:二维码过期
|
||||
|
||||
- 默认有效期 3 分钟
|
||||
- 重新生成二维码即可
|
||||
|
||||
## 与 OAuth2 方案对比
|
||||
|
||||
| 特性 | 扫码登录(当前方案) | OAuth2 授权 |
|
||||
|------|---------------------|-------------|
|
||||
| 需要注册应用 | ❌ 不需要 | ✅ 需要 |
|
||||
| 配置复杂度 | 低 | 高 |
|
||||
| 获取的凭证 | 真实 Cookie | Access Token |
|
||||
| 接口稳定性 | 中(内部 API) | 高(官方 API) |
|
||||
| 用户体验 | 好 | 好 |
|
||||
| 适用场景 | 个人项目 | 商业项目 |
|
||||
|
||||
## 未来改进
|
||||
|
||||
1. 添加错误重试机制
|
||||
2. 优化轮询策略(WebSocket)
|
||||
3. 添加二维码刷新功能
|
||||
4. 支持多账号批量添加
|
||||
5. 添加扫码记录和统计
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 微博网页版:https://weibo.com
|
||||
- 微博登录页面:https://login.sina.com.cn
|
||||
@@ -16,6 +16,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc default-libmysqlclient-dev curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置时区为上海
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
|
||||
|
||||
@@ -44,6 +48,8 @@ CMD ["uvicorn", "auth_service.app.main:app", "--host", "0.0.0.0", "--port", "800
|
||||
FROM base AS api-service
|
||||
|
||||
COPY api_service/ ./api_service/
|
||||
# api_service 代码 import 了 auth_service 的 security 模块
|
||||
COPY auth_service/ ./auth_service/
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
USER appuser
|
||||
|
||||
@@ -3,6 +3,8 @@ Weibo-HotSign API Service
|
||||
Main FastAPI application entry point — account management, task config, signin logs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -11,6 +13,15 @@ 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
|
||||
|
||||
|
||||
# 过滤 /health 的 access log,避免日志刷屏
|
||||
class _HealthFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return "/health" not in msg
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(_HealthFilter())
|
||||
|
||||
app = FastAPI(
|
||||
title="Weibo-HotSign API Service",
|
||||
version="1.0.0",
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -92,15 +92,49 @@ async def create_account(
|
||||
|
||||
@router.get("")
|
||||
async def list_accounts(
|
||||
page: int = 1,
|
||||
size: int = 12,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
# Total count
|
||||
count_q = select(sa_func.count()).select_from(
|
||||
select(Account).where(Account.user_id == user.id).subquery()
|
||||
)
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
# Status counts (for dashboard stats)
|
||||
status_q = (
|
||||
select(Account.status, sa_func.count())
|
||||
.where(Account.user_id == user.id)
|
||||
.group_by(Account.status)
|
||||
)
|
||||
status_rows = (await db.execute(status_q)).all()
|
||||
status_counts = {row[0]: row[1] for row in status_rows}
|
||||
|
||||
# Paginated list
|
||||
offset = (max(1, page) - 1) * size
|
||||
result = await db.execute(
|
||||
select(Account).where(Account.user_id == user.id)
|
||||
select(Account)
|
||||
.where(Account.user_id == user.id)
|
||||
.order_by(Account.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(size)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
total_pages = (total + size - 1) // size if total > 0 else 0
|
||||
|
||||
return success_response(
|
||||
[_account_to_dict(a) for a in accounts],
|
||||
{
|
||||
"items": [_account_to_dict(a) for a in accounts],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total_pages": total_pages,
|
||||
"status_counts": status_counts,
|
||||
},
|
||||
"Accounts retrieved",
|
||||
)
|
||||
|
||||
@@ -184,7 +218,12 @@ async def _verify_weibo_cookie(cookie_str: str) -> dict:
|
||||
headers=WEIBO_HEADERS,
|
||||
cookies=cookies,
|
||||
)
|
||||
data = resp.json()
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
# 非 JSON 响应(可能是 GBK 编码的登录页),视为 Cookie 失效
|
||||
return {"valid": False, "uid": None, "screen_name": None}
|
||||
|
||||
if data.get("ok") != 1:
|
||||
return {"valid": False, "uid": None, "screen_name": None}
|
||||
|
||||
@@ -203,7 +242,7 @@ async def _verify_weibo_cookie(cookie_str: str) -> dict:
|
||||
uid = str(user.get("idstr", user.get("id", "")))
|
||||
screen_name = user.get("screen_name", "")
|
||||
except Exception:
|
||||
pass # profile info is optional, login check already passed
|
||||
pass
|
||||
|
||||
return {"valid": True, "uid": uid, "screen_name": screen_name}
|
||||
|
||||
@@ -249,6 +288,52 @@ async def verify_account(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{account_id}/topics")
|
||||
async def list_topics(
|
||||
account_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取账号关注的超话列表,供用户勾选签到。"""
|
||||
account = await _get_owned_account(account_id, user, db)
|
||||
key = _encryption_key()
|
||||
|
||||
try:
|
||||
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
|
||||
except Exception:
|
||||
return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400)
|
||||
|
||||
topics = await _get_super_topics(cookie_str, account.weibo_user_id)
|
||||
return success_response({
|
||||
"topics": topics,
|
||||
"total": len(topics),
|
||||
"selected_topics": account.selected_topics,
|
||||
})
|
||||
|
||||
|
||||
@router.put("/{account_id}/topics")
|
||||
async def save_selected_topics(
|
||||
account_id: str,
|
||||
body: dict = Body(...),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""保存用户选择的签到超话列表。空列表或 null 表示签到全部。"""
|
||||
account = await _get_owned_account(account_id, user, db)
|
||||
selected = body.get("selected_topics")
|
||||
# null 或空列表都表示全部签到
|
||||
if selected and isinstance(selected, list) and len(selected) > 0:
|
||||
account.selected_topics = selected
|
||||
else:
|
||||
account.selected_topics = None
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return success_response(
|
||||
_account_to_dict(account),
|
||||
f"已保存 {len(selected) if selected else 0} 个超话" if selected else "已设为签到全部超话",
|
||||
)
|
||||
|
||||
|
||||
# ---- MANUAL SIGNIN ----
|
||||
|
||||
async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]:
|
||||
@@ -283,7 +368,10 @@ async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]:
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
)
|
||||
data = resp.json()
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
break
|
||||
if data.get("ok") != 1:
|
||||
break
|
||||
|
||||
@@ -360,7 +448,10 @@ async def _do_signin(cookie_str: str, topic_title: str, containerid: str) -> dic
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
)
|
||||
data = resp.json()
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return {"status": "failed", "message": f"非JSON响应: {resp.text[:100]}"}
|
||||
code = str(data.get("code", ""))
|
||||
msg = data.get("msg", "")
|
||||
|
||||
@@ -384,10 +475,12 @@ async def manual_signin(
|
||||
account_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
body: dict = Body(default=None),
|
||||
):
|
||||
"""
|
||||
Manually trigger sign-in for all followed super topics.
|
||||
Verifies cookie first, fetches topic list, signs each one, writes logs.
|
||||
Manually trigger sign-in for selected (or all) super topics.
|
||||
Body (optional): {"topic_indices": [0, 1, 3]} — indices of topics to sign.
|
||||
If omitted or empty, signs all topics.
|
||||
"""
|
||||
account = await _get_owned_account(account_id, user, db)
|
||||
key = _encryption_key()
|
||||
@@ -421,6 +514,18 @@ async def manual_signin(
|
||||
"No super topics found for this account",
|
||||
)
|
||||
|
||||
# Filter topics if specific indices provided
|
||||
selected_indices = None
|
||||
if body and isinstance(body, dict):
|
||||
selected_indices = body.get("topic_indices")
|
||||
if selected_indices and isinstance(selected_indices, list):
|
||||
topics = [topics[i] for i in selected_indices if 0 <= i < len(topics)]
|
||||
if not topics:
|
||||
return success_response(
|
||||
{"signed": 0, "already_signed": 0, "failed": 0, "topics": []},
|
||||
"No valid topics selected",
|
||||
)
|
||||
|
||||
# Sign each topic
|
||||
results = []
|
||||
signed = already = failed = 0
|
||||
|
||||
@@ -27,6 +27,7 @@ class AccountResponse(BaseModel):
|
||||
weibo_user_id: str
|
||||
remark: Optional[str]
|
||||
status: str
|
||||
selected_topics: Optional[list] = None
|
||||
last_checked_at: Optional[datetime]
|
||||
created_at: Optional[datetime]
|
||||
|
||||
|
||||
Binary file not shown.
@@ -7,12 +7,21 @@ 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
|
||||
from sqlalchemy import select, func as sa_func
|
||||
import uvicorn
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from shared.models import get_db, User
|
||||
# 过滤 /health 的 access log,避免日志刷屏
|
||||
class _HealthFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return "/health" not in record.getMessage()
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(_HealthFilter())
|
||||
|
||||
from shared.models import get_db, User, InviteCode
|
||||
from shared.config import shared_settings
|
||||
from auth_service.app.models.database import create_tables
|
||||
from auth_service.app.schemas.user import (
|
||||
@@ -113,11 +122,26 @@ async def health_check():
|
||||
@app.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Register a new user account and return tokens
|
||||
Register a new user account and return tokens.
|
||||
Requires a valid invite code.
|
||||
"""
|
||||
auth_service = AuthService(db)
|
||||
|
||||
# Validate invite code
|
||||
result = await db.execute(
|
||||
select(InviteCode).where(
|
||||
InviteCode.code == user_data.invite_code,
|
||||
InviteCode.is_used == False,
|
||||
)
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="邀请码无效或已被使用",
|
||||
)
|
||||
|
||||
# Check if user already exists - optimized with single query
|
||||
# Check if user already exists
|
||||
email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username)
|
||||
|
||||
if email_user:
|
||||
@@ -135,6 +159,12 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db
|
||||
# Create new user
|
||||
try:
|
||||
user = await auth_service.create_user(user_data)
|
||||
|
||||
# Mark invite code as used
|
||||
invite.is_used = True
|
||||
invite.used_by = str(user.id)
|
||||
invite.used_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Create tokens for auto-login
|
||||
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
|
||||
@@ -147,6 +177,8 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db
|
||||
expires_in=3600,
|
||||
user=UserResponse.from_orm(user)
|
||||
)
|
||||
except HTTPException:
|
||||
raise # 直接传递 HTTPException(如密码强度不够)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -333,3 +365,176 @@ async def wx_login(body: WxLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
expires_in=3600,
|
||||
user=UserResponse.from_orm(user),
|
||||
)
|
||||
|
||||
|
||||
# ===================== Admin Endpoints =====================
|
||||
|
||||
async def require_admin(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Dependency: require admin user."""
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
user_id = payload.get("sub")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
return user
|
||||
|
||||
|
||||
@app.get("/admin/users")
|
||||
async def admin_list_users(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员查看所有用户"""
|
||||
result = await db.execute(select(User).order_by(User.created_at.desc()))
|
||||
users = result.scalars().all()
|
||||
return {
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": str(u.created_at) if u.created_at else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.put("/admin/users/{user_id}/toggle")
|
||||
async def admin_toggle_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员启用/禁用用户"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if str(user.id) == str(admin.id):
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||
user.is_active = not user.is_active
|
||||
await db.commit()
|
||||
return {"success": True, "is_active": user.is_active}
|
||||
|
||||
|
||||
@app.post("/admin/invite-codes")
|
||||
async def admin_create_invite_code(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员生成邀请码"""
|
||||
code = secrets.token_urlsafe(8)[:12].upper()
|
||||
invite = InviteCode(code=code, created_by=str(admin.id))
|
||||
db.add(invite)
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"id": str(invite.id),
|
||||
"code": invite.code,
|
||||
"created_at": str(invite.created_at),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/admin/invite-codes")
|
||||
async def admin_list_invite_codes(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员查看所有邀请码"""
|
||||
result = await db.execute(select(InviteCode).order_by(InviteCode.created_at.desc()))
|
||||
codes = result.scalars().all()
|
||||
return {
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"code": c.code,
|
||||
"is_used": c.is_used,
|
||||
"used_by": c.used_by,
|
||||
"created_at": str(c.created_at) if c.created_at else None,
|
||||
"used_at": str(c.used_at) if c.used_at else None,
|
||||
}
|
||||
for c in codes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/admin/invite-codes/{code_id}")
|
||||
async def admin_delete_invite_code(
|
||||
code_id: str,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员删除未使用的邀请码"""
|
||||
result = await db.execute(select(InviteCode).where(InviteCode.id == code_id))
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="邀请码不存在")
|
||||
if invite.is_used:
|
||||
raise HTTPException(status_code=400, detail="已使用的邀请码不能删除")
|
||||
await db.delete(invite)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ===================== System Config Endpoints =====================
|
||||
|
||||
from shared.models.system_config import SystemConfig
|
||||
|
||||
|
||||
@app.get("/admin/config")
|
||||
async def admin_get_config(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取系统配置"""
|
||||
result = await db.execute(select(SystemConfig))
|
||||
rows = result.scalars().all()
|
||||
config = {r.key: r.value for r in rows}
|
||||
return {"success": True, "data": config}
|
||||
|
||||
|
||||
@app.put("/admin/config")
|
||||
async def admin_update_config(
|
||||
body: dict,
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新系统配置,同时通知调度器重新加载"""
|
||||
allowed_keys = {"webhook_url", "daily_report_hour", "daily_report_minute"}
|
||||
for key, value in body.items():
|
||||
if key not in allowed_keys:
|
||||
continue
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = str(value)
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=str(value)))
|
||||
await db.commit()
|
||||
|
||||
# 通过 Redis 通知调度器重新加载配置
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
r = aioredis.from_url(shared_settings.REDIS_URL, decode_responses=True)
|
||||
await r.publish("config_updates", "reload")
|
||||
await r.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"通知调度器失败(不影响保存): {e}")
|
||||
|
||||
return {"success": True, "message": "配置已保存"}
|
||||
|
||||
Binary file not shown.
@@ -16,6 +16,7 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for user registration request"""
|
||||
password: str = Field(..., min_length=8, description="Password (min 8 characters)")
|
||||
invite_code: str = Field(..., min_length=1, description="注册邀请码")
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""Schema for user login request"""
|
||||
@@ -35,6 +36,7 @@ class UserResponse(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
is_admin: bool = False
|
||||
wx_openid: Optional[str] = None
|
||||
wx_nickname: Optional[str] = None
|
||||
wx_avatar: Optional[str] = None
|
||||
|
||||
Binary file not shown.
@@ -34,7 +34,11 @@ class SharedSettings(BaseSettings):
|
||||
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
# Docker 环境下不读 .env 文件,靠 docker-compose environment 注入
|
||||
# 本地开发时 .env 文件仍然生效
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
shared_settings = SharedSettings()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Shared ORM models for Weibo-HotSign."""
|
||||
|
||||
from .base import Base, get_db, engine, AsyncSessionLocal
|
||||
from .user import User
|
||||
from .user import User, InviteCode
|
||||
from .account import Account
|
||||
from .task import Task
|
||||
from .signin_log import SigninLog
|
||||
from .system_config import SystemConfig
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -12,7 +13,9 @@ __all__ = [
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"User",
|
||||
"InviteCode",
|
||||
"Account",
|
||||
"Task",
|
||||
"SigninLog",
|
||||
"SystemConfig",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -19,12 +19,13 @@ class Account(Base):
|
||||
encrypted_cookies = Column(Text, nullable=False)
|
||||
iv = Column(String(32), nullable=False)
|
||||
status = Column(String(20), default="pending")
|
||||
selected_topics = Column(JSON, nullable=True) # 用户选择的签到超话列表,null=全部
|
||||
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")
|
||||
signin_logs = relationship("SigninLog", back_populates="account", cascade="all, delete-orphan", passive_deletes=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Account(id={self.id}, weibo_user_id='{self.weibo_user_id}')>"
|
||||
|
||||
@@ -10,9 +10,9 @@ 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)
|
||||
account_id = Column(String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False)
|
||||
topic_title = Column(String(100))
|
||||
status = Column(String(20), nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
reward_info = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
signed_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
17
backend/shared/models/system_config.py
Normal file
17
backend/shared/models/system_config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""SystemConfig ORM model - 系统配置键值表。"""
|
||||
|
||||
from sqlalchemy import Column, DateTime, String
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_config"
|
||||
|
||||
key = Column(String(64), primary_key=True)
|
||||
value = Column(String(500), nullable=False, default="")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemConfig(key='{self.key}', value='{self.value[:30]}')>"
|
||||
@@ -19,6 +19,7 @@ class User(Base):
|
||||
wx_openid = Column(String(64), unique=True, nullable=True, index=True)
|
||||
wx_nickname = Column(String(100), nullable=True)
|
||||
wx_avatar = Column(String(500), nullable=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
@@ -26,3 +27,15 @@ class User(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class InviteCode(Base):
|
||||
__tablename__ = "invite_codes"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
code = Column(String(32), unique=True, nullable=False, index=True)
|
||||
created_by = Column(String(36), nullable=False)
|
||||
used_by = Column(String(36), nullable=True)
|
||||
is_used = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
# Weibo-HotSign Task Scheduler Service Dockerfile
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.10-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc default-libmysqlclient-dev curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
# 设置时区为上海
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY task_scheduler/requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
# 复制共享模块和调度器代码
|
||||
COPY shared/ ./shared/
|
||||
COPY task_scheduler/ ./task_scheduler/
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
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"]
|
||||
# APScheduler 调度器
|
||||
CMD ["python", "-m", "task_scheduler.app.main"]
|
||||
|
||||
1
backend/task_scheduler/app/__init__.py
Normal file
1
backend/task_scheduler/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Task scheduler app
|
||||
@@ -1,213 +1 @@
|
||||
"""
|
||||
Weibo-HotSign Task Scheduler Service
|
||||
Celery Beat configuration for scheduled sign-in tasks
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
from datetime import datetime
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from croniter import croniter
|
||||
from sqlalchemy import select
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
||||
|
||||
from shared.models.base import AsyncSessionLocal
|
||||
from shared.models.task import Task
|
||||
from shared.models.account import Account
|
||||
from shared.config import shared_settings
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create Celery app
|
||||
celery_app = Celery(
|
||||
"weibo_hot_sign_scheduler",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=["task_scheduler.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,
|
||||
)
|
||||
|
||||
|
||||
class TaskSchedulerService:
|
||||
"""Service to manage scheduled tasks from database"""
|
||||
|
||||
def __init__(self):
|
||||
self.scheduled_tasks: Dict[str, dict] = {}
|
||||
|
||||
async def load_scheduled_tasks(self) -> List[Task]:
|
||||
"""
|
||||
Load enabled tasks from database and register them to Celery Beat.
|
||||
Returns list of loaded tasks.
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Query all enabled tasks with their accounts
|
||||
stmt = (
|
||||
select(Task, Account)
|
||||
.join(Account, Task.account_id == Account.id)
|
||||
.where(Task.is_enabled == True)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
task_account_pairs = result.all()
|
||||
|
||||
logger.info(f"📅 Loaded {len(task_account_pairs)} enabled tasks from database")
|
||||
|
||||
# Register tasks to Celery Beat dynamically
|
||||
beat_schedule = {}
|
||||
for task, account in task_account_pairs:
|
||||
try:
|
||||
# Validate cron expression
|
||||
if not croniter.is_valid(task.cron_expression):
|
||||
logger.warning(f"Invalid cron expression for task {task.id}: {task.cron_expression}")
|
||||
continue
|
||||
|
||||
# Create schedule entry
|
||||
schedule_name = f"task_{task.id}"
|
||||
beat_schedule[schedule_name] = {
|
||||
"task": "task_scheduler.app.tasks.signin_tasks.execute_signin_task",
|
||||
"schedule": self._parse_cron_to_celery(task.cron_expression),
|
||||
"args": (task.id, task.account_id, task.cron_expression),
|
||||
}
|
||||
|
||||
self.scheduled_tasks[task.id] = {
|
||||
"task_id": task.id,
|
||||
"account_id": task.account_id,
|
||||
"cron_expression": task.cron_expression,
|
||||
"account_status": account.status,
|
||||
}
|
||||
|
||||
logger.info(f"✅ Registered task {task.id} for account {account.weibo_user_id} with cron: {task.cron_expression}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register task {task.id}: {e}")
|
||||
continue
|
||||
|
||||
# Update Celery Beat schedule
|
||||
celery_app.conf.beat_schedule.update(beat_schedule)
|
||||
|
||||
return [task for task, _ in task_account_pairs]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading tasks from database: {e}")
|
||||
return []
|
||||
|
||||
def _parse_cron_to_celery(self, cron_expression: str) -> crontab:
|
||||
"""
|
||||
Parse cron expression string to Celery crontab schedule.
|
||||
Format: minute hour day month day_of_week
|
||||
"""
|
||||
parts = cron_expression.split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(f"Invalid cron expression: {cron_expression}")
|
||||
|
||||
return crontab(
|
||||
minute=parts[0],
|
||||
hour=parts[1],
|
||||
day_of_month=parts[2],
|
||||
month_of_year=parts[3],
|
||||
day_of_week=parts[4],
|
||||
)
|
||||
|
||||
async def add_task(self, task_id: str, account_id: str, cron_expression: str):
|
||||
"""Dynamically add a new task to the schedule"""
|
||||
try:
|
||||
if not croniter.is_valid(cron_expression):
|
||||
raise ValueError(f"Invalid cron expression: {cron_expression}")
|
||||
|
||||
schedule_name = f"task_{task_id}"
|
||||
celery_app.conf.beat_schedule[schedule_name] = {
|
||||
"task": "task_scheduler.app.tasks.signin_tasks.execute_signin_task",
|
||||
"schedule": self._parse_cron_to_celery(cron_expression),
|
||||
"args": (task_id, account_id, cron_expression),
|
||||
}
|
||||
|
||||
self.scheduled_tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"account_id": account_id,
|
||||
"cron_expression": cron_expression,
|
||||
}
|
||||
|
||||
logger.info(f"✅ Added task {task_id} to schedule")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add task {task_id}: {e}")
|
||||
raise
|
||||
|
||||
async def remove_task(self, task_id: str):
|
||||
"""Dynamically remove a task from the schedule"""
|
||||
try:
|
||||
schedule_name = f"task_{task_id}"
|
||||
if schedule_name in celery_app.conf.beat_schedule:
|
||||
del celery_app.conf.beat_schedule[schedule_name]
|
||||
logger.info(f"✅ Removed task {task_id} from schedule")
|
||||
|
||||
if task_id in self.scheduled_tasks:
|
||||
del self.scheduled_tasks[task_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove task {task_id}: {e}")
|
||||
raise
|
||||
|
||||
async def update_task(self, task_id: str, is_enabled: bool, cron_expression: str = None):
|
||||
"""Update an existing task in the schedule"""
|
||||
try:
|
||||
if is_enabled:
|
||||
# Re-add or update the task
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(Task).where(Task.id == task_id)
|
||||
result = await session.execute(stmt)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task:
|
||||
await self.add_task(
|
||||
task_id,
|
||||
task.account_id,
|
||||
cron_expression or task.cron_expression
|
||||
)
|
||||
else:
|
||||
# Remove the task
|
||||
await self.remove_task(task_id)
|
||||
|
||||
logger.info(f"✅ Updated task {task_id}, enabled={is_enabled}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update task {task_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Global scheduler service instance
|
||||
scheduler_service = TaskSchedulerService()
|
||||
|
||||
|
||||
# Synchronous wrapper for async function
|
||||
def sync_load_tasks():
|
||||
"""Synchronous wrapper to load tasks on startup"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(scheduler_service.load_scheduled_tasks())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# Import task modules to register them
|
||||
from .tasks import signin_tasks
|
||||
# 此文件已废弃,调度器改用 APScheduler,入口见 main.py
|
||||
|
||||
@@ -9,9 +9,9 @@ from typing import List
|
||||
class Settings(BaseSettings):
|
||||
"""Task Scheduler settings"""
|
||||
|
||||
# Celery settings
|
||||
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
||||
# Celery settings — 从环境变量 REDIS_URL 派生
|
||||
CELERY_BROKER_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# Task execution settings
|
||||
MAX_CONCURRENT_TASKS: int = int(os.getenv("MAX_CONCURRENT_TASKS", "10"))
|
||||
@@ -40,5 +40,6 @@ class Settings(BaseSettings):
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
settings = Settings()
|
||||
|
||||
1022
backend/task_scheduler/app/main.py
Normal file
1022
backend/task_scheduler/app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/task_scheduler/app/tasks/__init__.py
Normal file
1
backend/task_scheduler/app/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Task modules
|
||||
@@ -1,421 +1 @@
|
||||
"""
|
||||
Weibo-HotSign Sign-in Task Definitions
|
||||
Celery tasks for scheduled sign-in operations
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
import redis
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from celery import current_task
|
||||
from sqlalchemy import select
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
|
||||
from shared.models.base import AsyncSessionLocal
|
||||
from shared.models.task import Task
|
||||
from shared.models.account import Account
|
||||
from shared.config import shared_settings
|
||||
|
||||
from ..celery_app import celery_app
|
||||
from ..config import settings
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis client for distributed locks (可选)
|
||||
_redis_client = None
|
||||
|
||||
def get_redis_client():
|
||||
"""获取 Redis 客户端,如果未启用则返回 None"""
|
||||
global _redis_client
|
||||
if not shared_settings.USE_REDIS:
|
||||
return None
|
||||
|
||||
if _redis_client is None:
|
||||
try:
|
||||
_redis_client = redis.from_url(shared_settings.REDIS_URL, decode_responses=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 连接失败: {e},分布式锁将被禁用")
|
||||
return None
|
||||
return _redis_client
|
||||
|
||||
|
||||
# 内存锁(当 Redis 不可用时)
|
||||
_memory_locks = {}
|
||||
|
||||
|
||||
class DistributedLock:
|
||||
"""分布式锁(支持 Redis 或内存模式)"""
|
||||
|
||||
def __init__(self, lock_key: str, timeout: int = 300):
|
||||
"""
|
||||
Initialize distributed lock
|
||||
|
||||
Args:
|
||||
lock_key: Unique key for the lock
|
||||
timeout: Lock timeout in seconds (default 5 minutes)
|
||||
"""
|
||||
self.lock_key = f"lock:{lock_key}"
|
||||
self.timeout = timeout
|
||||
self.acquired = False
|
||||
self.redis_client = get_redis_client()
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""
|
||||
Acquire the lock using Redis SETNX or memory dict
|
||||
Returns True if lock acquired, False otherwise
|
||||
"""
|
||||
try:
|
||||
if self.redis_client:
|
||||
# 使用 Redis
|
||||
result = self.redis_client.set(self.lock_key, "1", nx=True, ex=self.timeout)
|
||||
self.acquired = bool(result)
|
||||
else:
|
||||
# 使用内存锁(本地开发)
|
||||
if self.lock_key not in _memory_locks:
|
||||
_memory_locks[self.lock_key] = True
|
||||
self.acquired = True
|
||||
else:
|
||||
self.acquired = False
|
||||
|
||||
return self.acquired
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to acquire lock {self.lock_key}: {e}")
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
"""Release the lock"""
|
||||
if self.acquired:
|
||||
try:
|
||||
if self.redis_client:
|
||||
# 使用 Redis
|
||||
self.redis_client.delete(self.lock_key)
|
||||
else:
|
||||
# 使用内存锁
|
||||
if self.lock_key in _memory_locks:
|
||||
del _memory_locks[self.lock_key]
|
||||
|
||||
self.acquired = False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to release lock {self.lock_key}: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry"""
|
||||
if not self.acquire():
|
||||
raise Exception(f"Failed to acquire lock: {self.lock_key}")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit"""
|
||||
self.release()
|
||||
|
||||
@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
|
||||
Uses distributed lock to prevent duplicate execution
|
||||
"""
|
||||
lock_key = f"signin_task:{task_id}:{account_id}"
|
||||
lock = DistributedLock(lock_key, timeout=300)
|
||||
|
||||
# Try to acquire lock
|
||||
if not lock.acquire():
|
||||
logger.warning(f"⚠️ Task {task_id} for account {account_id} is already running, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "Task already running (distributed lock)",
|
||||
"account_id": account_id,
|
||||
"task_id": task_id
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"🎯 Starting sign-in task {task_id} for account {account_id}")
|
||||
|
||||
# Update task status
|
||||
current_task.update_state(
|
||||
state="PROGRESS",
|
||||
meta={
|
||||
"current": 10,
|
||||
"total": 100,
|
||||
"status": "Initializing sign-in process...",
|
||||
"account_id": account_id
|
||||
}
|
||||
)
|
||||
|
||||
# Get account info from database
|
||||
account_info = _get_account_from_db(account_id)
|
||||
if not account_info:
|
||||
raise Exception(f"Account {account_id} not found in database")
|
||||
|
||||
# Check if account is active
|
||||
if account_info["status"] not in ["pending", "active"]:
|
||||
logger.warning(f"Account {account_id} status is {account_info['status']}, skipping sign-in")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Account status is {account_info['status']}",
|
||||
"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 with exponential backoff
|
||||
if self.request.retries < settings.MAX_RETRY_ATTEMPTS:
|
||||
retry_delay = settings.RETRY_DELAY_SECONDS * (2 ** self.request.retries)
|
||||
logger.info(f"🔄 Retrying task {task_id} (attempt {self.request.retries + 1}) in {retry_delay}s")
|
||||
|
||||
# Release lock before retry
|
||||
lock.release()
|
||||
|
||||
raise self.retry(exc=exc, countdown=retry_delay)
|
||||
|
||||
# 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
|
||||
|
||||
finally:
|
||||
# Always release lock
|
||||
lock.release()
|
||||
|
||||
|
||||
def _get_account_from_db(account_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Query account information from database (replaces mock data).
|
||||
Returns account dict or None if not found.
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(_async_get_account(account_id))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _async_get_account(account_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Async helper to query account from database"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(Account).where(Account.id == account_id)
|
||||
result = await session.execute(stmt)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if not account:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": account.id,
|
||||
"user_id": account.user_id,
|
||||
"weibo_user_id": account.weibo_user_id,
|
||||
"remark": account.remark,
|
||||
"status": account.status,
|
||||
"encrypted_cookies": account.encrypted_cookies,
|
||||
"iv": account.iv,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying account {account_id}: {e}")
|
||||
return None
|
||||
|
||||
@celery_app.task
|
||||
def schedule_daily_signin():
|
||||
"""
|
||||
Daily sign-in task - queries database for enabled tasks
|
||||
"""
|
||||
logger.info("📅 Executing daily sign-in schedule")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(_async_schedule_daily_signin())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _async_schedule_daily_signin():
|
||||
"""Async helper to query and schedule tasks"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Query all enabled tasks
|
||||
stmt = (
|
||||
select(Task, Account)
|
||||
.join(Account, Task.account_id == Account.id)
|
||||
.where(Task.is_enabled == True)
|
||||
.where(Account.status.in_(["pending", "active"]))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
task_account_pairs = result.all()
|
||||
|
||||
results = []
|
||||
for task, account in task_account_pairs:
|
||||
try:
|
||||
# Submit individual sign-in task for each account
|
||||
celery_task = execute_signin_task.delay(
|
||||
task_id=task.id,
|
||||
account_id=account.id,
|
||||
cron_expression=task.cron_expression
|
||||
)
|
||||
results.append({
|
||||
"account_id": account.id,
|
||||
"task_id": celery_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(task_account_pairs),
|
||||
"results": results
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in daily signin schedule: {e}")
|
||||
raise
|
||||
|
||||
@celery_app.task
|
||||
def process_pending_tasks():
|
||||
"""
|
||||
Process pending sign-in tasks from database
|
||||
Queries database for enabled tasks and submits them for execution
|
||||
"""
|
||||
logger.info("🔄 Processing pending sign-in tasks from database")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(_async_process_pending_tasks())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _async_process_pending_tasks():
|
||||
"""Async helper to process pending tasks"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Query enabled tasks that are due for execution
|
||||
stmt = (
|
||||
select(Task, Account)
|
||||
.join(Account, Task.account_id == Account.id)
|
||||
.where(Task.is_enabled == True)
|
||||
.where(Account.status.in_(["pending", "active"]))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
task_account_pairs = result.all()
|
||||
|
||||
tasks_submitted = 0
|
||||
tasks_skipped = 0
|
||||
|
||||
for task, account in task_account_pairs:
|
||||
try:
|
||||
# Submit task for execution
|
||||
execute_signin_task.delay(
|
||||
task_id=task.id,
|
||||
account_id=account.id,
|
||||
cron_expression=task.cron_expression
|
||||
)
|
||||
tasks_submitted += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to submit task {task.id}: {e}")
|
||||
tasks_skipped += 1
|
||||
|
||||
result = {
|
||||
"processed_at": datetime.now().isoformat(),
|
||||
"tasks_found": len(task_account_pairs),
|
||||
"tasks_submitted": tasks_submitted,
|
||||
"tasks_skipped": tasks_skipped,
|
||||
"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
|
||||
},
|
||||
}
|
||||
# 此文件已废弃,签到逻辑已迁移到 task_scheduler.app.main (APScheduler)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Weibo-HotSign Task Scheduler Service Requirements
|
||||
# Task Queue
|
||||
celery==5.3.6
|
||||
# Weibo-HotSign Task Scheduler
|
||||
apscheduler==3.10.4
|
||||
redis==5.0.1
|
||||
|
||||
# Database
|
||||
@@ -14,5 +13,11 @@ pydantic-settings==2.0.3
|
||||
# HTTP Client
|
||||
httpx==0.25.2
|
||||
|
||||
# Cron parsing (APScheduler 内置,但显式声明)
|
||||
croniter==2.0.1
|
||||
|
||||
# Crypto (for cookie decryption)
|
||||
pycryptodome==3.19.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
|
||||
Binary file not shown.
@@ -44,8 +44,8 @@ def create_database():
|
||||
hashed_password = hash_password(test_password)
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO users (id, username, email, hashed_password, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
INSERT INTO users (id, username, email, hashed_password, is_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, 1, 1)
|
||||
""", (test_user_id, test_username, test_email, hashed_password))
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================================
|
||||
# Weibo-HotSign Docker Compose
|
||||
# 使用方式:
|
||||
# 1. 填写下方 x-db-env 中的 MySQL/Redis 密码
|
||||
# 2. docker-compose up -d --build
|
||||
# 3. 访问 http://localhost:5000
|
||||
# 3. 访问 http://服务器IP:5000
|
||||
# ============================================================
|
||||
|
||||
# 共享环境变量(避免重复)
|
||||
# MySQL 地址用 1Panel 容器名,Redis 如果也是容器就用容器名
|
||||
x-db-env: &db-env
|
||||
DATABASE_URL: "mysql+aiomysql://weibo:123456@1Panel-mysql-lvRT:3306/weibo_hotsign?charset=utf8mb4"
|
||||
REDIS_URL: "redis://:123456@1Panel-redis-3ABC:6379/0"
|
||||
REDIS_URL: "redis://:123456@1Panel-redis-YY6z:6379/0"
|
||||
USE_REDIS: "true"
|
||||
JWT_SECRET_KEY: "change-me-to-a-random-string-in-production"
|
||||
JWT_ALGORITHM: "HS256"
|
||||
@@ -37,7 +36,7 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- weibo-net
|
||||
- 1panel-network
|
||||
|
||||
# API 服务 (端口 8000)
|
||||
api-service:
|
||||
@@ -56,7 +55,7 @@ services:
|
||||
depends_on:
|
||||
- auth-service
|
||||
networks:
|
||||
- weibo-net
|
||||
- 1panel-network
|
||||
|
||||
# Flask 前端 (端口 5000)
|
||||
frontend:
|
||||
@@ -71,15 +70,32 @@ services:
|
||||
FLASK_ENV: "production"
|
||||
FLASK_DEBUG: "False"
|
||||
SECRET_KEY: "change-me-flask-secret-key"
|
||||
API_BASE_URL: "http://api-service:8000"
|
||||
AUTH_BASE_URL: "http://auth-service:8001"
|
||||
API_BASE_URL: "http://weibo-api:8000"
|
||||
AUTH_BASE_URL: "http://weibo-auth:8001"
|
||||
SESSION_TYPE: "filesystem"
|
||||
depends_on:
|
||||
- api-service
|
||||
- auth-service
|
||||
networks:
|
||||
- weibo-net
|
||||
- 1panel-network
|
||||
|
||||
# 定时任务调度器 (APScheduler)
|
||||
task-scheduler:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: task_scheduler/Dockerfile
|
||||
container_name: weibo-scheduler
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *db-env
|
||||
WEBHOOK_URL: "https://open.feishu.cn/open-apis/bot/v2/hook/ba78bd75-baa3-4f14-990c-ae5a2b2d272a"
|
||||
DAILY_REPORT_HOUR: "23" # 每日报告推送时间(小时),默认 23 点
|
||||
SIGNIN_LOG_RETAIN_DAYS: "30"
|
||||
depends_on:
|
||||
- api-service
|
||||
networks:
|
||||
- 1panel-network
|
||||
|
||||
networks:
|
||||
weibo-net:
|
||||
driver: bridge
|
||||
1panel-network:
|
||||
external: true
|
||||
|
||||
@@ -13,6 +13,10 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置时区为上海
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
|
||||
|
||||
@@ -28,6 +32,6 @@ USER appuser
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/ || exit 1
|
||||
CMD curl -f http://localhost:5000/health || exit 1
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
258
frontend/app.py
258
frontend/app.py
@@ -105,6 +105,10 @@ def index():
|
||||
return redirect(url_for('dashboard'))
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return 'ok', 200
|
||||
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
@@ -112,15 +116,20 @@ def register():
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
invite_code = request.form.get('invite_code', '').strip()
|
||||
|
||||
if password != confirm_password:
|
||||
flash('两次输入的密码不一致', 'danger')
|
||||
return redirect(url_for('register'))
|
||||
|
||||
if not invite_code:
|
||||
flash('请输入邀请码', 'danger')
|
||||
return redirect(url_for('register'))
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f'{AUTH_BASE_URL}/auth/register',
|
||||
json={'username': username, 'email': email, 'password': password},
|
||||
json={'username': username, 'email': email, 'password': password, 'invite_code': invite_code},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
@@ -208,18 +217,32 @@ def logout():
|
||||
@app.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
try:
|
||||
response = api_request(
|
||||
'GET',
|
||||
f'{API_BASE_URL}/api/v1/accounts',
|
||||
params={'page': page, 'size': 12},
|
||||
)
|
||||
data = response.json()
|
||||
accounts = data.get('data', []) if data.get('success') else []
|
||||
if data.get('success'):
|
||||
payload = data.get('data', {})
|
||||
# 兼容旧版 API(返回列表)和新版(返回分页对象)
|
||||
if isinstance(payload, list):
|
||||
accounts = payload
|
||||
pagination = {'items': payload, 'total': len(payload), 'page': 1, 'size': len(payload), 'total_pages': 1, 'status_counts': {}}
|
||||
else:
|
||||
accounts = payload.get('items', [])
|
||||
pagination = payload
|
||||
else:
|
||||
accounts = []
|
||||
pagination = {'items': [], 'total': 0, 'page': 1, 'size': 12, 'total_pages': 0, 'status_counts': {}}
|
||||
except requests.RequestException:
|
||||
accounts = []
|
||||
pagination = {'items': [], 'total': 0, 'page': 1, 'size': 12, 'total_pages': 0, 'status_counts': {}}
|
||||
flash('加载账号列表失败', 'warning')
|
||||
|
||||
return render_template('dashboard.html', accounts=accounts, user=session.get('user'))
|
||||
return render_template('dashboard.html', accounts=accounts, pagination=pagination, user=session.get('user'))
|
||||
|
||||
@app.route('/accounts/new')
|
||||
@login_required
|
||||
@@ -694,7 +717,7 @@ def verify_account(account_id):
|
||||
def manual_signin(account_id):
|
||||
"""手动触发签到"""
|
||||
try:
|
||||
response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin')
|
||||
response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin', json={})
|
||||
data = response.json()
|
||||
if data.get('success'):
|
||||
result = data.get('data', {})
|
||||
@@ -836,9 +859,10 @@ def delete_task(task_id):
|
||||
def batch_verify():
|
||||
"""批量验证所有账号的 Cookie 有效性"""
|
||||
try:
|
||||
response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts')
|
||||
response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts', params={'page': 1, 'size': 500})
|
||||
data = response.json()
|
||||
accounts = data.get('data', []) if data.get('success') else []
|
||||
payload = data.get('data', {}) if data.get('success') else {}
|
||||
accounts = payload.get('items', []) if isinstance(payload, dict) else payload
|
||||
|
||||
valid = invalid = errors = 0
|
||||
for account in accounts:
|
||||
@@ -866,9 +890,10 @@ def batch_verify():
|
||||
def batch_signin():
|
||||
"""批量签到所有正常状态的账号"""
|
||||
try:
|
||||
response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts')
|
||||
response = api_request('GET', f'{API_BASE_URL}/api/v1/accounts', params={'page': 1, 'size': 500})
|
||||
data = response.json()
|
||||
accounts = data.get('data', []) if data.get('success') else []
|
||||
payload = data.get('data', {}) if data.get('success') else {}
|
||||
accounts = payload.get('items', []) if isinstance(payload, dict) else payload
|
||||
|
||||
total_signed = total_already = total_failed = 0
|
||||
processed = 0
|
||||
@@ -910,6 +935,223 @@ def not_found(error):
|
||||
def server_error(error):
|
||||
return render_template('500.html'), 500
|
||||
|
||||
|
||||
# ===================== Admin Routes =====================
|
||||
|
||||
def admin_required(f):
|
||||
"""管理员权限装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user' not in session:
|
||||
flash('请先登录', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
if not session.get('user', {}).get('is_admin'):
|
||||
flash('需要管理员权限', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@app.route('/admin')
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
"""管理员面板"""
|
||||
# 获取用户列表
|
||||
try:
|
||||
resp = api_request('GET', f'{AUTH_BASE_URL}/admin/users')
|
||||
users = resp.json().get('data', []) if resp.status_code == 200 else []
|
||||
except Exception:
|
||||
users = []
|
||||
|
||||
# 获取邀请码列表
|
||||
try:
|
||||
resp = api_request('GET', f'{AUTH_BASE_URL}/admin/invite-codes')
|
||||
codes = resp.json().get('data', []) if resp.status_code == 200 else []
|
||||
except Exception:
|
||||
codes = []
|
||||
|
||||
# 获取系统配置
|
||||
try:
|
||||
resp = api_request('GET', f'{AUTH_BASE_URL}/admin/config')
|
||||
config = resp.json().get('data', {}) if resp.status_code == 200 else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
return render_template('admin.html', users=users, invite_codes=codes, config=config, user=session.get('user'))
|
||||
|
||||
|
||||
@app.route('/admin/config/save', methods=['POST'])
|
||||
@admin_required
|
||||
def save_config():
|
||||
"""保存系统配置"""
|
||||
try:
|
||||
config_data = {
|
||||
'webhook_url': request.form.get('webhook_url', '').strip(),
|
||||
'daily_report_hour': request.form.get('daily_report_hour', '23').strip(),
|
||||
'daily_report_minute': request.form.get('daily_report_minute', '30').strip(),
|
||||
}
|
||||
resp = api_request('PUT', f'{AUTH_BASE_URL}/admin/config', json=config_data)
|
||||
data = resp.json()
|
||||
if resp.status_code == 200 and data.get('success'):
|
||||
flash('配置已保存,调度器将自动重新加载', 'success')
|
||||
else:
|
||||
flash(data.get('message', '保存失败'), 'danger')
|
||||
except Exception as e:
|
||||
flash(f'连接错误: {str(e)}', 'danger')
|
||||
return redirect(url_for('admin_panel'))
|
||||
|
||||
|
||||
@app.route('/admin/webhook/test', methods=['POST'])
|
||||
@admin_required
|
||||
def test_webhook():
|
||||
"""测试 Webhook 推送"""
|
||||
try:
|
||||
webhook_url = request.form.get('webhook_url', '').strip()
|
||||
if not webhook_url:
|
||||
return jsonify({'success': False, 'message': 'Webhook 地址为空'}), 400
|
||||
|
||||
import httpx
|
||||
# 飞书格式
|
||||
if 'open.feishu.cn' in webhook_url:
|
||||
payload = {"msg_type": "text", "content": {"text": "🔔 微博超话签到系统 Webhook 测试\n如果你看到这条消息,说明推送配置正确。"}}
|
||||
elif 'qyapi.weixin.qq.com' in webhook_url:
|
||||
payload = {"msgtype": "text", "text": {"content": "🔔 微博超话签到系统 Webhook 测试\n如果你看到这条消息,说明推送配置正确。"}}
|
||||
elif 'oapi.dingtalk.com' in webhook_url:
|
||||
payload = {"msgtype": "text", "text": {"content": "🔔 微博超话签到系统 Webhook 测试\n如果你看到这条消息,说明推送配置正确。"}}
|
||||
else:
|
||||
payload = {"text": "🔔 微博超话签到系统 Webhook 测试"}
|
||||
|
||||
resp = httpx.post(webhook_url, json=payload, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return jsonify({'success': True, 'message': '测试消息已发送'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': f'推送失败: HTTP {resp.status_code}'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/admin/invite-codes/create', methods=['POST'])
|
||||
@admin_required
|
||||
def create_invite_code():
|
||||
"""生成邀请码"""
|
||||
try:
|
||||
resp = api_request('POST', f'{AUTH_BASE_URL}/admin/invite-codes')
|
||||
data = resp.json()
|
||||
if resp.status_code == 200 and data.get('success'):
|
||||
code = data['data']['code']
|
||||
flash(f'邀请码已生成: {code}', 'success')
|
||||
else:
|
||||
flash('生成邀请码失败', 'danger')
|
||||
except Exception as e:
|
||||
flash(f'连接错误: {str(e)}', 'danger')
|
||||
return redirect(url_for('admin_panel'))
|
||||
|
||||
|
||||
@app.route('/admin/invite-codes/<code_id>/delete', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_invite_code(code_id):
|
||||
"""删除邀请码"""
|
||||
try:
|
||||
resp = api_request('DELETE', f'{AUTH_BASE_URL}/admin/invite-codes/{code_id}')
|
||||
data = resp.json()
|
||||
if resp.status_code == 200 and data.get('success'):
|
||||
flash('邀请码已删除', 'success')
|
||||
else:
|
||||
flash(data.get('detail', '删除失败'), 'danger')
|
||||
except Exception as e:
|
||||
flash(f'连接错误: {str(e)}', 'danger')
|
||||
return redirect(url_for('admin_panel'))
|
||||
|
||||
|
||||
@app.route('/admin/users/<user_id>/toggle', methods=['POST'])
|
||||
@admin_required
|
||||
def toggle_user(user_id):
|
||||
"""启用/禁用用户"""
|
||||
try:
|
||||
resp = api_request('PUT', f'{AUTH_BASE_URL}/admin/users/{user_id}/toggle')
|
||||
data = resp.json()
|
||||
if resp.status_code == 200 and data.get('success'):
|
||||
status_text = '已启用' if data.get('is_active') else '已禁用'
|
||||
flash(f'用户{status_text}', 'success')
|
||||
else:
|
||||
flash(data.get('detail', '操作失败'), 'danger')
|
||||
except Exception as e:
|
||||
flash(f'连接错误: {str(e)}', 'danger')
|
||||
return redirect(url_for('admin_panel'))
|
||||
|
||||
|
||||
# ===================== Topic Selection Signin =====================
|
||||
|
||||
@app.route('/accounts/<account_id>/topics')
|
||||
@login_required
|
||||
def account_topics(account_id):
|
||||
"""获取超话列表页面,供用户勾选签到"""
|
||||
try:
|
||||
resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics')
|
||||
data = resp.json()
|
||||
payload = data.get('data', {}) if data.get('success') else {}
|
||||
topics = payload.get('topics', [])
|
||||
selected_topics = payload.get('selected_topics') or []
|
||||
|
||||
# 获取账号信息
|
||||
acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}')
|
||||
acc_data = acc_resp.json()
|
||||
account = acc_data.get('data') if acc_data.get('success') else None
|
||||
|
||||
if not account:
|
||||
flash('账号不存在', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
return render_template('topics.html', account=account, topics=topics, selected_topics=selected_topics, user=session.get('user'))
|
||||
except Exception as e:
|
||||
flash(f'获取超话列表失败: {str(e)}', 'danger')
|
||||
return redirect(url_for('account_detail', account_id=account_id))
|
||||
|
||||
|
||||
@app.route('/accounts/<account_id>/topics/save', methods=['POST'])
|
||||
@login_required
|
||||
def save_topics(account_id):
|
||||
"""保存用户选择的签到超话"""
|
||||
try:
|
||||
body = request.json
|
||||
resp = api_request(
|
||||
'PUT',
|
||||
f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics',
|
||||
json=body,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('success'):
|
||||
return jsonify({'success': True, 'message': data.get('message', '保存成功')})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': data.get('message', '保存失败')}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/accounts/<account_id>/signin-selected', methods=['POST'])
|
||||
@login_required
|
||||
def signin_selected(account_id):
|
||||
"""签到选中的超话"""
|
||||
try:
|
||||
indices = request.json.get('topic_indices', [])
|
||||
resp = api_request(
|
||||
'POST',
|
||||
f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin',
|
||||
json={'topic_indices': indices},
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('success'):
|
||||
result = data.get('data', {})
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result,
|
||||
'message': f"签到完成: {result.get('signed', 0)} 成功, {result.get('already_signed', 0)} 已签, {result.get('failed', 0)} 失败",
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': data.get('message', '签到失败')}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() in ('true', '1', 'yes')
|
||||
# use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
frontend/flask_session/e14aa690816637ce96434acf5ba24a8e
Normal file
BIN
frontend/flask_session/e14aa690816637ce96434acf5ba24a8e
Normal file
Binary file not shown.
BIN
frontend/flask_session/f58a94e5e56b626ceb878155bdc43426
Normal file
BIN
frontend/flask_session/f58a94e5e56b626ceb878155bdc43426
Normal file
Binary file not shown.
@@ -4,3 +4,4 @@ requests==2.31.0
|
||||
python-dotenv==1.0.0
|
||||
Werkzeug==3.0.1
|
||||
qrcode[pil]==7.4.2
|
||||
httpx==0.25.2
|
||||
|
||||
@@ -4,44 +4,41 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.detail-header {
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;
|
||||
}
|
||||
.detail-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; }
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 24px;
|
||||
}
|
||||
@media (max-width: 768px) { .info-grid { grid-template-columns: 1fr; } }
|
||||
.info-table td { padding: 12px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px; }
|
||||
.detail-header h1 { font-size: 20px; font-weight: 700; color: #1e293b; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.info-table td { padding: 10px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
.info-table td:first-child { font-weight: 600; color: #64748b; width: 30%; }
|
||||
.action-btn {
|
||||
width: 100%; padding: 12px; border-radius: 14px; border: none;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
width: 100%; padding: 10px; border-radius: 12px; border: none;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
text-align: center; text-decoration: none; display: block;
|
||||
}
|
||||
.action-btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8); color: white;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
|
||||
}
|
||||
.action-btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
|
||||
.action-btn-primary { background: linear-gradient(135deg, #6366f1, #818cf8); color: white; }
|
||||
.action-btn-primary:hover { transform: translateY(-1px); }
|
||||
.action-btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.action-btn-secondary:hover { background: #e2e8f0; }
|
||||
.task-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 0; border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.task-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f1f5f9; flex-wrap: wrap; gap: 8px; }
|
||||
.task-row:last-child { border-bottom: none; }
|
||||
.task-cron {
|
||||
font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1;
|
||||
padding: 4px 12px; border-radius: 10px; font-size: 13px; font-weight: 600;
|
||||
.task-cron { font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1; padding: 3px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; }
|
||||
.task-actions { display: flex; gap: 6px; }
|
||||
.task-actions .btn { padding: 5px 12px; font-size: 11px; border-radius: 8px; }
|
||||
|
||||
/* 签到记录 - 移动端友好 */
|
||||
.log-item { padding: 10px 0; border-bottom: 1px solid #f1f5f9; }
|
||||
.log-item:last-child { border-bottom: none; }
|
||||
.log-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.log-topic { font-weight: 500; color: #1e293b; font-size: 14px; }
|
||||
.log-bottom { display: flex; justify-content: space-between; align-items: center; }
|
||||
.log-msg { font-size: 12px; color: #64748b; flex: 1; margin-right: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.log-date { font-size: 11px; color: #94a3b8; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.detail-header { flex-direction: column; align-items: flex-start; }
|
||||
.task-row { flex-direction: column; align-items: flex-start; }
|
||||
.task-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
.task-actions { display: flex; gap: 8px; }
|
||||
.task-actions .btn { padding: 6px 14px; font-size: 12px; border-radius: 10px; }
|
||||
.log-row {
|
||||
display: grid; grid-template-columns: 1fr auto auto auto; gap: 16px;
|
||||
align-items: center; padding: 12px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px;
|
||||
}
|
||||
.log-row:last-child { border-bottom: none; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -50,9 +47,9 @@
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h1>{{ account.remark or account.weibo_user_id }}</h1>
|
||||
<div style="color:#94a3b8; font-size:14px; margin-top:4px;">UID: {{ account.weibo_user_id }}</div>
|
||||
<div style="color:#94a3b8; font-size:13px; margin-top:2px;">UID: {{ account.weibo_user_id }}</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary">✏️ 编辑</a>
|
||||
<form method="POST" action="{{ url_for('delete_account', account_id=account.id) }}" style="display:inline;" onsubmit="return confirm('确定要删除此账号吗?');">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
@@ -81,13 +78,14 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">⚡ 快捷操作</div>
|
||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<form method="POST" action="{{ url_for('verify_account', account_id=account.id) }}">
|
||||
<button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('manual_signin', account_id=account.id) }}" onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='⏳ 签到中...';">
|
||||
<button type="submit" class="action-btn action-btn-primary">🚀 立即签到</button>
|
||||
<button type="submit" class="action-btn action-btn-primary">🚀 全部签到</button>
|
||||
</form>
|
||||
<a href="{{ url_for('account_topics', account_id=account.id) }}" class="action-btn action-btn-secondary">🎯 选择超话签到</a>
|
||||
<a href="{{ url_for('add_task', account_id=account.id) }}" class="action-btn action-btn-secondary">⏰ 添加定时任务</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +96,7 @@
|
||||
{% if tasks %}
|
||||
{% for task in tasks %}
|
||||
<div class="task-row">
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<span class="task-cron">{{ task.cron_expression }}</span>
|
||||
{% if task.is_enabled %}<span class="badge badge-success">已启用</span>
|
||||
{% else %}<span class="badge badge-warning">已禁用</span>{% endif %}
|
||||
@@ -109,7 +107,7 @@
|
||||
<input type="hidden" name="is_enabled" value="{{ task.is_enabled|lower }}">
|
||||
<button type="submit" class="btn btn-secondary">{% if task.is_enabled %}禁用{% else %}启用{% endif %}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('delete_task', task_id=task.id) }}" style="display:inline;" onsubmit="return confirm('确定要删除此任务吗?');">
|
||||
<form method="POST" action="{{ url_for('delete_task', task_id=task.id) }}" style="display:inline;" onsubmit="return confirm('确定删除?');">
|
||||
<input type="hidden" name="account_id" value="{{ account.id }}">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
@@ -117,47 +115,60 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无定时任务</p>
|
||||
<p style="color:#94a3b8; text-align:center; padding:20px; font-size:13px;">暂无定时任务</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">📝 签到记录</div>
|
||||
{% if logs['items'] %}
|
||||
<div class="card-header">📝 签到记录 {% if logs.get('total', 0) > 0 %}(共 {{ logs.total }} 条){% endif %}</div>
|
||||
{% if logs.get('items') %}
|
||||
{% for log in logs['items'] %}
|
||||
<div class="log-row">
|
||||
<div style="font-weight:500; color:#1e293b;">{{ log.topic_title or '-' }}</div>
|
||||
<div>
|
||||
<div class="log-item">
|
||||
<div class="log-top">
|
||||
<span class="log-topic">{{ log.topic_title or '-' }}</span>
|
||||
{% if log.status == 'success' %}<span class="badge badge-success">签到成功</span>
|
||||
{% elif log.status == 'failed_already_signed' %}<span class="badge badge-info">今日已签</span>
|
||||
{% elif log.status == 'failed_network' %}<span class="badge badge-warning">网络错误</span>
|
||||
{% elif log.status == 'failed_banned' %}<span class="badge badge-danger">已封禁</span>
|
||||
{% else %}<span class="badge badge-info">{{ log.status }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size:13px; color:#64748b;">
|
||||
{% if log.reward_info %}{{ log.reward_info.get('points', '-') }} 经验{% else %}-{% endif %}
|
||||
<div class="log-bottom">
|
||||
<span class="log-msg">
|
||||
{% if log.reward_info %}
|
||||
{% if log.reward_info is mapping %}{{ log.reward_info.get('message', '-') }}
|
||||
{% else %}{{ log.reward_info }}{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</span>
|
||||
<span class="log-date">{{ log.signed_at[:16] | replace('T', ' ') }}</span>
|
||||
</div>
|
||||
<div style="color:#94a3b8; font-size:13px;">{{ log.signed_at[:10] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if logs['total'] > logs['size'] %}
|
||||
|
||||
{% set p = logs.get('page', 1) %}
|
||||
{% set tp = logs.get('total_pages', 1) %}
|
||||
{% if tp > 1 %}
|
||||
<div class="pagination">
|
||||
{% if logs['page'] > 1 %}
|
||||
<a href="?page=1">首页</a>
|
||||
<a href="?page={{ logs['page'] - 1 }}">上一页</a>
|
||||
{% if p > 1 %}
|
||||
<a href="?page={{ p - 1 }}">‹ 上一页</a>
|
||||
{% else %}
|
||||
<span class="disabled">‹ 上一页</span>
|
||||
{% endif %}
|
||||
{% for p in range(max(1, logs['page'] - 2), min(logs['total'] // logs['size'] + 2, logs['page'] + 3)) %}
|
||||
{% if p == logs['page'] %}<span class="active">{{ p }}</span>
|
||||
{% else %}<a href="?page={{ p }}">{{ p }}</a>{% endif %}
|
||||
|
||||
{% for i in range([1, p - 2]|max, [tp, p + 2]|min + 1) %}
|
||||
{% if i == p %}<span class="active">{{ i }}</span>
|
||||
{% else %}<a href="?page={{ i }}">{{ i }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
{% if logs['page'] < logs['total'] // logs['size'] + 1 %}
|
||||
<a href="?page={{ logs['page'] + 1 }}">下一页</a>
|
||||
<a href="?page={{ logs['total'] // logs['size'] + 1 }}">末页</a>
|
||||
|
||||
{% if p < tp %}
|
||||
<a href="?page={{ p + 1 }}">下一页 ›</a>
|
||||
{% else %}
|
||||
<span class="disabled">下一页 ›</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无签到记录</p>
|
||||
<p style="color:#94a3b8; text-align:center; padding:20px; font-size:13px;">暂无签到记录</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
168
frontend/templates/admin.html
Normal file
168
frontend/templates/admin.html
Normal file
@@ -0,0 +1,168 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}管理面板 - 微博超话签到{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
|
||||
@media (max-width: 768px) { .admin-grid { grid-template-columns: 1fr; } }
|
||||
.admin-title {
|
||||
font-size: 28px; font-weight: 700; margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.stat-row { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
flex: 1; background: rgba(255,255,255,0.9); border-radius: 16px;
|
||||
padding: 20px; text-align: center; border: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
.stat-num { font-size: 32px; font-weight: 700; color: #6366f1; }
|
||||
.stat-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
|
||||
.user-row, .code-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px;
|
||||
}
|
||||
.user-row:last-child, .code-row:last-child { border-bottom: none; }
|
||||
.code-text {
|
||||
font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1;
|
||||
padding: 4px 12px; border-radius: 8px; font-size: 14px; font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.code-used { background: #f1f5f9; color: #94a3b8; text-decoration: line-through; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 960px; margin: 0 auto;">
|
||||
<h1 class="admin-title">🛡️ 管理面板</h1>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ users|length }}</div>
|
||||
<div class="stat-label">总用户数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ users|selectattr('is_active')|list|length }}</div>
|
||||
<div class="stat-label">活跃用户</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ invite_codes|rejectattr('is_used')|list|length }}</div>
|
||||
<div class="stat-label">可用邀请码</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-grid">
|
||||
<div class="card">
|
||||
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
🎟️ 邀请码管理
|
||||
<form method="POST" action="{{ url_for('create_invite_code') }}" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary" style="padding:6px 16px; font-size:13px;">+ 生成邀请码</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if invite_codes %}
|
||||
{% for code in invite_codes %}
|
||||
<div class="code-row">
|
||||
<div>
|
||||
<span class="code-text {{ 'code-used' if code.is_used }}">{{ code.code }}</span>
|
||||
{% if code.is_used %}
|
||||
<span style="color:#94a3b8; font-size:12px; margin-left:8px;">已使用</span>
|
||||
{% else %}
|
||||
<span style="color:#10b981; font-size:12px; margin-left:8px;">可用</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{% if not code.is_used %}
|
||||
<form method="POST" action="{{ url_for('delete_invite_code', code_id=code.id) }}" style="display:inline;" onsubmit="return confirm('确定删除?');">
|
||||
<button type="submit" class="btn btn-danger" style="padding:4px 12px; font-size:12px;">删除</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无邀请码,点击上方按钮生成</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">👥 用户管理</div>
|
||||
{% for u in users %}
|
||||
<div class="user-row">
|
||||
<div>
|
||||
<span style="font-weight:600; color:#1e293b;">{{ u.username }}</span>
|
||||
{% if u.is_admin %}<span class="badge badge-info" style="margin-left:6px;">管理员</span>{% endif %}
|
||||
<div style="font-size:12px; color:#94a3b8;">{{ u.email or '-' }}</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
{% if u.is_active %}
|
||||
<span class="badge badge-success">正常</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger">已禁用</span>
|
||||
{% endif %}
|
||||
{% if not u.is_admin %}
|
||||
<form method="POST" action="{{ url_for('toggle_user', user_id=u.id) }}" style="display:inline;">
|
||||
<button type="submit" class="btn {{ 'btn-danger' if u.is_active else 'btn-primary' }}" style="padding:4px 12px; font-size:12px;">
|
||||
{{ '禁用' if u.is_active else '启用' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推送设置 -->
|
||||
<div class="card" style="margin-bottom: 24px;">
|
||||
<div class="card-header">🔔 消息推送设置</div>
|
||||
<form method="POST" action="{{ url_for('save_config') }}">
|
||||
<div class="form-group">
|
||||
<label>Webhook 地址</label>
|
||||
<input type="text" name="webhook_url" value="{{ config.get('webhook_url', '') }}"
|
||||
placeholder="飞书/企业微信/钉钉机器人 Webhook URL" style="font-size:13px;">
|
||||
<div style="font-size:11px; color:#94a3b8; margin-top:4px;">支持飞书、企业微信、钉钉自定义机器人</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; align-items:flex-end;">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>推送时间(时)</label>
|
||||
<select name="daily_report_hour">
|
||||
{% for h in range(24) %}
|
||||
<option value="{{ h }}" {{ 'selected' if config.get('daily_report_hour', '23')|string == h|string }}>{{ '%02d'|format(h) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>推送时间(分)</label>
|
||||
<select name="daily_report_minute">
|
||||
{% for m in range(0, 60, 5) %}
|
||||
<option value="{{ m }}" {{ 'selected' if config.get('daily_report_minute', '30')|string == m|string }}>{{ '%02d'|format(m) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:8px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;">💾 保存配置</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="testWebhook()" id="test-btn">📤 测试推送</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testWebhook() {
|
||||
const btn = document.getElementById('test-btn');
|
||||
const url = document.querySelector('input[name="webhook_url"]').value.trim();
|
||||
if (!url) { alert('请先填写 Webhook 地址'); return; }
|
||||
btn.disabled = true; btn.textContent = '⏳ 发送中...';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('webhook_url', url);
|
||||
const resp = await fetch('{{ url_for("test_webhook") }}', {method: 'POST', body: form});
|
||||
const data = await resp.json();
|
||||
alert(data.success ? '✅ ' + data.message : '❌ ' + data.message);
|
||||
} catch(e) { alert('请求失败: ' + e.message); }
|
||||
btn.disabled = false; btn.textContent = '📤 测试推送';
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -2,216 +2,138 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{% block title %}微博超话签到{% endblock %}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #f0f2ff 0%, #faf5ff 50%, #fff0f6 100%);
|
||||
color: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
color: #1a1a2e; min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Navbar ---- */
|
||||
.navbar {
|
||||
background: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: rgba(255,255,255,0.85); backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(99,102,241,0.08);
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 0 16px; position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.navbar-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center; height: 52px;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-size: 18px; font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.navbar-menu {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.navbar-menu { display: flex; gap: 20px; align-items: center; }
|
||||
.navbar-menu > a {
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
padding: 6px 0;
|
||||
color: #64748b; text-decoration: none; font-size: 14px;
|
||||
font-weight: 500; transition: color 0.2s;
|
||||
}
|
||||
.navbar-menu > a:hover { color: #6366f1; }
|
||||
.navbar-user {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.navbar-user span {
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.navbar-user { display: flex; gap: 10px; align-items: center; }
|
||||
.navbar-user span { color: #475569; font-size: 13px; font-weight: 500; }
|
||||
.btn-logout {
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
color: white;
|
||||
padding: 7px 18px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s;
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48); color: white;
|
||||
padding: 5px 14px; border: none; border-radius: 20px; cursor: pointer;
|
||||
text-decoration: none; font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.btn-logout:hover { opacity: 0.85; }
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 28px auto;
|
||||
padding: 0 20px;
|
||||
.navbar-toggle {
|
||||
display: none; background: none; border: none;
|
||||
font-size: 22px; cursor: pointer; color: #475569; padding: 4px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* ---- Layout ---- */
|
||||
.container { max-width: 1200px; margin: 16px auto; padding: 0 16px; }
|
||||
|
||||
/* ---- Alerts ---- */
|
||||
.alert { padding: 10px 14px; margin-bottom: 14px; border-radius: 12px; font-size: 13px; font-weight: 500; }
|
||||
.alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.alert-danger { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
|
||||
.alert-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label { display: block; margin-bottom: 8px; font-weight: 600; color: #334155; font-size: 14px; }
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; margin-bottom: 6px; font-weight: 600; color: #334155; font-size: 13px; }
|
||||
input[type="text"], input[type="email"], input[type="password"], textarea, select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
width: 100%; padding: 10px 14px; border: 1.5px solid #e2e8f0;
|
||||
border-radius: 12px; font-size: 14px; font-family: inherit; background: #fff;
|
||||
}
|
||||
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus,
|
||||
textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #818cf8;
|
||||
box-shadow: 0 0 0 4px rgba(99,102,241,0.1);
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none; border-color: #818cf8; box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
padding: 10px 22px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
|
||||
padding: 8px 16px; border: none; border-radius: 12px; cursor: pointer;
|
||||
font-size: 13px; font-weight: 600; transition: all 0.2s;
|
||||
text-decoration: none; display: inline-block; white-space: nowrap;
|
||||
}
|
||||
.btn-primary { background: linear-gradient(135deg, #6366f1, #818cf8); color: white; box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
|
||||
.btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
|
||||
.btn-secondary { background: #f1f5f9; color: #475569; }
|
||||
.btn-secondary:hover { background: #e2e8f0; }
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(244,63,94,0.25);
|
||||
}
|
||||
.btn-danger:hover { box-shadow: 0 4px 16px rgba(244,63,94,0.35); }
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(16,185,129,0.25);
|
||||
}
|
||||
.btn-success:hover { box-shadow: 0 4px 16px rgba(16,185,129,0.35); }
|
||||
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.btn-danger { background: linear-gradient(135deg, #f43f5e, #e11d48); color: white; }
|
||||
.btn-success { background: linear-gradient(135deg, #10b981, #059669); color: white; }
|
||||
.btn-group { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
background: rgba(255,255,255,0.9); backdrop-filter: blur(8px);
|
||||
border-radius: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
padding: 16px; margin-bottom: 14px; border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
.card-header {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
font-size: 15px; font-weight: 700; margin-bottom: 12px;
|
||||
padding-bottom: 10px; border-bottom: 2px solid #f1f5f9; color: #1e293b;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* ---- Badges ---- */
|
||||
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
||||
.badge-success { background: #ecfdf5; color: #059669; }
|
||||
.badge-warning { background: #fffbeb; color: #d97706; }
|
||||
.badge-danger { background: #fef2f2; color: #dc2626; }
|
||||
.badge-info { background: #eff6ff; color: #2563eb; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
td { padding: 14px 16px; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
|
||||
tr:hover { background: #f8fafc; }
|
||||
/* ---- Tables ---- */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; padding: 8px 10px; text-align: left; font-weight: 600; font-size: 12px; color: #64748b; border-bottom: 2px solid #e2e8f0; }
|
||||
td { padding: 10px; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
|
||||
.pagination { display: flex; gap: 6px; justify-content: center; margin-top: 20px; }
|
||||
/* ---- Pagination ---- */
|
||||
.pagination { display: flex; gap: 4px; justify-content: center; margin-top: 16px; flex-wrap: wrap; }
|
||||
.pagination a, .pagination span {
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: #6366f1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px; border: 1.5px solid #e2e8f0; border-radius: 10px;
|
||||
text-decoration: none; color: #6366f1; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.pagination a:hover { background: #f1f5f9; }
|
||||
.pagination .active { background: #6366f1; color: white; border-color: #6366f1; }
|
||||
.pagination .disabled { color: #cbd5e1; pointer-events: none; }
|
||||
|
||||
/* ---- Mobile Responsive ---- */
|
||||
@media (max-width: 768px) {
|
||||
.navbar-menu { display: none; }
|
||||
.navbar-toggle { display: block; }
|
||||
.navbar-menu {
|
||||
display: none; flex-direction: column; gap: 0;
|
||||
position: absolute; top: 52px; left: 0; right: 0;
|
||||
background: rgba(255,255,255,0.98); backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid #e2e8f0; padding: 8px 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.navbar-menu.open { display: flex; }
|
||||
.navbar-menu > a { padding: 12px 20px; font-size: 15px; border-bottom: 1px solid #f1f5f9; }
|
||||
.navbar-user { padding: 12px 20px; gap: 12px; }
|
||||
.container { padding: 0 12px; margin: 12px auto; }
|
||||
.card { padding: 14px; border-radius: 14px; }
|
||||
.btn { padding: 8px 14px; font-size: 12px; }
|
||||
.btn-group { flex-direction: column; }
|
||||
.btn { width: 100%; text-align: center; }
|
||||
.btn-group .btn { width: 100%; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
@@ -221,8 +143,12 @@
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<a href="{{ url_for('dashboard') }}" class="navbar-brand">🔥 微博超话签到</a>
|
||||
<button class="navbar-toggle" onclick="document.querySelector('.navbar-menu').classList.toggle('open')">☰</button>
|
||||
<div class="navbar-menu">
|
||||
<a href="{{ url_for('dashboard') }}">控制台</a>
|
||||
{% if session.get('user', {}).get('is_admin') %}
|
||||
<a href="{{ url_for('admin_panel') }}">🛡️ 管理</a>
|
||||
{% endif %}
|
||||
<div class="navbar-user">
|
||||
<span>👤 {{ session.get('user').get('username') }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn-logout">退出</a>
|
||||
|
||||
@@ -4,200 +4,113 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.dash-header h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
.dash-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.dash-header h1 { font-size: 22px; font-weight: 700; color: #1e293b; }
|
||||
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
}
|
||||
.stat-card .stat-icon { font-size: 28px; margin-bottom: 8px; }
|
||||
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
|
||||
.stat-card .stat-label { font-size: 13px; color: #94a3b8; font-weight: 500; margin-top: 2px; }
|
||||
|
||||
/* 账号卡片网格 */
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 18px;
|
||||
background: rgba(255,255,255,0.9); backdrop-filter: blur(8px);
|
||||
border-radius: 16px; padding: 16px; border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
.stat-card .stat-icon { font-size: 22px; margin-bottom: 4px; }
|
||||
.stat-card .stat-value { font-size: 24px; font-weight: 700; color: #1e293b; }
|
||||
.stat-card .stat-label { font-size: 12px; color: #94a3b8; font-weight: 500; }
|
||||
.account-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.account-card {
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 4px;
|
||||
border-radius: 20px 20px 0 0;
|
||||
background: rgba(255,255,255,0.92); backdrop-filter: blur(8px);
|
||||
border-radius: 16px; padding: 18px; border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04); cursor: pointer;
|
||||
transition: all 0.2s; position: relative; overflow: hidden;
|
||||
}
|
||||
.account-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; border-radius: 16px 16px 0 0; }
|
||||
.account-card.status-active::before { background: linear-gradient(90deg, #10b981, #34d399); }
|
||||
.account-card.status-pending::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
||||
.account-card.status-invalid_cookie::before { background: linear-gradient(90deg, #ef4444, #f87171); }
|
||||
.account-card.status-banned::before { background: linear-gradient(90deg, #6b7280, #9ca3af); }
|
||||
|
||||
.account-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px rgba(99,102,241,0.12);
|
||||
}
|
||||
.account-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.account-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(99,102,241,0.1); }
|
||||
.account-card-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
||||
.account-avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 16px;
|
||||
width: 40px; height: 40px; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.account-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.account-remark {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-size: 16px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.account-name { font-size: 15px; font-weight: 700; color: #1e293b; word-break: break-all; }
|
||||
.account-remark { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
.account-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding-top: 10px; border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
.account-date { font-size: 12px; color: #cbd5e1; }
|
||||
|
||||
/* 删除按钮 */
|
||||
.account-date { font-size: 11px; color: #cbd5e1; }
|
||||
.account-del-btn {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid #fecaca;
|
||||
background: #fff;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
width: 28px; height: 28px; border-radius: 8px; border: 1.5px solid #fecaca;
|
||||
background: #fff; color: #ef4444; font-size: 12px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.account-del-btn:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #f87171;
|
||||
.account-del-btn:hover { background: #fef2f2; }
|
||||
.batch-bar {
|
||||
background: rgba(255,255,255,0.9); border-radius: 14px; padding: 12px 16px;
|
||||
margin-bottom: 14px; border: 1px solid rgba(255,255,255,0.6);
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.batch-bar .info { font-size: 13px; color: #64748b; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border-radius: 24px;
|
||||
border: 2px dashed #e2e8f0;
|
||||
text-align: center; padding: 60px 20px; background: rgba(255,255,255,0.7);
|
||||
border-radius: 20px; border: 2px dashed #e2e8f0;
|
||||
}
|
||||
.empty-state .empty-icon { font-size: 56px; margin-bottom: 16px; }
|
||||
.empty-state p { color: #94a3b8; margin-bottom: 24px; font-size: 16px; }
|
||||
|
||||
/* Cookie 批量验证 */
|
||||
.batch-verify-bar {
|
||||
background: rgba(255,255,255,0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state p { color: #94a3b8; margin-bottom: 20px; font-size: 15px; }
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.stat-card { padding: 12px; }
|
||||
.stat-card .stat-value { font-size: 20px; }
|
||||
.stat-card .stat-label { font-size: 11px; }
|
||||
.account-grid { grid-template-columns: 1fr; }
|
||||
.batch-bar { flex-direction: column; align-items: stretch; text-align: center; }
|
||||
.batch-bar .info { margin-bottom: 8px; }
|
||||
.dash-header { flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
}
|
||||
.batch-verify-bar .info { font-size: 14px; color: #64748b; }
|
||||
.batch-verify-bar .info strong { color: #1e293b; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dash-header">
|
||||
<h1>👋 控制台</h1>
|
||||
<div class="dash-actions">
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ 添加账号</a>
|
||||
</div>
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ 添加账号</a>
|
||||
</div>
|
||||
|
||||
{% if accounts %}
|
||||
<!-- 统计概览 -->
|
||||
{% set sc = pagination.get('status_counts', {}) %}
|
||||
{% set total_accounts = pagination.get('total', 0) %}
|
||||
{% set active_count = sc.get('active', 0) %}
|
||||
{% set need_attention = sc.get('pending', 0) + sc.get('invalid_cookie', 0) %}
|
||||
|
||||
{% if total_accounts > 0 %}
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value">{{ accounts|length }}</div>
|
||||
<div class="stat-value">{{ total_accounts }}</div>
|
||||
<div class="stat-label">账号总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-value">{{ accounts|selectattr('status','equalto','active')|list|length }}</div>
|
||||
<div class="stat-value">{{ active_count }}</div>
|
||||
<div class="stat-label">正常运行</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⚠️</div>
|
||||
<div class="stat-value">{{ accounts|selectattr('status','equalto','pending')|list|length + accounts|selectattr('status','equalto','invalid_cookie')|list|length }}</div>
|
||||
<div class="stat-value">{{ need_attention }}</div>
|
||||
<div class="stat-label">需要关注</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div class="batch-verify-bar">
|
||||
<div class="info">
|
||||
💡 系统每天 <strong>23:50</strong> 自动批量验证 Cookie,也可手动触发
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<button class="btn btn-secondary" id="batch-verify-btn" onclick="batchVerify()">🔍 批量验证 Cookie</button>
|
||||
<div class="batch-bar">
|
||||
<div class="info">💡 可手动触发批量操作</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button class="btn btn-secondary" id="batch-verify-btn" onclick="batchVerify()">🔍 批量验证</button>
|
||||
<button class="btn btn-primary" id="batch-signin-btn" onclick="batchSignin()">🚀 全部签到</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号卡片 -->
|
||||
<div class="account-grid">
|
||||
{% for account in accounts %}
|
||||
<div class="account-card status-{{ account.status }}" onclick="window.location.href='{{ url_for('account_detail', account_id=account.id) }}'">
|
||||
@@ -210,87 +123,88 @@
|
||||
</div>
|
||||
<div class="account-meta">
|
||||
<div>
|
||||
{% if account.status == 'active' %}
|
||||
<span class="badge badge-success">正常</span>
|
||||
{% elif account.status == 'pending' %}
|
||||
<span class="badge badge-warning">待验证</span>
|
||||
{% elif account.status == 'invalid_cookie' %}
|
||||
<span class="badge badge-danger">Cookie 失效</span>
|
||||
{% elif account.status == 'banned' %}
|
||||
<span class="badge badge-danger">已封禁</span>
|
||||
{% if account.status == 'active' %}<span class="badge badge-success">正常</span>
|
||||
{% elif account.status == 'pending' %}<span class="badge badge-warning">待验证</span>
|
||||
{% elif account.status == 'invalid_cookie' %}<span class="badge badge-danger">Cookie 失效</span>
|
||||
{% elif account.status == 'banned' %}<span class="badge badge-danger">已封禁</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<span class="account-date">{{ account.created_at[:10] }}</span>
|
||||
<button class="account-del-btn" title="删除账号" onclick="event.stopPropagation(); deleteAccount('{{ account.id }}', '{{ account.remark or account.weibo_user_id }}');">🗑</button>
|
||||
<button class="account-del-btn" title="删除" onclick="event.stopPropagation(); deleteAccount('{{ account.id }}', '{{ account.remark or account.weibo_user_id }}');">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pagination.get('total_pages', 0) > 1 %}
|
||||
<div class="pagination">
|
||||
{% set p = pagination.page %}
|
||||
{% set tp = pagination.total_pages %}
|
||||
{% if p > 1 %}
|
||||
<a href="?page={{ p - 1 }}">‹ 上一页</a>
|
||||
{% else %}
|
||||
<span class="disabled">‹ 上一页</span>
|
||||
{% endif %}
|
||||
|
||||
{% for i in range([1, p - 2]|max, [tp, p + 2]|min + 1) %}
|
||||
{% if i == p %}<span class="active">{{ i }}</span>
|
||||
{% else %}<a href="?page={{ i }}">{{ i }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if p < tp %}
|
||||
<a href="?page={{ p + 1 }}">下一页 ›</a>
|
||||
{% else %}
|
||||
<span class="disabled">下一页 ›</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📱</div>
|
||||
<p>暂无账号,扫码添加你的微博账号开始自动签到</p>
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary" style="font-size:16px; padding:14px 32px;">添加第一个账号</a>
|
||||
<a href="{{ url_for('add_account') }}" class="btn btn-primary" style="padding:12px 28px;">添加第一个账号</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
async function deleteAccount(accountId, name) {
|
||||
if (!confirm(`确定要删除账号「${name}」吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/accounts/${accountId}/delete`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} catch(e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
async function deleteAccount(id, name) {
|
||||
if (!confirm(`确定要删除账号「${name}」吗?`)) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/accounts/${id}/delete`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
async function batchVerify() {
|
||||
const btn = document.getElementById('batch-verify-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 验证中...';
|
||||
btn.disabled = true; btn.textContent = '⏳ 验证中...';
|
||||
try {
|
||||
const resp = await fetch('/api/batch/verify', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const r = data.data;
|
||||
alert(`验证完成:${r.valid} 个有效,${r.invalid} 个失效,${r.errors} 个出错`);
|
||||
} else {
|
||||
alert('验证失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('请求失败: ' + e.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 批量验证 Cookie';
|
||||
alert(`验证完成:${r.valid} 有效,${r.invalid} 失效,${r.errors} 出错`);
|
||||
} else { alert('验证失败: ' + (data.message || '未知错误')); }
|
||||
} catch(e) { alert('请求失败: ' + e.message); }
|
||||
btn.disabled = false; btn.textContent = '🔍 批量验证';
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function batchSignin() {
|
||||
const btn = document.getElementById('batch-signin-btn');
|
||||
if (!confirm('确定要对所有正常账号执行签到吗?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 签到中...';
|
||||
btn.disabled = true; btn.textContent = '⏳ 签到中...';
|
||||
try {
|
||||
const resp = await fetch('/api/batch/signin', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const r = data.data;
|
||||
alert(`签到完成:${r.total_accounts} 个账号,${r.total_signed} 成功,${r.total_already} 已签,${r.total_failed} 失败`);
|
||||
} else {
|
||||
alert('签到失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('请求失败: ' + e.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 全部签到';
|
||||
} else { alert('签到失败: ' + (data.message || '未知错误')); }
|
||||
} catch(e) { alert('请求失败: ' + e.message); }
|
||||
btn.disabled = false; btn.textContent = '🚀 全部签到';
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
<label for="confirm_password">确认密码</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="invite_code">邀请码</label>
|
||||
<input type="text" id="invite_code" name="invite_code" required placeholder="请输入邀请码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">注册</button>
|
||||
</form>
|
||||
<div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div>
|
||||
|
||||
207
frontend/templates/topics.html
Normal file
207
frontend/templates/topics.html
Normal file
@@ -0,0 +1,207 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}超话签到 - {{ account.remark or account.weibo_user_id }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.topics-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 10px; }
|
||||
.topics-header h1 { font-size: 22px; font-weight: 700; color: #1e293b; }
|
||||
.topic-list { display: flex; flex-direction: column; gap: 0; }
|
||||
.topic-item {
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px 16px;
|
||||
border-bottom: 1px solid #f1f5f9; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.topic-item:hover { background: #f8fafc; }
|
||||
.topic-item:last-child { border-bottom: none; }
|
||||
.topic-cb { width: 20px; height: 20px; accent-color: #6366f1; cursor: pointer; }
|
||||
.topic-name { font-weight: 500; color: #1e293b; font-size: 15px; }
|
||||
.topic-id { font-size: 12px; color: #94a3b8; font-family: monospace; }
|
||||
.select-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 8px;
|
||||
flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.select-bar label { font-weight: 600; color: #475569; font-size: 14px; cursor: pointer; }
|
||||
.action-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.signin-btn, .save-btn {
|
||||
padding: 10px 20px; border-radius: 12px; border: none; font-size: 14px;
|
||||
font-weight: 600; cursor: pointer; color: white; transition: all 0.2s;
|
||||
}
|
||||
.signin-btn { background: linear-gradient(135deg, #6366f1, #818cf8); box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
|
||||
.save-btn { background: linear-gradient(135deg, #10b981, #059669); box-shadow: 0 2px 8px rgba(16,185,129,0.25); }
|
||||
.signin-btn:disabled, .save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.result-box { margin-top: 16px; padding: 14px; border-radius: 12px; display: none; font-size: 13px; font-weight: 500; }
|
||||
.result-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
|
||||
.result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.tip-box { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; font-size: 13px; }
|
||||
@media (max-width: 768px) {
|
||||
.action-btns { width: 100%; }
|
||||
.signin-btn, .save-btn { flex: 1; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 720px; margin: 0 auto;">
|
||||
<div class="topics-header">
|
||||
<div>
|
||||
<h1>🔥 超话签到管理</h1>
|
||||
<div style="color:#94a3b8; font-size:13px; margin-top:4px;">
|
||||
{{ account.remark or account.weibo_user_id }} · 共 {{ topics|length }} 个超话
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">← 返回</a>
|
||||
</div>
|
||||
|
||||
<div class="tip-box">
|
||||
💡 勾选要参与定时签到的超话,点击「保存选择」后,定时任务和手动签到都只签选中的超话。不选则签到全部。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if topics %}
|
||||
<div class="select-bar">
|
||||
<label><input type="checkbox" id="selectAll" class="topic-cb" onchange="toggleAll()"> 全选 (<span id="selectedCount">0</span>/{{ topics|length }})</label>
|
||||
<div class="action-btns">
|
||||
<button class="save-btn" id="saveBtn" onclick="saveSelection()">💾 保存选择</button>
|
||||
<button class="signin-btn" id="signinBtn" onclick="doSignin()">🚀 立即签到选中</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topic-list" id="topicList">
|
||||
{% for topic in topics %}
|
||||
<label class="topic-item">
|
||||
<input type="checkbox" class="topic-cb topic-check"
|
||||
data-index="{{ loop.index0 }}"
|
||||
data-title="{{ topic.title }}"
|
||||
data-cid="{{ topic.containerid }}"
|
||||
onchange="updateCount()">
|
||||
<div>
|
||||
<div class="topic-name">{{ topic.title }}</div>
|
||||
<div class="topic-id">{{ topic.containerid }}</div>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#94a3b8; text-align:center; padding:40px; font-size:15px;">
|
||||
未找到关注的超话,请确认 Cookie 有效且已关注超话
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="resultBox" class="result-box"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 已保存的选中超话
|
||||
const savedTopics = {{ (selected_topics or [])|tojson }};
|
||||
const savedCids = new Set(savedTopics.map(t => t.containerid));
|
||||
|
||||
// 页面加载时恢复选中状态
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checks = document.querySelectorAll('.topic-check');
|
||||
if (savedCids.size > 0) {
|
||||
checks.forEach(cb => {
|
||||
cb.checked = savedCids.has(cb.dataset.cid);
|
||||
});
|
||||
} else {
|
||||
// 没有保存过 = 全部选中
|
||||
checks.forEach(cb => cb.checked = true);
|
||||
}
|
||||
updateCount();
|
||||
});
|
||||
|
||||
function toggleAll() {
|
||||
const checked = document.getElementById('selectAll').checked;
|
||||
document.querySelectorAll('.topic-check').forEach(cb => cb.checked = checked);
|
||||
updateCount();
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const total = document.querySelectorAll('.topic-check').length;
|
||||
const checked = document.querySelectorAll('.topic-check:checked').length;
|
||||
document.getElementById('selectedCount').textContent = checked;
|
||||
document.getElementById('selectAll').checked = (checked === total);
|
||||
}
|
||||
|
||||
function getSelectedTopics() {
|
||||
const selected = [];
|
||||
document.querySelectorAll('.topic-check:checked').forEach(cb => {
|
||||
selected.push({ title: cb.dataset.title, containerid: cb.dataset.cid });
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function saveSelection() {
|
||||
const btn = document.getElementById('saveBtn');
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
const selected = getSelectedTopics();
|
||||
const total = document.querySelectorAll('.topic-check').length;
|
||||
|
||||
btn.disabled = true; btn.textContent = '⏳ 保存中...';
|
||||
try {
|
||||
// 全选时传 null(签到全部)
|
||||
const body = (selected.length === total)
|
||||
? { selected_topics: null }
|
||||
: { selected_topics: selected };
|
||||
|
||||
const resp = await fetch('/accounts/{{ account.id }}/topics/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
resultBox.style.display = 'block';
|
||||
if (data.success) {
|
||||
resultBox.className = 'result-box result-success';
|
||||
resultBox.textContent = '✅ ' + data.message;
|
||||
} else {
|
||||
resultBox.className = 'result-box result-error';
|
||||
resultBox.textContent = data.message || '保存失败';
|
||||
}
|
||||
} catch(e) {
|
||||
resultBox.className = 'result-box result-error';
|
||||
resultBox.style.display = 'block';
|
||||
resultBox.textContent = '请求失败: ' + e.message;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = '💾 保存选择';
|
||||
}
|
||||
|
||||
async function doSignin() {
|
||||
const btn = document.getElementById('signinBtn');
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
const indices = [];
|
||||
document.querySelectorAll('.topic-check:checked').forEach(cb => {
|
||||
indices.push(parseInt(cb.dataset.index));
|
||||
});
|
||||
if (indices.length === 0) {
|
||||
resultBox.className = 'result-box result-error';
|
||||
resultBox.style.display = 'block';
|
||||
resultBox.textContent = '请至少选择一个超话';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true; btn.textContent = '⏳ 签到中...';
|
||||
resultBox.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("signin_selected", account_id=account.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({topic_indices: indices}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
resultBox.style.display = 'block';
|
||||
if (data.success) {
|
||||
resultBox.className = 'result-box result-success';
|
||||
resultBox.textContent = data.message;
|
||||
} else {
|
||||
resultBox.className = 'result-box result-error';
|
||||
resultBox.textContent = data.message || '签到失败';
|
||||
}
|
||||
} catch(e) {
|
||||
resultBox.className = 'result-box result-error';
|
||||
resultBox.style.display = 'block';
|
||||
resultBox.textContent = '请求失败: ' + e.message;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = '🚀 立即签到选中';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
wx_openid TEXT UNIQUE,
|
||||
wx_nickname TEXT,
|
||||
wx_avatar TEXT,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
@@ -61,3 +62,17 @@ CREATE INDEX IF NOT EXISTS idx_tasks_is_enabled ON tasks(is_enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_logs_account_id ON signin_logs(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_logs_signed_at ON signin_logs(signed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_logs_status ON signin_logs(status);
|
||||
|
||||
-- Invite codes table
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
used_by TEXT,
|
||||
is_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_codes_code ON invite_codes(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_codes_is_used ON invite_codes(is_used);
|
||||
|
||||
32
init-db.sql
32
init-db.sql
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
wx_openid VARCHAR(64) UNIQUE,
|
||||
wx_nickname VARCHAR(100),
|
||||
wx_avatar VARCHAR(500),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
INDEX idx_users_email (email),
|
||||
@@ -27,6 +28,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
encrypted_cookies TEXT NOT NULL,
|
||||
iv VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
selected_topics JSON DEFAULT NULL,
|
||||
last_checked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_accounts_user_id (user_id),
|
||||
@@ -51,12 +53,38 @@ 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,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
reward_info JSON,
|
||||
error_message TEXT,
|
||||
signed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_signin_logs_account_id (account_id),
|
||||
INDEX idx_signin_logs_signed_at (signed_at),
|
||||
INDEX idx_signin_logs_status (status),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Invite codes table
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
code VARCHAR(32) UNIQUE NOT NULL,
|
||||
created_by CHAR(36) NOT NULL,
|
||||
used_by CHAR(36),
|
||||
is_used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP NULL,
|
||||
INDEX idx_invite_codes_code (code),
|
||||
INDEX idx_invite_codes_is_used (is_used)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- System config table (key-value)
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
`key` VARCHAR(64) PRIMARY KEY,
|
||||
`value` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 默认配置
|
||||
INSERT IGNORE INTO system_config (`key`, `value`) VALUES
|
||||
('webhook_url', ''),
|
||||
('daily_report_hour', '23'),
|
||||
('daily_report_minute', '30');
|
||||
|
||||
@@ -93,15 +93,15 @@ USER_COUNT=$($ROOT_CMD -N -e "SELECT COUNT(*) FROM ${DB_NAME}.users" 2>/dev/null
|
||||
if [ "$USER_COUNT" = "0" ]; then
|
||||
# 检查 bcrypt 是否可用
|
||||
if python3 -c "import bcrypt" 2>/dev/null; then
|
||||
HASHED_PW=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'Admin123!', bcrypt.gensalt(12)).decode())")
|
||||
HASHED_PW=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'Admin123', bcrypt.gensalt(12)).decode())")
|
||||
USER_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
|
||||
$ROOT_CMD ${DB_NAME} -e "
|
||||
INSERT INTO users (id, username, email, hashed_password, is_active)
|
||||
VALUES ('${USER_ID}', 'admin', 'admin@example.com', '${HASHED_PW}', 1);
|
||||
INSERT INTO users (id, username, email, hashed_password, is_admin, is_active)
|
||||
VALUES ('${USER_ID}', 'admin', 'admin@example.com', '${HASHED_PW}', 1, 1);
|
||||
"
|
||||
info "测试用户已创建: admin / Admin123!"
|
||||
info "管理员用户已创建: admin@example.com / Admin123 (管理员)"
|
||||
else
|
||||
warn "bcrypt 未安装,跳过测试用户创建(运行 setup_linux.sh 安装依赖后可手动创建)"
|
||||
warn "bcrypt 未安装,跳过管理员创建(运行 setup_linux.sh 安装依赖后可手动创建)"
|
||||
fi
|
||||
else
|
||||
info "已有 ${USER_COUNT} 个用户,跳过"
|
||||
|
||||
4
migrate_add_selected_topics.sql
Normal file
4
migrate_add_selected_topics.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 给 accounts 表添加 selected_topics 字段
|
||||
-- 用法: mysql -u weibo -p123456 weibo_hotsign < migrate_add_selected_topics.sql
|
||||
|
||||
ALTER TABLE accounts ADD COLUMN selected_topics JSON DEFAULT NULL AFTER status;
|
||||
13
migrate_add_system_config.sql
Normal file
13
migrate_add_system_config.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 添加 system_config 表(已有数据库执行此脚本)
|
||||
-- 用法: mysql -u weibo -p weibo_hotsign < migrate_add_system_config.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
`key` VARCHAR(64) PRIMARY KEY,
|
||||
`value` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO system_config (`key`, `value`) VALUES
|
||||
('webhook_url', 'https://open.feishu.cn/open-apis/bot/v2/hook/ba78bd75-baa3-4f14-990c-ae5a2b2d272a'),
|
||||
('daily_report_hour', '23'),
|
||||
('daily_report_minute', '30');
|
||||
10
migrate_fix_signin_logs_fk.sql
Normal file
10
migrate_fix_signin_logs_fk.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- 修复 signin_logs 外键
|
||||
-- 用法: mysql -u weibo -p123456 weibo_hotsign < migrate_fix_signin_logs_fk.sql
|
||||
|
||||
-- 1. 清理孤儿记录
|
||||
DELETE FROM signin_logs WHERE account_id NOT IN (SELECT id FROM accounts);
|
||||
|
||||
-- 2. 重建外键(带 CASCADE),忽略已存在的情况
|
||||
ALTER TABLE signin_logs
|
||||
ADD CONSTRAINT fk_signin_logs_account
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE;
|
||||
9
migrate_fix_timezone.sql
Normal file
9
migrate_fix_timezone.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- 一次性迁移:将所有时间字段从 UTC 修正为 Asia/Shanghai (+8h)
|
||||
-- 用法: mysql -u weibo -p weibo_hotsign < migrate_fix_timezone.sql
|
||||
-- 注意: 只执行一次!重复执行会多加 8 小时
|
||||
|
||||
UPDATE signin_logs SET signed_at = DATE_ADD(signed_at, INTERVAL 8 HOUR);
|
||||
UPDATE accounts SET last_checked_at = DATE_ADD(last_checked_at, INTERVAL 8 HOUR) WHERE last_checked_at IS NOT NULL;
|
||||
UPDATE accounts SET created_at = DATE_ADD(created_at, INTERVAL 8 HOUR);
|
||||
UPDATE tasks SET created_at = DATE_ADD(created_at, INTERVAL 8 HOUR);
|
||||
UPDATE users SET created_at = DATE_ADD(created_at, INTERVAL 8 HOUR);
|
||||
124
test_fetch_topics.py
Normal file
124
test_fetch_topics.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
验证签到流程是否正常。
|
||||
在服务器上执行: docker exec -it weibo-scheduler python -m test_fetch_topics
|
||||
或本地: cd backend && python ../test_fetch_topics.py
|
||||
|
||||
会从数据库读取第一个 active 账号,解密 Cookie,模拟 _fetch_topics 流程。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import asyncio
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "backend"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
|
||||
# 兼容容器内运行(/app 目录)
|
||||
if os.path.exists("/app/shared"):
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from shared.config import shared_settings
|
||||
from shared.crypto import decrypt_cookie, derive_key
|
||||
|
||||
WEIBO_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Referer": "https://weibo.com/",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from shared.models.account import Account
|
||||
|
||||
engine = create_async_engine(shared_settings.DATABASE_URL, echo=False)
|
||||
Session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with Session() as session:
|
||||
result = await session.execute(
|
||||
select(Account).where(Account.status.in_(["active", "pending"])).limit(1)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
print("❌ 没有 active/pending 账号")
|
||||
return
|
||||
|
||||
print(f"📱 账号: {account.remark or account.weibo_user_id} (status={account.status})")
|
||||
|
||||
key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
|
||||
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
|
||||
cookies = {}
|
||||
for pair in cookie_str.split(";"):
|
||||
pair = pair.strip()
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
cookies[k.strip()] = v.strip()
|
||||
|
||||
print(f"🍪 Cookie 数量: {len(cookies)}, keys: {list(cookies.keys())}")
|
||||
|
||||
# 检查 Cookie 有效期
|
||||
from datetime import datetime
|
||||
alf = cookies.get("ALF", "")
|
||||
if alf and alf.isdigit():
|
||||
expire_time = datetime.fromtimestamp(int(alf))
|
||||
remain = (expire_time - datetime.now()).days
|
||||
print(f"📅 Cookie 过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (还剩 {remain} 天)")
|
||||
else:
|
||||
print(f"📅 ALF 字段: {alf or '无'} (无法判断过期时间)")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
# 测试 1: 访问 weibo.com
|
||||
print("\n--- 测试 1: GET https://weibo.com/ ---")
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||
resp = await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
|
||||
final_url = str(resp.url)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 最终URL: {final_url}")
|
||||
print(f" 是否登录页: {'login.sina.com.cn' in final_url or 'passport' in final_url}")
|
||||
|
||||
if "login.sina.com.cn" in final_url or "passport" in final_url:
|
||||
print("\n❌ Cookie 已失效,被重定向到登录页")
|
||||
return
|
||||
|
||||
xsrf = client.cookies.get("XSRF-TOKEN", "")
|
||||
print(f" XSRF-TOKEN: {'有' if xsrf else '无'}")
|
||||
|
||||
# 测试 2: 获取超话列表
|
||||
print("\n--- 测试 2: 获取超话列表 ---")
|
||||
headers = {**WEIBO_HEADERS, "X-Requested-With": "XMLHttpRequest"}
|
||||
if xsrf:
|
||||
headers["X-XSRF-TOKEN"] = xsrf
|
||||
|
||||
resp = await client.get(
|
||||
"https://weibo.com/ajax/profile/topicContent",
|
||||
params={"tabid": "231093_-_chaohua", "page": "1"},
|
||||
headers=headers, cookies=cookies,
|
||||
)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 最终URL: {resp.url}")
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
print(f" ok: {data.get('ok')}")
|
||||
topics = data.get("data", {}).get("list", [])
|
||||
print(f" 超话数量: {len(topics)}")
|
||||
for t in topics[:5]:
|
||||
title = t.get("topic_name", "") or t.get("title", "")
|
||||
print(f" - {title}")
|
||||
if topics:
|
||||
print("\n✅ Cookie 有效,超话获取正常")
|
||||
else:
|
||||
print("\n⚠️ Cookie 可能有效但没有关注超话")
|
||||
except Exception as e:
|
||||
print(f" ❌ 响应非 JSON: {resp.text[:300]}")
|
||||
print(f"\n❌ 获取超话失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
BIN
weibo_hotsign.db
BIN
weibo_hotsign.db
Binary file not shown.
Reference in New Issue
Block a user