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

@@ -13,6 +13,7 @@ import type {
DashboardStats, DashboardStats,
KnowledgeItem, KnowledgeItem,
ModelItem, ModelItem,
UserImportResult,
} from "./types/api"; } from "./types/api";
const admin = ref<AdminProfile | null>(null); const admin = ref<AdminProfile | null>(null);
@@ -40,6 +41,17 @@ const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; conten
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" }, { role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]); ]);
const studentForm = reactive({
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
const studentImportText = ref("手机号,姓名,昵称\n13800000001,张三,三三\n13800000002,李四,");
const studentImportResult = ref<UserImportResult | null>(null);
const knowledgeForm = reactive({ const knowledgeForm = reactive({
name: "", name: "",
feishuSpaceId: "", feishuSpaceId: "",
@@ -241,6 +253,59 @@ async function saveUser(row: AdminUser) {
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function createStudent() {
if (!studentForm.phone || !studentForm.name) {
ElMessage.error("请填写手机号和姓名");
return;
}
await api.createUser({
phone: studentForm.phone,
name: studentForm.name,
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
expiredAt: studentForm.expiredAt || null,
});
ElMessage.success("学员已添加");
resetStudentForm();
await loadCurrentMenu();
}
async function importStudents() {
const students = parseStudentImportText(studentImportText.value);
if (!students.length) {
ElMessage.error("请粘贴学员数据");
return;
}
studentImportResult.value = await api.importUsers(students);
ElMessage.success(
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
);
await loadCurrentMenu();
}
function parseStudentImportText(text: string) {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line, index) => !(index === 0 && line.includes("手机号")))
.map((line) => {
const [phone = "", name = "", nickname = ""] = line.split(/,|\t/).map((item) => item.trim());
return {
phone,
name,
nickname: nickname || null,
status: 1,
dailyChatLimit: 100,
};
});
}
function resetStudentForm() {
Object.assign(studentForm, { phone: "", name: "", nickname: "", status: 1, dailyChatLimit: 100, expiredAt: "" });
}
async function deleteUser(row: AdminUser) { async function deleteUser(row: AdminUser) {
if (!window.confirm(`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`)) return; if (!window.confirm(`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`)) return;
await api.deleteUser(row.id); await api.deleteUser(row.id);
@@ -659,9 +724,55 @@ function buildChatQuery() {
<template v-if="activeMenu === 'users'"> <template v-if="activeMenu === 'users'">
<div class="page-head inline"> <div class="page-head inline">
<div><h2>用户管理</h2><p>查询用户启用禁用和调整额度</p></div> <div><h2>用户管理</h2><p>维护学员名单只有名单内启用学员可以登录用户端</p></div>
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" /> <el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" />
</div> </div>
<section class="student-tools">
<div class="student-tool-panel">
<h3>手动添加学员</h3>
<div class="student-form-grid">
<el-input v-model="studentForm.phone" placeholder="手机号" />
<el-input v-model="studentForm.name" placeholder="姓名" />
<el-input v-model="studentForm.nickname" placeholder="昵称(可选)" />
<el-input-number v-model="studentForm.dailyChatLimit" :min="0" />
<el-date-picker
v-model="studentForm.expiredAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="有效期(可选)"
/>
<div class="form-switch-row">
<span>启用</span>
<el-switch v-model="studentForm.status" :active-value="1" :inactive-value="0" />
</div>
</div>
<div class="actions compact">
<el-button type="primary" @click="createStudent">添加学员</el-button>
<el-button @click="resetStudentForm">清空</el-button>
</div>
</div>
<div class="student-tool-panel">
<h3>批量导入学员</h3>
<el-input
v-model="studentImportText"
type="textarea"
:rows="5"
resize="none"
placeholder="每行一个学员:手机号,姓名,昵称"
/>
<div class="actions compact">
<el-button type="primary" @click="importStudents">导入学员</el-button>
<span v-if="studentImportResult" class="import-result">
新增 {{ studentImportResult.created }}更新 {{ studentImportResult.updated }}失败 {{ studentImportResult.failed }}
</span>
</div>
<div v-if="studentImportResult?.failures.length" class="import-failures">
<span v-for="failure in studentImportResult.failures.slice(0, 5)" :key="`${failure.row}-${failure.phone}`">
{{ failure.row }} {{ failure.phone || '-' }}{{ failure.reason }}
</span>
</div>
</div>
</section>
<el-table :data="users" stripe> <el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" /> <el-table-column prop="phone" label="手机号" width="150" />

View File

@@ -10,6 +10,7 @@ import type {
DashboardStats, DashboardStats,
KnowledgeItem, KnowledgeItem,
ModelItem, ModelItem,
UserImportResult,
} from "../types/api"; } from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
@@ -73,6 +74,10 @@ export const api = {
profile: () => request<AdminProfile>("/admin/profile"), profile: () => request<AdminProfile>("/admin/profile"),
dashboard: () => request<DashboardStats>("/admin/dashboard"), dashboard: () => request<DashboardStats>("/admin/dashboard"),
users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`), users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`),
createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) =>
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
updateUser: (id: number, payload: Record<string, unknown>) => updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }), deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),

View File

@@ -185,6 +185,58 @@ textarea {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
.student-tools {
display: grid;
grid-template-columns: minmax(520px, 1fr) minmax(420px, 0.9fr);
gap: 14px;
margin-bottom: 16px;
}
.student-tool-panel {
padding: 16px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.student-tool-panel h3 {
margin: 0 0 12px;
font-size: 16px;
}
.student-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
align-items: center;
}
.student-form-grid .el-input,
.student-form-grid .el-input-number,
.student-form-grid .el-date-editor {
width: 100%;
}
.actions.compact {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.import-result {
color: #40524b;
font-size: 13px;
}
.import-failures {
display: grid;
gap: 4px;
margin-top: 8px;
color: #b42318;
font-size: 12px;
}
.knowledge-form { .knowledge-form {
display: grid; display: grid;
grid-template-columns: minmax(160px, 1.1fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1.2fr) 100px 150px; grid-template-columns: minmax(160px, 1.1fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1.2fr) 100px 150px;

View File

@@ -32,6 +32,19 @@ export interface AdminUser {
createdAt?: string | null; createdAt?: string | null;
} }
export interface UserImportFailure {
row: number;
phone: string;
reason: string;
}
export interface UserImportResult {
created: number;
updated: number;
failed: number;
failures: UserImportFailure[];
}
export interface KnowledgeItem { export interface KnowledgeItem {
id: number; id: number;
name: string; name: string;

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.user import User 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 from app.services.admin_service import OperationLogService
router = APIRouter() router = APIRouter()
@@ -29,6 +30,86 @@ def list_users(
return api_success([_user_dict(user) for user in 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}") @router.get("/user/{user_id}")
def user_detail( def user_detail(
user_id: int, user_id: int,
@@ -96,3 +177,27 @@ def _user_dict(user: User) -> dict:
"lastLoginAt": user.last_login_at, "lastLoginAt": user.last_login_at,
"createdAt": user.created_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") @router.post("/sms/send")
def send_sms(payload: SendSmsRequest) -> dict: def send_sms(payload: SendSmsRequest, db: Session = Depends(get_db)) -> dict:
AuthService.send_sms_code(payload.phone) AuthService.send_sms_code(db, payload.phone)
return api_success(message="发送成功") return api_success(message="发送成功")

View File

@@ -39,6 +39,28 @@ class AdminUserUpdateRequest(BaseModel):
expiredAt: datetime | None = None 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): class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
feishuSpaceId: 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 import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import create_access_token from app.core.security import create_access_token
from app.models.user import User from app.models.user import User
from app.services.sms_code_service import SmsCodeService from app.services.sms_code_service import SmsCodeService
@@ -16,13 +15,15 @@ class AuthService:
_revoked_token_jti: set[str] = set() _revoked_token_jti: set[str] = set()
@classmethod @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) SmsCodeService.send_code(phone)
@classmethod @classmethod
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict: def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
SmsCodeService.verify_code(phone, code) 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) cls._ensure_user_can_login(user)
now = datetime.now(UTC).replace(tzinfo=None) now = datetime.now(UTC).replace(tzinfo=None)
@@ -46,24 +47,12 @@ class AuthService:
return bool(jti and str(jti) in cls._revoked_token_jti) return bool(jti and str(jti) in cls._revoked_token_jti)
@classmethod @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)) user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
if user is not None: if user is not None:
return user return user
settings = get_settings() raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
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
@staticmethod @staticmethod
def _ensure_user_can_login(user: User) -> None: def _ensure_user_can_login(user: User) -> None: