接口跑通,基础功能全部实现

This commit is contained in:
2026-03-16 16:14:08 +08:00
parent f81aec48ca
commit 2f2d5c3795
38 changed files with 3352 additions and 1754 deletions

View File

@@ -15,5 +15,9 @@ JWT_EXPIRATION_HOURS=24
# Cookie 加密密钥 (32字节)
COOKIE_ENCRYPTION_KEY=dev-cookie-encryption-key-32b
# 微信小程序配置
WX_APPID=
WX_SECRET=
# 环境
ENVIRONMENT=development

View File

@@ -3,12 +3,17 @@ Weibo Account CRUD router.
All endpoints require JWT authentication and enforce resource ownership.
"""
import logging
from datetime import datetime
from typing import Dict, List
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models import get_db, Account, User
from shared.models import get_db, Account, SigninLog, User
from shared.crypto import encrypt_cookie, decrypt_cookie, derive_key
from shared.config import shared_settings
from shared.response import success_response, error_response
@@ -19,8 +24,20 @@ from api_service.app.schemas.account import (
AccountResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/accounts", tags=["accounts"])
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",
}
def _encryption_key() -> bytes:
return derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
@@ -137,3 +154,312 @@ async def delete_account(
await db.delete(account)
await db.commit()
return success_response(None, "Account deleted")
# ---- helpers for verify / signin ----
def _parse_cookie_str(cookie_str: str) -> Dict[str, str]:
"""Parse 'k1=v1; k2=v2' into a dict."""
cookies: Dict[str, str] = {}
for pair in cookie_str.split(";"):
pair = pair.strip()
if "=" in pair:
k, v = pair.split("=", 1)
cookies[k.strip()] = v.strip()
return cookies
async def _verify_weibo_cookie(cookie_str: str) -> dict:
"""
Verify cookie via weibo.com PC API.
Uses /ajax/side/cards which returns ok=1 when logged in.
Returns {"valid": bool, "uid": str|None, "screen_name": str|None}.
"""
cookies = _parse_cookie_str(cookie_str)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
# Step 1: check login via /ajax/side/cards
resp = await client.get(
"https://weibo.com/ajax/side/cards",
params={"count": "1"},
headers=WEIBO_HEADERS,
cookies=cookies,
)
data = resp.json()
if data.get("ok") != 1:
return {"valid": False, "uid": None, "screen_name": None}
# Step 2: get user info via /ajax/profile/detail
uid = None
screen_name = None
try:
resp2 = await client.get(
"https://weibo.com/ajax/profile/info",
headers=WEIBO_HEADERS,
cookies=cookies,
)
info = resp2.json()
if info.get("ok") == 1:
user = info.get("data", {}).get("user", {})
uid = str(user.get("idstr", user.get("id", "")))
screen_name = user.get("screen_name", "")
except Exception:
pass # profile info is optional, login check already passed
return {"valid": True, "uid": uid, "screen_name": screen_name}
# ---- VERIFY COOKIE ----
@router.post("/{account_id}/verify")
async def verify_account(
account_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Verify the stored cookie is still valid and update account status."""
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:
account.status = "invalid_cookie"
await db.commit()
await db.refresh(account)
return success_response(
{**_account_to_dict(account), "cookie_valid": False},
"Cookie decryption failed",
)
result = await _verify_weibo_cookie(cookie_str)
if result["valid"]:
account.status = "active"
account.last_checked_at = datetime.utcnow()
else:
account.status = "invalid_cookie"
await db.commit()
await db.refresh(account)
return success_response(
{**_account_to_dict(account), "cookie_valid": result["valid"],
"weibo_screen_name": result.get("screen_name")},
"Cookie verified" if result["valid"] else "Cookie is invalid or expired",
)
# ---- MANUAL SIGNIN ----
async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]:
"""
Fetch followed super topics via weibo.com PC API.
GET /ajax/profile/topicContent?tabid=231093_-_chaohua
Returns list of {"title": str, "containerid": str}.
"""
import re
cookies = _parse_cookie_str(cookie_str)
topics: List[dict] = []
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
# First get XSRF-TOKEN by visiting weibo.com
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
xsrf = client.cookies.get("XSRF-TOKEN", "")
headers = {
**WEIBO_HEADERS,
"X-Requested-With": "XMLHttpRequest",
}
if xsrf:
headers["X-XSRF-TOKEN"] = xsrf
page = 1
max_page = 10
while page <= max_page:
params = {"tabid": "231093_-_chaohua", "page": str(page)}
resp = await client.get(
"https://weibo.com/ajax/profile/topicContent",
params=params,
headers=headers,
cookies=cookies,
)
data = resp.json()
if data.get("ok") != 1:
break
topic_list = data.get("data", {}).get("list", [])
if not topic_list:
break
for item in topic_list:
title = item.get("topic_name", "") or item.get("title", "")
# Extract containerid from oid "1022:100808xxx" or scheme
containerid = ""
oid = item.get("oid", "")
if "100808" in oid:
m = re.search(r"100808[0-9a-fA-F]+", oid)
if m:
containerid = m.group(0)
if not containerid:
scheme = item.get("scheme", "")
m = re.search(r"100808[0-9a-fA-F]+", scheme)
if m:
containerid = m.group(0)
if title and containerid:
topics.append({"title": title, "containerid": containerid})
# Check pagination
api_max = data.get("data", {}).get("max_page", 1)
if page >= api_max:
break
page += 1
return topics
async def _do_signin(cookie_str: str, topic_title: str, containerid: str) -> dict:
"""
Sign in to a single super topic via weibo.com PC API.
GET /p/aj/general/button with full browser-matching parameters.
Returns {"status": "success"|"already_signed"|"failed", "message": str}.
"""
import time as _time
cookies = _parse_cookie_str(cookie_str)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
# Get XSRF-TOKEN
await client.get("https://weibo.com/", headers=WEIBO_HEADERS, cookies=cookies)
xsrf = client.cookies.get("XSRF-TOKEN", "")
headers = {
**WEIBO_HEADERS,
"Referer": f"https://weibo.com/p/{containerid}/super_index",
"X-Requested-With": "XMLHttpRequest",
}
if xsrf:
headers["X-XSRF-TOKEN"] = xsrf
try:
resp = await client.get(
"https://weibo.com/p/aj/general/button",
params={
"ajwvr": "6",
"api": "http://i.huati.weibo.com/aj/super/checkin",
"texta": "签到",
"textb": "已签到",
"status": "0",
"id": containerid,
"location": "page_100808_super_index",
"timezone": "GMT+0800",
"lang": "zh-cn",
"plat": "Win32",
"ua": WEIBO_HEADERS["User-Agent"],
"screen": "1920*1080",
"__rnd": str(int(_time.time() * 1000)),
},
headers=headers,
cookies=cookies,
)
data = resp.json()
code = str(data.get("code", ""))
msg = data.get("msg", "")
if code == "100000":
tip = ""
if isinstance(data.get("data"), dict):
tip = data["data"].get("alert_title", "") or data["data"].get("tipMessage", "")
return {"status": "success", "message": tip or "签到成功"}
elif code == "382004":
return {"status": "already_signed", "message": msg or "今日已签到"}
elif code == "382003":
return {"status": "failed", "message": msg or "非超话成员"}
else:
return {"status": "failed", "message": f"code={code}, msg={msg}"}
except Exception as e:
return {"status": "failed", "message": str(e)}
@router.post("/{account_id}/signin")
async def manual_signin(
account_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Manually trigger sign-in for all followed super topics.
Verifies cookie first, fetches topic list, signs each one, writes logs.
"""
account = await _get_owned_account(account_id, user, db)
key = _encryption_key()
# Decrypt cookie
try:
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
except Exception:
account.status = "invalid_cookie"
await db.commit()
return error_response("Cookie decryption failed", "COOKIE_ERROR", status_code=400)
# Verify cookie
verify = await _verify_weibo_cookie(cookie_str)
if not verify["valid"]:
account.status = "invalid_cookie"
await db.commit()
return error_response("Cookie is invalid or expired", "COOKIE_EXPIRED", status_code=400)
# Activate account if pending
if account.status != "active":
account.status = "active"
account.last_checked_at = datetime.utcnow()
# Get super topics
topics = await _get_super_topics(cookie_str, account.weibo_user_id)
if not topics:
await db.commit()
return success_response(
{"signed": 0, "already_signed": 0, "failed": 0, "topics": []},
"No super topics found for this account",
)
# Sign each topic
results = []
signed = already = failed = 0
for topic in topics:
import asyncio
await asyncio.sleep(1.5) # anti-bot delay
r = await _do_signin(cookie_str, topic["title"], topic["containerid"])
r["topic"] = topic["title"]
results.append(r)
# Write signin log
log = SigninLog(
account_id=account.id,
topic_title=topic["title"],
status="success" if r["status"] == "success"
else "failed_already_signed" if r["status"] == "already_signed"
else "failed_network",
reward_info={"message": r["message"]},
signed_at=datetime.utcnow(),
)
db.add(log)
if r["status"] == "success":
signed += 1
elif r["status"] == "already_signed":
already += 1
else:
failed += 1
account.last_checked_at = datetime.utcnow()
await db.commit()
return success_response(
{
"signed": signed,
"already_signed": already,
"failed": failed,
"total_topics": len(topics),
"details": results,
},
f"Signed {signed} topics, {already} already signed, {failed} failed",
)

View File

@@ -13,9 +13,11 @@ import os
import logging
from shared.models import get_db, User
from shared.config import shared_settings
from auth_service.app.models.database import create_tables
from auth_service.app.schemas.user import (
UserCreate, UserLogin, UserResponse, Token, TokenData, RefreshTokenRequest, AuthResponse,
UserCreate, UserLogin, UserResponse, Token, TokenData,
RefreshTokenRequest, AuthResponse, WxLoginRequest,
)
from auth_service.app.services.auth_service import AuthService
from auth_service.app.utils.security import (
@@ -235,3 +237,99 @@ async def get_current_user_info(current_user: UserResponse = Depends(get_current
Get current user information
"""
return current_user
@app.post("/auth/wx-login", response_model=AuthResponse)
async def wx_login(body: WxLoginRequest, db: AsyncSession = Depends(get_db)):
"""
微信小程序登录。
流程:
1. 用 code 调微信 code2Session 接口换取 openid
2. 查找是否已有该 openid 的用户
3. 没有则自动注册,有则直接登录
4. 返回 JWT token
"""
import httpx
appid = shared_settings.WX_APPID
secret = shared_settings.WX_SECRET
if not appid or not secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="微信小程序未配置 APPID 和 SECRET",
)
# Step 1: code 换 openid
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://api.weixin.qq.com/sns/jscode2session",
params={
"appid": appid,
"secret": secret,
"js_code": body.code,
"grant_type": "authorization_code",
},
)
wx_data = resp.json()
openid = wx_data.get("openid")
if not openid:
errcode = wx_data.get("errcode", "unknown")
errmsg = wx_data.get("errmsg", "未知错误")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"微信登录失败: {errmsg} (errcode={errcode})",
)
# Step 2: 查找已有用户
result = await db.execute(select(User).where(User.wx_openid == openid))
user = result.scalar_one_or_none()
if user:
# 已有用户 — 更新昵称头像(如果传了)
if body.nickname and body.nickname != user.wx_nickname:
user.wx_nickname = body.nickname
if body.avatar_url and body.avatar_url != user.wx_avatar:
user.wx_avatar = body.avatar_url
await db.commit()
await db.refresh(user)
else:
# Step 3: 自动注册
import uuid
nickname = body.nickname or f"wx_{openid[:8]}"
# 生成唯一 username避免冲突
username = f"wx_{openid[:12]}"
user = User(
id=str(uuid.uuid4()),
username=username,
wx_openid=openid,
wx_nickname=nickname,
wx_avatar=body.avatar_url,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"微信用户自动注册: openid={openid[:16]}..., username={username}")
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已被禁用",
)
# Step 4: 签发 token
access_token = create_access_token(
data={"sub": str(user.id), "username": user.username}
)
refresh_token = await create_refresh_token(str(user.id))
return AuthResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=3600,
user=UserResponse.from_orm(user),
)

View File

@@ -28,12 +28,17 @@ class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = None
class UserResponse(UserBase):
class UserResponse(BaseModel):
"""Schema for user response data"""
id: UUID
username: str
email: Optional[EmailStr] = None
created_at: datetime
is_active: bool
wx_openid: Optional[str] = None
wx_nickname: Optional[str] = None
wx_avatar: Optional[str] = None
class Config:
from_attributes = True # Enable ORM mode
@@ -64,3 +69,10 @@ class TokenData(BaseModel):
sub: str = Field(..., description="Subject (user ID)")
username: str = Field(..., description="Username")
exp: Optional[int] = None
class WxLoginRequest(BaseModel):
"""微信小程序登录请求"""
code: str = Field(..., description="wx.login() 获取的临时登录凭证")
nickname: Optional[str] = Field(None, max_length=100, description="微信昵称")
avatar_url: Optional[str] = Field(None, max_length=500, description="微信头像 URL")

View File

@@ -24,7 +24,11 @@ class SharedSettings(BaseSettings):
# Cookie encryption
COOKIE_ENCRYPTION_KEY: str = "change-me-in-production"
# 微信小程序
WX_APPID: str = ""
WX_SECRET: str = ""
# Environment
ENVIRONMENT: str = "development"

View File

