diff --git a/README.md b/README.md
deleted file mode 100644
index f2b9cdd..0000000
--- a/README.md
+++ /dev/null
@@ -1,2 +0,0 @@
-# math-learing
-
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..1c15ac9
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,150 @@
+
+
+ 4.0.0
+
+ com.personalproject
+ math-learning
+ 1.0-SNAPSHOT
+ jar
+
+ Math Learning Application
+ A desktop application for math learning with exam functionality
+
+
+ 17
+ 17
+ UTF-8
+ 21
+ 0.0.8
+
+
+
+
+
+ org.openjfx
+ javafx-controls
+ ${javafx.version}
+
+
+ org.openjfx
+ javafx-fxml
+ ${javafx.version}
+
+
+
+
+ com.sun.mail
+ jakarta.mail
+ 2.0.1
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.9.2
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ 5.9.2
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 17
+ 17
+
+
+
+
+
+ org.openjfx
+ javafx-maven-plugin
+ ${javafx.maven.plugin.version}
+
+ com.personalproject.ui.MathExamGui
+
+
+
+
+
+
+
+
+
+ default-cli
+
+ com.personalproject.ui.MathExamGui
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.0.0
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.3.0
+
+
+
+ com.personalproject.ui.MathExamGui
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.4.1
+
+
+ package
+
+ shade
+
+
+
+
+ com.personalproject.ui.MathExamGui
+
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/personalproject/MathExamApplication.java b/src/main/java/com/personalproject/MathExamApplication.java
new file mode 100644
index 0000000..3298df1
--- /dev/null
+++ b/src/main/java/com/personalproject/MathExamApplication.java
@@ -0,0 +1,19 @@
+package com.personalproject;
+
+import com.personalproject.ui.MathExamGui;
+
+/**
+ * 数学学习软件主应用程序入口. 这个类现在启动JavaFX GUI应用程序.
+ */
+public final class MathExamApplication {
+
+ /**
+ * 程序入口:启动JavaFX GUI应用程序.
+ *
+ * @param args 命令行参数,当前未使用.
+ */
+ public static void main(String[] args) {
+ // 启动JavaFX应用程序
+ MathExamGui.main(args);
+ }
+}
diff --git a/src/main/java/com/personalproject/auth/AccountRepository.java b/src/main/java/com/personalproject/auth/AccountRepository.java
new file mode 100644
index 0000000..e30df5b
--- /dev/null
+++ b/src/main/java/com/personalproject/auth/AccountRepository.java
@@ -0,0 +1,324 @@
+package com.personalproject.auth;
+
+import com.personalproject.model.DifficultyLevel;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 存放用户账号并提供认证和注册查询能力.
+ */
+public final class AccountRepository {
+
+ private static final Path ACCOUNTS_DIRECTORY = Paths.get("user_data", "accounts");
+ private static final String FILE_EXTENSION = ".properties";
+
+ private final Map accounts = new ConcurrentHashMap<>();
+
+ /**
+ * 创建账号仓库并立即加载磁盘中的账号数据.
+ */
+ public AccountRepository() {
+ loadAccounts();
+ }
+
+ /**
+ * 根据用户名与密码查找匹配的账号.
+ *
+ * @param username 用户名.
+ * @param password 密码.
+ * @return 匹配成功时返回账号信息,否则返回空结果.
+ */
+ public Optional authenticate(String username, String password) {
+ if (username == null || password == null) {
+ return Optional.empty();
+ }
+ String normalizedUsername = username.trim();
+ String normalizedPassword = password.trim();
+ UserAccount account = accounts.get(normalizedUsername);
+ if (account == null || !account.isRegistered()) {
+ return Optional.empty();
+ }
+ if (!account.password().equals(hashPassword(normalizedPassword))) {
+ return Optional.empty();
+ }
+ return Optional.of(account);
+ }
+
+ /**
+ * 使用电子邮箱注册新用户账号.
+ *
+ * @param username 用户名
+ * @param email 邮箱地址
+ * @param difficultyLevel 选择的难度级别
+ * @return 若注册成功则返回 true,若用户名已存在则返回 false
+ */
+ public synchronized boolean registerUser(String username, String email,
+ DifficultyLevel difficultyLevel) {
+ if (username == null || email == null || difficultyLevel == null) {
+ return false;
+ }
+
+ String normalizedUsername = username.trim();
+ String normalizedEmail = email.trim();
+ if (normalizedUsername.isEmpty() || normalizedEmail.isEmpty()) {
+ return false;
+ }
+
+ UserAccount existing = accounts.get(normalizedUsername);
+ if (existing != null && existing.isRegistered()) {
+ return false;
+ }
+
+ if (isEmailInUse(normalizedEmail, normalizedUsername)) {
+ return false;
+ }
+
+ LocalDateTime registrationDate =
+ existing != null ? existing.registrationDate() : LocalDateTime.now();
+ UserAccount account = new UserAccount(
+ normalizedUsername,
+ normalizedEmail,
+ "",
+ difficultyLevel,
+ registrationDate,
+ false);
+
+ accounts.put(normalizedUsername, account);
+ persistAccount(account);
+ return true;
+ }
+
+ /**
+ * 在注册后为用户设置密码.
+ *
+ * @param username 用户名
+ * @param password 要设置的密码
+ * @return 设置成功返回 true,若用户不存在则返回 false
+ */
+ public synchronized boolean setPassword(String username, String password) {
+ if (username == null || password == null) {
+ return false;
+ }
+ String normalizedUsername = username.trim();
+ UserAccount account = accounts.get(normalizedUsername);
+ if (account == null || account.isRegistered()) {
+ return false;
+ }
+
+ UserAccount updatedAccount = new UserAccount(
+ account.username(),
+ account.email(),
+ hashPassword(password.trim()),
+ account.difficultyLevel(),
+ account.registrationDate(),
+ true);
+ accounts.put(normalizedUsername, updatedAccount);
+ persistAccount(updatedAccount);
+ return true;
+ }
+
+ /**
+ * 修改现有用户的密码.
+ *
+ * @param username 用户名
+ * @param oldPassword 当前密码
+ * @param newPassword 新密码
+ * @return 修改成功返回 true,若旧密码错误或用户不存在则返回 false
+ */
+ public synchronized boolean changePassword(String username, String oldPassword,
+ String newPassword) {
+ if (username == null || oldPassword == null || newPassword == null) {
+ return false;
+ }
+ String normalizedUsername = username.trim();
+ UserAccount account = accounts.get(normalizedUsername);
+ if (account == null || !account.isRegistered()) {
+ return false;
+ }
+
+ if (!account.password().equals(hashPassword(oldPassword.trim()))) {
+ return false;
+ }
+
+ UserAccount updatedAccount = new UserAccount(
+ account.username(),
+ account.email(),
+ hashPassword(newPassword.trim()),
+ account.difficultyLevel(),
+ account.registrationDate(),
+ true);
+ accounts.put(normalizedUsername, updatedAccount);
+ persistAccount(updatedAccount);
+ return true;
+ }
+
+ /**
+ * 移除未完成注册的用户,便于重新注册.
+ *
+ * @param username 待移除的用户名
+ */
+ public synchronized void removeUnverifiedUser(String username) {
+ if (username == null) {
+ return;
+ }
+ String normalizedUsername = username.trim();
+ UserAccount account = accounts.get(normalizedUsername);
+ if (account != null && !account.isRegistered()) {
+ accounts.remove(normalizedUsername);
+ deleteAccountFile(normalizedUsername);
+ }
+ }
+
+ /**
+ * 检查用户是否存在.
+ *
+ * @param username 待检查的用户名
+ * @return 若存在则返回 true,否则返回 false
+ */
+ public boolean userExists(String username) {
+ if (username == null) {
+ return false;
+ }
+ return accounts.containsKey(username.trim());
+ }
+
+ /**
+ * 按用户名获取用户账户.
+ *
+ * @param username 用户名
+ * @return 若找到则返回包含用户账户的 Optional
+ */
+ public Optional getUser(String username) {
+ if (username == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(accounts.get(username.trim()));
+ }
+
+ private void loadAccounts() {
+ try {
+ if (!Files.exists(ACCOUNTS_DIRECTORY)) {
+ Files.createDirectories(ACCOUNTS_DIRECTORY);
+ return;
+ }
+
+ try (var paths = Files.list(ACCOUNTS_DIRECTORY)) {
+ paths
+ .filter(path -> path.getFileName().toString().endsWith(FILE_EXTENSION))
+ .forEach(this::loadAccountFromFile);
+ }
+ } catch (IOException exception) {
+ throw new IllegalStateException("加载账户数据失败", exception);
+ }
+ }
+
+ private void loadAccountFromFile(Path file) {
+ Properties properties = new Properties();
+ try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
+ properties.load(reader);
+ } catch (IOException exception) {
+ System.err.println("读取账户文件失败: " + file + ",原因: " + exception.getMessage());
+ return;
+ }
+
+ String username = properties.getProperty("username");
+ if (username == null || username.isBlank()) {
+ return;
+ }
+
+ String email = properties.getProperty("email", "");
+ String passwordHash = properties.getProperty("passwordHash", "");
+ String difficultyValue = properties.getProperty("difficulty", DifficultyLevel.PRIMARY.name());
+ String registrationDateValue = properties.getProperty("registrationDate");
+ String registeredValue = properties.getProperty("registered", "false");
+
+ try {
+ DifficultyLevel difficultyLevel = DifficultyLevel.valueOf(difficultyValue);
+ LocalDateTime registrationDate = registrationDateValue == null
+ ? LocalDateTime.now()
+ : LocalDateTime.parse(registrationDateValue);
+ boolean registered = Boolean.parseBoolean(registeredValue);
+ UserAccount account = new UserAccount(
+ username,
+ email,
+ passwordHash,
+ difficultyLevel,
+ registrationDate,
+ registered);
+ accounts.put(username, account);
+ } catch (Exception exception) {
+ System.err.println("解析账户文件失败: " + file + ",原因: " + exception.getMessage());
+ }
+ }
+
+ private void persistAccount(UserAccount account) {
+ Properties properties = new Properties();
+ properties.setProperty("username", account.username());
+ properties.setProperty("email", account.email());
+ properties.setProperty("passwordHash", account.password());
+ properties.setProperty("difficulty", account.difficultyLevel().name());
+ properties.setProperty("registrationDate", account.registrationDate().toString());
+ properties.setProperty("registered", Boolean.toString(account.isRegistered()));
+
+ Path targetFile = accountFile(account.username());
+ try {
+ if (!Files.exists(ACCOUNTS_DIRECTORY)) {
+ Files.createDirectories(ACCOUNTS_DIRECTORY);
+ }
+ try (Writer writer = Files.newBufferedWriter(targetFile, StandardCharsets.UTF_8)) {
+ properties.store(writer, "User account data");
+ }
+ } catch (IOException exception) {
+ throw new IllegalStateException("保存账户数据失败: " + account.username(), exception);
+ }
+ }
+
+ private void deleteAccountFile(String username) {
+ Path file = accountFile(username);
+ try {
+ Files.deleteIfExists(file);
+ } catch (IOException exception) {
+ System.err.println("删除账户文件失败: " + file + ",原因: " + exception.getMessage());
+ }
+ }
+
+ private boolean isEmailInUse(String email, String currentUsername) {
+ return accounts.values().stream()
+ .anyMatch(account -> account.email().equalsIgnoreCase(email)
+ && !account.username().equals(currentUsername));
+ }
+
+ private Path accountFile(String username) {
+ return ACCOUNTS_DIRECTORY.resolve(username + FILE_EXTENSION);
+ }
+
+ private String hashPassword(String password) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(password.getBytes(StandardCharsets.UTF_8));
+ return toHex(hashBytes);
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException("当前运行环境不支持SHA-256算法", exception);
+ }
+ }
+
+ private String toHex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte value : bytes) {
+ builder.append(String.format("%02x", value));
+ }
+ return builder.toString();
+ }
+}
diff --git a/src/main/java/com/personalproject/auth/EmailService.java b/src/main/java/com/personalproject/auth/EmailService.java
new file mode 100644
index 0000000..f12a81d
--- /dev/null
+++ b/src/main/java/com/personalproject/auth/EmailService.java
@@ -0,0 +1,231 @@
+package com.personalproject.auth;
+
+import jakarta.mail.Authenticator;
+import jakarta.mail.Message;
+import jakarta.mail.MessagingException;
+import jakarta.mail.PasswordAuthentication;
+import jakarta.mail.Session;
+import jakarta.mail.Transport;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeMessage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * 用于发送带有注册码的电子邮件的工具类.
+ */
+public final class EmailService {
+
+ private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ private static final int CODE_LENGTH = 6;
+ private static final Random RANDOM = new Random();
+ private static final Path OUTBOX_DIRECTORY = Paths.get("user_data", "emails");
+ private static final String CONFIG_RESOURCE = "email-config.properties";
+ private static final DateTimeFormatter DATE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
+ private static final Map LAST_CODES = new ConcurrentHashMap<>();
+ private static final AtomicReference CONFIG_CACHE = new AtomicReference<>();
+ private static final String DEFAULT_SUBJECT = "数学学习软件注册验证码";
+
+ private EmailService() {
+ // 防止实例化此工具类
+ }
+
+ /**
+ * 生成随机注册码.
+ *
+ * @return 随机生成的注册码
+ */
+ public static String generateRegistrationCode() {
+ StringBuilder code = new StringBuilder();
+ for (int i = 0; i < CODE_LENGTH; i++) {
+ code.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length())));
+ }
+ return code.toString();
+ }
+
+ /**
+ * 将注册码发送到指定邮箱.
+ *
+ * @param email 收件人邮箱
+ * @param registrationCode 要发送的注册码
+ * @return 发送成功返回 true
+ */
+ public static boolean sendRegistrationCode(String email, String registrationCode) {
+ try {
+ MailConfiguration configuration = loadConfiguration();
+ sendEmail(email, registrationCode, configuration);
+ persistMessage(email, registrationCode);
+ LAST_CODES.put(email.trim().toLowerCase(), registrationCode);
+ return true;
+ } catch (Exception exception) {
+ System.err.println("发送验证码失败: " + exception.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 获取最近一次发送到指定邮箱的验证码(便于调试或测试).
+ *
+ * @param email 收件人邮箱
+ * @return 若存在则返回验证码
+ */
+ public static Optional getLastSentRegistrationCode(String email) {
+ if (email == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(LAST_CODES.get(email.trim().toLowerCase()));
+ }
+
+ /**
+ * 校验电子邮件地址的格式是否有效.
+ *
+ * @param email 待校验的邮箱地址
+ * @return 若格式有效则返回 true,否则返回 false
+ */
+ public static boolean isValidEmail(String email) {
+ if (email == null || email.trim().isEmpty()) {
+ return false;
+ }
+
+ String emailRegex = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@"
+ + "(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
+ return email.matches(emailRegex);
+ }
+
+ private static MailConfiguration loadConfiguration() throws IOException {
+ MailConfiguration cached = CONFIG_CACHE.get();
+ if (cached != null) {
+ return cached;
+ }
+
+ synchronized (CONFIG_CACHE) {
+ cached = CONFIG_CACHE.get();
+ if (cached != null) {
+ return cached;
+ }
+
+ Properties properties = new Properties();
+ try (InputStream inputStream =
+ EmailService.class.getClassLoader().getResourceAsStream(CONFIG_RESOURCE)) {
+ if (inputStream == null) {
+ throw new IllegalStateException(
+ "类路径下缺少邮箱配置文件: " + CONFIG_RESOURCE
+ + ",请在 src/main/resources 下提供该文件");
+ }
+ try (InputStreamReader reader =
+ new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
+ properties.load(reader);
+ }
+ }
+
+ String username = require(properties, "mail.username");
+ final String password = require(properties, "mail.password");
+ final String from = properties.getProperty("mail.from", username);
+ final String subject = properties.getProperty("mail.subject", DEFAULT_SUBJECT);
+
+ Properties smtpProperties = new Properties();
+ for (Map.Entry