Implement student roster login gating

This commit is contained in:
2026-07-07 18:45:07 +08:00
parent 4f11d8632c
commit 29f25dd767
8 changed files with 318 additions and 21 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -9,7 +10,7 @@ from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.user import User
from app.schemas.admin import AdminUserUpdateRequest
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService
router = APIRouter()
@@ -29,6 +30,86 @@ def list_users(
return api_success([_user_dict(user) for user in users])
@router.post("/user")
def create_user(
payload: AdminUserCreateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
phone = _normalize_phone(payload.phone)
_validate_phone(phone)
existing = db.scalar(select(User).where(User.phone == phone))
if existing is not None and not existing.is_deleted:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号已存在")
if existing is not None:
user = existing
user.is_deleted = 0
user.daily_chat_used = 0
else:
user = User(phone=phone, daily_chat_used=0)
_apply_user_payload(user, payload)
db.add(user)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
@router.post("/user/import")
def import_users(
payload: AdminUserImportRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
if not payload.students:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请提供学员数据")
created = 0
updated = 0
failed: list[dict] = []
seen: set[str] = set()
for index, item in enumerate(payload.students, start=1):
phone = _normalize_phone(item.phone)
name = item.name.strip()
try:
_validate_phone(phone)
if not name:
raise ValueError("姓名不能为空")
if phone in seen:
raise ValueError("手机号在本次导入中重复")
seen.add(phone)
user = db.scalar(select(User).where(User.phone == phone))
if user is None:
user = User(phone=phone, daily_chat_used=0)
created += 1
else:
user.is_deleted = 0
updated += 1
_apply_user_payload(user, item)
db.add(user)
except ValueError as exc:
failed.append({"row": index, "phone": item.phone, "reason": str(exc)})
if created or updated:
db.flush()
OperationLogService.write(
db,
admin_id=current_admin.id,
module="user",
action="import",
target_id=None,
result="SUCCESS",
)
db.commit()
return api_success({"created": created, "updated": updated, "failed": len(failed), "failures": failed})
@router.get("/user/{user_id}")
def user_detail(
user_id: int,
@@ -96,3 +177,27 @@ def _user_dict(user: User) -> dict:
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
}
def _apply_user_payload(user: User, payload: AdminUserCreateRequest) -> None:
settings_limit = 100
try:
from app.core.config import get_settings
settings_limit = get_settings().default_daily_chat_limit
except Exception:
pass
user.name = payload.name.strip()
user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else settings_limit
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
def _normalize_phone(phone: str) -> str:
return re.sub(r"\D", "", phone or "")
def _validate_phone(phone: str) -> None:
if not re.fullmatch(r"1[3-9]\d{9}", phone):
raise ValueError("手机号格式不正确")

View File

@@ -13,8 +13,8 @@ router = APIRouter()
@router.post("/sms/send")
def send_sms(payload: SendSmsRequest) -> dict:
AuthService.send_sms_code(payload.phone)
def send_sms(payload: SendSmsRequest, db: Session = Depends(get_db)) -> dict:
AuthService.send_sms_code(db, payload.phone)
return api_success(message="发送成功")

View File

@@ -39,6 +39,28 @@ class AdminUserUpdateRequest(BaseModel):
expiredAt: datetime | None = None
class AdminUserCreateRequest(BaseModel):
phone: str = Field(min_length=11, max_length=20)
name: str = Field(min_length=1, max_length=50)
nickname: str | None = Field(default=None, max_length=50)
status: int = Field(default=1, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None
class AdminUserImportItem(BaseModel):
phone: str = Field(default="", max_length=20)
name: str = Field(default="", max_length=50)
nickname: str | None = Field(default=None, max_length=50)
status: int = Field(default=1, ge=0, le=1)
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
expiredAt: datetime | None = None
class AdminUserImportRequest(BaseModel):
students: list[AdminUserImportItem] = Field(default_factory=list)
class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
feishuSpaceId: str = Field(min_length=1, max_length=100)

View File

@@ -6,7 +6,6 @@ from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import create_access_token
from app.models.user import User
from app.services.sms_code_service import SmsCodeService
@@ -16,13 +15,15 @@ class AuthService:
_revoked_token_jti: set[str] = set()
@classmethod
def send_sms_code(cls, phone: str) -> None:
def send_sms_code(cls, db: Session, phone: str) -> None:
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)
SmsCodeService.send_code(phone)
@classmethod
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
SmsCodeService.verify_code(phone, code)
user = cls._get_or_create_user(db, phone)
user = cls._get_existing_user(db, phone)
cls._ensure_user_can_login(user)
now = datetime.now(UTC).replace(tzinfo=None)
@@ -46,24 +47,12 @@ class AuthService:
return bool(jti and str(jti) in cls._revoked_token_jti)
@classmethod
def _get_or_create_user(cls, db: Session, phone: str) -> User:
def _get_existing_user(cls, db: Session, phone: str) -> User:
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
if user is not None:
return user
settings = get_settings()
user = User(
phone=phone,
name=f"{settings.default_user_name_prefix}{phone[-4:]}",
daily_chat_limit=settings.default_daily_chat_limit,
daily_chat_used=0,
status=1,
is_deleted=0,
)
db.add(user)
db.commit()
db.refresh(user)
return user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
@staticmethod
def _ensure_user_can_login(user: User) -> None: