diff --git a/src/core/data_handler.py b/src/core/data_handler.py new file mode 100644 index 0000000..59d8d28 --- /dev/null +++ b/src/core/data_handler.py @@ -0,0 +1,53 @@ +import json +import os +from pathlib import Path + + +class DataHandler: + def __init__(self): + self.base_dir = Path("math_learning_data") + self.users_file = self.base_dir / "users.json" + self.ensure_data_dir() + + def ensure_data_dir(self): + """确保数据目录和文件存在,并且文件内容是有效的JSON""" + if not self.base_dir.exists(): + self.base_dir.mkdir(parents=True) + + if not self.users_file.exists(): + with open(self.users_file, "w", encoding="utf-8") as f: + json.dump({}, f, ensure_ascii=False) # 写入空对象 + else: + # 如果文件存在但为空,写入空对象 + if os.path.getsize(self.users_file) == 0: + with open(self.users_file, "w", encoding="utf-8") as f: + json.dump({}, f, ensure_ascii=False) + + def load_users(self): + """加载所有用户数据""" + with open(self.users_file, "r", encoding="utf-8") as f: + try: + return json.load(f) + except json.JSONDecodeError: + # 如果文件内容损坏,返回空字典 + return {} + + def save_users(self, users_data): + """保存用户数据""" + with open(self.users_file, "w", encoding="utf-8") as f: + json.dump(users_data, f, ensure_ascii=False, indent=2) + + def get_user(self, email): + """获取用户信息""" + users = self.load_users() + return users.get(email) + + def add_user(self, email, user_data): + """添加新用户""" + users = self.load_users() + users[email] = user_data + self.save_users(users) + + def update_user(self, email, user_data): + """更新用户信息""" + self.add_user(email, user_data) # 直接覆盖更新 \ No newline at end of file diff --git a/src/core/email_service.py b/src/core/email_service.py new file mode 100644 index 0000000..ecca850 --- /dev/null +++ b/src/core/email_service.py @@ -0,0 +1,167 @@ +import random +import time +import smtplib +import logging +from email.mime.text import MIMEText +from email.header import Header +from email.utils import formataddr +from tkinter import messagebox +from typing import Dict +from smtplib import SMTPResponseException + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class EmailService: + def __init__(self, smtp_server: str, smtp_port: int, sender_email: str, sender_password: str): + self.smtp_server = smtp_server + self.smtp_port = smtp_port + self.sender_email = sender_email + self.sender_password = sender_password + self.verification_codes: Dict[str, tuple[str, float]] = {} # 邮箱: (验证码, 过期时间) + self.code_validity = 60 # 验证码有效期1分钟 + self.last_send_time: Dict[str, float] = {} # 记录上次发送时间 + self.send_interval = 60 # 发送间隔60秒 + + def generate_code(self) -> str: + """生成6位数字注册码""" + return str(random.randint(100000, 999999)) + + def can_send_code(self, email: str) -> bool: + """检查是否可以发送注册码(防止频繁发送)""" + if email in self.last_send_time: + elapsed = time.time() - self.last_send_time[email] + return elapsed >= self.send_interval + return True + + def send_verification_code(self, email: str, username: str = "用户") -> bool: + """发送验证码到用户邮箱。 + + Args: + email: 目标邮箱地址。 + username: 用户昵称,用于邮件正文。 + + Returns: + 成功返回 True,失败返回 False。 + """ + if not self.can_send_code(email): + logger.warning("发送过于频繁,邮箱: %s", email) + return False + + code = self.generate_code() + now = time.time() + self.verification_codes[email] = (code, now + self.code_validity) + self.last_send_time[email] = now + + subject = "数学学习软件 - 注册验证码" + formatted_email = self._format_email(email) + body = self._build_email_body(username, code) + + try: + self._send_email(email, subject, body) + logger.info("注册码已发送到 %s", email) + return True + except smtplib.SMTPAuthenticationError as e: + logger.error("邮箱认证失败: %s", e) + self._show_warning("邮箱认证失败", "请检查邮箱账号和授权码是否正确") + return False + except smtplib.SMTPConnectError as e: + logger.error("无法连接到SMTP服务器: %s", e) + self._show_warning("连接失败", "无法连接到邮件服务器,请检查网络连接") + return False + except Exception as e: + logger.error("邮件发送失败: %s", e, exc_info=True) + self._show_warning("发送失败", f"邮件发送失败: {e}") + return False + + + def _format_email(self, email: str) -> str: + """隐藏部分邮箱字符,保护隐私。""" + try: + at_index = email.index('@') + if at_index >= 4: + return email[:3] + '...' + email[at_index - 4:at_index] + return email + except ValueError: + return email + + + def _build_email_body(self, username: str, code: str) -> str: + """构建HTML邮件正文。""" + return f""" + + +
+
+