@@ -14,8 +14,11 @@ class User(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
email = Column(String(255), unique=True, nullable=True, index=True)
hashed_password = Column(String(255), nullable=True)
wx_openid = Column(String(64), unique=True, nullable=True, index=True)
wx_nickname = Column(String(100), nullable=True)
wx_avatar = Column(String(500), nullable=True)
created_at = Column(DateTime, server_default=func.now())
is_active = Column(Boolean, default=True)

View File

@@ -19,6 +19,8 @@ from app.services.signin_service import SignInService
from app.services.weibo_client import WeiboClient
from app.models.signin_models import SignInRequest, SignInResult, TaskStatus
logger = logging.getLogger(__name__)
# Initialize FastAPI app
app = FastAPI(
title="Weibo-HotSign Sign-in Executor",

View File

@@ -6,10 +6,9 @@ Handles Weibo super topic sign-in operations
import os
import sys
import asyncio
import httpx
import logging
import random
from datetime import datetime, timedelta
from datetime import datetime
from typing import Dict, Any, List, Optional
from uuid import UUID
from sqlalchemy import select, update
@@ -230,118 +229,116 @@ class SignInService:
logger.debug(f"Browser fingerprint: {fingerprint}")
async def _get_super_topics_list(self, account: WeiboAccount) -> List[WeiboSuperTopic]:
"""Get list of super topics for account"""
"""
Fetch the real list of followed super topics from Weibo API.
Delegates to WeiboClient.get_super_topics().
"""
try:
# Mock implementation - in real system, fetch from Weibo API
# Simulate API call delay
await asyncio.sleep(1)
# Return mock super topics
return [
WeiboSuperTopic(
id="topic_001",
title="Python编程",
url="https://weibo.com/p/100808xxx",
is_signed=False,
sign_url="https://weibo.com/p/aj/general/button",
reward_exp=2,
reward_credit=1
),
WeiboSuperTopic(
id="topic_002",
title="人工智能",
url="https://weibo.com/p/100808yyy",
is_signed=False,
sign_url="https://weibo.com/p/aj/general/button",
reward_exp=2,
reward_credit=1
),
WeiboSuperTopic(
id="topic_003",
title="机器学习",
url="https://weibo.com/p/100808zzz",
is_signed=True, # Already signed
sign_url="https://weibo.com/p/aj/general/button",
reward_exp=2,
reward_credit=1
)
]
topics = await self.weibo_client.get_super_topics(account)
logger.info(f"Fetched {len(topics)} super topics for account {account.weibo_user_id}")
return topics
except Exception as e:
logger.error(f"Error fetching super topics: {e}")
logger.error(f"Error fetching super topics for {account.weibo_user_id}: {e}")
return []
async def _execute_topic_signin(self, account: WeiboAccount, topics: List[WeiboSuperTopic], task_id: str) -> Dict[str, List[str]]:
"""Execute sign-in for each super topic"""
signed = []
already_signed = []
errors = []
for topic in topics:
"""
Execute sign-in for each super topic with retry logic and
per-topic progress updates.
"""
signed: List[str] = []
already_signed: List[str] = []
errors: List[str] = []
max_retries = 2
total = len(topics) if topics else 1
for idx, topic in enumerate(topics):
# Update progress: 50% -> 80% spread across topics
pct = 50 + int((idx / total) * 30)
await self._update_task_progress(task_id, pct)
try:
# Add small delay between requests
await asyncio.sleep(random.uniform(0.5, 1.5))
if topic.is_signed:
already_signed.append(topic.title)
# Write log for already signed
await self._write_signin_log(
account_id=str(account.id),
topic_title=topic.title,
status="failed_already_signed",
reward_info=None,
error_message="Already signed today"
error_message="Already signed today",
)
continue
# Execute signin for this topic
success, reward_info, error_msg = await self.weibo_client.sign_super_topic(
account=account,
topic=topic,
task_id=task_id
)
if success:
signed.append(topic.title)
logger.info(f"✅ Successfully signed topic: {topic.title}")
# Write success log
await self._write_signin_log(
account_id=str(account.id),
topic_title=topic.title,
status="success",
reward_info=reward_info,
error_message=None
# Retry loop
last_error: Optional[str] = None
succeeded = False
for attempt in range(1, max_retries + 1):
# Inter-request delay (longer for retries)
delay = random.uniform(1.0, 3.0) * attempt
await asyncio.sleep(delay)
success, reward_info, error_msg = await self.weibo_client.sign_super_topic(
account=account,
topic=topic,
task_id=task_id,
)
else:
errors.append(f"Failed to sign topic: {topic.title}")
# Write failure log
if success:
# "Already signed" from the API is still a success
if error_msg and "already" in error_msg.lower():
already_signed.append(topic.title)
await self._write_signin_log(
account_id=str(account.id),
topic_title=topic.title,
status="failed_already_signed",
reward_info=None,
error_message=error_msg,
)
else:
signed.append(topic.title)
logger.info(f"✅ Signed topic: {topic.title}")
await self._write_signin_log(
account_id=str(account.id),
topic_title=topic.title,
status="success",
reward_info=reward_info,
error_message=None,
)
succeeded = True
break
last_error = error_msg
logger.warning(
f"Attempt {attempt}/{max_retries} failed for "
f"{topic.title}: {error_msg}"
)
if not succeeded:
errors.append(f"{topic.title}: {last_error}")
await self._write_signin_log(
account_id=str(account.id),
topic_title=topic.title,
status="failed_network",
reward_info=None,
error_message=error_msg
error_message=last_error,
)
except Exception as e:
error_msg = f"Error signing topic {topic.title}: {str(e)}"
logger.error(error_msg)
errors.append(error_msg)
# Write error log
err = f"Error signing topic {topic.title}: {e}"
logger.error(err)
errors.append(err)
await self._write_signin_log(
account_id=str(account.id),
topic_title=topic.title,
status="failed_network",
reward_info=None,
error_message=str(e)
error_message=str(e),
)
return {
"signed": signed,
"already_signed": already_signed,
"errors": errors
"already_signed": already_signed,
"errors": errors,
}
async def _write_signin_log(

View File

@@ -1,6 +1,13 @@
"""
Weibo API Client
Handles all interactions with Weibo.com, including login, sign-in, and data fetching
Handles all interactions with Weibo.com, including cookie verification,
super topic listing, and sign-in execution.
Key Weibo API endpoints used:
- Cookie验证: GET https://m.weibo.cn/api/config
- 超话列表: GET https://m.weibo.cn/api/container/getIndex (containerid=100803_-_followsuper)
- 超话签到: GET https://m.weibo.cn/api/container/getIndex (containerid=100808{topic_id})
POST https://huati.weibo.cn/aj/super/checkin (actual sign-in)
"""
import os
@@ -9,6 +16,9 @@ import httpx
import asyncio
import logging
import random
import json
import re
import time
from typing import Dict, Any, Optional, List, Tuple
# Add parent directory to path for imports
@@ -23,200 +33,313 @@ from app.services.antibot import antibot
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants Weibo mobile API base URLs
# ---------------------------------------------------------------------------
WEIBO_PC_API = "https://weibo.com"
WEIBO_HUATI_CHECKIN = "http://i.huati.weibo.com/aj/super/checkin"
class WeiboClient:
"""Client for interacting with Weibo API"""
"""Client for interacting with Weibo mobile API."""
def __init__(self):
# Use antibot module for dynamic headers
self.base_headers = antibot.build_headers()
async def verify_cookies(self, account: WeiboAccount) -> bool:
"""Verify if Weibo cookies are still valid"""
try:
# Decrypt cookies using shared crypto module
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
if not cookies_dict:
logger.error(f"Failed to decrypt cookies for account {account.weibo_user_id}")
return False
# Get proxy (with fallback to direct connection)
proxy = await antibot.get_proxy()
# Use dynamic headers with random User-Agent
headers = antibot.build_headers()
# Add random delay before request
delay = antibot.get_random_delay()
await asyncio.sleep(delay)
async with httpx.AsyncClient(
cookies=cookies_dict,
headers=headers,
proxies=proxy,
timeout=10.0
) as client:
response = await client.get("https://weibo.com/mygroups", follow_redirects=True)
if response.status_code == 200 and "我的首页" in response.text:
logger.info(f"Cookies for account {account.weibo_user_id} are valid")
return True
else:
logger.warning(f"Cookies for account {account.weibo_user_id} are invalid")
return False
except Exception as e:
logger.error(f"Error verifying cookies: {e}")
return False
async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]:
"""Get list of super topics for an account"""
try:
# Mock implementation - in real system, this would involve complex API calls
# Simulate API call delay
await asyncio.sleep(random.uniform(1.0, 2.0))
# Return mock data
return [
WeiboSuperTopic(id="topic_001", title="Python编程", url="...", is_signed=False),
WeiboSuperTopic(id="topic_002", title="人工智能", url="...", is_signed=False),
WeiboSuperTopic(id="topic_003", title="机器学习", url="...", is_signed=True)
]
except Exception as e:
logger.error(f"Error fetching super topics: {e}")
return []
async def sign_super_topic(
self,
account: WeiboAccount,
topic: WeiboSuperTopic,
task_id: str
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Execute sign-in for a single super topic
Returns: (success, reward_info, error_message)
"""
try:
# Decrypt cookies using shared crypto module
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
if not cookies_dict:
error_msg = "Failed to decrypt cookies"
logger.error(error_msg)
return False, None, error_msg
# Get proxy (with fallback to direct connection)
proxy = await antibot.get_proxy()
# Use dynamic headers with random User-Agent
headers = antibot.build_headers()
# Add random delay before request (anti-bot protection)
delay = antibot.get_random_delay()
await asyncio.sleep(delay)
# Prepare request payload
payload = {
"ajwvr": "6",
"api": "http://i.huati.weibo.com/aj/super/checkin",
"id": topic.id,
"location": "page_100808_super_index",
"refer_flag": "100808_-_1",
"refer_lflag": "100808_-_1",
"ua": headers["User-Agent"],
"is_new": "1",
"is_from_ad": "0",
"ext": "mi_898_1_0_0"
}
# In a real scenario, we might need to call browser automation service
# to get signed parameters or handle JS challenges
# Simulate API call
await asyncio.sleep(random.uniform(0.5, 1.5))
# Mock response - assume success
response_data = {
"code": "100000",
"msg": "签到成功",
"data": {
"tip": "签到成功",
"alert_title": "签到成功",
"alert_subtitle": "恭喜你成为今天第12345位签到的人",
"reward": {"exp": 2, "credit": 1}
}
}
if response_data.get("code") == "100000":
logger.info(f"Successfully signed topic: {topic.title}")
reward_info = response_data.get("data", {}).get("reward", {})
return True, reward_info, None
elif response_data.get("code") == "382004":
logger.info(f"Topic {topic.title} already signed today")
return True, None, "Already signed"
else:
error_msg = response_data.get("msg", "Unknown error")
logger.error(f"Failed to sign topic {topic.title}: {error_msg}")
return False, None, error_msg
except Exception as e:
error_msg = f"Exception signing topic {topic.title}: {str(e)}"
logger.error(error_msg)
return False, None, error_msg
self.timeout = httpx.Timeout(15.0, connect=10.0)
# ------------------------------------------------------------------
# Cookie helpers
# ------------------------------------------------------------------
def _decrypt_cookies(self, encrypted_cookies: str, iv: str) -> Dict[str, str]:
"""
Decrypt cookies using AES-256-GCM from shared crypto module.
Returns dict of cookie key-value pairs.
"""
try:
# Derive encryption key from shared settings
key = derive_key(shared_settings.COOKIE_ENCRYPTION_KEY)
# Decrypt using shared crypto module
plaintext = decrypt_cookie(encrypted_cookies, iv, key)
# Parse cookie string into dict
# Expected format: "key1=value1; key2=value2; ..."
cookies_dict = {}
for cookie_pair in plaintext.split(";"):
cookie_pair = cookie_pair.strip()
if "=" in cookie_pair:
key, value = cookie_pair.split("=", 1)
cookies_dict[key.strip()] = value.strip()
cookies_dict: Dict[str, str] = {}
for pair in plaintext.split(";"):
pair = pair.strip()
if "=" in pair:
k, v = pair.split("=", 1)
cookies_dict[k.strip()] = v.strip()
return cookies_dict
except Exception as e:
logger.error(f"Failed to decrypt cookies: {e}")
return {}
async def get_proxy(self) -> Optional[Dict[str, str]]:
"""Get a proxy from the proxy pool service"""
def _build_client(
self,
cookies: Dict[str, str],
headers: Optional[Dict[str, str]] = None,
proxy: Optional[Dict[str, str]] = None,
) -> httpx.AsyncClient:
"""Create a configured httpx.AsyncClient for weibo.com PC API."""
hdrs = headers or antibot.build_headers()
hdrs["Referer"] = "https://weibo.com/"
hdrs["Accept"] = "*/*"
hdrs["Accept-Language"] = "zh-CN,zh;q=0.9,en;q=0.8"
return httpx.AsyncClient(
cookies=cookies,
headers=hdrs,
proxies=proxy,
timeout=self.timeout,
follow_redirects=True,
)
# ------------------------------------------------------------------
# 1. Cookie verification
# ------------------------------------------------------------------
async def verify_cookies(self, account: WeiboAccount) -> bool:
"""
Verify if Weibo cookies are still valid using the PC API.
GET https://weibo.com/ajax/side/cards?count=1
Returns ok=1 when logged in.
"""
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
if not cookies_dict:
logger.error(f"Failed to decrypt cookies for account {account.weibo_user_id}")
return False
proxy = await antibot.get_proxy()
headers = antibot.build_headers()
delay = antibot.get_random_delay()
await asyncio.sleep(delay)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{settings.PROXY_POOL_URL}/get")
if response.status_code == 200:
proxy_info = response.json()
return {
"http://": f"http://{proxy_info['proxy']}",
"https://": f"https://{proxy_info['proxy']}"
}
async with self._build_client(cookies_dict, headers, proxy) as client:
resp = await client.get(
f"{WEIBO_PC_API}/ajax/side/cards",
params={"count": "1"},
)
if resp.status_code != 200:
logger.warning(
f"Side cards API returned {resp.status_code} for {account.weibo_user_id}"
)
return False
data = resp.json()
if data.get("ok") == 1:
logger.info(f"Cookies valid for account {account.weibo_user_id}")
return True
else:
return None
logger.warning(f"Cookies invalid for account {account.weibo_user_id}")
return False
except Exception as e:
logger.error(f"Failed to get proxy: {e}")
return None
async def get_browser_fingerprint(self) -> Dict[str, Any]:
"""Get a browser fingerprint from the generator service"""
logger.error(f"Error verifying cookies for {account.weibo_user_id}: {e}")
return False
# ------------------------------------------------------------------
# 2. Fetch super topic list
# ------------------------------------------------------------------
async def get_super_topics(self, account: WeiboAccount) -> List[WeiboSuperTopic]:
"""
Fetch the list of super topics the account has followed.
Uses the PC API:
GET https://weibo.com/ajax/profile/topicContent?tabid=231093_-_chaohua&page={n}
Response contains data.list[] with topic objects including:
- topic_name: super topic name
- oid: "1022:100808xxx" (containerid)
- scheme: "sinaweibo://pageinfo?containerid=100808xxx"
"""
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
if not cookies_dict:
return []
proxy = await antibot.get_proxy()
headers = antibot.build_headers()
all_topics: List[WeiboSuperTopic] = []
page = 1
max_pages = 10
try:
# Mock implementation
return {
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"screen_resolution": "1920x1080",
"timezone": "Asia/Shanghai",
"plugins": ["PDF Viewer", "Chrome PDF Viewer", "Native Client"]
}
async with self._build_client(cookies_dict, headers, proxy) as client:
# Get XSRF-TOKEN first
await client.get(f"{WEIBO_PC_API}/", params={})
xsrf = client.cookies.get("XSRF-TOKEN", "")
if xsrf:
client.headers["X-XSRF-TOKEN"] = xsrf
client.headers["X-Requested-With"] = "XMLHttpRequest"
while page <= max_pages:
delay = antibot.get_random_delay()
await asyncio.sleep(delay)
resp = await client.get(
f"{WEIBO_PC_API}/ajax/profile/topicContent",
params={
"tabid": "231093_-_chaohua",
"page": str(page),
},
)
if resp.status_code != 200:
logger.warning(f"Topic list API returned {resp.status_code}")
break
body = resp.json()
if body.get("ok") != 1:
break
topic_list = body.get("data", {}).get("list", [])
if not topic_list:
break
for item in topic_list:
topic = self._parse_pc_topic(item)
if topic:
all_topics.append(topic)
logger.info(
f"Page {page}: found {len(topic_list)} topics "
f"(total so far: {len(all_topics)})"
)
api_max = body.get("data", {}).get("max_page", 1)
if page >= api_max:
break
page += 1
except Exception as e:
logger.error(f"Failed to get browser fingerprint: {e}")
return {}
logger.error(f"Error fetching super topics: {e}")
logger.info(
f"Fetched {len(all_topics)} super topics for account {account.weibo_user_id}"
)
return all_topics
def _parse_pc_topic(self, item: Dict[str, Any]) -> Optional[WeiboSuperTopic]:
"""Parse a topic item from /ajax/profile/topicContent response."""
title = item.get("topic_name", "") or item.get("title", "")
if not title:
return None
# Extract containerid from oid "1022:100808xxx" or scheme
containerid = ""
oid = item.get("oid", "")
if "100808" in oid:
match = re.search(r"100808[0-9a-fA-F]+", oid)
if match:
containerid = match.group(0)
if not containerid:
scheme = item.get("scheme", "")
match = re.search(r"100808[0-9a-fA-F]+", scheme)
if match:
containerid = match.group(0)
if not containerid:
return None
return WeiboSuperTopic(
id=containerid,
title=title,
url=f"https://weibo.com/p/{containerid}/super_index",
is_signed=False,
sign_url=f"{WEIBO_PC_API}/p/aj/general/button",
)
# ------------------------------------------------------------------
# 3. Execute sign-in for a single super topic
# ------------------------------------------------------------------
async def sign_super_topic(
self,
account: WeiboAccount,
topic: WeiboSuperTopic,
task_id: str,
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Execute sign-in for a single super topic via weibo.com PC API.
GET https://weibo.com/p/aj/general/button
?api=http://i.huati.weibo.com/aj/super/checkin&id={topic_id}
Returns: (success, reward_info, error_message)
"""
cookies_dict = self._decrypt_cookies(account.encrypted_cookies, account.iv)
if not cookies_dict:
return False, None, "Failed to decrypt cookies"
proxy = await antibot.get_proxy()
headers = antibot.build_headers()
# Anti-bot delay
delay = antibot.get_random_delay()
await asyncio.sleep(delay)
try:
headers["Referer"] = f"https://weibo.com/p/{topic.id}/super_index"
headers["X-Requested-With"] = "XMLHttpRequest"
async with httpx.AsyncClient(
cookies=cookies_dict,
headers=headers,
proxies=proxy,
timeout=self.timeout,
follow_redirects=True,
) as client:
# Get XSRF-TOKEN
await client.get(f"{WEIBO_PC_API}/")
xsrf = client.cookies.get("XSRF-TOKEN", "")
if xsrf:
client.headers["X-XSRF-TOKEN"] = xsrf
resp = await client.get(
f"{WEIBO_PC_API}/p/aj/general/button",
params={
"ajwvr": "6",
"api": WEIBO_HUATI_CHECKIN,
"texta": "签到",
"textb": "已签到",
"status": "0",
"id": topic.id,
"location": "page_100808_super_index",
"timezone": "GMT+0800",
"lang": "zh-cn",
"plat": "Win32",
"ua": headers.get("User-Agent", ""),
"screen": "1920*1080",
"__rnd": str(int(time.time() * 1000)),
},
)
if resp.status_code != 200:
return False, None, f"HTTP {resp.status_code}"
body = resp.json()
code = str(body.get("code", ""))
msg = body.get("msg", "")
data = body.get("data", {})
if code == "100000":
tip = ""
if isinstance(data, dict):
tip = data.get("alert_title", "") or data.get("tipMessage", "签到成功")
logger.info(f"Checkin success for {topic.title}: {tip}")
reward_info = {}
if isinstance(data, dict):
reward_info = {
"tip": data.get("tipMessage", ""),
"alert_title": data.get("alert_title", ""),
"alert_subtitle": data.get("alert_subtitle", ""),
}
return True, reward_info, None
elif code == "382004":
logger.info(f"Topic {topic.title} already signed today")
return False, None, "Already signed today"
elif code == "382003":
logger.warning(f"Not a member of topic {topic.title}")
return False, None, "Not a member of this super topic"
else:
logger.warning(f"Checkin unexpected code={code} msg={msg} for {topic.title}")
return False, None, f"Unexpected: code={code}, msg={msg}"
except httpx.TimeoutException:
return False, None, "Request timeout"
except Exception as e:
logger.error(f"Checkin error for {topic.title}: {e}")
return False, None, str(e)

Binary file not shown.

13
debug_cookies.json Normal file
View File

@@ -0,0 +1,13 @@
{
"cookies": {
"SUB": "_2A25Es9x8DeRhGeBP6VYU8SrOyDyIHXVnsVG0rDV8PUNbmtAbLWPukW9NRX34vRCN3Y6G7yWMXYmI6ePZCu0B1OTM",
"SUBP": "0033WrSXqPxfM725Ws9jqgMF55529P9D9WhVQ79mdyajoRQ5FLMr171v5JpX5KzhUgL.FoqpeoBfeKBEe052dJLoIXnLxKBLBonL12BLxK-LB.-L1KMLxKBLBonL12BLxK.L1K-LB.qLxK-LBKBLBKMLxKML1-2L1hBLxK-LBo5L12qLxK-LB-BLBK.t",
"ALF": "1776236842",
"SSOLoginState": "1773644844",
"SRT": "D.QqHBJZPEVqbtKmMb4cYGS4HAibSSPOYOV!yuMGbHNEYdUeidMqBpMERt4EPKRcsrAdHJPsBsTsVuObHRJqM-4!iKJs!-MqWuPrsRddsHicPAd!isJmzoOsMi*B.vAflW-P9Rc0lR-yk6DvnJqiQVbiRVPBtS!r3JZPQVqbgVdWiMZ4siOzu4DbmKPWfPbm3Kds!5dEgUsHMi4SJOQzoiqE-i49ndDPI5cYPSrnlMcyoVFP6AcSbNrHESOvsJcM1OFyHdFuJ5mkCOmH6U!noJ!HJ5mjkOmzlI4noNrsJ5mkCOmH6U!noJ!HJ5mjlODEII4oCIeEJ5mjkOmHIA!oCNrsJ5mkiODmkJ!noTmHJ5mjkOmH6i4noJeEJ5mjkOmzkA!oCNpur",
"SRF": "1773644844"
},
"uid": "6124514230",
"nick": "不得不长大的小孩",
"time": 1773644845.0194361
}

276
debug_full_signin.py Normal file
View File

@@ -0,0 +1,276 @@
"""
完整签到流程验证脚本
1. 用缓存 cookie 验证登录
2. 获取超话列表 (GET /ajax/profile/topicContent?tabid=231093_-_chaohua)
3. 逐个签到 (GET /p/aj/general/button?api=...checkin&id={containerid})
4. 汇总结果
Cookie 自动从 debug_cookies.json 加载,失效才重新扫码
"""
import re, json, time, sys, os, requests
WEIBO_HEADERS = {
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0'
),
'Referer': 'https://weibo.com/',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
COOKIE_FILE = 'debug_cookies.json'
def parse_jsonp(text):
if not text: return None
m = re.search(r'STK_\d+\s*\((.*)\)\s*;?\s*$', text, re.DOTALL)
if m:
try: return json.loads(m.group(1))
except: pass
m = re.search(r'\w+\s*\((.*)\)\s*;?\s*$', text, re.DOTALL)
if m:
try: return json.loads(m.group(1))
except: pass
try: return json.loads(text)
except: return None
def save_cookies(cookies, uid, nick):
with open(COOKIE_FILE, 'w', encoding='utf-8') as f:
json.dump({'cookies': cookies, 'uid': uid, 'nick': nick, 'time': time.time()},
f, ensure_ascii=False, indent=2)
def load_cookies():
if not os.path.exists(COOKIE_FILE):
return None, None, None
try:
with open(COOKIE_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
cookies = data.get('cookies', {})
uid = data.get('uid', '')
nick = data.get('nick', '')
age = time.time() - data.get('time', 0)
print(f"{COOKIE_FILE} 加载 (uid={uid}, nick={nick}, {age/3600:.1f}h ago)")
r = requests.get('https://weibo.com/ajax/side/cards', params={'count': 1},
cookies=cookies, headers=WEIBO_HEADERS, timeout=10)
if r.json().get('ok') == 1:
print(f" ✅ Cookie 有效")
return cookies, uid, nick
print(f" ❌ Cookie 已失效")
except Exception as e:
print(f" 加载失败: {e}")
return None, None, None
def qrcode_login():
print(" 扫码登录...")
sess = requests.Session()
sess.headers.update(WEIBO_HEADERS)
resp = sess.get('https://login.sina.com.cn/sso/qrcode/image',
params={'entry': 'weibo', 'size': '180',
'callback': f'STK_{int(time.time()*1000)}'}, timeout=10)
data = parse_jsonp(resp.text)
if not data or not isinstance(data.get('data'), dict):
print(f" ❌ 生成二维码失败"); sys.exit(1)
qrid = data['data']['qrid']
image = data['data']['image']
if image.startswith('//'): image = 'https:' + image
with open('qrcode.png', 'wb') as f:
f.write(requests.get(image, timeout=10).content)
print(f" 请扫描 qrcode.png ...")
alt_token = None
last = None
for i in range(120):
time.sleep(2)
resp = sess.get('https://login.sina.com.cn/sso/qrcode/check',
params={'entry': 'weibo', 'qrid': qrid,
'callback': f'STK_{int(time.time()*1000)}'}, timeout=10)
d = parse_jsonp(resp.text)
rc = d.get('retcode') if d else None
if rc != last: print(f" [{i+1}] {last} -> {rc}"); last = rc
if rc == 20000000 and isinstance(d.get('data'), dict) and d['data'].get('alt'):
alt_token = d['data']['alt']; break
if rc in (50114004, 50050002): sys.exit(1)
if not alt_token: sys.exit(1)
sso = requests.Session()
sso.headers.update(WEIBO_HEADERS)
resp = sso.get(
f"https://login.sina.com.cn/sso/login.php?entry=weibo&returntype=TEXT"
f"&crossdomain=1&cdult=3&domain=weibo.com&alt={alt_token}&savestate=30"
f"&callback=STK_{int(time.time()*1000)}",
allow_redirects=True, timeout=15)
sso_data = parse_jsonp(resp.text)
uid = str(sso_data.get('uid', ''))
nick = sso_data.get('nick', '')
for u in sso_data.get('crossDomainUrlList', []):
if isinstance(u, str) and u.startswith('http'):
try: sso.get(u, allow_redirects=True, timeout=10)
except: pass
cookies = {}
for c in sso.cookies:
if c.domain and 'weibo.com' in c.domain:
cookies[c.name] = c.value
print(f" ✅ uid={uid}, nick={nick}")
save_cookies(cookies, uid, nick)
return cookies, uid, nick
def get_super_topics(sess, xsrf, uid):
"""获取关注的超话列表"""
topics = []
page = 1
while page <= 10:
r = sess.get(
'https://weibo.com/ajax/profile/topicContent',
params={'tabid': '231093_-_chaohua', 'page': str(page)},
headers={
'Referer': f'https://weibo.com/u/page/follow/{uid}/231093_-_chaohua',
'X-XSRF-TOKEN': xsrf,
'X-Requested-With': 'XMLHttpRequest',
},
timeout=10,
)
d = r.json()
if d.get('ok') != 1:
break
topic_list = d.get('data', {}).get('list', [])
if not topic_list:
break
for item in topic_list:
title = item.get('topic_name', '') or item.get('title', '')
# 从 oid "1022:100808xxx" 提取 containerid
containerid = ''
oid = item.get('oid', '')
m = re.search(r'100808[0-9a-fA-F]+', oid)
if m:
containerid = m.group(0)
if not containerid:
scheme = item.get('scheme', '')
m = re.search(r'100808[0-9a-fA-F]+', scheme)
if m:
containerid = m.group(0)
if title and containerid:
topics.append({'title': title, 'containerid': containerid})
max_page = d.get('data', {}).get('max_page', 1)
if page >= max_page:
break
page += 1
return topics
def do_signin(sess, xsrf, containerid, topic_title):
"""
签到单个超话
完整参数来自浏览器抓包:
GET /p/aj/general/button?ajwvr=6&api=http://i.huati.weibo.com/aj/super/checkin
&texta=签到&textb=已签到&status=0&id={containerid}
&location=page_100808_super_index&...
"""
r = sess.get(
'https://weibo.com/p/aj/general/button',
params={
'ajwvr': '6',
'api': 'http://i.huati.weibo.com/aj/super/checkin',
'texta': '签到',
'textb': '已签到',
'status': '0',
'id': containerid,
'location': 'page_100808_super_index',
'timezone': 'GMT+0800',
'lang': 'zh-cn',
'plat': 'Win32',
'ua': WEIBO_HEADERS['User-Agent'],
'screen': '1920*1080',
'__rnd': str(int(time.time() * 1000)),
},
headers={
'Referer': f'https://weibo.com/p/{containerid}/super_index',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': xsrf,
},
timeout=10,
)
try:
d = r.json()
code = str(d.get('code', ''))
msg = d.get('msg', '')
data = d.get('data', {})
if code == '100000':
tip = ''
if isinstance(data, dict):
tip = data.get('alert_title', '') or data.get('tipMessage', '')
return {'status': 'success', 'message': tip or '签到成功', 'data': data}
elif code == '382004':
return {'status': 'already_signed', 'message': msg or '今日已签到'}
elif code == '382003':
return {'status': 'failed', 'message': msg or '非超话成员'}
else:
return {'status': 'failed', 'message': f'code={code}, msg={msg}'}
except Exception as e:
return {'status': 'failed', 'message': f'非 JSON: {r.text[:200]}'}
# ================================================================
# Main
# ================================================================
print("=" * 60)
print("微博超话自动签到 - 完整流程验证")
print("=" * 60)
# Step 1: 获取 cookie
print("\n--- Step 1: 初始化 ---")
cookies, uid, nick = load_cookies()
if not cookies:
cookies, uid, nick = qrcode_login()
# 建立 session
sess = requests.Session()
sess.headers.update(WEIBO_HEADERS)
for k, v in cookies.items():
sess.cookies.set(k, v, domain='.weibo.com')
sess.get('https://weibo.com/', timeout=10)
xsrf = sess.cookies.get('XSRF-TOKEN', '')
print(f" XSRF: {'' if xsrf else '❌ MISSING'}")
# Step 2: 获取超话列表
print(f"\n--- Step 2: 获取超话列表 ---")
topics = get_super_topics(sess, xsrf, uid)
print(f" 找到 {len(topics)} 个超话:")
for i, t in enumerate(topics):
print(f" [{i+1}] {t['title']} ({t['containerid'][:20]}...)")
if not topics:
print(" ❌ 没有找到超话,退出")
sys.exit(1)
# Step 3: 逐个签到
print(f"\n--- Step 3: 签到 ({len(topics)} 个超话) ---")
signed = already = failed = 0
results = []
for i, t in enumerate(topics):
print(f"\n [{i+1}/{len(topics)}] {t['title']}", end=' ... ')
r = do_signin(sess, xsrf, t['containerid'], t['title'])
results.append({**r, 'topic': t['title']})
if r['status'] == 'success':
signed += 1
print(f"{r['message']}")
elif r['status'] == 'already_signed':
already += 1
print(f" {r['message']}")
else:
failed += 1
print(f"{r['message']}")
if i < len(topics) - 1:
time.sleep(1.5) # 防封间隔
# Step 4: 汇总
print(f"\n{'=' * 60}")
print(f"签到完成!")
print(f" ✅ 成功: {signed}")
print(f" 已签: {already}")
print(f" ❌ 失败: {failed}")
print(f" 📊 总计: {len(topics)} 个超话")
print(f"{'=' * 60}")

509
debug_qrcode_flow.py Normal file
View File

@@ -0,0 +1,509 @@
"""
微博扫码登录 + 完整业务流程调试脚本。
模式:
python debug_qrcode_flow.py # 直连模式:扫码 + 添加 + 验证 + 签到
python debug_qrcode_flow.py --frontend # 前端模式:通过 Flask 路由
python debug_qrcode_flow.py --api-only # 仅测试后端 API跳过扫码用已有账号
"""
import re
import json
import time
import base64
import requests
import sys
# ---- 配置 ----
AUTH_BASE_URL = 'http://localhost:8001'
API_BASE_URL = 'http://localhost:8000'
FRONTEND_URL = 'http://localhost:5000'
TEST_EMAIL = 'admin@example.com'
TEST_PASSWORD = 'Admin123!'
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',
}
WEIBO_SESSION = requests.Session()
WEIBO_SESSION.headers.update(WEIBO_HEADERS)
def parse_jsonp(text):
m = re.search(r'\((.*)\)', text, re.DOTALL)
if m:
return json.loads(m.group(1))
try:
return json.loads(text)
except Exception:
return None
def api_headers(token):
return {'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}
# ================================================================
# STEP 0: 登录系统
# ================================================================
def step0_login():
print("=" * 60)
print("STEP 0: 登录系统")
print("=" * 60)
resp = requests.post(
f'{AUTH_BASE_URL}/auth/login',
json={'email': TEST_EMAIL, 'password': TEST_PASSWORD},
timeout=10,
)
print(f" Status: {resp.status_code}")
if resp.status_code != 200:
print(f" [ERROR] 登录失败: {resp.text[:300]}")
return None
data = resp.json()
token = data['access_token']
user = data.get('user', {})
print(f" 用户: {user.get('username')} ({user.get('email')})")
print(f" token: {token[:50]}...")
# 验证 token 对 API 服务有效
verify = requests.get(
f'{API_BASE_URL}/api/v1/accounts',
headers=api_headers(token),
timeout=10,
)
print(f" 验证 API 服务: GET /accounts -> {verify.status_code}")
if verify.status_code != 200:
print(f" [ERROR] API 服务 token 无效: {verify.text[:300]}")
return None
print(f" ✅ token 有效")
return token
# ================================================================
# STEP 1: 清理重复账号
# ================================================================
def step1_cleanup(token):
print("\n" + "=" * 60)
print("STEP 1: 清理重复账号")
print("=" * 60)
resp = requests.get(f'{API_BASE_URL}/api/v1/accounts', headers=api_headers(token), timeout=10)
accounts = resp.json().get('data', [])
print(f" 当前账号数: {len(accounts)}")
if len(accounts) <= 1:
print(" 无需清理")
return accounts[0]['id'] if accounts else None
# 按 weibo_user_id 分组,每组只保留最新的
groups = {}
for acc in accounts:
wid = acc['weibo_user_id']
if wid not in groups:
groups[wid] = []
groups[wid].append(acc)
keep_id = None
for wid, accs in groups.items():
accs.sort(key=lambda a: a['created_at'], reverse=True)
keep = accs[0]
keep_id = keep['id']
print(f" 保留: {keep['id'][:8]}... ({keep['remark']}) created={keep['created_at']}")
for dup in accs[1:]:
print(f" 删除: {dup['id'][:8]}... ({dup['remark']}) created={dup['created_at']}")
del_resp = requests.delete(
f'{API_BASE_URL}/api/v1/accounts/{dup["id"]}',
headers=api_headers(token),
timeout=10,
)
print(f" -> {del_resp.status_code}")
# 验证
resp2 = requests.get(f'{API_BASE_URL}/api/v1/accounts', headers=api_headers(token), timeout=10)
remaining = resp2.json().get('data', [])
print(f" 清理后账号数: {len(remaining)}")
return keep_id
# ================================================================
# STEP 2: 验证 CookiePOST /accounts/{id}/verify
# ================================================================
def step2_verify(token, account_id):
print("\n" + "=" * 60)
print("STEP 2: 验证 Cookie")
print("=" * 60)
resp = requests.post(
f'{API_BASE_URL}/api/v1/accounts/{account_id}/verify',
headers=api_headers(token),
timeout=20,
)
print(f" Status: {resp.status_code}")
print(f" Body: {resp.text[:500]}")
if resp.status_code == 200:
data = resp.json().get('data', {})
valid = data.get('cookie_valid')
status = data.get('status')
name = data.get('weibo_screen_name', '')
print(f" cookie_valid: {valid}")
print(f" status: {status}")
print(f" screen_name: {name}")
if valid:
print(" ✅ Cookie 有效,账号已激活")
else:
print(" ❌ Cookie 无效")
return valid
else:
print(f" ❌ 验证失败")
return False
# ================================================================
# STEP 3: 手动签到POST /accounts/{id}/signin
# ================================================================
def step3_signin(token, account_id):
print("\n" + "=" * 60)
print("STEP 3: 手动签到")
print("=" * 60)
print(" 调用 POST /accounts/{id}/signin ...")
print(" (这可能需要一些时间,每个超话间隔 1.5 秒)")
resp = requests.post(
f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin',
headers=api_headers(token),
timeout=300, # 签到可能很慢
)
print(f" Status: {resp.status_code}")
if resp.status_code == 200:
result = resp.json()
data = result.get('data', {})
msg = result.get('message', '')
print(f" 消息: {msg}")
print(f" 签到成功: {data.get('signed', 0)}")
print(f" 已签过: {data.get('already_signed', 0)}")
print(f" 失败: {data.get('failed', 0)}")
print(f" 总超话数: {data.get('total_topics', 0)}")
details = data.get('details', [])
for d in details[:10]: # 最多显示 10 条
icon = '' if d['status'] == 'success' else '⏭️' if d['status'] == 'already_signed' else ''
print(f" {icon} {d.get('topic', '?')}: {d.get('message', '')}")
if len(details) > 10:
print(f" ... 还有 {len(details) - 10}")
return True
else:
print(f" ❌ 签到失败: {resp.text[:500]}")
return False
# ================================================================
# STEP 4: 查看签到日志
# ================================================================
def step4_logs(token, account_id):
print("\n" + "=" * 60)
print("STEP 4: 查看签到日志")
print("=" * 60)
resp = requests.get(
f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs',
params={'page': 1, 'size': 10},
headers=api_headers(token),
timeout=10,
)
print(f" Status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json().get('data', {})
items = data.get('items', [])
total = data.get('total', 0)
print(f" 总日志数: {total}")
for log in items[:5]:
print(f" [{log.get('status')}] {log.get('topic_title', '?')} @ {log.get('signed_at', '?')[:19]}")
if not items:
print(" (暂无日志)")
else:
print(f" ❌ 获取日志失败: {resp.text[:300]}")
# ================================================================
# STEP 5: 查看账号详情(验证前端会用到的接口)
# ================================================================
def step5_detail(token, account_id):
print("\n" + "=" * 60)
print("STEP 5: 验证账号详情接口")
print("=" * 60)
# 账号详情
r1 = requests.get(f'{API_BASE_URL}/api/v1/accounts/{account_id}', headers=api_headers(token), timeout=10)
print(f" GET /accounts/{{id}} -> {r1.status_code}")
if r1.status_code == 200:
acc = r1.json().get('data', {})
print(f" status={acc.get('status')}, remark={acc.get('remark')}")
else:
print(f"{r1.text[:200]}")
# 任务列表
r2 = requests.get(f'{API_BASE_URL}/api/v1/accounts/{account_id}/tasks', headers=api_headers(token), timeout=10)
print(f" GET /accounts/{{id}}/tasks -> {r2.status_code}")
if r2.status_code == 200:
tasks = r2.json().get('data', [])
print(f" 任务数: {len(tasks)}")
else:
print(f"{r2.text[:200]}")
# 签到日志
r3 = requests.get(
f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin-logs',
params={'page': 1, 'size': 5},
headers=api_headers(token),
timeout=10,
)
print(f" GET /accounts/{{id}}/signin-logs -> {r3.status_code}")
if r3.status_code == 200:
logs = r3.json().get('data', {})
print(f" 日志数: {logs.get('total', 0)}, items: {len(logs.get('items', []))}")
else:
print(f"{r3.text[:200]}")
if all(r.status_code == 200 for r in [r1, r2, r3]):
print(" ✅ 所有详情接口正常")
else:
print(" ❌ 部分接口异常")
# ================================================================
# 扫码流程(直连模式用)
# ================================================================
def qrcode_flow(token):
"""生成二维码 → 轮询 → SSO 登录 → 添加账号,返回 account_id"""
# 生成二维码
print("\n" + "=" * 60)
print("QRCODE: 生成二维码")
print("=" * 60)
url = 'https://login.sina.com.cn/sso/qrcode/image'
params = {'entry': 'weibo', 'size': '180', 'callback': f'STK_{int(time.time() * 1000)}'}
resp = WEIBO_SESSION.get(url, params=params, timeout=10)
data = parse_jsonp(resp.text)
if not data or data.get('retcode') != 20000000:
print(" [ERROR] 生成二维码失败")
return None
qr_data = data.get('data', {})
qrid = qr_data.get('qrid')
image = qr_data.get('image', '')
if image.startswith('//'):
image = 'https:' + image
if image.startswith('http'):
img_resp = WEIBO_SESSION.get(image, timeout=10)
with open('qrcode.png', 'wb') as f:
f.write(img_resp.content)
print(f" qrid: {qrid}")
print(f" 二维码已保存到 qrcode.png")
# 轮询
print("\n 请用手机微博扫描 qrcode.png ...")
check_url = 'https://login.sina.com.cn/sso/qrcode/check'
last_retcode = None
alt_token = None
for i in range(120):
time.sleep(2)
params = {'entry': 'weibo', 'qrid': qrid, 'callback': f'STK_{int(time.time() * 1000)}'}
resp = WEIBO_SESSION.get(check_url, params=params, timeout=10)
data = parse_jsonp(resp.text)
retcode = data.get('retcode') if data else None
if retcode != last_retcode:
msg = data.get('msg', '') if data else ''
print(f" [{i+1}] retcode: {last_retcode} -> {retcode} ({msg})")
last_retcode = retcode
else:
print(f" [{i+1}] retcode={retcode}", end='\r')
if not data:
continue
nested = data.get('data')
alt = nested.get('alt', '') if isinstance(nested, dict) else ''
if retcode == 20000000 and alt:
print(f"\n ✅ 登录成功! alt={alt[:40]}...")
alt_token = alt
break
if retcode in (50114004, 50050002):
print(f"\n ❌ 二维码失效")
return None
if not alt_token:
print("\n ❌ 轮询超时")
return None
# SSO 登录
print("\n SSO 登录...")
sso_url = (
f"https://login.sina.com.cn/sso/login.php"
f"?entry=weibo&returntype=TEXT&crossdomain=1&cdult=3"
f"&domain=weibo.com&alt={alt_token}&savestate=30"
f"&callback=STK_{int(time.time() * 1000)}"
)
sso_session = requests.Session()
sso_session.headers.update(WEIBO_HEADERS)
resp = sso_session.get(sso_url, allow_redirects=True, timeout=15)
sso_data = parse_jsonp(resp.text)
uid = str(sso_data.get('uid', '')) if sso_data else ''
nick = sso_data.get('nick', '') if sso_data else ''
cross_urls = sso_data.get('crossDomainUrlList', []) if sso_data else []
print(f" uid={uid}, nick={nick}, crossDomainUrls={len(cross_urls)}")
for u in cross_urls:
if isinstance(u, str) and u.startswith('http'):
try:
sso_session.get(u, allow_redirects=True, timeout=10)
except Exception:
pass
all_cookies = {}
for c in sso_session.cookies:
if c.domain and 'weibo.com' in c.domain:
all_cookies[c.name] = c.value
cookie_str = '; '.join(f'{k}={v}' for k, v in all_cookies.items())
has_sub = 'SUB' in all_cookies
print(f" weibo.com Cookie 字段 ({len(all_cookies)}): {list(all_cookies.keys())}")
print(f" 包含 SUB: {'' if has_sub else ''}")
if not has_sub:
print(" [ERROR] 缺少 SUB cookie")
return None
# 添加账号
print("\n 添加账号到后端...")
remark = f"{nick} (调试脚本)" if nick else "调试脚本添加"
resp = requests.post(
f'{API_BASE_URL}/api/v1/accounts',
json={'weibo_user_id': uid, 'cookie': cookie_str, 'remark': remark},
headers=api_headers(token),
timeout=10,
)
print(f" Status: {resp.status_code}")
if resp.status_code in (200, 201):
acc = resp.json().get('data', {})
account_id = acc.get('id')
print(f" ✅ 账号添加成功: {account_id}")
return account_id
else:
print(f" ❌ 添加失败: {resp.text[:300]}")
return None
# ================================================================
# 前端模式
# ================================================================
def frontend_flow():
fe = requests.Session()
print("=" * 60)
print("FRONTEND: 登录")
print("=" * 60)
resp = fe.post(f'{FRONTEND_URL}/login', data={'email': TEST_EMAIL, 'password': TEST_PASSWORD}, allow_redirects=False, timeout=10)
print(f" POST /login -> {resp.status_code}")
if resp.status_code in (301, 302):
fe.get(f'{FRONTEND_URL}{resp.headers["Location"]}', timeout=10)
dash = fe.get(f'{FRONTEND_URL}/dashboard', timeout=10)
if dash.status_code != 200:
print(" [ERROR] 登录失败")
return
print(" ✅ 登录成功")
print("\n" + "=" * 60)
print("FRONTEND: 生成二维码")
print("=" * 60)
resp = fe.post(f'{FRONTEND_URL}/api/weibo/qrcode/generate', headers={'Content-Type': 'application/json'}, timeout=15)
print(f" Status: {resp.status_code}, Body: {resp.text[:300]}")
gen = resp.json()
if not gen.get('success'):
print(" [ERROR] 生成失败")
return
qrid = gen['qrid']
qr_image = gen.get('qr_image', '')
if qr_image.startswith('http'):
img = requests.get(qr_image, headers=WEIBO_HEADERS, timeout=10)
with open('qrcode.png', 'wb') as f:
f.write(img.content)
print(" 二维码已保存到 qrcode.png")
print("\n 请用手机微博扫描 qrcode.png ...")
last_status = None
for i in range(120):
time.sleep(2)
resp = fe.get(f'{FRONTEND_URL}/api/weibo/qrcode/check/{qrid}', timeout=15)
data = resp.json()
st = data.get('status')
if st != last_status:
print(f" [{i+1}] status: {last_status} -> {st} | {json.dumps(data, ensure_ascii=False)[:200]}")
last_status = st
else:
print(f" [{i+1}] status={st}", end='\r')
if st == 'success':
print(f"\n ✅ 扫码成功! uid={data.get('weibo_uid')}")
break
if st in ('expired', 'cancelled', 'error'):
print(f"\n{st}: {data.get('error', '')}")
return
else:
print("\n ❌ 超时")
return
# 添加账号
print("\n 添加账号...")
time.sleep(0.5)
resp = fe.post(
f'{FRONTEND_URL}/api/weibo/qrcode/add-account',
json={'qrid': qrid},
headers={'Content-Type': 'application/json'},
timeout=15,
)
print(f" Status: {resp.status_code}, Body: {resp.text[:500]}")
result = resp.json()
if result.get('success'):
print(f" ✅ 账号添加成功!")
else:
print(f" ❌ 添加失败: {result.get('message')}")
# ================================================================
# MAIN
# ================================================================
if __name__ == '__main__':
mode = 'direct'
if '--frontend' in sys.argv:
mode = 'frontend'
elif '--api-only' in sys.argv:
mode = 'api-only'
if mode == 'frontend':
print("🌐 前端模式")
frontend_flow()
else:
# 直连模式 或 api-only 模式
token = step0_login()
if not token:
sys.exit(1)
if mode == 'api-only':
print("\n📡 API-only 模式:跳过扫码,使用已有账号")
account_id = step1_cleanup(token)
if not account_id:
print("\n没有账号,请先用默认模式添加")
sys.exit(1)
else:
print("\n🔗 直连模式:扫码 + 完整业务流程")
# 先清理
step1_cleanup(token)
# 扫码添加
account_id = qrcode_flow(token)
if not account_id:
sys.exit(1)
# 后续业务验证
step2_verify(token, account_id)
step3_signin(token, account_id)
step4_logs(token, account_id)
step5_detail(token, account_id)
print("\n" + "=" * 60)
print("全部流程完成!")
print("=" * 60)

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}Page Not Found - Weibo-HotSign{% endblock %}
{% block title %}页面未找到 - 微博超话签到{% endblock %}
{% block content %}
<div style="text-align: center; padding: 60px 20px;">
<h1 style="font-size: 48px; color: #6366f1; margin-bottom: 20px;">404</h1>
<p style="font-size: 24px; color: #333; margin-bottom: 20px;">Page Not Found</p>
<p style="color: #999; margin-bottom: 30px;">The page you're looking for doesn't exist.</p>
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
<div style="text-align:center; padding:80px 20px;">
<div style="font-size:64px; margin-bottom:16px;">🔍</div>
<h1 style="font-size:48px; font-weight:700; background:linear-gradient(135deg,#6366f1,#a855f7); -webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:12px;">404</h1>
<p style="font-size:18px; color:#64748b; margin-bottom:32px;">页面未找到</p>
<a href="{{ url_for('dashboard') }}" class="btn btn-primary" style="padding:14px 32px; font-size:16px;">返回控制台</a>
</div>
{% endblock %}

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}Server Error - Weibo-HotSign{% endblock %}
{% block title %}服务器错误 - 微博超话签到{% endblock %}
{% block content %}
<div style="text-align: center; padding: 60px 20px;">
<h1 style="font-size: 48px; color: #dc3545; margin-bottom: 20px;">500</h1>
<p style="font-size: 24px; color: #333; margin-bottom: 20px;">Server Error</p>
<p style="color: #999; margin-bottom: 30px;">Something went wrong on our end.</p>
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
<div style="text-align:center; padding:80px 20px;">
<div style="font-size:64px; margin-bottom:16px;">⚠️</div>
<h1 style="font-size:48px; font-weight:700; color:#ef4444; margin-bottom:12px;">500</h1>
<p style="font-size:18px; color:#64748b; margin-bottom:32px;">服务器出了点问题,请稍后再试</p>
<a href="{{ url_for('dashboard') }}" class="btn btn-primary" style="padding:14px 32px; font-size:16px;">返回控制台</a>
</div>
{% endblock %}

View File

@@ -1,170 +1,163 @@
{% extends "base.html" %}
{% block title %}Account Detail - Weibo-HotSign{% endblock %}
{% block title %}账号详情 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.detail-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;
}
.detail-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; }
.info-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 24px;
}
@media (max-width: 768px) { .info-grid { grid-template-columns: 1fr; } }
.info-table td { padding: 12px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
.info-table td:first-child { font-weight: 600; color: #64748b; width: 30%; }
.action-btn {
width: 100%; padding: 12px; border-radius: 14px; border: none;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;
text-align: center; text-decoration: none; display: block;
}
.action-btn-primary {
background: linear-gradient(135deg, #6366f1, #818cf8); color: white;
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
}
.action-btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
.action-btn-secondary { background: #f1f5f9; color: #475569; }
.action-btn-secondary:hover { background: #e2e8f0; }
.task-row {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 0; border-bottom: 1px solid #f1f5f9;
}
.task-row:last-child { border-bottom: none; }
.task-cron {
font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1;
padding: 4px 12px; border-radius: 10px; font-size: 13px; font-weight: 600;
}
.task-actions { display: flex; gap: 8px; }
.task-actions .btn { padding: 6px 14px; font-size: 12px; border-radius: 10px; }
.log-row {
display: grid; grid-template-columns: 1fr auto auto auto; gap: 16px;
align-items: center; padding: 12px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px;
}
.log-row:last-child { border-bottom: none; }
</style>
{% endblock %}
{% block content %}
<div style="max-width: 1000px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h1>{{ account.weibo_user_id }}</h1>
<div style="display: flex; gap: 10px;">
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary">Edit</a>
<form method="POST" action="{{ url_for('delete_account', account_id=account.id) }}" style="display: inline;" onsubmit="return confirm('Are you sure?');">
<button type="submit" class="btn btn-danger">Delete</button>
<div style="max-width: 960px; margin: 0 auto;">
<div class="detail-header">
<div>
<h1>{{ account.remark or account.weibo_user_id }}</h1>
<div style="color:#94a3b8; font-size:14px; margin-top:4px;">UID: {{ account.weibo_user_id }}</div>
</div>
<div style="display:flex; gap:10px;">
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary">✏️ 编辑</a>
<form method="POST" action="{{ url_for('delete_account', account_id=account.id) }}" style="display:inline;" onsubmit="return confirm('确定要删除此账号吗?');">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px;">
<div class="info-grid">
<div class="card">
<div class="card-header">Account Info</div>
<table>
<div class="card-header">📋 账号信息</div>
<table class="info-table" style="width:100%;">
<tr>
<td style="font-weight: 500; width: 30%;">Status</td>
<td>状态</td>
<td>
{% if account.status == 'active' %}
<span class="badge badge-success">Active</span>
{% elif account.status == 'pending' %}
<span class="badge badge-warning">Pending</span>
{% elif account.status == 'invalid_cookie' %}
<span class="badge badge-danger">Invalid Cookie</span>
{% elif account.status == 'banned' %}
<span class="badge badge-danger">Banned</span>
{% if account.status == 'active' %}<span class="badge badge-success">正常</span>
{% elif account.status == 'pending' %}<span class="badge badge-warning">待验证</span>
{% elif account.status == 'invalid_cookie' %}<span class="badge badge-danger">Cookie 失效</span>
{% elif account.status == 'banned' %}<span class="badge badge-danger">已封禁</span>
{% endif %}
</td>
</tr>
<tr>
<td style="font-weight: 500;">Remark</td>
<td>{{ account.remark or '-' }}</td>
</tr>
<tr>
<td style="font-weight: 500;">Created</td>
<td>{{ account.created_at[:10] }}</td>
</tr>
<tr>
<td style="font-weight: 500;">Last Checked</td>
<td>{{ account.last_checked_at[:10] if account.last_checked_at else '-' }}</td>
</tr>
<tr><td>备注</td><td>{{ account.remark or '-' }}</td></tr>
<tr><td>添加时间</td><td>{{ account.created_at[:10] }}</td></tr>
<tr><td>上次检查</td><td>{{ account.last_checked_at[:10] if account.last_checked_at else '-' }}</td></tr>
</table>
</div>
<div class="card">
<div class="card-header">Quick Actions</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<a href="{{ url_for('add_task', account_id=account.id) }}" class="btn btn-primary" style="text-align: center;">+ Add Task</a>
<a href="{{ url_for('edit_account', account_id=account.id) }}" class="btn btn-secondary" style="text-align: center;">Update Cookie</a>
<div class="card-header">⚡ 快捷操作</div>
<div style="display:flex; flex-direction:column; gap:10px;">
<form method="POST" action="{{ url_for('verify_account', account_id=account.id) }}">
<button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button>
</form>
<form method="POST" action="{{ url_for('manual_signin', account_id=account.id) }}" onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='⏳ 签到中...';">
<button type="submit" class="action-btn action-btn-primary">🚀 立即签到</button>
</form>
<a href="{{ url_for('add_task', account_id=account.id) }}" class="action-btn action-btn-secondary">⏰ 添加定时任务</a>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Tasks</div>
<div class="card-header">⏰ 定时任务</div>
{% if tasks %}
<table>
<thead>
<tr>
<th>Cron Expression</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.cron_expression }}</td>
<td>
{% if task.is_enabled %}
<span class="badge badge-success">Enabled</span>
{% else %}
<span class="badge badge-warning">Disabled</span>
{% endif %}
</td>
<td>{{ task.created_at[:10] }}</td>
<td>
<form method="POST" action="{{ url_for('toggle_task', task_id=task.id) }}" style="display: inline;">
<input type="hidden" name="account_id" value="{{ account.id }}">
<input type="hidden" name="is_enabled" value="{{ task.is_enabled|lower }}">
<button type="submit" class="btn btn-secondary" style="padding: 6px 12px; font-size: 12px;">
{% if task.is_enabled %}Disable{% else %}Enable{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('delete_task', task_id=task.id) }}" style="display: inline;" onsubmit="return confirm('Are you sure?');">
<input type="hidden" name="account_id" value="{{ account.id }}">
<button type="submit" class="btn btn-danger" style="padding: 6px 12px; font-size: 12px;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for task in tasks %}
<div class="task-row">
<div style="display:flex; align-items:center; gap:12px;">
<span class="task-cron">{{ task.cron_expression }}</span>
{% if task.is_enabled %}<span class="badge badge-success">已启用</span>
{% else %}<span class="badge badge-warning">已禁用</span>{% endif %}
</div>
<div class="task-actions">
<form method="POST" action="{{ url_for('toggle_task', task_id=task.id) }}" style="display:inline;">
<input type="hidden" name="account_id" value="{{ account.id }}">
<input type="hidden" name="is_enabled" value="{{ task.is_enabled|lower }}">
<button type="submit" class="btn btn-secondary">{% if task.is_enabled %}禁用{% else %}启用{% endif %}</button>
</form>
<form method="POST" action="{{ url_for('delete_task', task_id=task.id) }}" style="display:inline;" onsubmit="return confirm('确定要删除此任务吗?');">
<input type="hidden" name="account_id" value="{{ account.id }}">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p style="color: #999; text-align: center; padding: 20px;">No tasks yet</p>
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无定时任务</p>
{% endif %}
</div>
<div class="card">
<div class="card-header">Signin Logs</div>
{% if logs.items %}
<table>
<thead>
<tr>
<th>Topic</th>
<th>Status</th>
<th>Reward</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for log in logs.items %}
<tr>
<td>{{ log.topic_title or '-' }}</td>
<td>
{% if log.status == 'success' %}
<span class="badge badge-success">Success</span>
{% elif log.status == 'failed_already_signed' %}
<span class="badge badge-info">Already Signed</span>
{% elif log.status == 'failed_network' %}
<span class="badge badge-warning">Network Error</span>
{% elif log.status == 'failed_banned' %}
<span class="badge badge-danger">Banned</span>
{% endif %}
</td>
<td>
{% if log.reward_info %}
{{ log.reward_info.get('points', '-') }} pts
{% else %}
-
{% endif %}
</td>
<td>{{ log.signed_at[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if logs.total > logs.size %}
<div class="pagination">
{% if logs.page > 1 %}
<a href="?page=1">First</a>
<a href="?page={{ logs.page - 1 }}">Previous</a>
{% endif %}
{% for p in range(max(1, logs.page - 2), min(logs.total // logs.size + 2, logs.page + 3)) %}
{% if p == logs.page %}
<span class="active">{{ p }}</span>
{% else %}
<a href="?page={{ p }}">{{ p }}</a>
<div class="card-header">📝 签到记录</div>
{% if logs['items'] %}
{% for log in logs['items'] %}
<div class="log-row">
<div style="font-weight:500; color:#1e293b;">{{ log.topic_title or '-' }}</div>
<div>
{% if log.status == 'success' %}<span class="badge badge-success">签到成功</span>
{% elif log.status == 'failed_already_signed' %}<span class="badge badge-info">今日已签</span>
{% elif log.status == 'failed_network' %}<span class="badge badge-warning">网络错误</span>
{% elif log.status == 'failed_banned' %}<span class="badge badge-danger">已封禁</span>
{% endif %}
</div>
<div style="font-size:13px; color:#64748b;">
{% if log.reward_info %}{{ log.reward_info.get('points', '-') }} 经验{% else %}-{% endif %}
</div>
<div style="color:#94a3b8; font-size:13px;">{{ log.signed_at[:10] }}</div>
</div>
{% endfor %}
{% if logs['total'] > logs['size'] %}
<div class="pagination">
{% if logs['page'] > 1 %}
<a href="?page=1">首页</a>
<a href="?page={{ logs['page'] - 1 }}">上一页</a>
{% endif %}
{% for p in range(max(1, logs['page'] - 2), min(logs['total'] // logs['size'] + 2, logs['page'] + 3)) %}
{% if p == logs['page'] %}<span class="active">{{ p }}</span>
{% else %}<a href="?page={{ p }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if logs.page < logs.total // logs.size + 1 %}
<a href="?page={{ logs.page + 1 }}">Next</a>
<a href="?page={{ logs.total // logs.size + 1 }}">Last</a>
{% if logs['page'] < logs['total'] // logs['size'] + 1 %}
<a href="?page={{ logs['page'] + 1 }}">下一页</a>
<a href="?page={{ logs['total'] // logs['size'] + 1 }}">末页</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p style="color: #999; text-align: center; padding: 20px;">No signin logs yet</p>
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无签到记录</p>
{% endif %}
</div>
</div>

View File

@@ -1,444 +1,149 @@
{% extends "base.html" %}
{% block title %}Add Account - Weibo-HotSign{% endblock %}
{% block title %}添加账号 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.tab-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
font-size: 16px;
color: #666;
transition: all 0.3s;
}
.tab.active {
color: #6366f1;
border-bottom: 2px solid #6366f1;
margin-bottom: -2px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.help-text {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-top: 20px;
font-size: 14px;
color: #666;
}
.help-text h4 {
margin-top: 0;
color: #333;
font-size: 16px;
}
.help-text ol, .help-text ul {
margin: 10px 0;
padding-left: 20px;
}
.help-text li {
margin: 8px 0;
line-height: 1.6;
}
.help-text code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 13px;
}
.method-card {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
transition: all 0.3s;
}
.method-card:hover {
border-color: #6366f1;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1);
}
.method-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
}
.method-badge {
.qr-wrapper { text-align: center; padding: 40px 20px; }
.qr-box {
background: #f8fafc;
padding: 28px;
border-radius: 24px;
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
margin-left: 10px;
border: 1.5px solid #e2e8f0;
margin-bottom: 20px;
}
.badge-recommended {
background: #d4edda;
color: #155724;
}
.warning-box {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.warning-box h4 {
color: #856404;
margin-top: 0;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
pre {
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 13px;
.qr-box img { width: 220px; height: 220px; display: block; margin: 0 auto 14px; border-radius: 12px; }
#qr-timer { color: #94a3b8; font-size: 14px; }
#qr-status { margin-top: 18px; font-size: 16px; min-height: 24px; font-weight: 500; }
.status-waiting { color: #64748b; }
.status-scanned { color: #f59e0b; }
.status-success { color: #10b981; }
.status-error { color: #ef4444; }
.info-box {
background: #f8fafc; border-radius: 16px; padding: 18px 22px; margin-top: 24px;
font-size: 14px; color: #64748b; text-align: left; max-width: 480px;
margin-left: auto; margin-right: auto; border: 1px solid #e2e8f0;
}
.info-box li { margin: 6px 0; line-height: 1.7; }
</style>
{% endblock %}
{% block content %}
<div style="max-width: 800px; margin: 0 auto;">
<h1 style="margin-bottom: 30px;">Add Weibo Account</h1>
<div style="max-width: 560px; margin: 0 auto;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<h1 style="font-size:24px; font-weight:700; color:#1e293b;">📱 添加微博账号</h1>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">返回</a>
</div>
<div class="card">
<div class="tab-container">
<button class="tab active" onclick="switchTab('guide')">获取教程</button>
<button class="tab" onclick="switchTab('qrcode')">扫码添加</button>
<button class="tab" onclick="switchTab('manual')">手动添加</button>
</div>
<!-- 获取教程 Tab -->
<div id="guide-tab" class="tab-content active">
<h3 style="margin-bottom: 20px;">如何获取微博 Cookie</h3>
<div class="method-card">
<div class="method-title">
方法一:使用浏览器开发者工具
<span class="method-badge badge-recommended">推荐</span>
<div class="qr-wrapper">
<button id="generate-qr-btn" class="btn btn-primary" style="font-size:18px; padding:16px 40px; border-radius:18px;">
📱 生成二维码
</button>
<div id="qr-display" style="display:none;">
<div class="qr-box">
<img id="qr-image" src="" alt="QR Code">
<p style="color:#64748b; margin:0 0 4px;">请使用微博 APP 扫描</p>
<p id="qr-timer">有效期: 3:00</p>
</div>
<div class="help-text">
<ol>
<li>在浏览器中打开 <a href="https://weibo.com" target="_blank">https://weibo.com</a> 并登录</li>
<li><code>F12</code> 打开开发者工具</li>
<li>切换到 <code>Network</code> (网络) 标签</li>
<li>刷新页面 (<code>F5</code>)</li>
<li>在请求列表中点击任意一个请求(通常是第一个)</li>
<li>在右侧找到 <code>Request Headers</code> (请求头)</li>
<li>找到 <code>Cookie:</code> 字段,复制整行内容</li>
<li>切换到"添加账号"标签页,粘贴 Cookie</li>
</ol>
</div>
</div>
<div class="warning-box">
<h4>⚠️ 重要提示</h4>
<ul>
<li>Cookie 包含你的登录凭证,请妥善保管</li>
<li>不要在公共场合或不信任的网站输入 Cookie</li>
<li>Cookie 会被加密存储在数据库中</li>
<li>如果 Cookie 失效,系统会提示你更新</li>
<li>建议使用小号或测试账号,避免主账号风险</li>
</ul>
</div>
<div style="text-align: center; margin-top: 30px;">
<button class="btn btn-primary" onclick="switchTab('qrcode')" style="margin-right: 10px;">
使用扫码添加 →
</button>
<button class="btn btn-secondary" onclick="switchTab('manual')">
手动添加账号 →
</button>
<div id="qr-status" class="status-waiting">等待扫码...</div>
</div>
</div>
<!-- 扫码添加 Tab -->
<div id="qrcode-tab" class="tab-content">
<h3 style="margin-bottom: 20px;">微博扫码登录</h3>
<div class="method-card">
<div class="method-title">
扫码快速添加账号
<span class="method-badge badge-recommended">推荐</span>
</div>
<div class="help-text">
<p style="font-size: 16px; margin-bottom: 15px;">
使用微博网页版扫码登录,安全便捷地添加账号。
</p>
<h4>使用步骤:</h4>
<ol>
<li>点击下方"生成二维码"按钮</li>
<li>使用手机微博 APP 扫描二维码</li>
<li>在手机上点击"确认登录"</li>
<li>等待页面自动完成账号添加</li>
</ol>
</div>
</div>
<div id="qr-container" style="text-align: center; margin: 40px 0;">
<button id="generate-qr-btn" class="btn btn-primary" style="font-size: 18px; padding: 15px 40px;">
📱 生成二维码
</button>
<div id="qr-display" style="display: none; margin-top: 30px;">
<div style="background: white; padding: 20px; border-radius: 8px; display: inline-block; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<img id="qr-image" src="" alt="QR Code" style="width: 256px; height: 256px; margin-bottom: 15px;">
<p style="color: #666; margin: 0;">请使用微博 APP 扫描</p>
<p id="qr-timer" style="color: #999; font-size: 14px; margin-top: 10px;">有效期: 3:00</p>
</div>
<div id="qr-status" style="margin-top: 20px; font-size: 16px; color: #666;">
等待扫码...
</div>
</div>
</div>
<div class="help-text">
<h4>💡 说明</h4>
<ul>
<li>使用微博网页版扫码登录接口,无需注册开放平台应用</li>
<li>扫码后自动获取登录 Cookie</li>
<li>Cookie 会被加密存储在数据库中</li>
<li>建议使用小号或测试账号,避免主账号风险</li>
</ul>
</div>
<div class="warning-box">
<h4>⚠️ 注意事项</h4>
<ul>
<li>二维码有效期 3 分钟,过期后需重新生成</li>
<li>扫码后请在手机上点击"确认登录"</li>
<li>如果长时间未响应,请刷新页面重试</li>
</ul>
</div>
</div>
<!-- 添加账号 Tab -->
<div id="manual-tab" class="tab-content">
<h3 style="margin-bottom: 20px;">手动添加账号</h3>
<form method="POST">
<input type="hidden" name="login_method" value="manual">
<div class="form-group">
<label for="weibo_user_id">Weibo User ID</label>
<input type="text" id="weibo_user_id" name="weibo_user_id" required placeholder="e.g., 123456789">
<small style="color: #999; display: block; margin-top: 8px;">
你的微博数字 ID可以在个人主页 URL 中找到
</small>
</div>
<div class="form-group">
<label for="cookie">Cookie</label>
<textarea id="cookie" name="cookie" required placeholder="Paste your Weibo cookie here" style="min-height: 150px;"></textarea>
<small style="color: #999; display: block; margin-top: 8px;">
粘贴从浏览器获取的完整 Cookie 字符串。Cookie 将被加密存储。
</small>
</div>
<div class="form-group">
<label for="remark">Remark (Optional)</label>
<input type="text" id="remark" name="remark" placeholder="e.g., My main account">
<small style="color: #999; display: block; margin-top: 8px;">
给这个账号添加备注,方便识别
</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary">Add Account</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
<div class="help-text" style="margin-top: 30px;">
<h4>💡 快速提示</h4>
<p><strong>Weibo User ID 在哪里找?</strong></p>
<ol>
<li>登录微博后,点击右上角头像进入个人主页</li>
<li>查看浏览器地址栏,格式类似:<code>https://weibo.com/u/1234567890</code></li>
<li>最后的数字 <code>1234567890</code> 就是你的 User ID</li>
</ol>
</div>
<div class="info-box">
<p style="margin:0 0 8px; font-weight:600; color:#334155;">使用说明</p>
<ol style="margin:0; padding-left:20px;">
<li>点击"生成二维码"</li>
<li>打开手机微博 APP扫描二维码</li>
<li>在手机上点击"确认登录"</li>
<li>等待自动完成,跳转到控制台</li>
</ol>
</div>
</div>
</div>
<script>
function switchTab(tabName) {
// 更新 tab 按钮状态
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
event.target.classList.add('active');
let pollInterval = null, timerInterval = null, currentQrid = null, timeLeft = 180, isProcessing = false;
// 更新 tab 内容显示
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(tabName + '-tab').classList.add('active');
}
// 微博扫码登录相关代码
let pollInterval = null;
let timerInterval = null;
let currentQrid = null;
let timeLeft = 180; // 3分钟
document.addEventListener('DOMContentLoaded', function() {
const generateBtn = document.getElementById('generate-qr-btn');
if (generateBtn) {
generateBtn.addEventListener('click', async function() {
try {
// 生成二维码
const response = await fetch('/api/weibo/qrcode/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!data.success) {
alert('生成二维码失败: ' + (data.error || '未知错误'));
return;
}
currentQrid = data.qrid;
const qrImage = data.qr_image;
// 显示二维码
document.getElementById('qr-image').src = qrImage;
document.getElementById('generate-qr-btn').style.display = 'none';
document.getElementById('qr-display').style.display = 'block';
document.getElementById('qr-status').textContent = '等待扫码...';
// 开始倒计时
timeLeft = 180;
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
// 开始轮询状态
pollInterval = setInterval(checkQRStatus, 2000);
} catch (error) {
alert('生成二维码失败: ' + error.message);
}
});
}
document.getElementById('generate-qr-btn').addEventListener('click', async function() {
try {
const resp = await fetch('/api/weibo/qrcode/generate', {method:'POST', headers:{'Content-Type':'application/json'}});
const data = await resp.json();
if (!data.success) { alert('生成二维码失败: ' + (data.error || '未知错误')); return; }
currentQrid = data.qrid;
document.getElementById('qr-image').src = data.qr_image;
document.getElementById('generate-qr-btn').style.display = 'none';
document.getElementById('qr-display').style.display = 'block';
document.getElementById('qr-status').textContent = '等待扫码...';
document.getElementById('qr-status').className = 'status-waiting';
timeLeft = 180;
updateTimer();
timerInterval = setInterval(updateTimer, 1000);
pollInterval = setInterval(checkQRStatus, 2000);
} catch(e) { alert('生成二维码失败: ' + e.message); }
});
function updateTimer() {
if (timeLeft <= 0) {
clearInterval(timerInterval);
clearInterval(pollInterval);
document.getElementById('qr-status').innerHTML = '<span style="color: #dc3545;">二维码已过期,请重新生成</span>';
clearInterval(timerInterval); clearInterval(pollInterval);
document.getElementById('qr-status').textContent = '二维码已过期,请重新生成';
document.getElementById('qr-status').className = 'status-error';
document.getElementById('generate-qr-btn').style.display = 'inline-block';
document.getElementById('qr-display').style.display = 'none';
return;
}
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
document.getElementById('qr-timer').textContent =
`有效期: ${minutes}:${seconds.toString().padStart(2, '0')}`;
const m = Math.floor(timeLeft / 60), s = timeLeft % 60;
document.getElementById('qr-timer').textContent = `有效期: ${m}:${s.toString().padStart(2,'0')}`;
timeLeft--;
}
async function checkQRStatus() {
if (isProcessing) return;
isProcessing = true;
try {
const response = await fetch(`/api/weibo/qrcode/check/${currentQrid}`);
const data = await response.json();
const resp = await fetch(`/api/weibo/qrcode/check/${currentQrid}`);
const data = await resp.json();
if (data.status === 'waiting') {
document.getElementById('qr-status').textContent = '等待扫码...';
document.getElementById('qr-status').className = 'status-waiting';
} else if (data.status === 'scanned') {
document.getElementById('qr-status').innerHTML =
'<span style="color: #ffc107;">✓ 已扫码,请在手机上确认登录</span>';
document.getElementById('qr-status').textContent = '✓ 已扫码,请在手机上确认登录';
document.getElementById('qr-status').className = 'status-scanned';
} else if (data.status === 'success') {
// 扫码成功
clearInterval(pollInterval);
clearInterval(timerInterval);
document.getElementById('qr-status').innerHTML =
'<span style="color: #28a745;">✓ 登录成功!正在添加账号...</span>';
// 添加账号
const addResponse = await fetch('/api/weibo/qrcode/add-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ qrid: currentQrid })
clearInterval(pollInterval); pollInterval = null; clearInterval(timerInterval);
document.getElementById('qr-status').textContent = '✓ 登录成功,正在添加账号...';
document.getElementById('qr-status').className = 'status-success';
await new Promise(r => setTimeout(r, 500));
const addResp = await fetch('/api/weibo/qrcode/add-account', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({qrid: currentQrid})
});
const addResult = await addResponse.json();
const addResult = await addResp.json();
if (addResult.success) {
document.getElementById('qr-status').innerHTML =
'<span style="color: #28a745;">✓ 账号添加成功!正在跳转...</span>';
setTimeout(() => {
window.location.href = '/dashboard';
}, 1500);
document.getElementById('qr-status').textContent = '✓ 账号添加成功,正在跳转...';
setTimeout(() => { window.location.href = '/dashboard'; }, 1000);
} else if (addResult.need_login || addResp.status === 401) {
document.getElementById('qr-status').textContent = '登录已过期,正在跳转...';
document.getElementById('qr-status').className = 'status-error';
setTimeout(() => { window.location.href = '/login'; }, 1500);
} else {
document.getElementById('qr-status').innerHTML =
'<span style="color: #dc3545;">添加账号失败: ' + addResult.message + '</span>';
document.getElementById('qr-status').textContent = '添加失败: ' + addResult.message;
document.getElementById('qr-status').className = 'status-error';
}
} else if (data.status === 'expired') {
clearInterval(pollInterval);
clearInterval(timerInterval);
document.getElementById('qr-status').innerHTML =
'<span style="color: #dc3545;">二维码已过期,请重新生成</span>';
document.getElementById('generate-qr-btn').style.display = 'inline-block';
document.getElementById('qr-display').style.display = 'none';
} else if (data.status === 'cancelled') {
clearInterval(pollInterval);
clearInterval(timerInterval);
document.getElementById('qr-status').innerHTML =
'<span style="color: #dc3545;">已取消登录</span>';
return;
} else if (data.status === 'expired' || data.status === 'cancelled') {
clearInterval(pollInterval); pollInterval = null; clearInterval(timerInterval);
document.getElementById('qr-status').textContent = data.status === 'expired' ? '二维码已过期,请重新生成' : '已取消登录';
document.getElementById('qr-status').className = 'status-error';
document.getElementById('generate-qr-btn').style.display = 'inline-block';
document.getElementById('qr-display').style.display = 'none';
} else if (data.status === 'error') {
clearInterval(pollInterval);
clearInterval(timerInterval);
document.getElementById('qr-status').innerHTML =
'<span style="color: #dc3545;">错误: ' + (data.error || '未知错误') + '</span>';
clearInterval(pollInterval); pollInterval = null; clearInterval(timerInterval);
document.getElementById('qr-status').textContent = '错误: ' + (data.error || '未知错误');
document.getElementById('qr-status').className = 'status-error';
}
} catch (error) {
console.error('检查二维码状态失败:', error);
}
} catch(e) { console.error('检查状态失败:', e); }
finally { isProcessing = false; }
}
</script>
{% endblock %}

View File

@@ -1,30 +1,365 @@
{% extends "base.html" %}
{% block title %}Add Task - Weibo-HotSign{% endblock %}
{% block title %}添加任务 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.task-container { max-width: 560px; margin: 0 auto; }
/* 时间轮盘 */
.wheel-section {
display: flex;
gap: 20px;
justify-content: center;
align-items: flex-start;
margin: 24px 0 28px;
}
.wheel-col { text-align: center; }
.wheel-col .wheel-label {
font-size: 13px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wheel-wrapper {
position: relative;
width: 90px;
height: 180px;
overflow: hidden;
border-radius: 20px;
background: #f8fafc;
border: 1.5px solid #e2e8f0;
}
.wheel-wrapper::before, .wheel-wrapper::after {
content: '';
position: absolute;
left: 0; right: 0;
height: 60px;
z-index: 2;
pointer-events: none;
}
.wheel-wrapper::before {
top: 0;
background: linear-gradient(to bottom, rgba(248,250,252,1), rgba(248,250,252,0));
}
.wheel-wrapper::after {
bottom: 0;
background: linear-gradient(to top, rgba(248,250,252,1), rgba(248,250,252,0));
}
.wheel-highlight {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 78px; height: 44px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(99,102,241,0.1), rgba(168,85,247,0.1));
border: 2px solid rgba(99,102,241,0.3);
z-index: 1;
pointer-events: none;
}
.wheel-scroll {
position: relative;
z-index: 1;
height: 100%;
overflow-y: scroll;
scroll-snap-type: y mandatory;
-ms-overflow-style: none;
scrollbar-width: none;
}
.wheel-scroll::-webkit-scrollbar { display: none; }
.wheel-item {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 600;
color: #cbd5e1;
scroll-snap-align: center;
transition: color 0.15s, transform 0.15s;
cursor: pointer;
user-select: none;
}
.wheel-item.active {
color: #6366f1;
transform: scale(1.1);
}
/* 重复模式 */
.repeat-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.repeat-chip {
padding: 8px 18px;
border-radius: 14px;
border: 1.5px solid #e2e8f0;
background: #fff;
font-size: 14px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.repeat-chip:hover { border-color: #818cf8; color: #6366f1; }
.repeat-chip.selected {
background: linear-gradient(135deg, #6366f1, #818cf8);
color: white;
border-color: transparent;
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
}
/* 星期选择 */
.weekday-row {
display: flex;
gap: 6px;
margin-top: 12px;
}
.weekday-btn {
width: 44px; height: 44px;
border-radius: 14px;
border: 1.5px solid #e2e8f0;
background: #fff;
font-size: 14px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.weekday-btn:hover { border-color: #818cf8; }
.weekday-btn.selected {
background: linear-gradient(135deg, #6366f1, #818cf8);
color: white;
border-color: transparent;
}
/* 预览 */
.cron-preview {
margin-top: 20px;
padding: 16px 20px;
background: #f8fafc;
border-radius: 16px;
border: 1.5px solid #e2e8f0;
font-size: 14px;
}
.cron-preview code {
display: inline-block;
background: #eef2ff;
color: #6366f1;
padding: 3px 10px;
border-radius: 8px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 14px;
font-weight: 600;
}
.cron-preview .desc { color: #64748b; margin-top: 6px; }
</style>
{% endblock %}
{% block content %}
<div style="max-width: 600px; margin: 0 auto;">
<h1 style="margin-bottom: 30px;">Add Signin Task</h1>
<div class="task-container">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<h1 style="font-size:24px; font-weight:700; color:#1e293b;">⏰ 添加签到任务</h1>
<a href="{{ url_for('account_detail', account_id=account_id) }}" class="btn btn-secondary">返回</a>
</div>
<div class="card">
<form method="POST">
<div class="form-group">
<label for="cron_expression">Cron Expression</label>
<input type="text" id="cron_expression" name="cron_expression" required placeholder="e.g., 0 9 * * *">
<small style="color: #999; display: block; margin-top: 8px;">
Standard cron format (minute hour day month weekday)<br>
Examples:<br>
<code>0 9 * * *</code> - Every day at 9:00 AM<br>
<code>0 9 * * 1-5</code> - Weekdays at 9:00 AM<br>
<code>0 9,21 * * *</code> - Every day at 9:00 AM and 9:00 PM
</small>
</div>
<div class="card-header">选择签到时间</div>
<!-- 时间轮盘 -->
<div class="wheel-section">
<div class="wheel-col">
<div class="wheel-label"></div>
<div class="wheel-wrapper">
<div class="wheel-highlight"></div>
<div class="wheel-scroll" id="hour-wheel"></div>
</div>
</div>
<div style="font-size:32px; font-weight:700; color:#cbd5e1; padding-top:50px;">:</div>
<div class="wheel-col">
<div class="wheel-label"></div>
<div class="wheel-wrapper">
<div class="wheel-highlight"></div>
<div class="wheel-scroll" id="minute-wheel"></div>
</div>
</div>
</div>
<!-- 重复模式 -->
<div class="card-header" style="margin-top:8px;">重复方式</div>
<div class="repeat-options">
<div class="repeat-chip selected" data-mode="daily" onclick="setRepeat('daily')">每天</div>
<div class="repeat-chip" data-mode="weekdays" onclick="setRepeat('weekdays')">工作日</div>
<div class="repeat-chip" data-mode="custom" onclick="setRepeat('custom')">自定义</div>
</div>
<div id="weekday-picker" style="display:none; margin-top:12px;">
<div class="weekday-row">
<div class="weekday-btn" data-day="1" onclick="toggleDay(this)"></div>
<div class="weekday-btn" data-day="2" onclick="toggleDay(this)"></div>
<div class="weekday-btn" data-day="3" onclick="toggleDay(this)"></div>
<div class="weekday-btn" data-day="4" onclick="toggleDay(this)"></div>
<div class="weekday-btn" data-day="5" onclick="toggleDay(this)"></div>
<div class="weekday-btn" data-day="6" onclick="toggleDay(this)"></div>
<div class="weekday-btn" data-day="0" onclick="toggleDay(this)"></div>
</div>
</div>
<!-- Cron 预览 -->
<div class="cron-preview">
<div>生成的 Cron 表达式: <code id="cron-display">0 8 * * *</code></div>
<div class="desc" id="cron-desc">每天 08:00 执行签到</div>
</div>
<!-- 提交 -->
<form method="POST" id="task-form" style="margin-top:20px;">
<input type="hidden" id="cron_expression" name="cron_expression" value="0 8 * * *">
<div class="btn-group">
<button type="submit" class="btn btn-primary">Create Task</button>
<a href="{{ url_for('account_detail', account_id=account_id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" style="flex:1; text-align:center;">创建任务</button>
<a href="{{ url_for('account_detail', account_id=account_id) }}" class="btn btn-secondary" style="text-align:center;">取消</a>
</div>
</form>
</div>
</div>
<script>
let selectedHour = 8;
let selectedMinute = 0;
let repeatMode = 'daily';
let customDays = new Set();
// 初始化轮盘
function initWheel(containerId, count, padFn, onSelect) {
const container = document.getElementById(containerId);
// 添加前后空白占位(让第一个和最后一个能滚到中间)
const padCount = 2;
for (let i = 0; i < padCount; i++) {
const spacer = document.createElement('div');
spacer.className = 'wheel-item';
spacer.style.visibility = 'hidden';
container.appendChild(spacer);
}
for (let i = 0; i < count; i++) {
const item = document.createElement('div');
item.className = 'wheel-item';
item.textContent = padFn(i);
item.dataset.value = i;
item.addEventListener('click', () => {
item.scrollIntoView({behavior:'smooth', block:'center'});
});
container.appendChild(item);
}
for (let i = 0; i < padCount; i++) {
const spacer = document.createElement('div');
spacer.className = 'wheel-item';
spacer.style.visibility = 'hidden';
container.appendChild(spacer);
}
// 滚动监听
let scrollTimer;
container.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
const items = container.querySelectorAll('.wheel-item:not([style*="hidden"])');
const center = container.scrollTop + container.clientHeight / 2;
let closest = null, minDist = Infinity;
items.forEach(el => {
const dist = Math.abs(el.offsetTop + 22 - center);
if (dist < minDist) { minDist = dist; closest = el; }
});
if (closest && closest.dataset.value !== undefined) {
items.forEach(el => el.classList.remove('active'));
closest.classList.add('active');
onSelect(parseInt(closest.dataset.value));
}
}, 60);
});
return container;
}
function pad2(n) { return n.toString().padStart(2, '0'); }
const hourWheel = initWheel('hour-wheel', 24, pad2, v => { selectedHour = v; updateCron(); });
const minuteWheel = initWheel('minute-wheel', 60, pad2, v => { selectedMinute = v; updateCron(); });
// 初始滚动到默认值
setTimeout(() => {
scrollToValue(hourWheel, selectedHour);
scrollToValue(minuteWheel, selectedMinute);
}, 100);
function scrollToValue(container, value) {
const items = container.querySelectorAll('.wheel-item');
items.forEach(el => {
if (el.dataset.value == value) {
el.scrollIntoView({block:'center'});
el.classList.add('active');
}
});
}
function setRepeat(mode) {
repeatMode = mode;
document.querySelectorAll('.repeat-chip').forEach(c => c.classList.remove('selected'));
document.querySelector(`.repeat-chip[data-mode="${mode}"]`).classList.add('selected');
document.getElementById('weekday-picker').style.display = mode === 'custom' ? 'block' : 'none';
updateCron();
}
function toggleDay(el) {
const day = el.dataset.day;
if (customDays.has(day)) {
customDays.delete(day);
el.classList.remove('selected');
} else {
customDays.add(day);
el.classList.add('selected');
}
updateCron();
}
const dayNames = ['日','一','二','三','四','五','六'];
function updateCron() {
let dow = '*';
let desc = '';
if (repeatMode === 'daily') {
dow = '*';
desc = '每天';
} else if (repeatMode === 'weekdays') {
dow = '1-5';
desc = '工作日';
} else {
if (customDays.size === 0) {
dow = '*';
desc = '每天';
} else if (customDays.size === 7) {
dow = '*';
desc = '每天';
} else {
const sorted = [...customDays].sort((a,b) => a - b);
dow = sorted.join(',');
desc = '每周' + sorted.map(d => dayNames[d]).join('、');
}
}
const cron = `${selectedMinute} ${selectedHour} * * ${dow}`;
const timeStr = pad2(selectedHour) + ':' + pad2(selectedMinute);
document.getElementById('cron-display').textContent = cron;
document.getElementById('cron-desc').textContent = `${desc} ${timeStr} 执行签到`;
document.getElementById('cron_expression').value = cron;
}
</script>
{% endblock %}

View File

@@ -3,366 +3,215 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Weibo-HotSign{% endblock %}</title>
<title>{% block title %}微博超话签到{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5;
color: #333;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #f0f2ff 0%, #faf5ff 50%, #fff0f6 100%);
color: #1a1a2e;
min-height: 100vh;
}
.navbar {
background-color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 0 20px;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(99,102,241,0.08);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.navbar-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
height: 64px;
}
.navbar-brand {
font-size: 24px;
font-weight: bold;
color: #6366f1;
font-size: 22px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.navbar-menu {
display: flex;
gap: 30px;
gap: 28px;
align-items: center;
}
.navbar-menu a {
color: #666;
.navbar-menu > a {
color: #64748b;
text-decoration: none;
transition: color 0.3s;
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 {
display: flex;
gap: 15px;
gap: 14px;
align-items: center;
}
.navbar-user span {
color: #475569;
font-size: 14px;
font-weight: 500;
}
.btn-logout {
background-color: #dc3545;
background: linear-gradient(135deg, #f43f5e, #e11d48);
color: white;
padding: 8px 16px;
padding: 7px 18px;
border: none;
border-radius: 4px;
border-radius: 20px;
cursor: pointer;
text-decoration: none;
transition: background-color 0.3s;
}
.btn-logout:hover {
background-color: #c82333;
font-size: 13px;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-logout:hover { opacity: 0.85; }
.container {
max-width: 1200px;
margin: 30px auto;
margin: 28px auto;
padding: 0 20px;
}
.alert {
padding: 12px 20px;
padding: 14px 20px;
margin-bottom: 20px;
border-radius: 4px;
border-left: 4px solid;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #28a745;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border-color: #ffeaa7;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-color: #bee5eb;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
border-radius: 16px;
font-size: 14px;
font-weight: 500;
color: #333;
}
.alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.alert-danger { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
.alert-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
input[type="text"],
input[type="email"],
input[type="password"],
textarea,
select {
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; font-weight: 600; color: #334155; font-size: 14px; }
input[type="text"], input[type="email"], input[type="password"], textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px 16px;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
font-size: 14px;
font-family: inherit;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
textarea:focus,
select:focus {
input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus,
textarea:focus, select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
textarea {
resize: vertical;
min-height: 100px;
border-color: #818cf8;
box-shadow: 0 0 0 4px rgba(99,102,241,0.1);
}
textarea { resize: vertical; min-height: 100px; }
.btn {
padding: 10px 20px;
padding: 10px 22px;
border: none;
border-radius: 4px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
font-weight: 600;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #6366f1;
background: linear-gradient(135deg, #6366f1, #818cf8);
color: white;
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
}
.btn-primary:hover {
background-color: #4f46e5;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,0.35); transform: translateY(-1px); }
.btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; }
.btn-danger {
background-color: #dc3545;
background: linear-gradient(135deg, #f43f5e, #e11d48);
color: white;
box-shadow: 0 2px 8px rgba(244,63,94,0.25);
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-danger:hover { box-shadow: 0 4px 16px rgba(244,63,94,0.35); }
.btn-success {
background-color: #28a745;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 2px 8px rgba(16,185,129,0.25);
}
.btn-success:hover {
background-color: #218838;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-success:hover { box-shadow: 0 4px 16px rgba(16,185,129,0.35); }
.btn-group { display: flex; gap: 10px; margin-top: 20px; }
.card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 20px;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
border-radius: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
padding: 24px;
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.6);
}
.card-header {
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #f0f0f0;
font-size: 17px;
font-weight: 700;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #f1f5f9;
color: #1e293b;
}
.badge {
display: inline-block;
padding: 4px 12px;
padding: 4px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-info {
background-color: #d1ecf1;
color: #0c5460;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
font-weight: 600;
}
.badge-success { background: #ecfdf5; color: #059669; }
.badge-warning { background: #fffbeb; color: #d97706; }
.badge-danger { background: #fef2f2; color: #dc2626; }
.badge-info { background: #eff6ff; color: #2563eb; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th {
background-color: #f8f9fa;
padding: 12px;
background: #f8fafc;
padding: 12px 16px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
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; }
td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
tr:hover {
background-color: #f8f9fa;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.grid-item {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 20px;
cursor: pointer;
transition: all 0.3s;
}
.grid-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.grid-item-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
.grid-item-subtitle {
font-size: 12px;
color: #999;
margin-bottom: 10px;
}
.grid-item-status {
margin-top: 10px;
}
.pagination {
display: flex;
gap: 5px;
justify-content: center;
margin-top: 20px;
}
.pagination a,
.pagination span {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
.pagination { display: flex; gap: 6px; justify-content: center; margin-top: 20px; }
.pagination a, .pagination span {
padding: 8px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
text-decoration: none;
color: #6366f1;
font-size: 14px;
font-weight: 500;
}
.pagination a:hover {
background-color: #f0f0f0;
}
.pagination .active {
background-color: #6366f1;
color: white;
border-color: #6366f1;
}
.pagination .disabled {
color: #ccc;
cursor: not-allowed;
}
.pagination a:hover { background: #f1f5f9; }
.pagination .active { background: #6366f1; color: white; border-color: #6366f1; }
@media (max-width: 768px) {
.navbar-menu {
display: none;
}
.grid {
grid-template-columns: 1fr;
}
.btn-group {
flex-direction: column;
}
.btn {
width: 100%;
text-align: center;
}
.navbar-menu { display: none; }
.btn-group { flex-direction: column; }
.btn { width: 100%; text-align: center; }
}
</style>
{% block extra_css %}{% endblock %}
@@ -371,12 +220,12 @@
{% if session.get('user') %}
<nav class="navbar">
<div class="navbar-content">
<a href="{{ url_for('dashboard') }}" class="navbar-brand">Weibo-HotSign</a>
<a href="{{ url_for('dashboard') }}" class="navbar-brand">🔥 微博超话签到</a>
<div class="navbar-menu">
<a href="{{ url_for('dashboard') }}">Dashboard</a>
<a href="{{ url_for('dashboard') }}">控制台</a>
<div class="navbar-user">
<span>{{ session.get('user').get('username') }}</span>
<a href="{{ url_for('logout') }}" class="btn-logout">Logout</a>
<span>👤 {{ session.get('user').get('username') }}</span>
<a href="{{ url_for('logout') }}" class="btn-logout">退出</a>
</div>
</div>
</div>
@@ -391,7 +240,6 @@
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>

View File

@@ -1,40 +1,297 @@
{% extends "base.html" %}
{% block title %}Dashboard - Weibo-HotSign{% endblock %}
{% block title %}控制台 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.dash-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.dash-header h1 {
font-size: 26px;
font-weight: 700;
color: #1e293b;
}
.dash-actions { display: flex; gap: 10px; }
/* 统计卡片 */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
border-radius: 20px;
padding: 22px 24px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
}
.stat-card .stat-icon { font-size: 28px; margin-bottom: 8px; }
.stat-card .stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
.stat-card .stat-label { font-size: 13px; color: #94a3b8; font-weight: 500; margin-top: 2px; }
/* 账号卡片网格 */
.account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 18px;
}
.account-card {
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
cursor: pointer;
transition: all 0.25s ease;
position: relative;
overflow: hidden;
}
.account-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
border-radius: 20px 20px 0 0;
}
.account-card.status-active::before { background: linear-gradient(90deg, #10b981, #34d399); }
.account-card.status-pending::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.account-card.status-invalid_cookie::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.account-card.status-banned::before { background: linear-gradient(90deg, #6b7280, #9ca3af); }
.account-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(99,102,241,0.12);
}
.account-card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 14px;
}
.account-avatar {
width: 48px; height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1, #a855f7);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: 700;
flex-shrink: 0;
}
.account-name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin-bottom: 4px;
word-break: break-all;
}
.account-remark {
font-size: 13px;
color: #94a3b8;
margin-bottom: 14px;
}
.account-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 14px;
border-top: 1px solid #f1f5f9;
}
.account-date { font-size: 12px; color: #cbd5e1; }
/* 删除按钮 */
.account-del-btn {
width: 32px; height: 32px;
border-radius: 10px;
border: 1.5px solid #fecaca;
background: #fff;
color: #ef4444;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.account-del-btn:hover {
background: #fef2f2;
border-color: #f87171;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 20px;
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 p { color: #94a3b8; margin-bottom: 24px; font-size: 16px; }
/* Cookie 批量验证 */
.batch-verify-bar {
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
border-radius: 20px;
padding: 16px 24px;
margin-bottom: 20px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
display: flex;
justify-content: space-between;
align-items: center;
}
.batch-verify-bar .info { font-size: 14px; color: #64748b; }
.batch-verify-bar .info strong { color: #1e293b; }
</style>
{% endblock %}
{% block content %}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h1>Weibo Accounts</h1>
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ Add Account</a>
<div class="dash-header">
<h1>👋 控制台</h1>
<div class="dash-actions">
<a href="{{ url_for('add_account') }}" class="btn btn-primary">+ 添加账号</a>
</div>
</div>
{% if accounts %}
<div class="grid">
{% for account in accounts %}
<div class="grid-item" onclick="window.location.href='{{ url_for('account_detail', account_id=account.id) }}'">
<div class="grid-item-title">{{ account.weibo_user_id }}</div>
<div class="grid-item-subtitle">{{ account.remark or 'No remark' }}</div>
<div class="grid-item-status">
<!-- 统计概览 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-value">{{ accounts|length }}</div>
<div class="stat-label">账号总数</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-value">{{ accounts|selectattr('status','equalto','active')|list|length }}</div>
<div class="stat-label">正常运行</div>
</div>
<div class="stat-card">
<div class="stat-icon">⚠️</div>
<div class="stat-value">{{ accounts|selectattr('status','equalto','pending')|list|length + accounts|selectattr('status','equalto','invalid_cookie')|list|length }}</div>
<div class="stat-label">需要关注</div>
</div>
</div>
<!-- 批量操作栏 -->
<div class="batch-verify-bar">
<div class="info">
💡 系统每天 <strong>23:50</strong> 自动批量验证 Cookie也可手动触发
</div>
<div style="display:flex; gap:10px;">
<button class="btn btn-secondary" id="batch-verify-btn" onclick="batchVerify()">🔍 批量验证 Cookie</button>
<button class="btn btn-primary" id="batch-signin-btn" onclick="batchSignin()">🚀 全部签到</button>
</div>
</div>
<!-- 账号卡片 -->
<div class="account-grid">
{% for account in accounts %}
<div class="account-card status-{{ account.status }}" onclick="window.location.href='{{ url_for('account_detail', account_id=account.id) }}'">
<div class="account-card-top">
<div style="flex:1; min-width:0;">
<div class="account-name">{{ account.remark or account.weibo_user_id }}</div>
<div class="account-remark">UID: {{ account.weibo_user_id }}</div>
</div>
<div class="account-avatar">{{ (account.remark or account.weibo_user_id)[:1] }}</div>
</div>
<div class="account-meta">
<div>
{% if account.status == 'active' %}
<span class="badge badge-success">Active</span>
<span class="badge badge-success">正常</span>
{% elif account.status == 'pending' %}
<span class="badge badge-warning">Pending</span>
<span class="badge badge-warning">待验证</span>
{% elif account.status == 'invalid_cookie' %}
<span class="badge badge-danger">Invalid Cookie</span>
<span class="badge badge-danger">Cookie 失效</span>
{% elif account.status == 'banned' %}
<span class="badge badge-danger">Banned</span>
<span class="badge badge-danger">已封禁</span>
{% endif %}
</div>
<div style="font-size: 12px; color: #999; margin-top: 10px;">
Created: {{ account.created_at[:10] }}
<div style="display:flex; align-items:center; gap:10px;">
<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>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align: center; padding: 60px 20px;">
<p style="color: #999; margin-bottom: 20px;">No accounts yet</p>
<a href="{{ url_for('add_account') }}" class="btn btn-primary">Add your first account</a>
</div>
<div class="empty-state">
<div class="empty-icon">📱</div>
<p>暂无账号,扫码添加你的微博账号开始自动签到</p>
<a href="{{ url_for('add_account') }}" class="btn btn-primary" style="font-size:16px; padding:14px 32px;">添加第一个账号</a>
</div>
{% endif %}
<script>
async function deleteAccount(accountId, name) {
if (!confirm(`确定要删除账号「${name}」吗?此操作不可恢复。`)) return;
try {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/accounts/${accountId}/delete`;
document.body.appendChild(form);
form.submit();
} catch(e) {
alert('删除失败: ' + e.message);
}
}
async function batchVerify() {
const btn = document.getElementById('batch-verify-btn');
btn.disabled = true;
btn.textContent = '⏳ 验证中...';
try {
const resp = await fetch('/api/batch/verify', {method: 'POST'});
const data = await resp.json();
if (data.success) {
const r = data.data;
alert(`验证完成:${r.valid} 个有效,${r.invalid} 个失效,${r.errors} 个出错`);
} else {
alert('验证失败: ' + (data.message || '未知错误'));
}
} catch(e) {
alert('请求失败: ' + e.message);
}
btn.disabled = false;
btn.textContent = '🔍 批量验证 Cookie';
location.reload();
}
async function batchSignin() {
const btn = document.getElementById('batch-signin-btn');
if (!confirm('确定要对所有正常账号执行签到吗?')) return;
btn.disabled = true;
btn.textContent = '⏳ 签到中...';
try {
const resp = await fetch('/api/batch/signin', {method: 'POST'});
const data = await resp.json();
if (data.success) {
const r = data.data;
alert(`签到完成:${r.total_accounts} 个账号,${r.total_signed} 成功,${r.total_already} 已签,${r.total_failed} 失败`);
} else {
alert('签到失败: ' + (data.message || '未知错误'));
}
} catch(e) {
alert('请求失败: ' + e.message);
}
btn.disabled = false;
btn.textContent = '🚀 全部签到';
location.reload();
}
</script>
{% endblock %}

View File

@@ -1,29 +1,24 @@
{% extends "base.html" %}
{% block title %}Edit Account - Weibo-HotSign{% endblock %}
{% block title %}编辑账号 - 微博超话签到{% endblock %}
{% block content %}
<div style="max-width: 600px; margin: 0 auto;">
<h1 style="margin-bottom: 30px;">Edit Account</h1>
<div style="max-width: 560px; margin: 0 auto;">
<h1 style="font-size:24px; font-weight:700; color:#1e293b; margin-bottom:24px;">✏️ 编辑账号</h1>
<div class="card">
<form method="POST">
<div class="form-group">
<label for="remark">Remark</label>
<input type="text" id="remark" name="remark" value="{{ account.remark or '' }}" placeholder="e.g., My main account">
<label for="remark">备注</label>
<input type="text" id="remark" name="remark" value="{{ account.remark or '' }}" placeholder="例如:我的主账号">
</div>
<div class="form-group">
<label for="cookie">Cookie (Leave empty to keep current)</label>
<textarea id="cookie" name="cookie" placeholder="Paste new cookie here if you want to update"></textarea>
<small style="color: #999; display: block; margin-top: 8px;">
Your cookie will be encrypted and stored securely.
</small>
<label for="cookie">Cookie(留空则保持不变)</label>
<textarea id="cookie" name="cookie" placeholder="如需更新 Cookie请在此粘贴新的 Cookie" style="border-radius:14px;"></textarea>
<small style="color:#94a3b8; display:block; margin-top:8px; font-size:13px;">Cookie 将被加密存储</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" style="flex:1; text-align:center;">保存修改</button>
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary" style="text-align:center;">取消</a>
</div>
</form>
</div>

View File

@@ -1,69 +1,47 @@
{% extends "base.html" %}
{% block title %}Login - Weibo-HotSign{% endblock %}
{% block title %}登录 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.auth-container {
max-width: 400px;
margin: 60px auto;
}
.auth-container { max-width: 420px; margin: 60px auto; }
.auth-card {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 40px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(12px);
border-radius: 24px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
padding: 44px 36px;
border: 1px solid rgba(255,255,255,0.6);
}
.auth-title {
text-align: center;
font-size: 28px;
font-weight: bold;
margin-bottom: 30px;
color: #6366f1;
}
.auth-link {
text-align: center;
margin-top: 20px;
color: #666;
}
.auth-link a {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.auth-link a:hover {
text-decoration: underline;
text-align: center; font-size: 28px; font-weight: 700; margin-bottom: 8px;
background: linear-gradient(135deg, #6366f1, #a855f7);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.auth-subtitle { text-align: center; color: #94a3b8; font-size: 15px; margin-bottom: 32px; }
.auth-link { text-align: center; margin-top: 24px; color: #94a3b8; font-size: 14px; }
.auth-link a { color: #6366f1; text-decoration: none; font-weight: 600; }
.auth-link a:hover { text-decoration: underline; }
</style>
{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<h1 class="auth-title">Login</h1>
<h1 class="auth-title">🔥 微博超话签到</h1>
<p class="auth-subtitle">登录你的账号</p>
<form method="POST">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="email">邮箱</label>
<input type="email" id="email" name="email" required placeholder="请输入邮箱">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<label for="password">密码</label>
<input type="password" id="password" name="password" required placeholder="请输入密码">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Login</button>
<button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">登录</button>
</form>
<div class="auth-link">
Don't have an account? <a href="{{ url_for('register') }}">Register</a>
</div>
<div class="auth-link">还没有账号?<a href="{{ url_for('register') }}">注册</a></div>
</div>
</div>
{% endblock %}

View File

@@ -1,133 +1,76 @@
{% extends "base.html" %}
{% block title %}Register - Weibo-HotSign{% endblock %}
{% block title %}注册 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.auth-container {
max-width: 400px;
margin: 60px auto;
}
.auth-container { max-width: 420px; margin: 60px auto; }
.auth-card {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 40px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(12px);
border-radius: 24px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
padding: 44px 36px;
border: 1px solid rgba(255,255,255,0.6);
}
.auth-title {
text-align: center;
font-size: 28px;
font-weight: bold;
margin-bottom: 30px;
color: #6366f1;
}
.password-strength {
display: flex;
gap: 4px;
margin-top: 8px;
}
.strength-bar {
flex: 1;
height: 4px;
background-color: #ddd;
border-radius: 2px;
}
.strength-bar.active {
background-color: #28a745;
}
.strength-text {
font-size: 12px;
color: #666;
margin-top: 8px;
}
.auth-link {
text-align: center;
margin-top: 20px;
color: #666;
}
.auth-link a {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.auth-link a:hover {
text-decoration: underline;
text-align: center; font-size: 28px; font-weight: 700; margin-bottom: 8px;
background: linear-gradient(135deg, #6366f1, #a855f7);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.auth-subtitle { text-align: center; color: #94a3b8; font-size: 15px; margin-bottom: 32px; }
.strength-bar-row { display: flex; gap: 4px; margin-top: 8px; }
.strength-bar { flex: 1; height: 4px; background: #e2e8f0; border-radius: 4px; transition: background 0.3s; }
.strength-bar.active { background: #10b981; }
.auth-link { text-align: center; margin-top: 24px; color: #94a3b8; font-size: 14px; }
.auth-link a { color: #6366f1; text-decoration: none; font-weight: 600; }
.auth-link a:hover { text-decoration: underline; }
</style>
{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<h1 class="auth-title">Create Account</h1>
<form method="POST" id="registerForm">
<h1 class="auth-title">🔥 微博超话签到</h1>
<p class="auth-subtitle">创建你的账号</p>
<form method="POST">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
<label for="username">用户名</label>
<input type="text" id="username" name="username" required placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="email">邮箱</label>
<input type="email" id="email" name="email" required placeholder="请输入邮箱">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required onchange="checkPasswordStrength()">
<div class="password-strength" id="strengthBars">
<div class="strength-bar"></div>
<div class="strength-bar"></div>
<div class="strength-bar"></div>
<div class="strength-bar"></div>
<div class="strength-bar"></div>
</div>
<div class="strength-text">
Must contain: uppercase, lowercase, number, special character, 8+ chars
<label for="password">密码</label>
<input type="password" id="password" name="password" required oninput="checkStrength()">
<div class="strength-bar-row">
<div class="strength-bar"></div><div class="strength-bar"></div>
<div class="strength-bar"></div><div class="strength-bar"></div><div class="strength-bar"></div>
</div>
<small style="color:#94a3b8; font-size:12px; margin-top:6px; display:block;">需包含大小写字母、数字、特殊字符,至少 8 位</small>
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Register</button>
<button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">注册</button>
</form>
<div class="auth-link">
Already have an account? <a href="{{ url_for('login') }}">Login</a>
</div>
<div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div>
</div>
</div>
<script>
function checkPasswordStrength() {
const password = document.getElementById('password').value;
let strength = 0;
if (password.length >= 8) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[!@#$%^&*]/.test(password)) strength++;
const bars = document.querySelectorAll('.strength-bar');
bars.forEach((bar, index) => {
if (index < strength) {
bar.classList.add('active');
} else {
bar.classList.remove('active');
}
function checkStrength() {
const pw = document.getElementById('password').value;
let s = 0;
if (pw.length >= 8) s++;
if (/[a-z]/.test(pw)) s++;
if (/[A-Z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[!@#$%^&*]/.test(pw)) s++;
document.querySelectorAll('.strength-bar').forEach((b, i) => {
b.classList.toggle('active', i < s);
});
}
</script>

View File

@@ -1,82 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微博授权 - Weibo-HotSign</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
text-align: center;
}
.icon {
font-size: 64px;
margin-bottom: 20px;
}
.success { color: #28a745; }
.error { color: #dc3545; }
h1 {
color: #333;
margin-bottom: 15px;
font-size: 24px;
}
p {
color: #666;
line-height: 1.6;
margin-bottom: 20px;
}
.message {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
color: #333;
}
.close-btn {
background: #6366f1;
color: white;
border: none;
padding: 12px 30px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.close-btn:hover {
background: #5558dd;
}
</style>
</head>
<body>
<div class="container">
{% if status == 'success' %}
<div class="icon success"></div>
<h1>授权成功!</h1>
<p>你的微博账号 <strong>{{ screen_name }}</strong> 已成功授权。</p>
<p>请返回电脑端页面完成账号添加。</p>
{% else %}
<div class="icon error"></div>
<h1>授权失败</h1>
<div class="message">{{ message }}</div>
<p>请返回电脑端页面重试。</p>
{% endif %}
<button class="close-btn" onclick="window.close()">关闭此页面</button>
</div>
</body>
</html>

View File

@@ -5,8 +5,11 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
email TEXT UNIQUE,
hashed_password TEXT,
wx_openid TEXT UNIQUE,
wx_nickname TEXT,
wx_avatar TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER DEFAULT 1
);
@@ -50,6 +53,7 @@ CREATE TABLE IF NOT EXISTS signin_logs (
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_wx_openid ON users(wx_openid);
CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
CREATE INDEX IF NOT EXISTS idx_tasks_account_id ON tasks(account_id);

48
migrate_add_wx_fields.py Normal file
View File

@@ -0,0 +1,48 @@
"""
数据库迁移:给 users 表添加微信登录字段
运行方式: python migrate_add_wx_fields.py
"""
import sqlite3
import os
DB_PATHS = [
"weibo_hotsign.db",
"backend/weibo_hotsign.db",
]
def migrate(db_path):
if not os.path.exists(db_path):
print(f" 跳过 {db_path}(文件不存在)")
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(users)")
columns = {row[1] for row in cursor.fetchall()}
added = []
for col in ["wx_openid", "wx_nickname", "wx_avatar"]:
if col not in columns:
cursor.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT")
added.append(col)
# 创建唯一索引
try:
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wx_openid ON users(wx_openid)")
except Exception:
pass
conn.commit()
conn.close()
if added:
print(f"{db_path}: 添加了 {', '.join(added)}")
else:
print(f"{db_path}: 字段已存在,无需迁移")
if __name__ == "__main__":
print("正在迁移数据库...")
for p in DB_PATHS:
migrate(p)
print("迁移完成!")

BIN
qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -62,7 +62,7 @@ if not exist "venv" (
exit /b 1
)
) else (
call \Scripts\activate.bat
call venv\Scripts\activate.bat
)
cd ..
@@ -74,12 +74,12 @@ echo.
REM 启动 Auth Service (端口 8001)
echo [启动] Auth Service (端口 8001)...
start "Auth Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn auth_service.app.main:app --host 0.0.0.0 --port 8001 --reload"
start "Auth Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn auth_service.app.main:app --host 0.0.0.0 --port 8001"
timeout /t 3 >nul
REM 启动 API Service (端口 8000)
echo [启动] API Service (端口 8000)...
start "API Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn api_service.app.main:app --host 0.0.0.0 --port 8000 --reload"
start "API Service" cmd /k "cd backend && venv\Scripts\activate.bat && set PYTHONPATH=%CD% && python -m uvicorn api_service.app.main:app --host 0.0.0.0 --port 8000"
timeout /t 3 >nul
REM 启动 Frontend (端口 5000)

Binary file not shown.