diff --git a/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc index e339a61..5debed1 100644 Binary files a/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc and b/backend/api_service/app/routers/__pycache__/accounts.cpython-311.pyc differ diff --git a/backend/api_service/app/routers/accounts.py b/backend/api_service/app/routers/accounts.py index 6713e9f..2608404 100644 --- a/backend/api_service/app/routers/accounts.py +++ b/backend/api_service/app/routers/accounts.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Dict, List import httpx -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Body from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -249,6 +249,25 @@ async def verify_account( ) +@router.get("/{account_id}/topics") +async def list_topics( + account_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取账号关注的超话列表,供用户勾选签到。""" + account = await _get_owned_account(account_id, user, db) + key = _encryption_key() + + try: + cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key) + except Exception: + return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400) + + topics = await _get_super_topics(cookie_str, account.weibo_user_id) + return success_response({"topics": topics, "total": len(topics)}) + + # ---- MANUAL SIGNIN ---- async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]: @@ -384,10 +403,12 @@ async def manual_signin( account_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), + body: dict = Body(default=None), ): """ - Manually trigger sign-in for all followed super topics. - Verifies cookie first, fetches topic list, signs each one, writes logs. + Manually trigger sign-in for selected (or all) super topics. + Body (optional): {"topic_indices": [0, 1, 3]} — indices of topics to sign. + If omitted or empty, signs all topics. """ account = await _get_owned_account(account_id, user, db) key = _encryption_key() @@ -421,6 +442,18 @@ async def manual_signin( "No super topics found for this account", ) + # Filter topics if specific indices provided + selected_indices = None + if body and isinstance(body, dict): + selected_indices = body.get("topic_indices") + if selected_indices and isinstance(selected_indices, list): + topics = [topics[i] for i in selected_indices if 0 <= i < len(topics)] + if not topics: + return success_response( + {"signed": 0, "already_signed": 0, "failed": 0, "topics": []}, + "No valid topics selected", + ) + # Sign each topic results = [] signed = already = failed = 0 diff --git a/backend/auth_service/app/__pycache__/main.cpython-311.pyc b/backend/auth_service/app/__pycache__/main.cpython-311.pyc index 8a87473..a82adb3 100644 Binary files a/backend/auth_service/app/__pycache__/main.cpython-311.pyc and b/backend/auth_service/app/__pycache__/main.cpython-311.pyc differ diff --git a/backend/auth_service/app/main.py b/backend/auth_service/app/main.py index 86cbce9..95c8078 100644 --- a/backend/auth_service/app/main.py +++ b/backend/auth_service/app/main.py @@ -7,12 +7,14 @@ from fastapi import FastAPI, Depends, HTTPException, status, Security from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func as sa_func import uvicorn import os import logging +import secrets +from datetime import datetime -from shared.models import get_db, User +from shared.models import get_db, User, InviteCode from shared.config import shared_settings from auth_service.app.models.database import create_tables from auth_service.app.schemas.user import ( @@ -113,11 +115,26 @@ async def health_check(): @app.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)): """ - Register a new user account and return tokens + Register a new user account and return tokens. + Requires a valid invite code. """ auth_service = AuthService(db) + + # Validate invite code + result = await db.execute( + select(InviteCode).where( + InviteCode.code == user_data.invite_code, + InviteCode.is_used == False, + ) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="邀请码无效或已被使用", + ) - # Check if user already exists - optimized with single query + # Check if user already exists email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username) if email_user: @@ -135,6 +152,12 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db # Create new user try: user = await auth_service.create_user(user_data) + + # Mark invite code as used + invite.is_used = True + invite.used_by = str(user.id) + invite.used_at = datetime.utcnow() + await db.commit() # Create tokens for auto-login access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) @@ -335,3 +358,128 @@ async def wx_login(body: WxLoginRequest, db: AsyncSession = Depends(get_db)): expires_in=3600, user=UserResponse.from_orm(user), ) + + +# ===================== Admin Endpoints ===================== + +async def require_admin( + credentials: HTTPAuthorizationCredentials = Security(security), + db: AsyncSession = Depends(get_db), +) -> User: + """Dependency: require admin user.""" + payload = decode_access_token(credentials.credentials) + if not payload: + raise HTTPException(status_code=401, detail="Invalid token") + user_id = payload.get("sub") + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="User not found") + if not user.is_admin: + raise HTTPException(status_code=403, detail="需要管理员权限") + return user + + +@app.get("/admin/users") +async def admin_list_users( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """管理员查看所有用户""" + result = await db.execute(select(User).order_by(User.created_at.desc())) + users = result.scalars().all() + return { + "success": True, + "data": [ + { + "id": str(u.id), + "username": u.username, + "email": u.email, + "is_active": u.is_active, + "is_admin": u.is_admin, + "created_at": str(u.created_at) if u.created_at else None, + } + for u in users + ], + } + + +@app.put("/admin/users/{user_id}/toggle") +async def admin_toggle_user( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """管理员启用/禁用用户""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if str(user.id) == str(admin.id): + raise HTTPException(status_code=400, detail="不能禁用自己") + user.is_active = not user.is_active + await db.commit() + return {"success": True, "is_active": user.is_active} + + +@app.post("/admin/invite-codes") +async def admin_create_invite_code( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """管理员生成邀请码""" + code = secrets.token_urlsafe(8)[:12].upper() + invite = InviteCode(code=code, created_by=str(admin.id)) + db.add(invite) + await db.commit() + await db.refresh(invite) + return { + "success": True, + "data": { + "id": str(invite.id), + "code": invite.code, + "created_at": str(invite.created_at), + }, + } + + +@app.get("/admin/invite-codes") +async def admin_list_invite_codes( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """管理员查看所有邀请码""" + result = await db.execute(select(InviteCode).order_by(InviteCode.created_at.desc())) + codes = result.scalars().all() + return { + "success": True, + "data": [ + { + "id": str(c.id), + "code": c.code, + "is_used": c.is_used, + "used_by": c.used_by, + "created_at": str(c.created_at) if c.created_at else None, + "used_at": str(c.used_at) if c.used_at else None, + } + for c in codes + ], + } + + +@app.delete("/admin/invite-codes/{code_id}") +async def admin_delete_invite_code( + code_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """管理员删除未使用的邀请码""" + result = await db.execute(select(InviteCode).where(InviteCode.id == code_id)) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(status_code=404, detail="邀请码不存在") + if invite.is_used: + raise HTTPException(status_code=400, detail="已使用的邀请码不能删除") + await db.delete(invite) + await db.commit() + return {"success": True} diff --git a/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc b/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc index 966539c..4e7dcab 100644 Binary files a/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc and b/backend/auth_service/app/schemas/__pycache__/user.cpython-311.pyc differ diff --git a/backend/auth_service/app/schemas/user.py b/backend/auth_service/app/schemas/user.py index b2c2e1f..2200925 100644 --- a/backend/auth_service/app/schemas/user.py +++ b/backend/auth_service/app/schemas/user.py @@ -16,6 +16,7 @@ class UserBase(BaseModel): class UserCreate(UserBase): """Schema for user registration request""" password: str = Field(..., min_length=8, description="Password (min 8 characters)") + invite_code: str = Field(..., min_length=1, description="注册邀请码") class UserLogin(BaseModel): """Schema for user login request""" @@ -35,6 +36,7 @@ class UserResponse(BaseModel): email: Optional[EmailStr] = None created_at: datetime is_active: bool + is_admin: bool = False wx_openid: Optional[str] = None wx_nickname: Optional[str] = None wx_avatar: Optional[str] = None diff --git a/backend/shared/models/__init__.py b/backend/shared/models/__init__.py index d8c6290..cb955d1 100644 --- a/backend/shared/models/__init__.py +++ b/backend/shared/models/__init__.py @@ -1,7 +1,7 @@ """Shared ORM models for Weibo-HotSign.""" from .base import Base, get_db, engine, AsyncSessionLocal -from .user import User +from .user import User, InviteCode from .account import Account from .task import Task from .signin_log import SigninLog @@ -12,6 +12,7 @@ __all__ = [ "engine", "AsyncSessionLocal", "User", + "InviteCode", "Account", "Task", "SigninLog", diff --git a/backend/shared/models/__pycache__/__init__.cpython-311.pyc b/backend/shared/models/__pycache__/__init__.cpython-311.pyc index 8f36003..5946dac 100644 Binary files a/backend/shared/models/__pycache__/__init__.cpython-311.pyc and b/backend/shared/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/signin_log.cpython-311.pyc b/backend/shared/models/__pycache__/signin_log.cpython-311.pyc index 37e5483..c9ac3d4 100644 Binary files a/backend/shared/models/__pycache__/signin_log.cpython-311.pyc and b/backend/shared/models/__pycache__/signin_log.cpython-311.pyc differ diff --git a/backend/shared/models/__pycache__/user.cpython-311.pyc b/backend/shared/models/__pycache__/user.cpython-311.pyc index 257278c..08fc234 100644 Binary files a/backend/shared/models/__pycache__/user.cpython-311.pyc and b/backend/shared/models/__pycache__/user.cpython-311.pyc differ diff --git a/backend/shared/models/user.py b/backend/shared/models/user.py index c1f78e0..e72b2ff 100644 --- a/backend/shared/models/user.py +++ b/backend/shared/models/user.py @@ -19,6 +19,7 @@ class User(Base): wx_openid = Column(String(64), unique=True, nullable=True, index=True) wx_nickname = Column(String(100), nullable=True) wx_avatar = Column(String(500), nullable=True) + is_admin = Column(Boolean, default=False) created_at = Column(DateTime, server_default=func.now()) is_active = Column(Boolean, default=True) @@ -26,3 +27,15 @@ class User(Base): def __repr__(self): return f"" + + +class InviteCode(Base): + __tablename__ = "invite_codes" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + code = Column(String(32), unique=True, nullable=False, index=True) + created_by = Column(String(36), nullable=False) + used_by = Column(String(36), nullable=True) + is_used = Column(Boolean, default=False) + created_at = Column(DateTime, server_default=func.now()) + used_at = Column(DateTime, nullable=True) diff --git a/backend/weibo_hotsign.db b/backend/weibo_hotsign.db index 5b8b19a..032f316 100644 Binary files a/backend/weibo_hotsign.db and b/backend/weibo_hotsign.db differ diff --git a/create_sqlite_db.py b/create_sqlite_db.py index 6473feb..ca1f875 100644 --- a/create_sqlite_db.py +++ b/create_sqlite_db.py @@ -44,8 +44,8 @@ def create_database(): hashed_password = hash_password(test_password) cursor.execute(""" - INSERT INTO users (id, username, email, hashed_password, is_active) - VALUES (?, ?, ?, ?, 1) + INSERT INTO users (id, username, email, hashed_password, is_admin, is_active) + VALUES (?, ?, ?, ?, 1, 1) """, (test_user_id, test_username, test_email, hashed_password)) conn.commit() diff --git a/frontend/app.py b/frontend/app.py index 0c85473..a690be5 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -112,15 +112,20 @@ def register(): email = request.form.get('email') password = request.form.get('password') confirm_password = request.form.get('confirm_password') + invite_code = request.form.get('invite_code', '').strip() if password != confirm_password: flash('两次输入的密码不一致', 'danger') return redirect(url_for('register')) + if not invite_code: + flash('请输入邀请码', 'danger') + return redirect(url_for('register')) + try: response = requests.post( f'{AUTH_BASE_URL}/auth/register', - json={'username': username, 'email': email, 'password': password}, + json={'username': username, 'email': email, 'password': password, 'invite_code': invite_code}, timeout=10 ) @@ -694,7 +699,7 @@ def verify_account(account_id): def manual_signin(account_id): """手动触发签到""" try: - response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin') + response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin', json={}) data = response.json() if data.get('success'): result = data.get('data', {}) @@ -910,6 +915,144 @@ def not_found(error): def server_error(error): return render_template('500.html'), 500 + +# ===================== Admin Routes ===================== + +def admin_required(f): + """管理员权限装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user' not in session: + flash('请先登录', 'warning') + return redirect(url_for('login')) + if not session.get('user', {}).get('is_admin'): + flash('需要管理员权限', 'danger') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function + + +@app.route('/admin') +@admin_required +def admin_panel(): + """管理员面板""" + # 获取用户列表 + try: + resp = api_request('GET', f'{AUTH_BASE_URL}/admin/users') + users = resp.json().get('data', []) if resp.status_code == 200 else [] + except Exception: + users = [] + + # 获取邀请码列表 + try: + resp = api_request('GET', f'{AUTH_BASE_URL}/admin/invite-codes') + codes = resp.json().get('data', []) if resp.status_code == 200 else [] + except Exception: + codes = [] + + return render_template('admin.html', users=users, invite_codes=codes, user=session.get('user')) + + +@app.route('/admin/invite-codes/create', methods=['POST']) +@admin_required +def create_invite_code(): + """生成邀请码""" + try: + resp = api_request('POST', f'{AUTH_BASE_URL}/admin/invite-codes') + data = resp.json() + if resp.status_code == 200 and data.get('success'): + code = data['data']['code'] + flash(f'邀请码已生成: {code}', 'success') + else: + flash('生成邀请码失败', 'danger') + except Exception as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('admin_panel')) + + +@app.route('/admin/invite-codes//delete', methods=['POST']) +@admin_required +def delete_invite_code(code_id): + """删除邀请码""" + try: + resp = api_request('DELETE', f'{AUTH_BASE_URL}/admin/invite-codes/{code_id}') + data = resp.json() + if resp.status_code == 200 and data.get('success'): + flash('邀请码已删除', 'success') + else: + flash(data.get('detail', '删除失败'), 'danger') + except Exception as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('admin_panel')) + + +@app.route('/admin/users//toggle', methods=['POST']) +@admin_required +def toggle_user(user_id): + """启用/禁用用户""" + try: + resp = api_request('PUT', f'{AUTH_BASE_URL}/admin/users/{user_id}/toggle') + data = resp.json() + if resp.status_code == 200 and data.get('success'): + status_text = '已启用' if data.get('is_active') else '已禁用' + flash(f'用户{status_text}', 'success') + else: + flash(data.get('detail', '操作失败'), 'danger') + except Exception as e: + flash(f'连接错误: {str(e)}', 'danger') + return redirect(url_for('admin_panel')) + + +# ===================== Topic Selection Signin ===================== + +@app.route('/accounts//topics') +@login_required +def account_topics(account_id): + """获取超话列表页面,供用户勾选签到""" + try: + resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics') + data = resp.json() + topics = data.get('data', {}).get('topics', []) if data.get('success') else [] + + # 获取账号信息 + acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}') + acc_data = acc_resp.json() + account = acc_data.get('data') if acc_data.get('success') else None + + if not account: + flash('账号不存在', 'danger') + return redirect(url_for('dashboard')) + + return render_template('topics.html', account=account, topics=topics, user=session.get('user')) + except Exception as e: + flash(f'获取超话列表失败: {str(e)}', 'danger') + return redirect(url_for('account_detail', account_id=account_id)) + + +@app.route('/accounts//signin-selected', methods=['POST']) +@login_required +def signin_selected(account_id): + """签到选中的超话""" + try: + indices = request.json.get('topic_indices', []) + resp = api_request( + 'POST', + f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin', + json={'topic_indices': indices}, + ) + data = resp.json() + if data.get('success'): + result = data.get('data', {}) + return jsonify({ + 'success': True, + 'data': result, + 'message': f"签到完成: {result.get('signed', 0)} 成功, {result.get('already_signed', 0)} 已签, {result.get('failed', 0)} 失败", + }) + else: + return jsonify({'success': False, 'message': data.get('message', '签到失败')}), 400 + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + if __name__ == '__main__': debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() in ('true', '1', 'yes') # use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起 diff --git a/frontend/flask_session/2029240f6d1128be89ddc32729463129 b/frontend/flask_session/2029240f6d1128be89ddc32729463129 index ffb2cd9..04de15d 100644 Binary files a/frontend/flask_session/2029240f6d1128be89ddc32729463129 and b/frontend/flask_session/2029240f6d1128be89ddc32729463129 differ diff --git a/frontend/flask_session/841ecc86f9b7cf9085a6ad3204aa3a43 b/frontend/flask_session/841ecc86f9b7cf9085a6ad3204aa3a43 index 585a8e0..51c4341 100644 Binary files a/frontend/flask_session/841ecc86f9b7cf9085a6ad3204aa3a43 and b/frontend/flask_session/841ecc86f9b7cf9085a6ad3204aa3a43 differ diff --git a/frontend/flask_session/f58a94e5e56b626ceb878155bdc43426 b/frontend/flask_session/f58a94e5e56b626ceb878155bdc43426 new file mode 100644 index 0000000..50d665d Binary files /dev/null and b/frontend/flask_session/f58a94e5e56b626ceb878155bdc43426 differ diff --git a/frontend/templates/account_detail.html b/frontend/templates/account_detail.html index d69bd84..8cbfdc0 100644 --- a/frontend/templates/account_detail.html +++ b/frontend/templates/account_detail.html @@ -86,8 +86,9 @@
- +
+ 🎯 选择超话签到 ⏰ 添加定时任务 diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html new file mode 100644 index 0000000..3960f8b --- /dev/null +++ b/frontend/templates/admin.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} + +{% block title %}管理面板 - 微博超话签到{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

🛡️ 管理面板

+ +
+
+
{{ users|length }}
+
总用户数
+
+
+
{{ users|selectattr('is_active')|list|length }}
+
活跃用户
+
+
+
{{ invite_codes|rejectattr('is_used')|list|length }}
+
可用邀请码
+
+
+ +
+
+
+ 🎟️ 邀请码管理 +
+ +
+
+ {% if invite_codes %} + {% for code in invite_codes %} +
+
+ {{ code.code }} + {% if code.is_used %} + 已使用 + {% else %} + 可用 + {% endif %} +
+
+ {% if not code.is_used %} +
+ +
+ {% endif %} +
+
+ {% endfor %} + {% else %} +

暂无邀请码,点击上方按钮生成

+ {% endif %} +
+ +
+
👥 用户管理
+ {% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}管理员{% endif %} +
{{ u.email or '-' }}
+
+
+ {% if u.is_active %} + 正常 + {% else %} + 已禁用 + {% endif %} + {% if not u.is_admin %} +
+ +
+ {% endif %} +
+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index b072d05..bea130d 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -223,6 +223,9 @@ 🔥 微博超话签到