"custom numFmtId starts at 164 but found a value of 0" 에러 - 구글시트/WPS 등에서 내보낸 일부 xlsx 파일은 styles.xml에 커스텀 숫자서식 id를 164 미만으로 적어두는데, excel 패키지가 이를 엄격하게 검증하며 예외를 던져 파일 전체를 못 읽었다. 파싱 전에 zip 안의 styles.xml만 열어 그 규격 위반 서식 선언을 제거하고 다시 압축해서 넘기도록 전처리 단계를 추가. 합성 xlsx로 버그 재현 + 수정 후 정상 파싱까지 직접 검증함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
346 lines
12 KiB
Dart
346 lines
12 KiB
Dart
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
|
|
import 'dart:convert';
|
|
import 'package:archive/archive.dart';
|
|
import 'package:excel/excel.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../config.dart';
|
|
|
|
/// 📄 엑셀 한 줄에서 뽑아낸 학생 정보 (아직 서버에 보내기 전, 미리보기용).
|
|
class BulkStudentRow {
|
|
final String studentId;
|
|
final String name;
|
|
final int? grade;
|
|
|
|
const BulkStudentRow({
|
|
required this.studentId,
|
|
required this.name,
|
|
this.grade,
|
|
});
|
|
}
|
|
|
|
/// 📊 일괄 등록 결과. [failures]는 "학번 이름: 실패사유" 형태의 문자열 목록.
|
|
class BulkImportResult {
|
|
final int successCount;
|
|
final List<String> failures;
|
|
|
|
const BulkImportResult({required this.successCount, required this.failures});
|
|
}
|
|
|
|
class TeacherStudentManagementController extends ChangeNotifier {
|
|
bool _isWorking = false;
|
|
bool get isWorking => _isWorking;
|
|
|
|
int _bulkTotal = 0;
|
|
int _bulkDone = 0;
|
|
int get bulkTotal => _bulkTotal;
|
|
int get bulkDone => _bulkDone;
|
|
|
|
List<dynamic> _students = [];
|
|
bool _isLoadingStudents = true;
|
|
List<dynamic> get students => _students;
|
|
bool get isLoadingStudents => _isLoadingStudents;
|
|
|
|
Future<void> init() => fetchStudents();
|
|
|
|
/// 🌐 전체 학생 목록 불러오기 (학번/이름/학년/기기등록상태).
|
|
Future<void> fetchStudents() async {
|
|
_isLoadingStudents = true;
|
|
notifyListeners();
|
|
try {
|
|
final response = await http.get(Uri.parse('$baseUrl/api/users'));
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
|
_students = data['users'] ?? [];
|
|
}
|
|
} catch (_) {
|
|
// 목록 새로고침 실패는 조용히 무시 — 화면엔 마지막으로 불러온 목록이 남는다.
|
|
} finally {
|
|
_isLoadingStudents = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 🌐 학생의 등록된 기기(스마트폰) UUID + 비밀번호(1234)를 초기화한다.
|
|
/// (학생이 폰을 바꿨을 때 다시 등록할 수 있게 해주는 용도)
|
|
Future<(bool success, String message)> resetDevice(
|
|
String studentId,
|
|
String studentName,
|
|
) async {
|
|
_isWorking = true;
|
|
notifyListeners();
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl/api/users/reset'),
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({"studentId": studentId}),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
await fetchStudents();
|
|
return (true, '$studentName 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
|
|
}
|
|
return (false, '초기화 통신 실패');
|
|
} catch (e) {
|
|
return (false, '초기화 통신 실패: $e');
|
|
} finally {
|
|
_isWorking = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 📄 엑셀 파일 바이트를 파싱한다. 1행은 머리글로 보고 건너뛰며,
|
|
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
|
|
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
|
|
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
|
|
final excel = Excel.decodeBytes(_sanitizeXlsxBytes(bytes));
|
|
if (excel.tables.isEmpty) return [];
|
|
|
|
final sheet = excel.tables.values.first;
|
|
final rows = <BulkStudentRow>[];
|
|
|
|
for (final row in sheet.rows.skip(1)) {
|
|
final studentId = _cellText(row.isNotEmpty ? row[0]?.value : null);
|
|
final name = _cellText(row.length > 1 ? row[1]?.value : null);
|
|
if (studentId == null || name == null) continue;
|
|
|
|
final grade = _cellToGrade(row.length > 2 ? row[2]?.value : null);
|
|
rows.add(BulkStudentRow(studentId: studentId, name: name, grade: grade));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/// 🩹 [엑셀 파싱 버그 우회] 일부 엑셀 파일(구글시트/WPS 등에서 내보낸 파일 포함)은
|
|
/// styles.xml에 커스텀 숫자서식 id를 164 미만(예: 0)으로 잘못 적어두는데, excel
|
|
/// 패키지가 이걸 엄격하게 걸러내며 예외를 던져서 파일 전체를 못 읽게 만든다.
|
|
/// 우리는 셀 값(학번/이름/학년)만 필요하고 서식은 필요 없으니, zip 안의 styles.xml만
|
|
/// 열어서 그 잘못된 커스텀 서식 선언을 지우고 다시 압축해서 넘긴다.
|
|
Uint8List _sanitizeXlsxBytes(Uint8List bytes) {
|
|
try {
|
|
final zip = ZipDecoder().decodeBytes(bytes);
|
|
final stylesFile = zip.files.cast<ArchiveFile?>().firstWhere(
|
|
(f) => f?.name == 'xl/styles.xml',
|
|
orElse: () => null,
|
|
);
|
|
if (stylesFile == null) return bytes;
|
|
|
|
final xml = utf8.decode(stylesFile.content as List<int>);
|
|
final fixedXml = _stripInvalidCustomNumFmts(xml);
|
|
if (fixedXml == xml) return bytes; // 고칠 게 없었으면 원본 그대로.
|
|
|
|
final newZip = Archive();
|
|
for (final file in zip.files) {
|
|
if (!file.isFile) continue;
|
|
if (file.name == 'xl/styles.xml') {
|
|
final content = utf8.encode(fixedXml);
|
|
newZip.addFile(ArchiveFile('xl/styles.xml', content.length, content));
|
|
} else {
|
|
newZip.addFile(ArchiveFile(file.name, file.size, file.content));
|
|
}
|
|
}
|
|
final rezipped = ZipEncoder().encode(newZip);
|
|
return rezipped != null ? Uint8List.fromList(rezipped) : bytes;
|
|
} catch (_) {
|
|
return bytes; // 손대다 실패하면 원본 그대로 시도 (기존 동작 유지).
|
|
}
|
|
}
|
|
|
|
/// styles.xml 문자열에서 `<numFmt numFmtId="N" .../>` 태그 중 N이 164 미만인
|
|
/// (규격 위반인) 것만 통째로 제거한다. 그 서식을 참조하던 셀은 기본 서식으로 처리될
|
|
/// 뿐이라 값 읽기에는 영향이 없다.
|
|
String _stripInvalidCustomNumFmts(String xml) {
|
|
final buffer = StringBuffer();
|
|
int i = 0;
|
|
while (true) {
|
|
final tagStart = xml.indexOf('<numFmt ', i);
|
|
if (tagStart == -1) {
|
|
buffer.write(xml.substring(i));
|
|
break;
|
|
}
|
|
final tagEnd = xml.indexOf('/>', tagStart);
|
|
if (tagEnd == -1) {
|
|
buffer.write(xml.substring(i));
|
|
break;
|
|
}
|
|
buffer.write(xml.substring(i, tagStart));
|
|
final tag = xml.substring(tagStart, tagEnd + 2);
|
|
final idMatch = RegExp(r'numFmtId="(\d+)"').firstMatch(tag);
|
|
final id = idMatch != null ? int.tryParse(idMatch.group(1)!) : null;
|
|
if (id == null || id >= 164) {
|
|
buffer.write(tag); // 정상 범위의 커스텀 서식은 그대로 둔다.
|
|
}
|
|
i = tagEnd + 2;
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
String? _cellText(CellValue? value) {
|
|
if (value == null) return null;
|
|
String text;
|
|
if (value is IntCellValue) {
|
|
text = value.value.toString();
|
|
} else if (value is DoubleCellValue) {
|
|
text = value.value == value.value.roundToDouble()
|
|
? value.value.toInt().toString()
|
|
: value.value.toString();
|
|
} else {
|
|
text = value.toString().trim();
|
|
}
|
|
return text.isEmpty ? null : text;
|
|
}
|
|
|
|
int? _cellToGrade(CellValue? value) {
|
|
if (value == null) return null;
|
|
if (value is IntCellValue) return value.value;
|
|
if (value is DoubleCellValue) return value.value.round();
|
|
return int.tryParse(value.toString().trim());
|
|
}
|
|
|
|
/// ➕ 엑셀에서 뽑아낸 학생 목록을 한 명씩 순서대로 서버에 등록한다 (기본 비밀번호 1234).
|
|
/// 한 명 실패해도 나머지는 계속 진행하고, 마지막에 성공/실패를 모아서 돌려준다.
|
|
Future<BulkImportResult> bulkAddStudents(List<BulkStudentRow> rows) async {
|
|
_isWorking = true;
|
|
_bulkTotal = rows.length;
|
|
_bulkDone = 0;
|
|
notifyListeners();
|
|
|
|
int successCount = 0;
|
|
final failures = <String>[];
|
|
|
|
for (final row in rows) {
|
|
final (success, message) = await _createStudentRequest(
|
|
studentId: row.studentId,
|
|
name: row.name,
|
|
password: "1234",
|
|
grade: row.grade,
|
|
);
|
|
if (success) {
|
|
successCount++;
|
|
} else {
|
|
failures.add('${row.studentId} ${row.name}: $message');
|
|
}
|
|
_bulkDone++;
|
|
notifyListeners();
|
|
}
|
|
|
|
await fetchStudents();
|
|
_isWorking = false;
|
|
notifyListeners();
|
|
return BulkImportResult(successCount: successCount, failures: failures);
|
|
}
|
|
|
|
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
|
|
Future<(bool success, String message)> addStudentAccount({
|
|
required String studentId,
|
|
required String name,
|
|
required String password,
|
|
int? grade,
|
|
}) async {
|
|
_isWorking = true;
|
|
notifyListeners();
|
|
try {
|
|
final result = await _createStudentRequest(
|
|
studentId: studentId,
|
|
name: name,
|
|
password: password,
|
|
grade: grade,
|
|
);
|
|
if (result.$1) await fetchStudents();
|
|
return result;
|
|
} finally {
|
|
_isWorking = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 실제 계정 생성 HTTP 요청. isWorking 상태는 건드리지 않는다 —
|
|
/// 단건 등록(addStudentAccount)과 일괄 등록(bulkAddStudents) 양쪽에서
|
|
/// 각자 알맞은 시점에 isWorking을 관리하기 위해 분리했다.
|
|
Future<(bool success, String message)> _createStudentRequest({
|
|
required String studentId,
|
|
required String name,
|
|
required String password,
|
|
int? grade,
|
|
}) async {
|
|
try {
|
|
final url = Uri.parse('$baseUrl/api/users/create');
|
|
final response = await http.post(
|
|
url,
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({
|
|
"studentId": studentId,
|
|
"name": name,
|
|
"password": password,
|
|
"grade": grade,
|
|
}),
|
|
);
|
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
|
|
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
|
return (true, '${result['message'] ?? '계정이 생성되었습니다.'}');
|
|
} else {
|
|
return (false, '생성 실패: ${result['message'] ?? '오류 발생'}');
|
|
}
|
|
} catch (e) {
|
|
return (false, '네트워크 에러: $e');
|
|
}
|
|
}
|
|
|
|
/// 🏫 이미 만들어진 학생 계정의 학년을 지정/변경한다. grade가 null이면 "미배정"으로 되돌린다.
|
|
Future<(bool success, String message)> updateStudentGrade({
|
|
required String studentId,
|
|
int? grade,
|
|
}) async {
|
|
_isWorking = true;
|
|
notifyListeners();
|
|
try {
|
|
final url = Uri.parse('$baseUrl/api/users/update-grade');
|
|
final response = await http.post(
|
|
url,
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({"studentId": studentId, "grade": grade}),
|
|
);
|
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
|
|
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
|
await fetchStudents();
|
|
return (true, '${result['message'] ?? '학년이 지정되었습니다.'}');
|
|
} else {
|
|
return (false, '지정 실패: ${result['message'] ?? '오류 발생'}');
|
|
}
|
|
} catch (e) {
|
|
return (false, '네트워크 에러: $e');
|
|
} finally {
|
|
_isWorking = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// ❌ 학생 계정 삭제.
|
|
Future<(bool success, String message)> deleteStudentAccount(
|
|
String studentId,
|
|
) async {
|
|
_isWorking = true;
|
|
notifyListeners();
|
|
final url = Uri.parse('$baseUrl/api/users/delete');
|
|
try {
|
|
final response = await http.delete(
|
|
url,
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({"studentId": studentId}),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
|
await fetchStudents();
|
|
return (true, '${resData['message']}');
|
|
} else {
|
|
return (false, '삭제 실패하였습니다.');
|
|
}
|
|
} catch (e) {
|
|
return (false, '서버 에러 발생');
|
|
} finally {
|
|
_isWorking = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|