Compare commits

...

26 Commits

Author SHA1 Message Date
dccfae5227 日报增加Cookie有效期检测(ALF字段), 按剩余天数分级预警 2026-04-09 08:59:46 +08:00
4fea75c49f 修复: 恢复follow_redirects避免误判Cookie失效, 改为检查最终URL判断登录态 2026-04-09 08:51:07 +08:00
905125f23b 修复: 恢复follow_redirects避免误判Cookie失效, 改为检查最终URL判断登录态 2026-04-09 08:49:30 +08:00
420033c61f Cookie失效时标记账号状态+推送明确提示重新扫码 2026-04-09 08:47:15 +08:00
c2bcde5e59 修复0超话签到推送消息显示为成功的问题 2026-04-09 08:43:27 +08:00
7fed107c3c 签到重试机制+超话选择持久化+healthcheck优化+JSON解码防护 2026-04-09 08:40:29 +08:00
9e69d34f81 解决超话签到中选择性签到的逻辑问题 2026-04-08 08:34:59 +08:00
31d862dfa0 SQL更新 2026-04-01 15:13:37 +08:00
30bddea103 修复cookie失效后的一些问题 2026-04-01 15:09:21 +08:00
3c50cfcfc5 调整代码机制 2026-04-01 14:57:39 +08:00
82f673d806 优化签到逻辑: 提前预加载+整点签到+实时推送签到结果含名次和耗时 2026-04-01 14:14:14 +08:00
c25c766223 加入部分消息通知入口,同步前端管理 2026-03-19 10:45:58 +08:00
1e25c085a5 修改docker容器时间 2026-03-19 09:17:35 +08:00
9272f416a2 修改定时任务触发逻辑 2026-03-18 15:29:43 +08:00
2427237116 新增任务,手动触发定时任务更新 2026-03-18 13:58:37 +08:00
07097091fe 定时任务逻辑优化 2026-03-18 13:52:47 +08:00
fecb42838c 修复错误 2026-03-18 09:51:27 +08:00
8f4e0a2411 前后端数据流对齐:
后端改动:

GET /api/v1/accounts 现在返回分页格式 {items, total, page, size, total_pages, status_counts},默认每页 12 个
批量操作用 size=500 一次拉全部
前端改动:

base.html — 加了移动端汉堡菜单、全局响应式样式、pagination disabled 状态
dashboard.html — 账号卡片分页,统计数据从 API 的 status_counts 取,移动端单列布局
account_detail.html — 签到记录改成上下两行布局(topic+状态 / 消息+时间),分页用统一的上一页/下一页样式,移动端适配
分页逻辑统一:前后端都用 page/total_pages 字段,pagination 组件显示当前页 ±2 页码。
2026-03-18 09:45:55 +08:00
642cf76b61 分批删除,每批 1000 条,删完一批 commit 一次,不会锁表
默认保留 30 天,可通过环境变量 SIGNIN_LOG_RETAIN_DAYS 调整
每天凌晨 3 点执行,错开签到高峰
有完整日志记录删了多少条、截止日期是什么
用独立 engine 避免之前的 event loop 问题
2026-03-18 09:33:33 +08:00
9a3e846be9 每次任务执行时用 _make_session() 创建独立的 engine + session,用完 eng.dispose() 释放,彻底避免 loop 冲突。 2026-03-18 09:27:20 +08:00
633e4249de 定时任务细化,签到成功页结果展示 2026-03-18 09:19:29 +08:00
e514a11e62 注册码 + 管理员系统:
User 模型新增 is_admin 字段
新增 InviteCode 模型(邀请码表)
注册接口必须提供有效邀请码,使用后自动标记
管理员接口:查看所有用户、启用/禁用用户、生成/删除邀请码
前端新增管理面板页面 /admin,导航栏对管理员显示入口
注册页面新增邀请码输入框
选择性超话签到:

新增 GET /api/v1/accounts/{id}/topics 接口获取超话列表
POST /signin 接口支持 {"topic_indices": [0,1,3]} 选择性签到
新增超话选择页面 /accounts/{id}/topics,支持全选/手动勾选
账号详情页新增"选择超话签到"按钮
2026-03-17 17:05:28 +08:00
2fb27aa714 优化数据库结构 2026-03-17 13:54:35 +08:00
0a3d7daf2c 更新注册方式 2026-03-17 13:47:44 +08:00
c64d8e67dc 修改git目录 2026-03-17 13:28:05 +08:00
cdf08d28fa 修复docker环境异常 2026-03-17 13:01:51 +08:00
55 changed files with 2571 additions and 1562 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -16,6 +16,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc default-libmysqlclient-dev curl \ gcc default-libmysqlclient-dev curl \
&& rm -rf /var/lib/apt/lists/* && 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r 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 FROM base AS api-service
COPY api_service/ ./api_service/ COPY api_service/ ./api_service/
# api_service 代码 import 了 auth_service 的 security 模块
COPY auth_service/ ./auth_service/
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
USER appuser USER appuser

View File

@@ -3,6 +3,8 @@ Weibo-HotSign API Service
Main FastAPI application entry point — account management, task config, signin logs. Main FastAPI application entry point — account management, task config, signin logs.
""" """
import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware 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 shared.response import success_response, error_response
from api_service.app.routers import accounts, tasks, signin_logs 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( app = FastAPI(
title="Weibo-HotSign API Service", title="Weibo-HotSign API Service",
version="1.0.0", version="1.0.0",

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Dict, List from typing import Dict, List
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -92,15 +92,49 @@ async def create_account(
@router.get("") @router.get("")
async def list_accounts( async def list_accounts(
page: int = 1,
size: int = 12,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), 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( 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() accounts = result.scalars().all()
total_pages = (total + size - 1) // size if total > 0 else 0
return success_response( 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", "Accounts retrieved",
) )
@@ -184,7 +218,12 @@ async def _verify_weibo_cookie(cookie_str: str) -> dict:
headers=WEIBO_HEADERS, headers=WEIBO_HEADERS,
cookies=cookies, 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: if data.get("ok") != 1:
return {"valid": False, "uid": None, "screen_name": None} 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", ""))) uid = str(user.get("idstr", user.get("id", "")))
screen_name = user.get("screen_name", "") screen_name = user.get("screen_name", "")
except Exception: except Exception:
pass # profile info is optional, login check already passed pass
return {"valid": True, "uid": uid, "screen_name": screen_name} 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 ---- # ---- MANUAL SIGNIN ----
async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]: 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, headers=headers,
cookies=cookies, cookies=cookies,
) )
data = resp.json() try:
data = resp.json()
except Exception:
break
if data.get("ok") != 1: if data.get("ok") != 1:
break break
@@ -360,7 +448,10 @@ async def _do_signin(cookie_str: str, topic_title: str, containerid: str) -> dic
headers=headers, headers=headers,
cookies=cookies, 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", "")) code = str(data.get("code", ""))
msg = data.get("msg", "") msg = data.get("msg", "")
@@ -384,10 +475,12 @@ async def manual_signin(
account_id: str, account_id: str,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
body: dict = Body(default=None),
): ):
""" """
Manually trigger sign-in for all followed super topics. Manually trigger sign-in for selected (or all) super topics.
Verifies cookie first, fetches topic list, signs each one, writes logs. 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) account = await _get_owned_account(account_id, user, db)
key = _encryption_key() key = _encryption_key()
@@ -421,6 +514,18 @@ async def manual_signin(
"No super topics found for this account", "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 # Sign each topic
results = [] results = []
signed = already = failed = 0 signed = already = failed = 0

View File

@@ -27,6 +27,7 @@ class AccountResponse(BaseModel):
weibo_user_id: str weibo_user_id: str
remark: Optional[str] remark: Optional[str]
status: str status: str
selected_topics: Optional[list] = None
last_checked_at: Optional[datetime] last_checked_at: Optional[datetime]
created_at: Optional[datetime] created_at: Optional[datetime]

View File

@@ -7,12 +7,21 @@ from fastapi import FastAPI, Depends, HTTPException, status, Security
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, func as sa_func
import uvicorn import uvicorn
import os import os
import logging 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 shared.config import shared_settings
from auth_service.app.models.database import create_tables from auth_service.app.models.database import create_tables
from auth_service.app.schemas.user import ( 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) @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)): 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) auth_service = AuthService(db)
# Check if user already exists - optimized with single query # 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
email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username) email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username)
if email_user: if email_user:
@@ -136,6 +160,12 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db
try: try:
user = await auth_service.create_user(user_data) 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 # Create tokens for auto-login
access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
refresh_token = await create_refresh_token(str(user.id)) refresh_token = await create_refresh_token(str(user.id))
@@ -147,6 +177,8 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db
expires_in=3600, expires_in=3600,
user=UserResponse.from_orm(user) user=UserResponse.from_orm(user)
) )
except HTTPException:
raise # 直接传递 HTTPException如密码强度不够
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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, expires_in=3600,
user=UserResponse.from_orm(user), 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": "配置已保存"}

View File

@@ -16,6 +16,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
"""Schema for user registration request""" """Schema for user registration request"""
password: str = Field(..., min_length=8, description="Password (min 8 characters)") password: str = Field(..., min_length=8, description="Password (min 8 characters)")
invite_code: str = Field(..., min_length=1, description="注册邀请码")
class UserLogin(BaseModel): class UserLogin(BaseModel):
"""Schema for user login request""" """Schema for user login request"""
@@ -35,6 +36,7 @@ class UserResponse(BaseModel):
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
created_at: datetime created_at: datetime
is_active: bool is_active: bool
is_admin: bool = False
wx_openid: Optional[str] = None wx_openid: Optional[str] = None
wx_nickname: Optional[str] = None wx_nickname: Optional[str] = None
wx_avatar: Optional[str] = None wx_avatar: Optional[str] = None

View File

@@ -34,7 +34,11 @@ class SharedSettings(BaseSettings):
class Config: class Config:
case_sensitive = True case_sensitive = True
# Docker 环境下不读 .env 文件,靠 docker-compose environment 注入
# 本地开发时 .env 文件仍然生效
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
shared_settings = SharedSettings() shared_settings = SharedSettings()

View File

@@ -1,10 +1,11 @@
"""Shared ORM models for Weibo-HotSign.""" """Shared ORM models for Weibo-HotSign."""
from .base import Base, get_db, engine, AsyncSessionLocal from .base import Base, get_db, engine, AsyncSessionLocal
from .user import User from .user import User, InviteCode
from .account import Account from .account import Account
from .task import Task from .task import Task
from .signin_log import SigninLog from .signin_log import SigninLog
from .system_config import SystemConfig
__all__ = [ __all__ = [
"Base", "Base",
@@ -12,7 +13,9 @@ __all__ = [
"engine", "engine",
"AsyncSessionLocal", "AsyncSessionLocal",
"User", "User",
"InviteCode",
"Account", "Account",
"Task", "Task",
"SigninLog", "SigninLog",
"SystemConfig",
] ]

View File

@@ -2,7 +2,7 @@
import uuid 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.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -19,12 +19,13 @@ class Account(Base):
encrypted_cookies = Column(Text, nullable=False) encrypted_cookies = Column(Text, nullable=False)
iv = Column(String(32), nullable=False) iv = Column(String(32), nullable=False)
status = Column(String(20), default="pending") status = Column(String(20), default="pending")
selected_topics = Column(JSON, nullable=True) # 用户选择的签到超话列表null=全部
last_checked_at = Column(DateTime, nullable=True) last_checked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="accounts") user = relationship("User", back_populates="accounts")
tasks = relationship("Task", back_populates="account", cascade="all, delete-orphan") 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): def __repr__(self):
return f"<Account(id={self.id}, weibo_user_id='{self.weibo_user_id}')>" return f"<Account(id={self.id}, weibo_user_id='{self.weibo_user_id}')>"

View File

@@ -10,9 +10,9 @@ from .base import Base
class SigninLog(Base): class SigninLog(Base):
__tablename__ = "signin_logs" __tablename__ = "signin_logs"
id = Column(Integer, primary_key=True, autoincrement=True) 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)) topic_title = Column(String(100))
status = Column(String(20), nullable=False) status = Column(String(50), nullable=False)
reward_info = Column(JSON, nullable=True) reward_info = Column(JSON, nullable=True)
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
signed_at = Column(DateTime, server_default=func.now()) signed_at = Column(DateTime, server_default=func.now())

View 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]}')>"

View File

@@ -19,6 +19,7 @@ class User(Base):
wx_openid = Column(String(64), unique=True, nullable=True, index=True) wx_openid = Column(String(64), unique=True, nullable=True, index=True)
wx_nickname = Column(String(100), nullable=True) wx_nickname = Column(String(100), nullable=True)
wx_avatar = Column(String(500), nullable=True) wx_avatar = Column(String(500), nullable=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
@@ -26,3 +27,15 @@ class User(Base):
def __repr__(self): def __repr__(self):
return f"<User(id={self.id}, username='{self.username}')>" 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)

View File

@@ -1,30 +1,31 @@
# Weibo-HotSign Task Scheduler Service Dockerfile # Weibo-HotSign Task Scheduler Service Dockerfile
FROM python:3.11-slim FROM python:3.10-slim
# Set working directory
WORKDIR /app WORKDIR /app
# Install system dependencies # 使用阿里云镜像加速
RUN apt-get update && apt-get install -y \ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
gcc \ sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
default-libmysqlclient-dev \
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc default-libmysqlclient-dev curl \
&& rm -rf /var/lib/apt/lists/* && 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 COPY task_scheduler/requirements.txt .
RUN pip install --no-cache-dir -r 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 RUN groupadd -r appuser && useradd -r -g appuser appuser
ENV PYTHONPATH=/app
USER appuser USER appuser
# Expose port (optional, as scheduler doesn't need external access) # APScheduler 调度器
# EXPOSE 8000 CMD ["python", "-m", "task_scheduler.app.main"]
# Start Celery Beat scheduler
CMD ["celery", "-A", "app.celery_app", "beat", "--loglevel=info"]

View File

@@ -0,0 +1 @@
# Task scheduler app

View File

@@ -1,213 +1 @@
""" # 此文件已废弃,调度器改用 APScheduler入口见 main.py
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

View File

@@ -9,9 +9,9 @@ from typing import List
class Settings(BaseSettings): class Settings(BaseSettings):
"""Task Scheduler settings""" """Task Scheduler settings"""
# Celery settings # Celery settings — 从环境变量 REDIS_URL 派生
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") CELERY_BROKER_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") CELERY_RESULT_BACKEND: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
# Task execution settings # Task execution settings
MAX_CONCURRENT_TASKS: int = int(os.getenv("MAX_CONCURRENT_TASKS", "10")) MAX_CONCURRENT_TASKS: int = int(os.getenv("MAX_CONCURRENT_TASKS", "10"))
@@ -40,5 +40,6 @@ class Settings(BaseSettings):
class Config: class Config:
case_sensitive = True case_sensitive = True
env_file = ".env" env_file = ".env"
extra = "ignore"
settings = Settings() settings = Settings()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
# Task modules

View File

@@ -1,421 +1 @@
""" # 此文件已废弃,签到逻辑已迁移到 task_scheduler.app.main (APScheduler)
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
},
}

View File

@@ -1,6 +1,5 @@
# Weibo-HotSign Task Scheduler Service Requirements # Weibo-HotSign Task Scheduler
# Task Queue apscheduler==3.10.4
celery==5.3.6
redis==5.0.1 redis==5.0.1
# Database # Database
@@ -14,5 +13,11 @@ pydantic-settings==2.0.3
# HTTP Client # HTTP Client
httpx==0.25.2 httpx==0.25.2
# Cron parsing (APScheduler 内置,但显式声明)
croniter==2.0.1
# Crypto (for cookie decryption)
pycryptodome==3.19.0
# Utilities # Utilities
python-dotenv==1.0.0 python-dotenv==1.0.0

Binary file not shown.

View File

@@ -44,8 +44,8 @@ def create_database():
hashed_password = hash_password(test_password) hashed_password = hash_password(test_password)
cursor.execute(""" cursor.execute("""
INSERT INTO users (id, username, email, hashed_password, is_active) INSERT INTO users (id, username, email, hashed_password, is_admin, is_active)
VALUES (?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, 1, 1)
""", (test_user_id, test_username, test_email, hashed_password)) """, (test_user_id, test_username, test_email, hashed_password))
conn.commit() conn.commit()

View File

@@ -1,17 +1,16 @@
version: '3.8'
# ============================================================ # ============================================================
# Weibo-HotSign Docker Compose # Weibo-HotSign Docker Compose
# 使用方式: # 使用方式:
# 1. 填写下方 x-db-env 中的 MySQL/Redis 密码 # 1. 填写下方 x-db-env 中的 MySQL/Redis 密码
# 2. docker-compose up -d --build # 2. docker-compose up -d --build
# 3. 访问 http://localhost:5000 # 3. 访问 http://服务器IP:5000
# ============================================================ # ============================================================
# 共享环境变量(避免重复) # 共享环境变量(避免重复)
# MySQL 地址用 1Panel 容器名Redis 如果也是容器就用容器名
x-db-env: &db-env x-db-env: &db-env
DATABASE_URL: "mysql+aiomysql://weibo:123456@1Panel-mysql-lvRT:3306/weibo_hotsign?charset=utf8mb4" 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" USE_REDIS: "true"
JWT_SECRET_KEY: "change-me-to-a-random-string-in-production" JWT_SECRET_KEY: "change-me-to-a-random-string-in-production"
JWT_ALGORITHM: "HS256" JWT_ALGORITHM: "HS256"
@@ -37,7 +36,7 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
networks: networks:
- weibo-net - 1panel-network
# API 服务 (端口 8000) # API 服务 (端口 8000)
api-service: api-service:
@@ -56,7 +55,7 @@ services:
depends_on: depends_on:
- auth-service - auth-service
networks: networks:
- weibo-net - 1panel-network
# Flask 前端 (端口 5000) # Flask 前端 (端口 5000)
frontend: frontend:
@@ -71,15 +70,32 @@ services:
FLASK_ENV: "production" FLASK_ENV: "production"
FLASK_DEBUG: "False" FLASK_DEBUG: "False"
SECRET_KEY: "change-me-flask-secret-key" SECRET_KEY: "change-me-flask-secret-key"
API_BASE_URL: "http://api-service:8000" API_BASE_URL: "http://weibo-api:8000"
AUTH_BASE_URL: "http://auth-service:8001" AUTH_BASE_URL: "http://weibo-auth:8001"
SESSION_TYPE: "filesystem" SESSION_TYPE: "filesystem"
depends_on: depends_on:
- api-service - api-service
- auth-service - auth-service
networks: 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: networks:
weibo-net: 1panel-network:
driver: bridge external: true

View File

@@ -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 \ RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* && 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r 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 EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ 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"] CMD ["python", "app.py"]

View File

@@ -105,6 +105,10 @@ def index():
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/health')
def health():
return 'ok', 200
@app.route('/register', methods=['GET', 'POST']) @app.route('/register', methods=['GET', 'POST'])
def register(): def register():
if request.method == 'POST': if request.method == 'POST':
@@ -112,15 +116,20 @@ def register():
email = request.form.get('email') email = request.form.get('email')
password = request.form.get('password') password = request.form.get('password')
confirm_password = request.form.get('confirm_password') confirm_password = request.form.get('confirm_password')
invite_code = request.form.get('invite_code', '').strip()
if password != confirm_password: if password != confirm_password:
flash('两次输入的密码不一致', 'danger') flash('两次输入的密码不一致', 'danger')
return redirect(url_for('register')) return redirect(url_for('register'))
if not invite_code:
flash('请输入邀请码', 'danger')
return redirect(url_for('register'))
try: try:
response = requests.post( response = requests.post(
f'{AUTH_BASE_URL}/auth/register', 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 timeout=10
) )
@@ -208,18 +217,32 @@ def logout():
@app.route('/dashboard') @app.route('/dashboard')
@login_required @login_required
def dashboard(): def dashboard():
page = request.args.get('page', 1, type=int)
try: try:
response = api_request( response = api_request(
'GET', 'GET',
f'{API_BASE_URL}/api/v1/accounts', f'{API_BASE_URL}/api/v1/accounts',
params={'page': page, 'size': 12},
) )
data = response.json() 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: except requests.RequestException:
accounts = [] accounts = []
pagination = {'items': [], 'total': 0, 'page': 1, 'size': 12, 'total_pages': 0, 'status_counts': {}}
flash('加载账号列表失败', 'warning') 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') @app.route('/accounts/new')
@login_required @login_required
@@ -694,7 +717,7 @@ def verify_account(account_id):
def manual_signin(account_id): def manual_signin(account_id):
"""手动触发签到""" """手动触发签到"""
try: 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() data = response.json()
if data.get('success'): if data.get('success'):
result = data.get('data', {}) result = data.get('data', {})
@@ -836,9 +859,10 @@ def delete_task(task_id):
def batch_verify(): def batch_verify():
"""批量验证所有账号的 Cookie 有效性""" """批量验证所有账号的 Cookie 有效性"""
try: 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() 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 valid = invalid = errors = 0
for account in accounts: for account in accounts:
@@ -866,9 +890,10 @@ def batch_verify():
def batch_signin(): def batch_signin():
"""批量签到所有正常状态的账号""" """批量签到所有正常状态的账号"""
try: 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() 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 total_signed = total_already = total_failed = 0
processed = 0 processed = 0
@@ -910,6 +935,223 @@ def not_found(error):
def server_error(error): def server_error(error):
return render_template('500.html'), 500 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__': if __name__ == '__main__':
debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() in ('true', '1', 'yes') debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() in ('true', '1', 'yes')
# use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起 # use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起

View File

@@ -4,3 +4,4 @@ requests==2.31.0
python-dotenv==1.0.0 python-dotenv==1.0.0
Werkzeug==3.0.1 Werkzeug==3.0.1
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
httpx==0.25.2

View File

@@ -4,44 +4,41 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.detail-header { .detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px; }
display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; .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; }
.detail-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; } .info-table td { padding: 10px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
.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; }
.info-table td:first-child { font-weight: 600; color: #64748b; width: 30%; } .info-table td:first-child { font-weight: 600; color: #64748b; width: 30%; }
.action-btn { .action-btn {
width: 100%; padding: 12px; border-radius: 14px; border: none; width: 100%; padding: 10px; border-radius: 12px; border: none;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.2s;
text-align: center; text-decoration: none; display: block; text-align: center; text-decoration: none; display: block;
} }
.action-btn-primary { .action-btn-primary { background: linear-gradient(135deg, #6366f1, #818cf8); color: white; }
background: linear-gradient(135deg, #6366f1, #818cf8); color: white; .action-btn-primary:hover { transform: translateY(-1px); }
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-secondary { background: #f1f5f9; color: #475569; } .action-btn-secondary { background: #f1f5f9; color: #475569; }
.action-btn-secondary:hover { background: #e2e8f0; } .action-btn-secondary:hover { background: #e2e8f0; }
.task-row { .task-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f1f5f9; flex-wrap: wrap; gap: 8px; }
display: flex; align-items: center; justify-content: space-between;
padding: 14px 0; border-bottom: 1px solid #f1f5f9;
}
.task-row:last-child { border-bottom: none; } .task-row:last-child { border-bottom: none; }
.task-cron { .task-cron { font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1; padding: 3px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; }
font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1; .task-actions { display: flex; gap: 6px; }
padding: 4px 12px; border-radius: 10px; font-size: 13px; font-weight: 600; .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> </style>
{% endblock %} {% endblock %}
@@ -50,9 +47,9 @@
<div class="detail-header"> <div class="detail-header">
<div> <div>
<h1>{{ account.remark or account.weibo_user_id }}</h1> <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>
<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> <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('确定要删除此账号吗?');"> <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> <button type="submit" class="btn btn-danger">删除</button>
@@ -81,13 +78,14 @@
</div> </div>
<div class="card"> <div class="card">
<div class="card-header">⚡ 快捷操作</div> <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) }}"> <form method="POST" action="{{ url_for('verify_account', account_id=account.id) }}">
<button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button> <button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button>
</form> </form>
<form method="POST" action="{{ url_for('manual_signin', account_id=account.id) }}" onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='⏳ 签到中...';"> <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> </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> <a href="{{ url_for('add_task', account_id=account.id) }}" class="action-btn action-btn-secondary">⏰ 添加定时任务</a>
</div> </div>
</div> </div>
@@ -98,7 +96,7 @@
{% if tasks %} {% if tasks %}
{% for task in tasks %} {% for task in tasks %}
<div class="task-row"> <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> <span class="task-cron">{{ task.cron_expression }}</span>
{% if task.is_enabled %}<span class="badge badge-success">已启用</span> {% if task.is_enabled %}<span class="badge badge-success">已启用</span>
{% else %}<span class="badge badge-warning">已禁用</span>{% endif %} {% else %}<span class="badge badge-warning">已禁用</span>{% endif %}
@@ -109,7 +107,7 @@
<input type="hidden" name="is_enabled" value="{{ task.is_enabled|lower }}"> <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> <button type="submit" class="btn btn-secondary">{% if task.is_enabled %}禁用{% else %}启用{% endif %}</button>
</form> </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 }}"> <input type="hidden" name="account_id" value="{{ account.id }}">
<button type="submit" class="btn btn-danger">删除</button> <button type="submit" class="btn btn-danger">删除</button>
</form> </form>
@@ -117,47 +115,60 @@
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% 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 %} {% endif %}
</div> </div>
<div class="card"> <div class="card">
<div class="card-header">📝 签到记录</div> <div class="card-header">📝 签到记录 {% if logs.get('total', 0) > 0 %}(共 {{ logs.total }} 条){% endif %}</div>
{% if logs['items'] %} {% if logs.get('items') %}
{% for log in logs['items'] %} {% for log in logs['items'] %}
<div class="log-row"> <div class="log-item">
<div style="font-weight:500; color:#1e293b;">{{ log.topic_title or '-' }}</div> <div class="log-top">
<div> <span class="log-topic">{{ log.topic_title or '-' }}</span>
{% if log.status == 'success' %}<span class="badge badge-success">签到成功</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_already_signed' %}<span class="badge badge-info">今日已签</span>
{% elif log.status == 'failed_network' %}<span class="badge badge-warning">网络错误</span> {% elif log.status == 'failed_network' %}<span class="badge badge-warning">网络错误</span>
{% elif log.status == 'failed_banned' %}<span class="badge badge-danger">已封禁</span> {% elif log.status == 'failed_banned' %}<span class="badge badge-danger">已封禁</span>
{% else %}<span class="badge badge-info">{{ log.status }}</span>
{% endif %} {% endif %}
</div> </div>
<div style="font-size:13px; color:#64748b;"> <div class="log-bottom">
{% if log.reward_info %}{{ log.reward_info.get('points', '-') }} 经验{% else %}-{% endif %} <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>
<div style="color:#94a3b8; font-size:13px;">{{ log.signed_at[:10] }}</div>
</div> </div>
{% endfor %} {% 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"> <div class="pagination">
{% if logs['page'] > 1 %} {% if p > 1 %}
<a href="?page=1"></a> <a href="?page={{ p - 1 }}"> 上一</a>
<a href="?page={{ logs['page'] - 1 }}">上一页</a> {% else %}
<span class="disabled"> 上一页</span>
{% endif %} {% 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> {% for i in range([1, p - 2]|max, [tp, p + 2]|min + 1) %}
{% else %}<a href="?page={{ p }}">{{ p }}</a>{% endif %} {% if i == p %}<span class="active">{{ i }}</span>
{% else %}<a href="?page={{ i }}">{{ i }}</a>{% endif %}
{% endfor %} {% endfor %}
{% if logs['page'] < logs['total'] // logs['size'] + 1 %}
<a href="?page={{ logs['page'] + 1 }}">下一页</a> {% if p < tp %}
<a href="?page={{ logs['total'] // logs['size'] + 1 }}">末页</a> <a href="?page={{ p + 1 }}">下一页 </a>
{% else %}
<span class="disabled">下一页 </span>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% else %} {% 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 %} {% endif %}
</div> </div>
</div> </div>

View 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 %}

View File

@@ -2,216 +2,138 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <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> <title>{% block title %}微博超话签到{% endblock %}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #f0f2ff 0%, #faf5ff 50%, #fff0f6 100%); background: linear-gradient(135deg, #f0f2ff 0%, #faf5ff 50%, #fff0f6 100%);
color: #1a1a2e; color: #1a1a2e; min-height: 100vh;
min-height: 100vh;
} }
/* ---- Navbar ---- */
.navbar { .navbar {
background: rgba(255,255,255,0.85); background: rgba(255,255,255,0.85); backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(99,102,241,0.08); border-bottom: 1px solid rgba(99,102,241,0.08);
padding: 0 24px; padding: 0 16px; position: sticky; top: 0; z-index: 100;
position: sticky;
top: 0;
z-index: 100;
} }
.navbar-content { .navbar-content {
max-width: 1200px; max-width: 1200px; margin: 0 auto;
margin: 0 auto; display: flex; justify-content: space-between; align-items: center; height: 52px;
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
} }
.navbar-brand { .navbar-brand {
font-size: 22px; font-size: 18px; font-weight: 700;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #a855f7); background: linear-gradient(135deg, #6366f1, #a855f7);
-webkit-background-clip: text; -webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
text-decoration: none; text-decoration: none; white-space: nowrap;
}
.navbar-menu {
display: flex;
gap: 28px;
align-items: center;
} }
.navbar-menu { display: flex; gap: 20px; align-items: center; }
.navbar-menu > a { .navbar-menu > a {
color: #64748b; color: #64748b; text-decoration: none; font-size: 14px;
text-decoration: none; font-weight: 500; transition: color 0.2s;
font-size: 15px;
font-weight: 500;
transition: color 0.2s;
padding: 6px 0;
} }
.navbar-menu > a:hover { color: #6366f1; } .navbar-menu > a:hover { color: #6366f1; }
.navbar-user { .navbar-user { display: flex; gap: 10px; align-items: center; }
display: flex; .navbar-user span { color: #475569; font-size: 13px; font-weight: 500; }
gap: 14px;
align-items: center;
}
.navbar-user span {
color: #475569;
font-size: 14px;
font-weight: 500;
}
.btn-logout { .btn-logout {
background: linear-gradient(135deg, #f43f5e, #e11d48); background: linear-gradient(135deg, #f43f5e, #e11d48); color: white;
color: white; padding: 5px 14px; border: none; border-radius: 20px; cursor: pointer;
padding: 7px 18px; text-decoration: none; font-size: 12px; font-weight: 500;
border: none;
border-radius: 20px;
cursor: pointer;
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: opacity 0.2s;
} }
.btn-logout:hover { opacity: 0.85; } .navbar-toggle {
display: none; background: none; border: none;
.container { font-size: 22px; cursor: pointer; color: #475569; padding: 4px;
max-width: 1200px;
margin: 28px auto;
padding: 0 20px;
} }
.alert { /* ---- Layout ---- */
padding: 14px 20px; .container { max-width: 1200px; margin: 16px auto; padding: 0 16px; }
margin-bottom: 20px;
border-radius: 16px; /* ---- Alerts ---- */
font-size: 14px; .alert { padding: 10px 14px; margin-bottom: 14px; border-radius: 12px; font-size: 13px; font-weight: 500; }
font-weight: 500;
}
.alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } .alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.alert-danger { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } .alert-danger { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; } .alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
.alert-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; } .alert-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
.form-group { margin-bottom: 20px; } /* ---- Forms ---- */
label { display: block; margin-bottom: 8px; font-weight: 600; color: #334155; font-size: 14px; } .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 { input[type="text"], input[type="email"], input[type="password"], textarea, select {
width: 100%; width: 100%; padding: 10px 14px; border: 1.5px solid #e2e8f0;
padding: 12px 16px; border-radius: 12px; font-size: 14px; font-family: inherit; background: #fff;
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;
} }
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus, input:focus, textarea:focus, select:focus {
textarea:focus, select:focus { outline: none; border-color: #818cf8; box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
outline: none;
border-color: #818cf8;
box-shadow: 0 0 0 4px rgba(99,102,241,0.1);
} }
textarea { resize: vertical; min-height: 100px; } textarea { resize: vertical; min-height: 80px; }
/* ---- Buttons ---- */
.btn { .btn {
padding: 10px 22px; padding: 8px 16px; border: none; border-radius: 12px; cursor: pointer;
border: none; font-size: 13px; font-weight: 600; transition: all 0.2s;
border-radius: 14px; text-decoration: none; display: inline-block; white-space: nowrap;
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);
} }
.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-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
.btn-secondary { background: #f1f5f9; color: #475569; } .btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; } .btn-secondary:hover { background: #e2e8f0; }
.btn-danger { .btn-danger { background: linear-gradient(135deg, #f43f5e, #e11d48); color: white; }
background: linear-gradient(135deg, #f43f5e, #e11d48); .btn-success { background: linear-gradient(135deg, #10b981, #059669); color: white; }
color: white; .btn-group { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
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; }
/* ---- Cards ---- */
.card { .card {
background: rgba(255,255,255,0.9); background: rgba(255,255,255,0.9); backdrop-filter: blur(8px);
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);
border-radius: 20px; padding: 16px; margin-bottom: 14px; 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);
padding: 24px;
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.6);
} }
.card-header { .card-header {
font-size: 17px; font-size: 15px; font-weight: 700; margin-bottom: 12px;
font-weight: 700; padding-bottom: 10px; border-bottom: 2px solid #f1f5f9; color: #1e293b;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #f1f5f9;
color: #1e293b;
} }
.badge { /* ---- Badges ---- */
display: inline-block; .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
padding: 4px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-success { background: #ecfdf5; color: #059669; } .badge-success { background: #ecfdf5; color: #059669; }
.badge-warning { background: #fffbeb; color: #d97706; } .badge-warning { background: #fffbeb; color: #d97706; }
.badge-danger { background: #fef2f2; color: #dc2626; } .badge-danger { background: #fef2f2; color: #dc2626; }
.badge-info { background: #eff6ff; color: #2563eb; } .badge-info { background: #eff6ff; color: #2563eb; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; } /* ---- Tables ---- */
th { table { width: 100%; border-collapse: collapse; }
background: #f8fafc; th { background: #f8fafc; padding: 8px 10px; text-align: left; font-weight: 600; font-size: 12px; color: #64748b; border-bottom: 2px solid #e2e8f0; }
padding: 12px 16px; td { padding: 10px; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
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; }
.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 { .pagination a, .pagination span {
padding: 8px 14px; padding: 6px 12px; border: 1.5px solid #e2e8f0; border-radius: 10px;
border: 1.5px solid #e2e8f0; text-decoration: none; color: #6366f1; font-size: 13px; font-weight: 500;
border-radius: 12px;
text-decoration: none;
color: #6366f1;
font-size: 14px;
font-weight: 500;
} }
.pagination a:hover { background: #f1f5f9; } .pagination a:hover { background: #f1f5f9; }
.pagination .active { background: #6366f1; color: white; border-color: #6366f1; } .pagination .active { background: #6366f1; color: white; border-color: #6366f1; }
.pagination .disabled { color: #cbd5e1; pointer-events: none; }
/* ---- Mobile Responsive ---- */
@media (max-width: 768px) { @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-group { flex-direction: column; }
.btn { width: 100%; text-align: center; } .btn-group .btn { width: 100%; text-align: center; }
} }
</style> </style>
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
@@ -221,8 +143,12 @@
<nav class="navbar"> <nav class="navbar">
<div class="navbar-content"> <div class="navbar-content">
<a href="{{ url_for('dashboard') }}" class="navbar-brand">🔥 微博超话签到</a> <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"> <div class="navbar-menu">
<a href="{{ url_for('dashboard') }}">控制台</a> <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"> <div class="navbar-user">
<span>👤 {{ session.get('user').get('username') }}</span> <span>👤 {{ session.get('user').get('username') }}</span>
<a href="{{ url_for('logout') }}" class="btn-logout">退出</a> <a href="{{ url_for('logout') }}" class="btn-logout">退出</a>

View File

@@ -4,200 +4,113 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.dash-header { .dash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
display: flex; .dash-header h1 { font-size: 22px; font-weight: 700; color: #1e293b; }
justify-content: space-between; .stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
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;
}
.stat-card { .stat-card {
background: rgba(255,255,255,0.9); background: rgba(255,255,255,0.9); backdrop-filter: blur(8px);
backdrop-filter: blur(8px); border-radius: 16px; padding: 16px; border: 1px solid rgba(255,255,255,0.6);
border-radius: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);
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;
} }
.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 { .account-card {
background: rgba(255,255,255,0.92); background: rgba(255,255,255,0.92); backdrop-filter: blur(8px);
backdrop-filter: blur(8px); border-radius: 16px; padding: 18px; border: 1px solid rgba(255,255,255,0.6);
border-radius: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); cursor: pointer;
padding: 24px; transition: all 0.2s; position: relative; overflow: hidden;
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;
} }
.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-active::before { background: linear-gradient(90deg, #10b981, #34d399); }
.account-card.status-pending::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); } .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-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(-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-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-avatar { .account-avatar {
width: 48px; height: 48px; width: 40px; height: 40px; border-radius: 12px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1, #a855f7); background: linear-gradient(135deg, #6366f1, #a855f7);
display: flex; display: flex; align-items: center; justify-content: center;
align-items: center; color: white; font-size: 16px; font-weight: 700; flex-shrink: 0;
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;
} }
.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 { .account-meta {
display: flex; display: flex; justify-content: space-between; align-items: center;
justify-content: space-between; padding-top: 10px; border-top: 1px solid #f1f5f9;
align-items: center;
padding-top: 14px;
border-top: 1px solid #f1f5f9;
} }
.account-date { font-size: 12px; color: #cbd5e1; } .account-date { font-size: 11px; color: #cbd5e1; }
/* 删除按钮 */
.account-del-btn { .account-del-btn {
width: 32px; height: 32px; width: 28px; height: 28px; border-radius: 8px; border: 1.5px solid #fecaca;
border-radius: 10px; background: #fff; color: #ef4444; font-size: 12px; cursor: pointer;
border: 1.5px solid #fecaca; display: flex; align-items: center; justify-content: center;
background: #fff;
color: #ef4444;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
} }
.account-del-btn:hover { .account-del-btn:hover { background: #fef2f2; }
background: #fef2f2; .batch-bar {
border-color: #f87171; 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 { .empty-state {
text-align: center; text-align: center; padding: 60px 20px; background: rgba(255,255,255,0.7);
padding: 80px 20px; border-radius: 20px; border: 2px dashed #e2e8f0;
background: rgba(255,255,255,0.7);
border-radius: 24px;
border: 2px dashed #e2e8f0;
} }
.empty-state .empty-icon { font-size: 56px; margin-bottom: 16px; } .empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { color: #94a3b8; margin-bottom: 24px; font-size: 16px; } .empty-state p { color: #94a3b8; margin-bottom: 20px; font-size: 15px; }
@media (max-width: 768px) {
/* Cookie 批量验证 */ .stats-row { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.batch-verify-bar { .stat-card { padding: 12px; }
background: rgba(255,255,255,0.9); .stat-card .stat-value { font-size: 20px; }
backdrop-filter: blur(8px); .stat-card .stat-label { font-size: 11px; }
border-radius: 20px; .account-grid { grid-template-columns: 1fr; }
padding: 16px 24px; .batch-bar { flex-direction: column; align-items: stretch; text-align: center; }
margin-bottom: 20px; .batch-bar .info { margin-bottom: 8px; }
border: 1px solid rgba(255,255,255,0.6); .dash-header { flex-direction: column; gap: 10px; align-items: flex-start; }
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
display: flex;
justify-content: space-between;
align-items: center;
} }
.batch-verify-bar .info { font-size: 14px; color: #64748b; }
.batch-verify-bar .info strong { color: #1e293b; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="dash-header"> <div class="dash-header">
<h1>👋 控制台</h1> <h1>👋 控制台</h1>
<div class="dash-actions"> <a href="{{ url_for('add_account') }}" class="btn btn-primary">+ 添加账号</a>
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ 添加账号</a>
</div>
</div> </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="stats-row">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">📊</div> <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 class="stat-label">账号总数</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon"></div> <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 class="stat-label">正常运行</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">⚠️</div> <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 class="stat-label">需要关注</div>
</div> </div>
</div> </div>
<!-- 批量操作栏 --> <div class="batch-bar">
<div class="batch-verify-bar"> <div class="info">💡 可手动触发批量操作</div>
<div class="info"> <div style="display:flex; gap:8px; flex-wrap:wrap;">
💡 系统每天 <strong>23:50</strong> 自动批量验证 Cookie也可手动触发 <button class="btn btn-secondary" id="batch-verify-btn" onclick="batchVerify()">🔍 批量验证</button>
</div>
<div style="display:flex; gap:10px;">
<button class="btn btn-secondary" id="batch-verify-btn" onclick="batchVerify()">🔍 批量验证 Cookie</button>
<button class="btn btn-primary" id="batch-signin-btn" onclick="batchSignin()">🚀 全部签到</button> <button class="btn btn-primary" id="batch-signin-btn" onclick="batchSignin()">🚀 全部签到</button>
</div> </div>
</div> </div>
<!-- 账号卡片 -->
<div class="account-grid"> <div class="account-grid">
{% for account in accounts %} {% for account in accounts %}
<div class="account-card status-{{ account.status }}" onclick="window.location.href='{{ url_for('account_detail', account_id=account.id) }}'"> <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>
<div class="account-meta"> <div class="account-meta">
<div> <div>
{% if account.status == 'active' %} {% if account.status == 'active' %}<span class="badge badge-success">正常</span>
<span class="badge badge-success">正常</span> {% elif account.status == 'pending' %}<span class="badge badge-warning">待验证</span>
{% elif account.status == 'pending' %} {% elif account.status == 'invalid_cookie' %}<span class="badge badge-danger">Cookie 失效</span>
<span class="badge badge-warning">待验证</span> {% elif account.status == 'banned' %}<span class="badge badge-danger">已封禁</span>
{% elif account.status == 'invalid_cookie' %}
<span class="badge badge-danger">Cookie 失效</span>
{% elif account.status == 'banned' %}
<span class="badge badge-danger">已封禁</span>
{% endif %} {% endif %}
</div> </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> <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> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </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 %} {% else %}
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">📱</div> <div class="empty-icon">📱</div>
<p>暂无账号,扫码添加你的微博账号开始自动签到</p> <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> </div>
{% endif %} {% endif %}
<script> <script>
async function deleteAccount(accountId, name) { async function deleteAccount(id, name) {
if (!confirm(`确定要删除账号「${name}」吗?此操作不可恢复。`)) return; if (!confirm(`确定要删除账号「${name}」吗?`)) return;
try { const form = document.createElement('form');
const form = document.createElement('form'); form.method = 'POST';
form.method = 'POST'; form.action = `/accounts/${id}/delete`;
form.action = `/accounts/${accountId}/delete`; document.body.appendChild(form);
document.body.appendChild(form); form.submit();
form.submit();
} catch(e) {
alert('删除失败: ' + e.message);
}
} }
async function batchVerify() { async function batchVerify() {
const btn = document.getElementById('batch-verify-btn'); const btn = document.getElementById('batch-verify-btn');
btn.disabled = true; btn.disabled = true; btn.textContent = '⏳ 验证中...';
btn.textContent = '⏳ 验证中...';
try { try {
const resp = await fetch('/api/batch/verify', {method: 'POST'}); const resp = await fetch('/api/batch/verify', {method: 'POST'});
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
const r = data.data; const r = data.data;
alert(`验证完成:${r.valid} 有效,${r.invalid} 失效,${r.errors} 出错`); alert(`验证完成:${r.valid} 有效,${r.invalid} 失效,${r.errors} 出错`);
} else { } else { alert('验证失败: ' + (data.message || '未知错误')); }
alert('验证失败: ' + (data.message || '未知错误')); } catch(e) { alert('请求失败: ' + e.message); }
} btn.disabled = false; btn.textContent = '🔍 批量验证';
} catch(e) {
alert('请求失败: ' + e.message);
}
btn.disabled = false;
btn.textContent = '🔍 批量验证 Cookie';
location.reload(); location.reload();
} }
async function batchSignin() { async function batchSignin() {
const btn = document.getElementById('batch-signin-btn'); const btn = document.getElementById('batch-signin-btn');
if (!confirm('确定要对所有正常账号执行签到吗?')) return; if (!confirm('确定要对所有正常账号执行签到吗?')) return;
btn.disabled = true; btn.disabled = true; btn.textContent = '⏳ 签到中...';
btn.textContent = '⏳ 签到中...';
try { try {
const resp = await fetch('/api/batch/signin', {method: 'POST'}); const resp = await fetch('/api/batch/signin', {method: 'POST'});
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
const r = data.data; const r = data.data;
alert(`签到完成:${r.total_accounts} 个账号,${r.total_signed} 成功,${r.total_already} 已签,${r.total_failed} 失败`); alert(`签到完成:${r.total_accounts} 个账号,${r.total_signed} 成功,${r.total_already} 已签,${r.total_failed} 失败`);
} else { } else { alert('签到失败: ' + (data.message || '未知错误')); }
alert('签到失败: ' + (data.message || '未知错误')); } catch(e) { alert('请求失败: ' + e.message); }
} btn.disabled = false; btn.textContent = '🚀 全部签到';
} catch(e) {
alert('请求失败: ' + e.message);
}
btn.disabled = false;
btn.textContent = '🚀 全部签到';
location.reload(); location.reload();
} }
</script> </script>

View File

@@ -55,6 +55,10 @@
<label for="confirm_password">确认密码</label> <label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码"> <input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码">
</div> </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> <button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">注册</button>
</form> </form>
<div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div> <div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div>

View 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 %}

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
wx_openid TEXT UNIQUE, wx_openid TEXT UNIQUE,
wx_nickname TEXT, wx_nickname TEXT,
wx_avatar TEXT, wx_avatar TEXT,
is_admin INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER DEFAULT 1 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_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_signed_at ON signin_logs(signed_at);
CREATE INDEX IF NOT EXISTS idx_signin_logs_status ON signin_logs(status); 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);

View File

@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
wx_openid VARCHAR(64) UNIQUE, wx_openid VARCHAR(64) UNIQUE,
wx_nickname VARCHAR(100), wx_nickname VARCHAR(100),
wx_avatar VARCHAR(500), wx_avatar VARCHAR(500),
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
INDEX idx_users_email (email), INDEX idx_users_email (email),
@@ -27,6 +28,7 @@ CREATE TABLE IF NOT EXISTS accounts (
encrypted_cookies TEXT NOT NULL, encrypted_cookies TEXT NOT NULL,
iv VARCHAR(32) NOT NULL, iv VARCHAR(32) NOT NULL,
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
selected_topics JSON DEFAULT NULL,
last_checked_at TIMESTAMP NULL, last_checked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_accounts_user_id (user_id), INDEX idx_accounts_user_id (user_id),
@@ -51,12 +53,38 @@ CREATE TABLE IF NOT EXISTS signin_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
account_id CHAR(36) NOT NULL, account_id CHAR(36) NOT NULL,
topic_title VARCHAR(100), topic_title VARCHAR(100),
status VARCHAR(20) NOT NULL, status VARCHAR(50) NOT NULL,
reward_info JSON, reward_info JSON,
error_message TEXT, error_message TEXT,
signed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, signed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_signin_logs_account_id (account_id), INDEX idx_signin_logs_account_id (account_id),
INDEX idx_signin_logs_signed_at (signed_at), INDEX idx_signin_logs_signed_at (signed_at),
INDEX idx_signin_logs_status (status), 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; ) 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');

View File

@@ -93,15 +93,15 @@ USER_COUNT=$($ROOT_CMD -N -e "SELECT COUNT(*) FROM ${DB_NAME}.users" 2>/dev/null
if [ "$USER_COUNT" = "0" ]; then if [ "$USER_COUNT" = "0" ]; then
# 检查 bcrypt 是否可用 # 检查 bcrypt 是否可用
if python3 -c "import bcrypt" 2>/dev/null; then 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()))") USER_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
$ROOT_CMD ${DB_NAME} -e " $ROOT_CMD ${DB_NAME} -e "
INSERT INTO users (id, username, email, hashed_password, is_active) INSERT INTO users (id, username, email, hashed_password, is_admin, is_active)
VALUES ('${USER_ID}', 'admin', 'admin@example.com', '${HASHED_PW}', 1); VALUES ('${USER_ID}', 'admin', 'admin@example.com', '${HASHED_PW}', 1, 1);
" "
info "测试用户已创建: admin / Admin123!" info "管理员用户已创建: admin@example.com / Admin123 (管理员)"
else else
warn "bcrypt 未安装,跳过测试用户创建(运行 setup_linux.sh 安装依赖后可手动创建)" warn "bcrypt 未安装,跳过管理员创建(运行 setup_linux.sh 安装依赖后可手动创建)"
fi fi
else else
info "已有 ${USER_COUNT} 个用户,跳过" info "已有 ${USER_COUNT} 个用户,跳过"

View 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;

View 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');

View 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
View 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
View 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())

Binary file not shown.