refactor: use backend API for user ID uniqueness validation

- Add GET /api/v1/users/check-id/{user_id} endpoint for real-time validation
- Replace frontend local validation with debounced API calls (500ms)
- Support exclude_user_id parameter for edit scenarios
- Add UserIdCheckResponse schema
- Maintain format validation (alphanumeric + underscore) on frontend
- Clean up timeout on component unmount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-19 11:04:28 +08:00
parent 46fb766861
commit 6cd959c528
3 changed files with 87 additions and 23 deletions
+19
View File
@@ -11,6 +11,7 @@ from ...schemas.user import (
UserPublic,
UserRoleUpdateRequest,
UserUpdateRequest,
UserIdCheckResponse,
)
from ...services.user_service import (
UserCreateError,
@@ -29,6 +30,24 @@ from ...services.user_service import (
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/check-id/{user_id}", response_model=UserIdCheckResponse)
def check_user_id_availability(
user_id: str,
exclude_user_id: str | None = Query(default=None),
_: CurrentUser = Depends(require_permission("user.manage")),
db: Session = Depends(get_db),
) -> UserIdCheckResponse:
"""Check if a user ID is available. Use exclude_user_id when editing an existing user."""
existing_user = get_user_by_id(db, user_id)
if existing_user:
if exclude_user_id and existing_user.id.lower() == exclude_user_id.lower():
return UserIdCheckResponse(available=True, message="Current user ID")
return UserIdCheckResponse(available=False, message="User ID already exists")
return UserIdCheckResponse(available=True, message="User ID is available")
@router.post("", response_model=UserPublic)
def create_user_account(
payload: UserCreateRequest,
+5
View File
@@ -20,6 +20,11 @@ class UserListResponse(BaseModel):
total: int
class UserIdCheckResponse(BaseModel):
available: bool
message: str
class UserUpdateRequest(BaseModel):
email: str | None = None
username: str | None = Field(default=None, min_length=3, max_length=64)
+63 -23
View File
@@ -50,6 +50,7 @@ type EditUserValues = {
user_id: string;
username: string;
email: string;
status: "active" | "disabled";
};
type ResetPasswordValues = {
@@ -96,6 +97,8 @@ export default function AdminUsersPage() {
const [success, setSuccess] = useState("");
const [userIdValidationError, setUserIdValidationError] = useState("");
const [editUserIdValidationError, setEditUserIdValidationError] = useState("");
const [checkingUserId, setCheckingUserId] = useState(false);
const userIdCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const canManage = hasPermission("user.manage");
const canReadRoles = hasPermission("role.read") || hasPermission("role.manage");
@@ -347,7 +350,27 @@ export default function AdminUsersPage() {
return null;
};
const handleUserIdChange = (value: string, isEdit: boolean = false) => {
const checkUserIdAvailability = async (userId: string, excludeUserId?: string) => {
try {
const params = new URLSearchParams({ user_id: userId });
if (excludeUserId) {
params.set("exclude_user_id", excludeUserId);
}
const response = await fetchWithAuth(`/api/v1/users/check-id/${encodeURIComponent(userId)}?${excludeUserId ? `exclude_user_id=${encodeURIComponent(excludeUserId)}` : ""}`);
if (!response.ok) {
return { available: false, message: "检查失败" };
}
return (await response.json()) as { available: boolean; message: string };
} catch {
return { available: false, message: "检查失败" };
}
};
const handleUserIdChange = async (value: string, isEdit: boolean = false) => {
if (userIdCheckTimeoutRef.current) {
clearTimeout(userIdCheckTimeoutRef.current);
}
const formatError = validateUserIdFormat(value);
if (formatError) {
@@ -359,7 +382,7 @@ export default function AdminUsersPage() {
return;
}
const trimmedValue = value.trim().toLowerCase();
const trimmedValue = value.trim();
if (!trimmedValue) {
if (isEdit) {
setEditUserIdValidationError("");
@@ -369,22 +392,25 @@ export default function AdminUsersPage() {
return;
}
if (isEdit && editingUser) {
if (trimmedValue !== editingUser.id.toLowerCase() && existingUserIds.has(trimmedValue)) {
setEditUserIdValidationError("用户 ID 已存在,请更换后重试");
setCheckingUserId(true);
userIdCheckTimeoutRef.current = setTimeout(async () => {
const result = await checkUserIdAvailability(
trimmedValue,
isEdit && editingUser ? editingUser.id : undefined
);
setCheckingUserId(false);
if (isEdit) {
setEditUserIdValidationError(result.available ? "" : result.message);
} else {
setEditUserIdValidationError("");
setUserIdValidationError(result.available ? "" : result.message);
}
} else {
if (existingUserIds.has(trimmedValue)) {
setUserIdValidationError("用户 ID 已存在,请更换后重试");
} else {
setUserIdValidationError("");
}
}
}, 500);
};
const handleCreateUser = (values: CreateUserValues) => {
const handleCreateUser = async (values: CreateUserValues) => {
setError("");
setSuccess("");
@@ -404,14 +430,15 @@ export default function AdminUsersPage() {
return;
}
const candidateUserId = payload.user_id.toLowerCase();
const availabilityCheck = await checkUserIdAvailability(payload.user_id);
if (!availabilityCheck.available) {
setError(availabilityCheck.message);
return;
}
const candidateEmail = payload.email?.toLowerCase();
const candidateUsername = payload.username.toLowerCase();
if (existingUserIds.has(candidateUserId)) {
setError("用户 ID 已存在,请更换后重试");
return;
}
if (candidateEmail && existingEmails.has(candidateEmail)) {
setError("邮箱已存在,请更换后重试");
return;
@@ -477,6 +504,7 @@ export default function AdminUsersPage() {
user_id: target.id,
username: target.username,
email: target.email,
status: target.status === "disabled" ? "disabled" : "active",
});
};
@@ -487,11 +515,12 @@ export default function AdminUsersPage() {
editUserForm.resetFields();
};
const handleSubmitEditUser = (values: EditUserValues) => {
const handleSubmitEditUser = async (values: EditUserValues) => {
if (!editingUser) return;
const nextUserId = values.user_id.trim();
const nextUsername = values.username.trim();
const nextEmail = values.email ? values.email.trim().toLowerCase() : "";
const nextStatus = values.status;
const formatError = validateUserIdFormat(nextUserId);
if (formatError) {
@@ -502,9 +531,9 @@ export default function AdminUsersPage() {
const payload: { new_user_id?: string; username?: string; email?: string; status?: "active" | "disabled" } = {};
if (nextUserId !== editingUser.id) {
const lowerUserId = nextUserId.toLowerCase();
if (existingUserIds.has(lowerUserId) && lowerUserId !== editingUser.id.toLowerCase()) {
setError("用户 ID 已存在,请更换后重试");
const availabilityCheck = await checkUserIdAvailability(nextUserId, editingUser.id);
if (!availabilityCheck.available) {
setError(availabilityCheck.message);
return;
}
payload.new_user_id = nextUserId;
@@ -515,6 +544,9 @@ export default function AdminUsersPage() {
if (nextEmail && nextEmail !== editingUser.email.toLowerCase()) {
payload.email = nextEmail;
}
if (nextStatus !== editingUser.status) {
payload.status = nextStatus;
}
if (Object.keys(payload).length === 0) {
setSuccess("未检测到变更");
@@ -598,6 +630,14 @@ export default function AdminUsersPage() {
window.requestAnimationFrame(updateTableScrollY);
}, [anyError, paginationCurrent, paginationPageSize, users.length, usersQuery.isFetching, updateTableScrollY]);
useEffect(() => {
return () => {
if (userIdCheckTimeoutRef.current) {
clearTimeout(userIdCheckTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;