数学学习软件

+

注册验证码

+
+
+

亲爱的 {username},您好!

+

您正在注册数学学习软件,请使用以下注册码完成注册:

+
+ + {code} + +
+

+ ⚠️ 注册码有效期1分钟,请尽快完成注册。 +

+
+
+

如果这不是您的操作,请忽略此邮件。

+

此为系统邮件,请勿回复

+

数学学习软件团队

+
+
+ + + """ + + + def _send_email(self, to_email: str, subject: str, body: str) -> None: + """发送邮件。""" + msg = MIMEText(body, "html", "utf-8") + msg["Subject"] = Header(subject, "utf-8") + msg["From"] = formataddr(("数学学习软件", self.sender_email)) + msg["To"] = to_email + + if self.smtp_port == 587: + server = smtplib.SMTP(self.smtp_server, self.smtp_port, timeout=10) + server.starttls() + else: + server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, timeout=10) + + server.set_debuglevel(1) + server.login(self.sender_email, self.sender_password) + server.sendmail(self.sender_email, to_email, msg.as_string()) + server.quit() + + + def _show_warning(self, title: str, message: str) -> None: + """弹出警告框。""" + messagebox.warning(None, title, message) + + def verify_code(self, email: str, code: str) -> bool: + """验证注册码是否有效""" + if email not in self.verification_codes: + return False + + stored_code, expire_time = self.verification_codes[email] + if time.time() > expire_time: + del self.verification_codes[email] + return False + + return stored_code == code \ No newline at end of file diff --git a/src/core/question_bank.py b/src/core/question_bank.py new file mode 100644 index 0000000..2d75ede --- /dev/null +++ b/src/core/question_bank.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +"""数学学习软件 - 题目生成与试卷管理模块 + +提供小学、初中、高中三个学段的数学选择题自动生成、查重、保存功能。 +""" + +from __future__ import annotations + +import json +import math +import os +import random +from datetime import datetime +from typing import List, Tuple + +# 模块级常量 +PRIMARY_OPS = ("+", "-", "*", "/") +MIDDLE_OPS = ("+", "-", "*", "/", "^2", "sqrt") +HIGH_OPS = ("+", "-", "*", "/", "sin", "cos", "tan") +PRIMARY_RANGE = (1, 50) +MIDDLE_RANGE = (1, 100) +HIGH_ANGLE_RANGE = (0, 90) # 角度制 +MAX_OPTS = 4 +MAX_TRIES = 100 +PAPERS_DIR = "generated_papers" + + +class Question: + """单道选择题.""" + + __slots__ = ("content", "options", "answer") + + def __init__(self, content: str, options: List[str], answer: str): + """初始化. + + Args: + content: 题干(含表达式)。 + options: 四个选项文本。 + answer: 正确答案文本。 + """ + self.content = content + self.options = options + self.answer = answer + + +class QuestionBank: + """题库:按学段生成不重复的选择题,并保存为文本试卷.""" + + def __init__(self) -> None: + """初始化.""" + self.operators = { + "primary": PRIMARY_OPS, + "middle": MIDDLE_OPS, + "high": HIGH_OPS, + } + self.generated_questions: List[Question] = [] + self.papers_dir = PAPERS_DIR + os.makedirs(self.papers_dir, exist_ok=True) + + # ------------------------------------------------------------------ + # 公共接口 + # ------------------------------------------------------------------ + + def generate_question(self, level: str) -> Question: + for attempt in range(MAX_TRIES): + try: + expr, value = self._make_expression(level) + if not self._is_valid_value(value) or expr in [q.content for q in self.generated_questions]: + continue + opts, ans = self._make_options(value, level) + return Question(f"计算:{expr}", opts, ans) + except Exception as e: + print(f"[WARNING] 题目生成失败,尝试 {attempt + 1}/{MAX_TRIES},错误:{e}") + continue + + # 兜底题目 + print("[WARNING] 使用兜底题目") + expr, value = self._fallback_expression(level) + opts, ans = self._make_options(value, level) + return Question(f"计算:{expr}", opts, ans) + + def generate_paper(self, level: str, count: int, username: str = "unknown") -> List[Question]: + """生成整张试卷并落盘. + + Args: + level: 学段 + count: 题目数量 + username: 用户昵称 + + Returns: + 题目列表 + """ + self.generated_questions.clear() + questions = [self.generate_question(level) for _ in range(count)] + self._save_paper(questions, level, username) + return questions + + def calculate_score(self, answers: List[bool]) -> int: + """计算百分制得分. + + Args: + answers: 每题是否正确 + + Returns: + 0~100 + """ + if not answers: + return 0 + return round(sum(answers) / len(answers) * 100) + + # ------------------------------------------------------------------ + # 私有辅助 + # ------------------------------------------------------------------ + + def _make_expression(self, level: str) -> Tuple[str, float]: + """返回表达式字符串与数值结果.""" + if level == "primary": + return self._primary_expr() + if level == "middle": + return self._middle_expr() + return self._high_expr() + + def _primary_expr(self) -> Tuple[str, float]: + """小学表达式:整数结果.""" + nums = [random.randint(*PRIMARY_RANGE) for _ in range(random.randint(2, 3))] + ops = list(random.choices(PRIMARY_OPS, k=len(nums) - 1)) + self._ensure_int_div_sub(nums, ops) + + parts = [str(nums[0])] + for o, n in zip(ops, nums[1:]): + parts.extend([o, str(n)]) + expr = self._add_parentheses(parts) + val = self._safe_int_eval(expr) + if val is not None: + return expr, val + + a, b = sorted(random.randint(*PRIMARY_RANGE) for _ in range(2)) + op = random.choice(["+", "-", "*"]) + expr = f"{a} {op} {b}" + return expr, eval(expr) + + def _middle_expr(self) -> Tuple[str, float]: + """初中表达式:含平方或开方.""" + base = random.randint(*MIDDLE_RANGE) + if random.choice([True, False]): + inner = f"{base}^2" + val = base ** 2 + else: + inner = f"sqrt({base})" + val = math.sqrt(base) + + if random.choice([True, False]): + n2 = random.randint(*MIDDLE_RANGE) + op = random.choice(PRIMARY_OPS) + inner = f"({inner} {op} {n2})" + val = eval(f"{val} {op} {n2}") + + return inner, val + + def _high_expr(self) -> Tuple[str, float]: + """高中表达式:含三角函数.""" + angle = random.randint(*HIGH_ANGLE_RANGE) + func = random.choice(["sin", "cos", "tan"]) + expr = f"{func}({angle})" + val = getattr(math, func)(math.radians(angle)) + + if random.choice([True, False]): + n2 = random.randint(1, 90) + op = random.choice(PRIMARY_OPS) + expr = f"({expr} {op} {n2})" + val = eval(f"{val} {op} {n2}") + + return expr, val + + def _fallback_expression(self, level: str) -> Tuple[str, float]: + """兜底简单表达式.""" + if level == "primary": + a, b = sorted(random.randint(*PRIMARY_RANGE) for _ in range(2)) + expr = f"{a} + {b}" + return expr, eval(expr) + if level == "middle": + n = random.randint(1, 10) + expr = f"{n}^2" + return expr, n ** 2 + angle = random.randint(1, 89) + expr = f"sin({angle})" + return expr, math.sin(math.radians(angle)) + + # -- 工具 ----------------------------------------------------------- + + @staticmethod + def _ensure_int_div_sub(nums: List[int], ops: List[str]) -> None: + """调整 nums/ops 保证整数结果.""" + for i, op in enumerate(ops): + if op == "/": + nums[i] *= nums[i + 1] + elif op == "-" and nums[i] < nums[i + 1]: + nums[i], nums[i + 1] = nums[i + 1], nums[i] + + @staticmethod + def _add_parentheses(parts: List[str]) -> str: + """随机给表达式加括号.""" + if len(parts) >= 5 and random.choice([True, False]): + start = random.randint(0, len(parts) - 4) + if start % 2 == 0: + parts.insert(start, "(") + parts.insert(start + 4, ")") + return " ".join(parts) + + @staticmethod + def _safe_int_eval(expr: str) -> float | None: + """安全计算并返回整数结果.""" + try: + val = eval(expr) + if abs(val - round(val)) < 1e-4: + return round(val) + except Exception: + pass + return None + + @staticmethod + def _is_valid_value(val: float) -> bool: + """检查数值是否合法.""" + return not (math.isnan(val) or math.isinf(val) or abs(val) > 1e10) + + # -- 选项 & 保存 ------------------------------------------------------ + + def _make_options(self, correct: float, level: str) -> Tuple[List[str], str]: + """生成四个选项(1正确+3干扰)。""" + if level == "primary": + correct_val = int(round(correct)) + else: + correct_val = round(correct, 2) + + opts = [correct_val] + while len(opts) < 4: + distractor = self._make_distractor(opts, correct_val, level) + if distractor not in opts: + opts.append(distractor) + + random.shuffle(opts) + + # ✅ 确保答案是从 opts 中获取的,而不是原始浮点数 + ans = str(opts[opts.index(correct_val)]) + return [str(o) for o in opts], ans + + def _make_distractor(self, existing: List[float], correct: float, level: str) -> float: + """生成一个不重复的干扰项.""" + while True: + if level == "primary": + d = int(correct) + random.randint(-5, 5) + if d != correct and d > 0: + return d + else: + delta = abs(correct) * 0.3 if correct else 1 + d = round(correct + random.uniform(-delta, delta), 2) + if d not in existing: + return d + + def _save_paper(self, questions: List[Question], level: str, username: str) -> None: + """试卷落盘.""" + level_name = {"primary": "小学", "middle": "初中", "high": "高中"}.get(level, level) + timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + path = os.path.join(self.papers_dir, f"{timestamp}.txt") + with open(path, "w", encoding="utf-8") as f: + f.write(f"数学学习软件 - {level_name}数学试卷\n") + f.write(f"生成时间:{datetime.now()}\n") + f.write("=" * 50 + "\n\n") + for idx, q in enumerate(questions, 1): + f.write(f"第{idx}题:{q.content}\n选项:\n") + for i, opt in enumerate(q.options): + f.write(f" {chr(65+i)}. {opt}\n") + f.write(f"正确答案:{q.answer}\n\n") + f.write("=" * 50 + "\n") + f.write(f"共{len(questions)}题\n") + print(f"试卷已保存到:{path}") \ No newline at end of file diff --git a/src/core/user_system.py b/src/core/user_system.py new file mode 100644 index 0000000..d7074a3 --- /dev/null +++ b/src/core/user_system.py @@ -0,0 +1,163 @@ +import re +import json +import os +import sys +from typing import Dict, Optional +from .email_service import EmailService + +# 获取exe所在目录的绝对路径 +if getattr(sys, 'frozen', False): + # 如果是打包后的exe + BASE_DIR = os.path.dirname(sys.executable) +else: + # 如果是开发环境 + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +USER_DATA_FILE = os.path.join(BASE_DIR, "data", "users.json") + +class UserSystem: + def __init__(self, email_service: EmailService): + self.email_service = email_service + # 确保data目录存在 + os.makedirs(os.path.dirname(USER_DATA_FILE), exist_ok=True) + self.users: Dict[str, dict] = self.load_users() + self.current_user: Optional[str] = None + self.current_level: Optional[str] = None + + def load_users(self) -> Dict[str, dict]: + """从文件加载用户数据""" + print(f"尝试加载用户文件: {USER_DATA_FILE}") # 调试信息 + print(f"文件是否存在: {os.path.exists(USER_DATA_FILE)}") # 调试信息 + + if os.path.exists(USER_DATA_FILE): + try: + with open(USER_DATA_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError) as e: + print(f"加载用户文件失败: {e}") # 调试信息 + return {} + else: + print("用户文件不存在,创建空用户字典") # 调试信息 + return {} + + def save_users(self): + """保存用户数据到文件""" + print(f"保存用户数据到: {USER_DATA_FILE}") # 调试信息 + # 确保目录存在 + os.makedirs(os.path.dirname(USER_DATA_FILE), exist_ok=True) + try: + with open(USER_DATA_FILE, "w", encoding="utf-8") as f: + json.dump(self.users, f, ensure_ascii=False, indent=4) + print("用户数据保存成功") # 调试信息 + except Exception as e: + print(f"保存用户数据失败: {e}") # 调试信息 + + # 其他方法保持不变... + + def is_valid_email(self, email: str) -> bool: + """验证邮箱格式""" + pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$" + return re.match(pattern, email) is not None + + def is_valid_password(self, password: str) -> bool: + """密码要求:6-10位,包含大小写字母和数字""" + if len(password) < 6 or len(password) > 10: + return False + has_upper = any(c.isupper() for c in password) + has_lower = any(c.islower() for c in password) + has_digit = any(c.isdigit() for c in password) + return has_upper and has_lower and has_digit + + def is_valid_username(self, username: str) -> bool: + """用户名要求:2-10位中文、字母或数字""" + if len(username) < 2 or len(username) > 10: + return False + pattern = r"^[\u4e00-\u9fa5a-zA-Z0-9]+$" + return re.match(pattern, username) is not None + + def is_username_exists(self, username: str) -> bool: + """检查用户名是否已存在""" + return username in self.users + + def send_verification(self, email: str, username: str = "用户") -> bool: + """发送验证码""" + # 检查邮箱是否已被其他用户使用 + for user_data in self.users.values(): + if user_data.get("email") == email: + return False + return self.email_service.send_verification_code(email, username) + + def register(self, username: str, email: str, code: str, password: str) -> bool: + """注册新用户""" + if self.is_username_exists(username): + return False + if not self.is_valid_password(password): + return False + if not self.is_valid_username(username): + return False + if not self.is_valid_email(email): + return False + # 检查邮箱是否已被使用 + for user_data in self.users.values(): + if user_data.get("email") == email: + return False + if not self.email_service.verify_code(email, code): + return False + + self.users[username] = { + "password": password, + "email": email, + "level": None + } + self.save_users() + return True + + def login(self, username: str, password: str) -> bool: + """登录验证 - 使用用户名登录""" + user = self.users.get(username) + if user and user["password"] == password: + self.current_user = username + self.current_level = user.get("level") + return True + return False + + def change_password(self, old_password: str, new_password: str) -> bool: + """修改当前登录用户密码""" + if not self.current_user: + return False + user = self.users.get(self.current_user) + if not user or user["password"] != old_password: + return False + if not self.is_valid_password(new_password): + return False + user["password"] = new_password + self.save_users() + return True + + def set_username(self, new_username: str) -> bool: + """修改当前用户名""" + if not self.current_user: + return False + if not self.is_valid_username(new_username): + return False + if self.is_username_exists(new_username): + return False + + # 更新用户名 + user_data = self.users.pop(self.current_user) + self.users[new_username] = user_data + self.current_user = new_username + self.save_users() + return True + + def set_level(self, level: str): + """设置当前用户学段""" + if self.current_user: + self.users[self.current_user]["level"] = level + self.current_level = level + self.save_users() + + def get_user_email(self, username: str) -> Optional[str]: + """获取用户的邮箱""" + user = self.users.get(username) + return user.get("email") if user else None \ No newline at end of file