注册码 + 管理员系统:
User 模型新增 is_admin 字段
新增 InviteCode 模型(邀请码表)
注册接口必须提供有效邀请码,使用后自动标记
管理员接口:查看所有用户、启用/禁用用户、生成/删除邀请码
前端新增管理面板页面 /admin,导航栏对管理员显示入口
注册页面新增邀请码输入框
选择性超话签到:
新增 GET /api/v1/accounts/{id}/topics 接口获取超话列表
POST /signin 接口支持 {"topic_indices": [0,1,3]} 选择性签到
新增超话选择页面 /accounts/{id}/topics,支持全选/手动勾选
账号详情页新增"选择超话签到"按钮
This commit is contained in:
Binary file not shown.
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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}
|
||||
|
||||
Binary file not shown.
@@ -16,6 +16,7 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for user registration request"""
|
||||
password: str = Field(..., min_length=8, description="Password (min 8 characters)")
|
||||
invite_code: str = Field(..., min_length=1, description="注册邀请码")
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""Schema for user login request"""
|
||||
@@ -35,6 +36,7 @@ class UserResponse(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
is_admin: bool = False
|
||||
wx_openid: Optional[str] = None
|
||||
wx_nickname: Optional[str] = None
|
||||
wx_avatar: Optional[str] = None
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -19,6 +19,7 @@ class User(Base):
|
||||
wx_openid = Column(String(64), unique=True, nullable=True, index=True)
|
||||
wx_nickname = Column(String(100), nullable=True)
|
||||
wx_avatar = Column(String(500), nullable=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
@@ -26,3 +27,15 @@ class User(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class InviteCode(Base):
|
||||
__tablename__ = "invite_codes"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
code = Column(String(32), unique=True, nullable=False, index=True)
|
||||
created_by = Column(String(36), nullable=False)
|
||||
used_by = Column(String(36), nullable=True)
|
||||
is_used = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user